Compare commits
8 Commits
ef128b47d2
...
be57d2839a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be57d2839a | ||
|
|
769d75d937 | ||
|
|
f440fd7751 | ||
|
|
29615cb4f3 | ||
|
|
376ed9a98d | ||
|
|
b42a2d7ea7 | ||
|
|
560d023250 | ||
|
|
f91ef84832 |
@@ -431,3 +431,7 @@ Things I discovered about myself building a sister platform across multiple sess
|
|||||||
20. **Parallel state fields that track related things will drift apart — and the bugs are silent.** When two fields represent aspects of the same state (`captureMode` and `vkiMode`, or `isLoading` and `error`, or `connection_status` and `companion_last_seen`), every code path that mutates one must also update the other. But new code paths get added over time, and they only update the field they know about. Future me: when you see two fields tracking related state, grep for ALL mutation sites of each — if any path updates one but not the other, that's a bug waiting to happen. And when you add a new mutation path, check every sibling field, not just the obvious one.
|
20. **Parallel state fields that track related things will drift apart — and the bugs are silent.** When two fields represent aspects of the same state (`captureMode` and `vkiMode`, or `isLoading` and `error`, or `connection_status` and `companion_last_seen`), every code path that mutates one must also update the other. But new code paths get added over time, and they only update the field they know about. Future me: when you see two fields tracking related state, grep for ALL mutation sites of each — if any path updates one but not the other, that's a bug waiting to happen. And when you add a new mutation path, check every sibling field, not just the obvious one.
|
||||||
|
|
||||||
21. **Route through the component that survives transitions, not the one that doesn't.** When two systems can handle the same job but one is resilient to failure modes and the other isn't, route through the survivor. Don't build infrastructure to prop up the fragile path when the robust path already exists. In this project: NATS request-reply through the companion agent is the robust path; direct WebSocket to the browser is the fragile one. If a feature can work through either, prefer the path that handles disconnects, reconnects, and restarts gracefully. One routing change beats an entire retry/recovery subsystem.
|
21. **Route through the component that survives transitions, not the one that doesn't.** When two systems can handle the same job but one is resilient to failure modes and the other isn't, route through the survivor. Don't build infrastructure to prop up the fragile path when the robust path already exists. In this project: NATS request-reply through the companion agent is the robust path; direct WebSocket to the browser is the fragile one. If a feature can work through either, prefer the path that handles disconnects, reconnects, and restarts gracefully. One routing change beats an entire retry/recovery subsystem.
|
||||||
|
|
||||||
|
22. **Build-green is not render-correct — visually verify UI work before calling it done.** The entire design-system re-skin (50+ files, six green commits) rendered almost completely unstyled in the browser — white background, no surfaces, no accent — because the design tokens never loaded. `vue-tsc -b` + `vite build` passed clean the whole time; CSS that *compiles* can still apply *zero* styles. One Playwright screenshot of the login exposed it in seconds. When the deliverable is visual, a green build is necessary but not sufficient: load it in a real browser (Playwright on the dev server at :5174), screenshot it, and assert on `getComputedStyle` — don't trust compilation alone. This is Lesson 17 with teeth.
|
||||||
|
|
||||||
|
23. **Tailwind v4 silently drops a nested `@import` barrel placed after `@import "tailwindcss"`.** `style.css` did `@import "tailwindcss"; @import "./styles/corrosion.css";` where corrosion.css was a barrel of eight `@import` token files. Once Tailwind v4 expands the tailwindcss import in place, the barrel's inner @imports no longer precede all statements, so PostCSS drops them — emitting only an easily-ignored "@import must precede all other statements" warning. Result: every design token resolved empty and the whole panel rendered unstyled. Import token/design CSS files **directly and contiguously** in the entry stylesheet; never via a nested barrel after the Tailwind import. The build warning you wave off as "pre-existing" may be the entire feature silently failing.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" class="dark">
|
<html lang="en" class="dark" data-theme="dark" data-game="rust">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
@@ -9,8 +9,24 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0a0a0a" />
|
<meta name="theme-color" content="#0a0a0a" />
|
||||||
<title>Corrosion Management</title>
|
<title>Corrosion Management</title>
|
||||||
|
<script>
|
||||||
|
/* FOUC guard — apply persisted theme/game to <html> before the app mounts,
|
||||||
|
so the design-system tokens paint with the right skin from frame one. */
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var el = document.documentElement;
|
||||||
|
var t = localStorage.getItem('cc-theme');
|
||||||
|
var g = localStorage.getItem('cc-game');
|
||||||
|
if (t === 'dark' || t === 'light') {
|
||||||
|
el.setAttribute('data-theme', t);
|
||||||
|
el.classList.toggle('dark', t === 'dark');
|
||||||
|
}
|
||||||
|
if (g) el.setAttribute('data-game', g);
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-neutral-950">
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
8
frontend/src/assets/corrosion-mark.svg
Normal file
8
frontend/src/assets/corrosion-mark.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Corrosion">
|
||||||
|
<path d="M40.2 9.45 A24 24 0 0 1 54.6 23.8" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||||
|
<path d="M54.6 40.2 A24 24 0 0 1 40.2 54.6" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||||
|
<path d="M23.8 54.6 A24 24 0 0 1 9.45 40.2" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||||
|
<path d="M9.45 23.8 A24 24 0 0 1 23.8 9.45" stroke="currentColor" stroke-width="6.4" stroke-linecap="round"></path>
|
||||||
|
<path d="M32 16V24M32 40V48M16 32H24M40 32H48" stroke="currentColor" stroke-width="3.6" stroke-linecap="round"></path>
|
||||||
|
<circle cx="32" cy="32" r="4.4" fill="currentColor"></circle>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 770 B |
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onErrorCaptured } from 'vue'
|
import { ref, onErrorCaptured } from 'vue'
|
||||||
import { AlertTriangle } from 'lucide-vue-next'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
|
||||||
const hasError = ref(false)
|
const hasError = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
@@ -20,18 +21,67 @@ function retry() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="hasError" class="min-h-screen bg-neutral-950 flex items-center justify-center p-6">
|
<div v-if="hasError" class="eb-screen">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-8 max-w-md w-full text-center">
|
<div class="eb-card">
|
||||||
<AlertTriangle class="w-12 h-12 text-red-500 mx-auto mb-4" />
|
<div class="eb-icon-wrap">
|
||||||
<h1 class="text-xl font-bold text-neutral-100 mb-2">Something went wrong</h1>
|
<Icon name="triangle-alert" :size="24" :stroke-width="1.75" />
|
||||||
<p class="text-sm text-neutral-400 mb-6">{{ errorMessage }}</p>
|
</div>
|
||||||
<button
|
<h1 class="eb-title">Something went wrong</h1>
|
||||||
@click="retry"
|
<p class="eb-msg">{{ errorMessage }}</p>
|
||||||
class="px-4 py-2 bg-oxide-500 hover:bg-oxide-600 text-white font-medium rounded-lg transition-colors"
|
<Button icon="refresh-cw" @click="retry">Retry</Button>
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot v-else />
|
<slot v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.eb-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-card {
|
||||||
|
background: var(--surface-base);
|
||||||
|
box-shadow: var(--ring-default), var(--shadow-md);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-8);
|
||||||
|
max-width: 380px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-icon-wrap {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--status-offline-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--status-offline-border);
|
||||||
|
color: var(--status-offline);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eb-msg {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.55;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
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>
|
||||||
81
frontend/src/components/ds/core/Icon.vue
Normal file
81
frontend/src/components/ds/core/Icon.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<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,
|
||||||
|
Ban, Flag,
|
||||||
|
CircleAlert, ArrowDown, Award, DollarSign, FlaskConical, Mail, Package,
|
||||||
|
Pencil, Save, ShoppingBag, Target, User,
|
||||||
|
} 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,
|
||||||
|
ban: Ban, flag: Flag,
|
||||||
|
'alert-circle': CircleAlert, 'arrow-down': ArrowDown, award: Award,
|
||||||
|
'dollar-sign': DollarSign, 'flask-conical': FlaskConical, mail: Mail,
|
||||||
|
package: Package, pencil: Pencil, save: Save, 'shopping-bag': ShoppingBag,
|
||||||
|
target: Target, user: User,
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
<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 { ref } from 'vue'
|
||||||
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import {
|
import { useThemeGame } from '@/composables/useThemeGame'
|
||||||
LayoutDashboard,
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
Server,
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
Terminal,
|
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||||
Users,
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
Puzzle,
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
RefreshCw,
|
import Avatar from '@/components/ds/data/Avatar.vue'
|
||||||
Map,
|
import NavItem from '@/components/ds/navigation/NavItem.vue'
|
||||||
MessageSquare,
|
import GameSwitcher from '@/components/ds/navigation/GameSwitcher.vue'
|
||||||
BarChart3,
|
import type { GameOption } from '@/components/ds/navigation/GameSwitcher.vue'
|
||||||
Bell,
|
import type { ActiveGame } from '@/composables/useThemeGame'
|
||||||
UserPlus,
|
|
||||||
ShoppingBag,
|
|
||||||
Package,
|
|
||||||
Settings,
|
|
||||||
LogOut,
|
|
||||||
Shield,
|
|
||||||
Key,
|
|
||||||
CreditCard,
|
|
||||||
Network,
|
|
||||||
Clock,
|
|
||||||
AlertTriangle,
|
|
||||||
FileText,
|
|
||||||
FolderOpen,
|
|
||||||
Menu,
|
|
||||||
X,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
|
// ---- Stores / composables ----
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
const sidebarOpen = ref(false)
|
const { theme, activeGame, setActiveGame, toggleTheme } = useThemeGame()
|
||||||
|
|
||||||
type NavItem = { name: string; path: string; icon: any; permission: string | null }
|
// ---- Mobile sidebar ----
|
||||||
type NavSection = { label: string; items: NavItem[] }
|
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[] = [
|
const navSections: NavSection[] = [
|
||||||
{
|
{
|
||||||
label: '',
|
label: '',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Dashboard', path: '/', icon: LayoutDashboard, permission: null },
|
{ name: 'Dashboard', path: '/', icon: 'layout-dashboard', permission: null },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Server',
|
label: 'Server',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Server', path: '/server', icon: Server, permission: 'server.view' },
|
{ name: 'Server', path: '/server', icon: 'server', permission: 'server.view' },
|
||||||
{ name: 'Console', path: '/console', icon: Terminal, permission: 'console.view' },
|
{ name: 'Console', path: '/console', icon: 'terminal', permission: 'console.view' },
|
||||||
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
|
{ name: 'Players', path: '/players', icon: 'users', permission: 'players.view' },
|
||||||
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
|
{ name: 'Plugins', path: '/plugins', icon: 'puzzle', permission: 'plugins.view' },
|
||||||
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
|
{ name: 'File manager', path: '/files', icon: 'folder-open', permission: 'files.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Plugin Configs',
|
label: 'Plugin configs',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Plugin Configs', path: '/plugin-configs', icon: Puzzle, permission: null },
|
{ name: 'Plugin configs', path: '/plugin-configs', icon: 'puzzle', permission: null },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Operations',
|
label: 'Operations',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
|
{ name: 'Wipe manager', path: '/wipes', icon: 'trash-2', permission: 'wipes.view' },
|
||||||
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
|
{ name: 'Maps', path: '/maps', icon: 'map', permission: 'maps.view' },
|
||||||
{ name: 'Schedules', path: '/schedules', icon: Clock, permission: 'schedules.view' },
|
{ name: 'Schedules', path: '/schedules', icon: 'calendar-clock', permission: 'schedules.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Monitoring',
|
label: 'Monitoring',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
|
{ name: 'Chat log', path: '/chat', icon: 'message-square', permission: 'chat.view' },
|
||||||
{ name: 'Analytics', path: '/analytics', icon: BarChart3, permission: 'analytics.view' },
|
{ name: 'Analytics', path: '/analytics', icon: 'bar-chart-3', permission: 'analytics.view' },
|
||||||
{ name: 'Alerts', path: '/alerts', icon: AlertTriangle, permission: 'alerts.view' },
|
{ name: 'Alerts', path: '/alerts', icon: 'triangle-alert', permission: 'alerts.view' },
|
||||||
{ name: 'Notifications', path: '/notifications', icon: Bell, permission: 'notifications.view' },
|
{ name: 'Notifications', path: '/notifications', icon: 'bell', permission: 'notifications.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Management',
|
label: 'Management',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Team', path: '/team', icon: UserPlus, permission: null },
|
{ name: 'Team', path: '/team', icon: 'users', permission: null },
|
||||||
{ name: 'Store', path: '/store/config', icon: ShoppingBag, permission: 'store.view' },
|
{ name: 'Store', path: '/store/config', icon: 'shopping-cart', permission: 'store.view' },
|
||||||
{ name: 'Modules', path: '/modules', icon: Package, permission: 'modules.view' },
|
{ name: 'Modules', path: '/modules', icon: 'layers', permission: 'modules.view' },
|
||||||
{ name: 'Changelog', path: '/changelog', icon: FileText, permission: 'changelog.view' },
|
{ name: 'Changelog', path: '/changelog', icon: 'file-text', permission: 'changelog.view' },
|
||||||
{ name: 'Settings', path: '/settings', icon: Settings, permission: 'settings.view' },
|
{ name: 'Settings', path: '/settings', icon: 'settings', permission: 'settings.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
{ name: 'Admin Home', path: '/admin', icon: Shield },
|
{ name: 'Admin home', path: '/admin', icon: 'shield' },
|
||||||
{ name: 'Licenses', path: '/admin/licenses', icon: Key },
|
{ name: 'Licenses', path: '/admin/licenses', icon: 'key' },
|
||||||
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: CreditCard },
|
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: 'credit-card' },
|
||||||
{ name: 'Users', path: '/admin/users', icon: Users },
|
{ name: 'Users', path: '/admin/users', icon: 'users' },
|
||||||
{ name: 'Server Fleet', path: '/admin/servers', icon: Network },
|
{ name: 'Server fleet', path: '/admin/servers', icon: 'server' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function isActive(path: string): boolean {
|
function isActive(path: string): boolean {
|
||||||
@@ -105,16 +122,12 @@ function isActive(path: string): boolean {
|
|||||||
return route.path.startsWith(path)
|
return route.path.startsWith(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
function navigate(path: string) {
|
||||||
auth.logout()
|
router.push(path)
|
||||||
router.push('/login')
|
closeSidebar()
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSidebar() {
|
function canShowNavItem(item: NavItemDef): boolean {
|
||||||
sidebarOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function canShowNavItem(item: NavItem): boolean {
|
|
||||||
if (!item.permission) return true
|
if (!item.permission) return true
|
||||||
return auth.hasPermission(item.permission)
|
return auth.hasPermission(item.permission)
|
||||||
}
|
}
|
||||||
@@ -122,134 +135,486 @@ function canShowNavItem(item: NavItem): boolean {
|
|||||||
function hasVisibleItems(section: NavSection): boolean {
|
function hasVisibleItems(section: NavSection): boolean {
|
||||||
return section.items.some(canShowNavItem)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen bg-neutral-950">
|
<!-- Outer app grid: sidebar | main -->
|
||||||
<!-- Mobile Hamburger -->
|
<div class="app">
|
||||||
<button
|
<!-- ===================================================== SIDEBAR ===== -->
|
||||||
@click="sidebarOpen = true"
|
<!-- Mobile overlay -->
|
||||||
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) -->
|
|
||||||
<div
|
<div
|
||||||
v-if="sidebarOpen"
|
v-if="sidebarOpen"
|
||||||
|
class="sidebar-overlay"
|
||||||
@click="closeSidebar"
|
@click="closeSidebar"
|
||||||
class="md:hidden fixed inset-0 bg-black/50 z-40"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside
|
<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="app__sidebar"
|
||||||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
|
:class="sidebarOpen ? 'app__sidebar--open' : ''"
|
||||||
>
|
>
|
||||||
<!-- Logo -->
|
<!-- Brand -->
|
||||||
<div class="p-4 border-b border-neutral-800">
|
<div class="side__brand">
|
||||||
<div class="flex items-center justify-between">
|
<Logo :size="22" />
|
||||||
<div class="flex items-center gap-3">
|
<Badge tone="neutral" :mono="true" class="side__ver">{{ APP_VERSION }}</Badge>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Server Status Indicator -->
|
<!-- Active game switcher -->
|
||||||
<div class="px-4 py-3 border-b border-neutral-800">
|
<div class="side__game">
|
||||||
<div class="flex items-center gap-2">
|
<div class="t-eyebrow side__lbl">
|
||||||
<div
|
Active game · {{ GAME_LABEL[activeGame] ?? 'All games' }}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<GameSwitcher
|
||||||
|
:model-value="activeGame"
|
||||||
|
:games="GAME_OPTIONS"
|
||||||
|
:show-labels="false"
|
||||||
|
@update:model-value="onActiveGame"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- 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-for="section in navSections" :key="section.label">
|
||||||
<template v-if="hasVisibleItems(section)">
|
<template v-if="hasVisibleItems(section)">
|
||||||
<!-- Section Header -->
|
<div class="side__sec">
|
||||||
<div v-if="section.label" class="mt-4 mb-1 px-4">
|
<div v-if="section.label" class="t-eyebrow side__lbl">{{ section.label }}</div>
|
||||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-neutral-500">{{ section.label }}</span>
|
<NavItem
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Section Items -->
|
|
||||||
<RouterLink
|
|
||||||
v-for="item in section.items"
|
v-for="item in section.items"
|
||||||
v-show="canShowNavItem(item)"
|
v-show="canShowNavItem(item)"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
:to="item.path"
|
:icon="item.icon"
|
||||||
@click="closeSidebar"
|
:label="item.name"
|
||||||
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
:active="isActive(item.path)"
|
||||||
:class="isActive(item.path)
|
@click="navigate(item.path)"
|
||||||
? 'bg-oxide-500/10 text-oxide-400'
|
/>
|
||||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
</div>
|
||||||
>
|
|
||||||
<component :is="item.icon" class="w-4 h-4" />
|
|
||||||
{{ item.name }}
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Platform Admin Section (super-admin only) -->
|
<!-- Platform admin section (super-admin only) -->
|
||||||
<template v-if="auth.isSuperAdmin">
|
<div v-if="auth.isSuperAdmin" class="side__sec">
|
||||||
<div class="mt-4 mb-1 px-4">
|
<div class="t-eyebrow side__lbl side__lbl--platform">Platform</div>
|
||||||
<span class="text-[10px] font-semibold uppercase tracking-widest text-oxide-500">Platform</span>
|
<NavItem
|
||||||
</div>
|
|
||||||
<RouterLink
|
|
||||||
v-for="item in adminNavItems"
|
v-for="item in adminNavItems"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
:to="item.path"
|
:icon="item.icon"
|
||||||
@click="closeSidebar"
|
:label="item.name"
|
||||||
class="flex items-center gap-3 px-4 py-2 mx-2 rounded-lg text-sm transition-colors"
|
:active="isActive(item.path)"
|
||||||
:class="isActive(item.path)
|
@click="navigate(item.path)"
|
||||||
? 'bg-oxide-500/10 text-oxide-400'
|
/>
|
||||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
</div>
|
||||||
>
|
|
||||||
<component :is="item.icon" class="w-4 h-4" />
|
|
||||||
{{ item.name }}
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User -->
|
<!-- Agent health footer -->
|
||||||
<div class="p-4 border-t border-neutral-800">
|
<div class="side__foot">
|
||||||
<div class="flex items-center justify-between">
|
<div class="agent">
|
||||||
<div>
|
<div class="agent__row">
|
||||||
<p class="text-sm text-neutral-300">{{ auth.user?.username }}</p>
|
<StatusDot :tone="agentTone" :pulse="agentTone === 'online'" />
|
||||||
<p class="text-xs text-neutral-500">{{ auth.user?.email }}</p>
|
<span class="agent__name">{{ agentName }}</span>
|
||||||
|
<Badge :tone="agentTone" size="md">{{ agentLabel }}</Badge>
|
||||||
</div>
|
</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
|
<button
|
||||||
@click="handleLogout"
|
type="button"
|
||||||
class="text-neutral-500 hover:text-oxide-400 transition-colors"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content (offset by sidebar width on desktop) -->
|
<!-- ======================================================= MAIN ===== -->
|
||||||
<main class="flex-1 overflow-y-auto md:pl-64">
|
<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 />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-neutral-950">
|
<div class="pub-shell">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|
||||||
<footer class="py-6 text-center text-neutral-600 text-sm border-t border-neutral-800">
|
<footer class="pub-footer">
|
||||||
<div class="flex items-center justify-center gap-2">
|
<Logo :size="18" :wordmark="true" />
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-4 w-4 opacity-60" />
|
|
||||||
<span>Powered by <span class="text-oxide-500 font-semibold">Corrosion</span></span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pub-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pub-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: var(--space-6) var(--space-6);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { rustContainers, containerCategories } from '@/data/rust-containers'
|
import { rustContainers, containerCategories } from '@/data/rust-containers'
|
||||||
import { Search, Box, Cylinder, Shield, Users, HelpCircle } from 'lucide-vue-next'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import DsInput from '@/components/ds/forms/Input.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
lootTable: Record<string, any>
|
lootTable: Record<string, any>
|
||||||
@@ -14,20 +15,21 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
const categoryIcons: Record<string, any> = {
|
// Map container categories to DS icon names
|
||||||
crates: Box,
|
const categoryIcons: Record<string, string> = {
|
||||||
barrels: Cylinder,
|
crates: 'box',
|
||||||
military: Shield,
|
barrels: 'flask-conical',
|
||||||
npcs: Users,
|
military: 'shield',
|
||||||
other: HelpCircle,
|
npcs: 'users',
|
||||||
|
other: 'info',
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
const categoryLabels: Record<string, string> = {
|
||||||
crates: 'CRATES',
|
crates: 'Crates',
|
||||||
barrels: 'BARRELS',
|
barrels: 'Barrels',
|
||||||
military: 'MILITARY',
|
military: 'Military',
|
||||||
npcs: 'NPCs',
|
npcs: 'NPCs',
|
||||||
other: 'OTHER',
|
other: 'Other',
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredContainers = computed(() => {
|
const filteredContainers = computed(() => {
|
||||||
@@ -56,48 +58,136 @@ function isConfigured(prefab: string): boolean {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-64 shrink-0 bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden flex flex-col">
|
<aside class="lcs-root">
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="p-3 border-b border-neutral-800">
|
<div class="lcs-search">
|
||||||
<div class="relative">
|
<DsInput
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="Search containers..."
|
icon="search"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200 placeholder-neutral-500"
|
placeholder="Search containers…"
|
||||||
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Container list -->
|
||||||
|
<div class="lcs-list">
|
||||||
|
<template v-for="(containers, category) in groupedContainers" :key="category">
|
||||||
|
<!-- Category heading -->
|
||||||
|
<div class="lcs-cat">
|
||||||
|
<Icon
|
||||||
|
:name="categoryIcons[category] ?? 'box'"
|
||||||
|
:size="12"
|
||||||
|
class="lcs-cat__icon"
|
||||||
|
/>
|
||||||
|
<span class="lcs-cat__label">{{ categoryLabels[category] ?? category }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Container List -->
|
<!-- Container rows -->
|
||||||
<div class="flex-1 overflow-y-auto py-2">
|
|
||||||
<template v-for="(containers, category) in groupedContainers" :key="category">
|
|
||||||
<div class="px-3 pt-3 pb-1">
|
|
||||||
<div class="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
|
|
||||||
<component :is="categoryIcons[category]" class="w-3 h-3" />
|
|
||||||
{{ categoryLabels[category] || category }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
v-for="c in containers"
|
v-for="c in containers"
|
||||||
:key="c.prefab"
|
:key="c.prefab"
|
||||||
|
class="lcs-item"
|
||||||
|
:class="{ 'lcs-item--active': selected === c.prefab }"
|
||||||
@click="emit('select', c.prefab)"
|
@click="emit('select', c.prefab)"
|
||||||
class="w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 transition-colors"
|
|
||||||
:class="selected === c.prefab
|
|
||||||
? 'bg-oxide-500/10 text-oxide-400'
|
|
||||||
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
|
||||||
>
|
>
|
||||||
<span class="truncate flex-1">{{ c.name }}</span>
|
<span class="lcs-item__name">{{ c.name }}</span>
|
||||||
<span
|
<span v-if="isConfigured(c.prefab)" class="lcs-item__dot" />
|
||||||
v-if="isConfigured(c.prefab)"
|
|
||||||
class="w-1.5 h-1.5 rounded-full bg-oxide-500 shrink-0"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-if="Object.keys(groupedContainers).length === 0" class="px-3 py-6 text-center text-neutral-500 text-sm">
|
<div v-if="Object.keys(groupedContainers).length === 0" class="lcs-empty">
|
||||||
No containers match
|
No containers match
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lcs-root {
|
||||||
|
width: 240px;
|
||||||
|
flex: none;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lcs-search {
|
||||||
|
padding: 10px 10px 8px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lcs-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category heading */
|
||||||
|
.lcs-cat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 12px 4px;
|
||||||
|
}
|
||||||
|
.lcs-cat__icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.lcs-cat__label {
|
||||||
|
font-size: var(--text-2xs, 10px);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container row */
|
||||||
|
.lcs-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: left;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.lcs-item:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lcs-item--active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
.lcs-item__name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lcs-item__dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lcs-empty {
|
||||||
|
padding: 20px 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { rustItems } from '@/data/rust-items'
|
import { rustItems } from '@/data/rust-items'
|
||||||
import { Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import DsInput from '@/components/ds/forms/Input.vue'
|
||||||
import type { LootGroupProfile } from '@/types'
|
import type { LootGroupProfile } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -65,120 +71,260 @@ function updateGroupItemField(groupName: string, shortname: string, field: strin
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="lge-root">
|
||||||
<!-- Add Group -->
|
<!-- Add group panel -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel>
|
||||||
<div class="flex gap-2">
|
<div class="lge-add">
|
||||||
<input
|
<DsInput
|
||||||
v-model="newGroupName"
|
v-model="newGroupName"
|
||||||
placeholder="New group name..."
|
placeholder="New group name…"
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
|
||||||
@keydown.enter="addGroup"
|
@keydown.enter="addGroup"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
@click="addGroup"
|
icon="plus"
|
||||||
:disabled="!newGroupName.trim()"
|
:disabled="!newGroupName.trim()"
|
||||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
@click="addGroup"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4" />
|
Add group
|
||||||
Add Group
|
</Button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Group List -->
|
<!-- Empty state -->
|
||||||
<div v-if="groupEntries.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
<Panel v-if="groupEntries.length === 0">
|
||||||
No loot groups defined. Groups let you create reusable item pools that can be assigned to multiple containers.
|
<EmptyState
|
||||||
</div>
|
icon="layers"
|
||||||
|
title="No loot groups"
|
||||||
|
description="Groups let you create reusable item pools that can be assigned to multiple containers."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Group cards -->
|
||||||
<div
|
<div
|
||||||
v-for="entry in groupEntries"
|
v-for="entry in groupEntries"
|
||||||
:key="entry.name"
|
:key="entry.name"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden"
|
class="lge-card"
|
||||||
>
|
>
|
||||||
<!-- Group Header -->
|
<!-- Group header -->
|
||||||
<button
|
<button
|
||||||
|
class="lge-card__head"
|
||||||
@click="toggleGroup(entry.name)"
|
@click="toggleGroup(entry.name)"
|
||||||
class="w-full flex items-center justify-between px-4 py-3 hover:bg-neutral-800/50 transition-colors"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<Icon
|
||||||
<component
|
:name="expandedGroup === entry.name ? 'chevron-down' : 'chevron-right'"
|
||||||
:is="expandedGroup === entry.name ? ChevronDown : ChevronRight"
|
:size="16"
|
||||||
class="w-4 h-4 text-neutral-500"
|
class="lge-card__chevron"
|
||||||
/>
|
/>
|
||||||
<span class="text-neutral-200 font-medium">{{ entry.name }}</span>
|
<span class="lge-card__name">{{ entry.name }}</span>
|
||||||
<span class="text-xs text-neutral-500">{{ entry.itemCount }} items</span>
|
<Badge tone="neutral" mono>{{ entry.itemCount }}</Badge>
|
||||||
</div>
|
<IconButton
|
||||||
<button
|
icon="trash-2"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
label="Delete group"
|
||||||
|
class="lge-card__del"
|
||||||
@click.stop="deleteGroup(entry.name)"
|
@click.stop="deleteGroup(entry.name)"
|
||||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
/>
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Group Items -->
|
<!-- Expanded items -->
|
||||||
<div v-if="expandedGroup === entry.name" class="border-t border-neutral-800 p-4">
|
<div v-if="expandedGroup === entry.name" class="lge-card__body">
|
||||||
<table v-if="entry.itemCount > 0" class="w-full text-sm">
|
<table v-if="entry.itemCount > 0" class="lge-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800">
|
<tr>
|
||||||
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
<th class="lge-th">Item</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
<th class="lge-th lge-th--num">Min</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
<th class="lge-th lge-th--num">Max</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
<th class="lge-th lge-th--num">Prob %</th>
|
||||||
<th class="w-10"></th>
|
<th class="lge-th lge-th--action"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="(itemData, shortname) in entry.data.ItemList"
|
v-for="(itemData, shortname) in entry.data.ItemList"
|
||||||
:key="shortname"
|
:key="shortname"
|
||||||
class="border-b border-neutral-800/50"
|
class="lge-tr"
|
||||||
>
|
>
|
||||||
<td class="py-2 px-2 text-neutral-200">{{ getItemName(shortname as string) }}</td>
|
<td class="lge-td">{{ getItemName(shortname as string) }}</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lge-td lge-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="(itemData as any).Min ?? 1"
|
:value="(itemData as any).Min ?? 1"
|
||||||
@input="updateGroupItemField(entry.name, shortname as string, 'Min', Number(($event.target as HTMLInputElement).value))"
|
@input="updateGroupItemField(entry.name, shortname as string, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lge-td lge-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="(itemData as any).Max ?? 1"
|
:value="(itemData as any).Max ?? 1"
|
||||||
@input="updateGroupItemField(entry.name, shortname as string, 'Max', Number(($event.target as HTMLInputElement).value))"
|
@input="updateGroupItemField(entry.name, shortname as string, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lge-td lge-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="(itemData as any).Probability ?? 100"
|
:value="(itemData as any).Probability ?? 100"
|
||||||
@input="updateGroupItemField(entry.name, shortname as string, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
@input="updateGroupItemField(entry.name, shortname as string, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lge-td lge-td--action">
|
||||||
<button
|
<IconButton
|
||||||
|
icon="trash-2"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
label="Remove item"
|
||||||
@click="removeItemFromGroup(entry.name, shortname as string)"
|
@click="removeItemFromGroup(entry.name, shortname as string)"
|
||||||
class="text-neutral-600 hover:text-red-400"
|
/>
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p v-else class="text-neutral-500 text-sm text-center py-4">
|
<p v-else class="lge-card__empty">
|
||||||
No items in this group yet. Add items from the container editor.
|
No items in this group yet. Add items from the container editor.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lge-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add group row */
|
||||||
|
.lge-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.lge-add > :first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group card */
|
||||||
|
.lge-card {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.lge-card__head {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
text-align: left;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.lge-card__head:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.lge-card__chevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.lge-card__name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lge-card__del {
|
||||||
|
margin-left: auto;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded body */
|
||||||
|
.lge-card__body {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.lge-card__empty {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.lge-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.lge-th {
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lge-th--num {
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.lge-th--action {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
.lge-tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.lge-tr:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.lge-tr:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.lge-td {
|
||||||
|
padding: 7px 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.lge-td--num {
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.lge-td--action {
|
||||||
|
width: 40px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared number input (same as LootItemEditor) */
|
||||||
|
.cc-num-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-num-input:focus {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||||
|
}
|
||||||
|
.cc-num-input--center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { rustItems } from '@/data/rust-items'
|
import { rustItems } from '@/data/rust-items'
|
||||||
import { rustContainers } from '@/data/rust-containers'
|
import { rustContainers } from '@/data/rust-containers'
|
||||||
import { Trash2, Plus, Settings2 } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
import type { PrefabLoot } from '@/types'
|
import type { PrefabLoot } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -76,157 +81,273 @@ const ungroupedItems = computed(() => {
|
|||||||
...(data as any),
|
...(data as any),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Computed boolean for the Switch v-model
|
||||||
|
const isEnabled = computed({
|
||||||
|
get: () => containerData.value?.Enabled ?? true,
|
||||||
|
set: () => toggleEnabled(),
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="lie-root">
|
||||||
<!-- Container Header -->
|
<!-- Container settings panel -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel :title="containerName">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<template #actions>
|
||||||
<div class="flex items-center gap-3">
|
<Badge tone="neutral" mono class="lie-prefab">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100">{{ containerName }}</h2>
|
{{ containerKey.split('/').pop() }}
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
</Badge>
|
||||||
<input
|
<Switch v-model="isEnabled" label="Enabled" size="sm" />
|
||||||
type="checkbox"
|
</template>
|
||||||
:checked="containerData?.Enabled ?? true"
|
|
||||||
@change="toggleEnabled"
|
|
||||||
class="rounded bg-neutral-800 border-neutral-600 text-oxide-500 focus:ring-oxide-500"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-neutral-400">Enabled</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Settings2 class="w-4 h-4 text-neutral-500" />
|
|
||||||
<span class="text-xs text-neutral-500 font-mono">{{ containerKey.split('/').pop() }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item Settings -->
|
<!-- Item settings grid -->
|
||||||
<div class="grid grid-cols-4 gap-3" v-if="containerData">
|
<div v-if="containerData" class="lie-settings">
|
||||||
<div>
|
<div class="lie-setting">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Items Min</label>
|
<label class="lie-setting__label">Items min</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="containerData.ItemSettings?.ItemsMin ?? 1"
|
:value="containerData.ItemSettings?.ItemsMin ?? 1"
|
||||||
@input="updateSettings('ItemsMin', Number(($event.target as HTMLInputElement).value))"
|
@input="updateSettings('ItemsMin', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
class="cc-num-input"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="lie-setting">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Items Max</label>
|
<label class="lie-setting__label">Items max</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="containerData.ItemSettings?.ItemsMax ?? 6"
|
:value="containerData.ItemSettings?.ItemsMax ?? 6"
|
||||||
@input="updateSettings('ItemsMax', Number(($event.target as HTMLInputElement).value))"
|
@input="updateSettings('ItemsMax', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
class="cc-num-input"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="lie-setting">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Min Scrap</label>
|
<label class="lie-setting__label">Min scrap</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="containerData.ItemSettings?.MinScrap ?? 0"
|
:value="containerData.ItemSettings?.MinScrap ?? 0"
|
||||||
@input="updateSettings('MinScrap', Number(($event.target as HTMLInputElement).value))"
|
@input="updateSettings('MinScrap', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
class="cc-num-input"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="lie-setting">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Max Scrap</label>
|
<label class="lie-setting__label">Max scrap</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="containerData.ItemSettings?.MaxScrap ?? 0"
|
:value="containerData.ItemSettings?.MaxScrap ?? 0"
|
||||||
@input="updateSettings('MaxScrap', Number(($event.target as HTMLInputElement).value))"
|
@input="updateSettings('MaxScrap', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
class="cc-num-input"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p v-else class="lie-unconfigured">
|
||||||
|
Container not yet configured. Add an item to initialise its settings.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Ungrouped Items Table -->
|
<!-- Ungrouped items panel -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel title="Ungrouped items" :flush-body="ungroupedItems.length > 0">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<template #actions>
|
||||||
<h3 class="text-sm font-semibold text-neutral-300">Ungrouped Items</h3>
|
<Button size="sm" variant="outline" icon="plus" @click="emit('add-item')">
|
||||||
<button
|
Add item
|
||||||
@click="emit('add-item')"
|
</Button>
|
||||||
class="flex items-center gap-1 px-3 py-1.5 bg-oxide-500/10 text-oxide-400 rounded-lg hover:bg-oxide-500/20 text-sm"
|
</template>
|
||||||
>
|
|
||||||
<Plus class="w-3.5 h-3.5" />
|
|
||||||
Add Item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="ungroupedItems.length > 0" class="overflow-x-auto">
|
<div v-if="ungroupedItems.length > 0" class="lie-table-wrap">
|
||||||
<table class="w-full text-sm">
|
<table class="lie-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800">
|
<tr>
|
||||||
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
<th class="lie-th">Item</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
<th class="lie-th lie-th--num">Min</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
<th class="lie-th lie-th--num">Max</th>
|
||||||
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
<th class="lie-th lie-th--num">Prob %</th>
|
||||||
<th class="w-10"></th>
|
<th class="lie-th lie-th--action"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="item in ungroupedItems"
|
v-for="item in ungroupedItems"
|
||||||
:key="item.shortname"
|
:key="item.shortname"
|
||||||
class="border-b border-neutral-800/50 hover:bg-neutral-800/30"
|
class="lie-tr"
|
||||||
>
|
>
|
||||||
<td class="py-2 px-2">
|
<td class="lie-td">
|
||||||
<div>
|
<span class="lie-item-name">{{ item.name }}</span>
|
||||||
<span class="text-neutral-200">{{ item.name }}</span>
|
<span class="lie-item-short">{{ item.shortname }}</span>
|
||||||
<span class="text-neutral-600 text-xs ml-2">{{ item.shortname }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lie-td lie-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="item.Min"
|
:value="item.Min"
|
||||||
@input="updateItemField(item.shortname, 'Min', Number(($event.target as HTMLInputElement).value))"
|
@input="updateItemField(item.shortname, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lie-td lie-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="item.Max"
|
:value="item.Max"
|
||||||
@input="updateItemField(item.shortname, 'Max', Number(($event.target as HTMLInputElement).value))"
|
@input="updateItemField(item.shortname, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lie-td lie-td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="item.Probability ?? 100"
|
:value="item.Probability ?? 100"
|
||||||
@input="updateItemField(item.shortname, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
@input="updateItemField(item.shortname, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
class="cc-num-input cc-num-input--center"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-2">
|
<td class="lie-td lie-td--action">
|
||||||
<button
|
<IconButton
|
||||||
|
icon="trash-2"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
label="Remove item"
|
||||||
@click="removeItem(item.shortname)"
|
@click="removeItem(item.shortname)"
|
||||||
class="text-neutral-600 hover:text-red-400 transition-colors"
|
/>
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="text-center py-6 text-neutral-500 text-sm">
|
<EmptyState
|
||||||
No items configured for this container.
|
v-else
|
||||||
<button @click="emit('add-item')" class="text-oxide-400 hover:underline ml-1">Add one</button>
|
icon="package"
|
||||||
</div>
|
title="No items configured"
|
||||||
</div>
|
description="Add items to configure what this container can spawn."
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button size="sm" variant="outline" icon="plus" @click="emit('add-item')">
|
||||||
|
Add item
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lie-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge for prefab key */
|
||||||
|
.lie-prefab {
|
||||||
|
font-size: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lie-unconfigured {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings grid */
|
||||||
|
.lie-settings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.lie-setting {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.lie-setting__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.lie-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.lie-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.lie-th {
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.lie-th--num {
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.lie-th--action {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
.lie-tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.lie-tr:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.lie-tr:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.lie-td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.lie-td--num {
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.lie-td--action {
|
||||||
|
width: 40px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.lie-item-name {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lie-item-short {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared number input */
|
||||||
|
.cc-num-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-num-input:focus {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||||
|
}
|
||||||
|
.cc-num-input--center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { rustItems, itemCategories } from '@/data/rust-items'
|
import { rustItems, itemCategories } from '@/data/rust-items'
|
||||||
import { Search, X } from 'lucide-vue-next'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import DsInput from '@/components/ds/forms/Input.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [shortname: string]
|
select: [shortname: string]
|
||||||
@@ -25,64 +27,200 @@ const filteredItems = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="emit('close')">
|
<Teleport to="body">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
<div class="lip-overlay" @click.self="emit('close')">
|
||||||
|
<div class="lip-modal">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="p-4 border-b border-neutral-800 flex items-center justify-between">
|
<div class="lip-head">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100">Add Item</h2>
|
<span class="lip-head__title">Add item</span>
|
||||||
<button @click="emit('close')" class="text-neutral-500 hover:text-neutral-300">
|
<IconButton icon="x" size="sm" label="Close" @click="emit('close')" />
|
||||||
<X class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search + Filter -->
|
<!-- Search + category filter -->
|
||||||
<div class="p-4 space-y-3 border-b border-neutral-800">
|
<div class="lip-filters">
|
||||||
<div class="relative">
|
<DsInput
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="Search items..."
|
icon="search"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200"
|
placeholder="Search items…"
|
||||||
autofocus
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div class="lip-cats">
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
<button
|
<button
|
||||||
|
class="lip-cat"
|
||||||
|
:class="{ 'lip-cat--active': selectedCategory === 'all' }"
|
||||||
@click="selectedCategory = 'all'"
|
@click="selectedCategory = 'all'"
|
||||||
class="px-2 py-1 rounded text-xs"
|
|
||||||
:class="selectedCategory === 'all' ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="cat in itemCategories"
|
v-for="cat in itemCategories"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
|
class="lip-cat"
|
||||||
|
:class="{ 'lip-cat--active': selectedCategory === cat }"
|
||||||
@click="selectedCategory = cat"
|
@click="selectedCategory = cat"
|
||||||
class="px-2 py-1 rounded text-xs capitalize"
|
|
||||||
:class="selectedCategory === cat ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
>
|
||||||
{{ cat }}
|
{{ cat }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Item Grid -->
|
<!-- Item grid -->
|
||||||
<div class="flex-1 overflow-y-auto p-4">
|
<div class="lip-grid-wrap">
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
<div v-if="filteredItems.length > 0" class="lip-grid">
|
||||||
<button
|
<button
|
||||||
v-for="item in filteredItems"
|
v-for="item in filteredItems"
|
||||||
:key="item.shortname"
|
:key="item.shortname"
|
||||||
|
class="lip-item"
|
||||||
@click="emit('select', item.shortname)"
|
@click="emit('select', item.shortname)"
|
||||||
class="text-left px-3 py-2 bg-neutral-800 rounded-lg hover:bg-neutral-700 transition-colors group"
|
|
||||||
>
|
>
|
||||||
<div class="text-sm text-neutral-200 group-hover:text-oxide-400">{{ item.name }}</div>
|
<span class="lip-item__name">{{ item.name }}</span>
|
||||||
<div class="text-xs text-neutral-500">{{ item.shortname }}</div>
|
<span class="lip-item__short">{{ item.shortname }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filteredItems.length === 0" class="text-center py-8 text-neutral-500">
|
<div v-else class="lip-empty">
|
||||||
No items found
|
<Icon name="search" :size="20" class="lip-empty__icon" />
|
||||||
|
<span>No items found</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lip-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lip-modal {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.lip-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.lip-head__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.lip-filters {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.lip-cats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.lip-cat {
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: capitalize;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.lip-cat:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lip-cat--active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.lip-cat--active:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
|
.lip-grid-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.lip-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.lip-item {
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.lip-item:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.lip-item__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.lip-item:hover .lip-item__name {
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
.lip-item__short {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty */
|
||||||
|
.lip-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 48px 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.lip-empty__icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
configData: Record<string, any>
|
configData: Record<string, any>
|
||||||
@@ -47,7 +49,6 @@ function ensurePaths(data: Record<string, any>) {
|
|||||||
function addGroup() {
|
function addGroup() {
|
||||||
const name = newGroupName.value.trim()
|
const name = newGroupName.value.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
// Check if already exists
|
|
||||||
if (groups.value.some(g => g.name === name)) return
|
if (groups.value.some(g => g.name === name)) return
|
||||||
|
|
||||||
const updated = { ...props.configData }
|
const updated = { ...props.configData }
|
||||||
@@ -95,96 +96,95 @@ function updateField(groupName: string, field: string, value: number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="pge">
|
||||||
<div class="flex items-center justify-between">
|
<div class="pge__head">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">VIP Permission Groups</h3>
|
<div class="pge__section-label">VIP permission groups</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Group -->
|
<!-- Add group row -->
|
||||||
<div class="flex gap-2">
|
<div class="pge__add-row">
|
||||||
<input
|
<input
|
||||||
v-model="newGroupName"
|
v-model="newGroupName"
|
||||||
placeholder="New group name (e.g. vip, vip+, mvp)..."
|
type="text"
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
class="pge__name-input"
|
||||||
|
placeholder="New group name (e.g. vip, vip+, mvp)…"
|
||||||
@keydown.enter="addGroup"
|
@keydown.enter="addGroup"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
@click="addGroup"
|
size="sm"
|
||||||
|
icon="plus"
|
||||||
:disabled="!newGroupName.trim()"
|
:disabled="!newGroupName.trim()"
|
||||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
@click="addGroup"
|
||||||
>
|
>Add group</Button>
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
Add Group
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty state -->
|
||||||
<div v-if="groups.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
<EmptyState
|
||||||
No VIP groups defined. Add groups to configure per-permission teleport limits, cooldowns, and countdowns.
|
v-if="groups.length === 0"
|
||||||
</div>
|
icon="users"
|
||||||
|
title="No VIP groups defined"
|
||||||
|
description="Add groups to configure per-permission teleport limits, cooldowns, and countdowns."
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Groups Table -->
|
<!-- Groups table -->
|
||||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
<div v-else class="pge__table-wrap">
|
||||||
<table class="w-full text-sm">
|
<table class="pge__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800">
|
<tr>
|
||||||
<th class="text-left py-3 px-4 text-neutral-500 font-medium">Group Name</th>
|
<th class="pge__th pge__th--left">Group name</th>
|
||||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Homes Limit</th>
|
<th class="pge__th">Homes limit</th>
|
||||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Cooldown (s)</th>
|
<th class="pge__th">Cooldown (s)</th>
|
||||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Countdown (s)</th>
|
<th class="pge__th">Countdown (s)</th>
|
||||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Daily Limit</th>
|
<th class="pge__th">Daily limit</th>
|
||||||
<th class="w-12"></th>
|
<th class="pge__th pge__th--action" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="group in groups"
|
v-for="group in groups"
|
||||||
:key="group.name"
|
:key="group.name"
|
||||||
class="border-b border-neutral-800/50"
|
class="pge__tr"
|
||||||
>
|
>
|
||||||
<td class="py-3 px-4 text-neutral-200 font-medium">{{ group.name }}</td>
|
<td class="pge__td pge__td--name">{{ group.name }}</td>
|
||||||
<td class="py-3 px-4">
|
<td class="pge__td pge__td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
class="pge__num-input"
|
||||||
:value="group.homesLimit"
|
:value="group.homesLimit"
|
||||||
|
min="0"
|
||||||
@input="updateField(group.name, 'homesLimit', Number(($event.target as HTMLInputElement).value))"
|
@input="updateField(group.name, 'homesLimit', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4">
|
<td class="pge__td pge__td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
class="pge__num-input"
|
||||||
:value="group.cooldown"
|
:value="group.cooldown"
|
||||||
|
min="0"
|
||||||
@input="updateField(group.name, 'cooldown', Number(($event.target as HTMLInputElement).value))"
|
@input="updateField(group.name, 'cooldown', Number(($event.target as HTMLInputElement).value))"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4">
|
<td class="pge__td pge__td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
class="pge__num-input"
|
||||||
:value="group.countdown"
|
:value="group.countdown"
|
||||||
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
|
||||||
min="0"
|
min="0"
|
||||||
|
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4">
|
<td class="pge__td pge__td--num">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
class="pge__num-input"
|
||||||
:value="group.dailyLimit"
|
:value="group.dailyLimit"
|
||||||
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
|
||||||
min="0"
|
min="0"
|
||||||
|
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4">
|
<td class="pge__td pge__td--action">
|
||||||
<button
|
<button class="pge__del" type="button" @click="removeGroup(group.name)">
|
||||||
@click="removeGroup(group.name)"
|
<Icon name="trash-2" :size="15" />
|
||||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -193,3 +193,63 @@ function updateField(groupName: string, field: string, value: number) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ---------- Shell ---------- */
|
||||||
|
.pge { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
|
||||||
|
/* ---------- Head ---------- */
|
||||||
|
.pge__head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.pge__section-label {
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Add row ---------- */
|
||||||
|
.pge__add-row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.pge__name-input {
|
||||||
|
flex: 1; height: var(--control-h-sm); padding: 0 10px;
|
||||||
|
background: var(--surface-inset); border: 0; border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--ring-default); font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm); color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.pge__name-input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.pge__name-input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ---------- Table ---------- */
|
||||||
|
.pge__table-wrap { border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--ring-default); }
|
||||||
|
.pge__table { width: 100%; border-collapse: collapse; }
|
||||||
|
.pge__th {
|
||||||
|
padding: 9px 12px; font-size: var(--text-xs); font-weight: 600;
|
||||||
|
color: var(--text-tertiary); text-align: center;
|
||||||
|
background: var(--surface-raised); border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.pge__th--left { text-align: left; }
|
||||||
|
.pge__th--action { width: 44px; }
|
||||||
|
.pge__tr { border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.pge__tr:last-child { border-bottom: 0; }
|
||||||
|
.pge__tr:hover { background: var(--surface-hover); }
|
||||||
|
.pge__td { padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
|
.pge__td--name { font-weight: 500; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.pge__td--num { text-align: center; }
|
||||||
|
.pge__td--action { text-align: center; }
|
||||||
|
|
||||||
|
/* ---------- Number input (table cell) ---------- */
|
||||||
|
.pge__num-input {
|
||||||
|
width: 80px; height: 28px; padding: 0 8px; text-align: center;
|
||||||
|
background: var(--surface-inset); border: 0; border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--ring-default); font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm); color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.pge__num-input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
|
||||||
|
/* ---------- Delete button ---------- */
|
||||||
|
.pge__del {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; border-radius: var(--radius-sm); border: none;
|
||||||
|
background: transparent; color: var(--text-muted); cursor: pointer;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.pge__del:hover { color: var(--danger); background: var(--status-offline-soft); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
warps: Record<string, { x: number; y: number; z: number }>
|
warps: Record<string, { x: number; y: number; z: number }>
|
||||||
@@ -28,49 +30,139 @@ function removeWarp(name: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-4">
|
<div class="warp-editor">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Warps</h3>
|
<div class="warp-editor__label">Warps</div>
|
||||||
|
|
||||||
<!-- Add Warp -->
|
<!-- Add warp row -->
|
||||||
<div class="flex gap-2">
|
<div class="warp-editor__add">
|
||||||
<input
|
<Input
|
||||||
v-model="newWarpName"
|
v-model="newWarpName"
|
||||||
placeholder="Warp name..."
|
placeholder="Warp name..."
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
:mono="true"
|
||||||
|
style="flex: 1"
|
||||||
@keydown.enter="addWarp"
|
@keydown.enter="addWarp"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
@click="addWarp"
|
size="sm"
|
||||||
|
icon="plus"
|
||||||
:disabled="!newWarpName.trim()"
|
:disabled="!newWarpName.trim()"
|
||||||
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
@click="addWarp"
|
||||||
>
|
>Add</Button>
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Warp List -->
|
<!-- Empty state -->
|
||||||
<div v-if="Object.keys(warps).length === 0" class="text-neutral-500 text-sm text-center py-4">
|
<div v-if="Object.keys(warps).length === 0" class="warp-editor__empty">
|
||||||
No warps defined. Add warps here and set coordinates in-game.
|
No warps defined. Add warps here and set coordinates in-game.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Warp list -->
|
||||||
<div
|
<div
|
||||||
v-for="(coords, name) in warps"
|
v-for="(coords, name) in warps"
|
||||||
:key="name"
|
:key="name"
|
||||||
class="flex items-center justify-between bg-neutral-800/50 border border-neutral-700/50 rounded-lg px-4 py-3"
|
class="warp-row"
|
||||||
>
|
>
|
||||||
<div>
|
<div class="warp-row__id">
|
||||||
<span class="text-neutral-200 font-medium">{{ name }}</span>
|
<span class="warp-row__name">{{ name }}</span>
|
||||||
<span class="text-neutral-500 text-xs ml-3">
|
<span class="warp-row__coords">
|
||||||
{{ coords.x.toFixed(1) }}, {{ coords.y.toFixed(1) }}, {{ coords.z.toFixed(1) }}
|
{{ (coords as { x: number; y: number; z: number }).x.toFixed(1) }},
|
||||||
|
{{ (coords as { x: number; y: number; z: number }).y.toFixed(1) }},
|
||||||
|
{{ (coords as { x: number; y: number; z: number }).z.toFixed(1) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
class="warp-row__remove"
|
||||||
|
type="button"
|
||||||
|
:aria-label="`Remove warp ${name}`"
|
||||||
@click="removeWarp(name as string)"
|
@click="removeWarp(name as string)"
|
||||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4" />
|
<Icon name="trash-2" :size="14" :stroke-width="2" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.warp-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-editor__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-editor__add {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-editor__empty {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2-5) var(--space-3);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__id {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__coords {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__remove {
|
||||||
|
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-muted);
|
||||||
|
transition: background var(--dur-fast) var(--ease-standard),
|
||||||
|
color var(--dur-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__remove:hover {
|
||||||
|
background: var(--status-offline-soft);
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warp-row__remove:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
114
frontend/src/composables/useThemeGame.ts
Normal file
114
frontend/src/composables/useThemeGame.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* useThemeGame — the Corrosion design-system theming contract.
|
||||||
|
*
|
||||||
|
* Drives `data-theme` and `data-game` on <html>, the two attributes the token
|
||||||
|
* system keys off (see styles/tokens/colors.css + game-themes.css):
|
||||||
|
* <html data-theme="dark|light" data-game="rust|dune|conan|soulmask|...">
|
||||||
|
*
|
||||||
|
* Dark is primary; Rust (Oxide Orange) is the default/brand accent.
|
||||||
|
*
|
||||||
|
* Runtime swaps add the `cc-skin-swap` class for one frame so every
|
||||||
|
* accent-consuming surface repaints immediately — without it Chrome leaves
|
||||||
|
* elements that read var(--accent) AND have a color/bg transition on the old
|
||||||
|
* accent until the next reflow (see styles/tokens/base.css + readme).
|
||||||
|
*/
|
||||||
|
import { ref, readonly } from 'vue'
|
||||||
|
|
||||||
|
export type Theme = 'dark' | 'light'
|
||||||
|
export type Game =
|
||||||
|
| 'rust'
|
||||||
|
| 'dune'
|
||||||
|
| 'conan'
|
||||||
|
| 'soulmask'
|
||||||
|
| 'ark'
|
||||||
|
| 'valheim'
|
||||||
|
| 'palworld'
|
||||||
|
|
||||||
|
/** The fleet filter: 'all' (every game) plus each individual game. */
|
||||||
|
export type ActiveGame = 'all' | Game
|
||||||
|
|
||||||
|
const THEME_KEY = 'cc-theme'
|
||||||
|
const GAME_KEY = 'cc-game'
|
||||||
|
const ACTIVE_GAME_KEY = 'cc-active-game'
|
||||||
|
|
||||||
|
const VALID_THEMES: readonly Theme[] = ['dark', 'light']
|
||||||
|
const VALID_GAMES: readonly Game[] = [
|
||||||
|
'rust',
|
||||||
|
'dune',
|
||||||
|
'conan',
|
||||||
|
'soulmask',
|
||||||
|
'ark',
|
||||||
|
'valheim',
|
||||||
|
'palworld',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Module-scope singletons so every caller shares one reactive source.
|
||||||
|
const theme = ref<Theme>('dark')
|
||||||
|
const game = ref<Game>('rust')
|
||||||
|
// Fleet filter: 'all' shows every game and uses the neutral house skin (Oxide);
|
||||||
|
// a specific game both filters the fleet AND re-skins the shell (the drill-in rule).
|
||||||
|
const activeGame = ref<ActiveGame>('all')
|
||||||
|
|
||||||
|
function apply(): void {
|
||||||
|
const el = document.documentElement
|
||||||
|
el.classList.add('cc-skin-swap')
|
||||||
|
el.setAttribute('data-theme', theme.value)
|
||||||
|
el.setAttribute('data-game', game.value)
|
||||||
|
// Keep Tailwind's `dark` class in sync — existing views may use `dark:` utilities.
|
||||||
|
el.classList.toggle('dark', theme.value === 'dark')
|
||||||
|
// Drop the swap guard after the paint that picked up the new accent.
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => el.classList.remove('cc-skin-swap')),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialized = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read persisted prefs and apply them to <html>. Call once at app start
|
||||||
|
* (after a tiny inline FOUC guard in index.html has set the initial attrs).
|
||||||
|
*/
|
||||||
|
export function initThemeGame(): void {
|
||||||
|
if (initialized) return
|
||||||
|
const t = localStorage.getItem(THEME_KEY)
|
||||||
|
if (t && (VALID_THEMES as string[]).includes(t)) theme.value = t as Theme
|
||||||
|
const ag = localStorage.getItem(ACTIVE_GAME_KEY)
|
||||||
|
if (ag && (ag === 'all' || (VALID_GAMES as string[]).includes(ag))) {
|
||||||
|
activeGame.value = ag as ActiveGame
|
||||||
|
}
|
||||||
|
// Skin follows the filter: 'all' -> neutral house (rust/oxide), else the game.
|
||||||
|
game.value = activeGame.value === 'all' ? 'rust' : activeGame.value
|
||||||
|
apply()
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemeGame() {
|
||||||
|
function setTheme(t: Theme): void {
|
||||||
|
theme.value = t
|
||||||
|
localStorage.setItem(THEME_KEY, t)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
function setGame(g: Game): void {
|
||||||
|
game.value = g
|
||||||
|
localStorage.setItem(GAME_KEY, g)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
function setActiveGame(g: ActiveGame): void {
|
||||||
|
activeGame.value = g
|
||||||
|
localStorage.setItem(ACTIVE_GAME_KEY, g)
|
||||||
|
// 'all' uses the neutral house skin (rust/oxide); a game re-skins to itself.
|
||||||
|
setGame(g === 'all' ? 'rust' : g)
|
||||||
|
}
|
||||||
|
function toggleTheme(): void {
|
||||||
|
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
theme: readonly(theme),
|
||||||
|
game: readonly(game),
|
||||||
|
activeGame: readonly(activeGame),
|
||||||
|
setTheme,
|
||||||
|
setGame,
|
||||||
|
setActiveGame,
|
||||||
|
toggleTheme,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
|||||||
import { VueFinderPlugin } from 'vuefinder'
|
import { VueFinderPlugin } from 'vuefinder'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { initThemeGame } from './composables/useThemeGame'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import 'vuefinder/dist/vuefinder.css'
|
import 'vuefinder/dist/vuefinder.css'
|
||||||
|
|
||||||
@@ -17,4 +18,7 @@ app.use(pinia)
|
|||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(VueFinderPlugin)
|
app.use(VueFinderPlugin)
|
||||||
|
|
||||||
|
// Apply the design-system theming contract (data-theme/data-game on <html>).
|
||||||
|
initThemeGame()
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* Corrosion Brand — Oxide Orange #F26622 */
|
/* Corrosion design tokens — load order matters: fonts → primitives → colors →
|
||||||
|
game themes → elevation → base (base references color/accent tokens).
|
||||||
|
Imported DIRECTLY here (not via a nested ./styles/corrosion.css barrel): a
|
||||||
|
nested @import barrel placed after `@import "tailwindcss"` gets its inner
|
||||||
|
@imports dropped, because once Tailwind v4 expands in place they no longer
|
||||||
|
precede all other statements. Keeping them flat + contiguous fixes that. */
|
||||||
|
@import "./styles/tokens/fonts.css";
|
||||||
|
@import "./styles/tokens/spacing.css";
|
||||||
|
@import "./styles/tokens/typography.css";
|
||||||
|
@import "./styles/tokens/motion.css";
|
||||||
|
@import "./styles/tokens/colors.css";
|
||||||
|
@import "./styles/tokens/game-themes.css";
|
||||||
|
@import "./styles/tokens/elevation.css";
|
||||||
|
@import "./styles/tokens/base.css";
|
||||||
|
|
||||||
|
/* Tailwind utility colors — Oxide ramp (existing views use bg-oxide-*).
|
||||||
|
The full design-token system (neutral ramp, surfaces, per-game accents,
|
||||||
|
typography, spacing, elevation, motion) lives in ./styles/ and is the
|
||||||
|
source of truth for the redesign. */
|
||||||
@theme {
|
@theme {
|
||||||
--color-oxide-50: #FEF3EB;
|
--color-oxide-50: #FEF3EB;
|
||||||
--color-oxide-100: #FDE3D0;
|
--color-oxide-100: #FDE3D0;
|
||||||
@@ -15,7 +33,8 @@
|
|||||||
--color-oxide-950: #3D1506;
|
--color-oxide-950: #3D1506;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode is default — Rust servers run at night */
|
/* Legacy brand vars — retained during the redesign port so any view still
|
||||||
|
referencing them keeps working; superseded by the ./styles tokens. */
|
||||||
:root {
|
:root {
|
||||||
--corrosion-accent: #F26622;
|
--corrosion-accent: #F26622;
|
||||||
--corrosion-dark: #000000;
|
--corrosion-dark: #000000;
|
||||||
@@ -24,12 +43,8 @@
|
|||||||
--corrosion-border: #2a2a2a;
|
--corrosion-border: #2a2a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* Body background / text / font now come from the design system
|
||||||
@apply bg-neutral-950 text-neutral-100 antialiased;
|
(./styles/tokens/base.css → var(--surface-canvas), var(--text-primary)). */
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|||||||
15
frontend/src/styles/corrosion.css
Normal file
15
frontend/src/styles/corrosion.css
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Design System
|
||||||
|
Root stylesheet. Consumers link THIS one file.
|
||||||
|
Import order matters: fonts → primitives → colors → game themes
|
||||||
|
→ base. (base.css references color/accent tokens.)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@import url("tokens/fonts.css");
|
||||||
|
@import url("tokens/spacing.css");
|
||||||
|
@import url("tokens/typography.css");
|
||||||
|
@import url("tokens/motion.css");
|
||||||
|
@import url("tokens/colors.css");
|
||||||
|
@import url("tokens/game-themes.css");
|
||||||
|
@import url("tokens/elevation.css");
|
||||||
|
@import url("tokens/base.css");
|
||||||
75
frontend/src/styles/tokens/base.css
Normal file
75
frontend/src/styles/tokens/base.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Base / Reset
|
||||||
|
Minimal, opinionated. Applies the token system to bare HTML so
|
||||||
|
specimen cards and kits inherit the look without boilerplate.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--surface-canvas);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-feature-settings: "cv01", "ss01";
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6, p, figure { margin: 0; }
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--accent-soft-strong);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabular numbers everywhere numbers matter */
|
||||||
|
.tnum { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Custom scrollbars — quiet, on-brand */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-strong);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: var(--text-muted); background-clip: content-box; }
|
||||||
|
|
||||||
|
/* Focus-visible default */
|
||||||
|
:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion guard */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Skin-swap repaint guard ----
|
||||||
|
When flipping data-game or data-theme on the root, add the .cc-skin-swap
|
||||||
|
class for ONE frame. This suppresses transitions during the swap so every
|
||||||
|
accent-consuming surface repaints immediately — works around a Chrome
|
||||||
|
custom-property + transition staleness where elements that both read
|
||||||
|
var(--accent)/var(--accent-text) AND have a color/background transition keep
|
||||||
|
the old accent until the next reflow. See readme "Theming contract". */
|
||||||
|
.cc-skin-swap *,
|
||||||
|
.cc-skin-swap *::before,
|
||||||
|
.cc-skin-swap *::after { transition: none !important; }
|
||||||
136
frontend/src/styles/tokens/colors.css
Normal file
136
frontend/src/styles/tokens/colors.css
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Color System
|
||||||
|
------------------------------------------------------------
|
||||||
|
Three layers:
|
||||||
|
1. Raw neutral ramp (absolute; identical in both themes)
|
||||||
|
2. Semantic surface / text / border tokens (flip per theme)
|
||||||
|
3. Status colors (online/offline/warn/info/...) — game-agnostic
|
||||||
|
Game ACCENT colors live in game-themes.css.
|
||||||
|
|
||||||
|
Theme + game are set together on <html>:
|
||||||
|
<html data-theme="dark" data-game="rust">
|
||||||
|
Dark is primary. Light is full-parity.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ---- Raw neutral ramp (warm-cool slate, absolute) ---- */
|
||||||
|
--n-0: #ffffff;
|
||||||
|
--n-25: #fafbfc;
|
||||||
|
--n-50: #f3f5f7;
|
||||||
|
--n-100: #e8ebef;
|
||||||
|
--n-150: #dce0e6;
|
||||||
|
--n-200: #ccd2da;
|
||||||
|
--n-300: #aeb6c1;
|
||||||
|
--n-400: #8a929e;
|
||||||
|
--n-500: #6b7280;
|
||||||
|
--n-600: #515862;
|
||||||
|
--n-650: #444a53;
|
||||||
|
--n-700: #363b43;
|
||||||
|
--n-750: #2b2f36;
|
||||||
|
--n-800: #1f2329;
|
||||||
|
--n-850: #181b20;
|
||||||
|
--n-900: #121419;
|
||||||
|
--n-925: #0e0f13;
|
||||||
|
--n-950: #0a0b0e;
|
||||||
|
--n-975: #060709;
|
||||||
|
|
||||||
|
/* ---- Semantic: DARK (default) ---- */
|
||||||
|
--surface-canvas: var(--n-950); /* app background */
|
||||||
|
--surface-sunken: var(--n-975); /* wells, deep insets */
|
||||||
|
--surface-base: var(--n-925); /* primary panels */
|
||||||
|
--surface-raised: var(--n-850); /* cards, rows */
|
||||||
|
--surface-raised-2:var(--n-800); /* nested cards, hover cards */
|
||||||
|
--surface-overlay: var(--n-800); /* menus, popovers, dialogs */
|
||||||
|
--surface-inset: #07080a; /* inputs, console, code */
|
||||||
|
--surface-hover: rgba(255, 255, 255, 0.045);
|
||||||
|
--surface-active: rgba(255, 255, 255, 0.075);
|
||||||
|
--surface-selected:rgba(255, 255, 255, 0.06);
|
||||||
|
|
||||||
|
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||||
|
--border-default: rgba(255, 255, 255, 0.10);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.16);
|
||||||
|
|
||||||
|
--text-primary: #f2f4f7;
|
||||||
|
--text-secondary: #aeb4bf;
|
||||||
|
--text-tertiary: #767d89;
|
||||||
|
--text-muted: #565d68;
|
||||||
|
--text-inverse: #0a0b0e;
|
||||||
|
--text-on-accent: var(--accent-contrast);
|
||||||
|
|
||||||
|
/* Scrim for modals / image overlays */
|
||||||
|
--scrim: rgba(4, 5, 7, 0.66);
|
||||||
|
|
||||||
|
/* ---- Status / semantic (consistent across themes) ---- */
|
||||||
|
--status-online: #36c780;
|
||||||
|
--status-online-soft: rgba(54, 199, 128, 0.14);
|
||||||
|
--status-online-border: rgba(54, 199, 128, 0.38);
|
||||||
|
|
||||||
|
--status-offline: #e5484d;
|
||||||
|
--status-offline-soft: rgba(229, 72, 77, 0.14);
|
||||||
|
--status-offline-border: rgba(229, 72, 77, 0.40);
|
||||||
|
|
||||||
|
--status-warn: #e8a33c;
|
||||||
|
--status-warn-soft: rgba(232, 163, 60, 0.15);
|
||||||
|
--status-warn-border: rgba(232, 163, 60, 0.40);
|
||||||
|
|
||||||
|
--status-info: #4c8df0;
|
||||||
|
--status-info-soft: rgba(76, 141, 240, 0.15);
|
||||||
|
--status-info-border: rgba(76, 141, 240, 0.40);
|
||||||
|
|
||||||
|
--status-starting: #2bc2d4; /* booting / updating / restarting */
|
||||||
|
--status-starting-soft: rgba(43, 194, 212, 0.15);
|
||||||
|
--status-starting-border: rgba(43, 194, 212, 0.40);
|
||||||
|
|
||||||
|
--status-wiping: #9b7bf0; /* Rust map wipe / maintenance */
|
||||||
|
--status-wiping-soft: rgba(155, 123, 240, 0.16);
|
||||||
|
--status-wiping-border: rgba(155, 123, 240, 0.40);
|
||||||
|
|
||||||
|
/* Aliases used by components */
|
||||||
|
--success: var(--status-online);
|
||||||
|
--danger: var(--status-offline);
|
||||||
|
--warning: var(--status-warn);
|
||||||
|
--info: var(--status-info);
|
||||||
|
|
||||||
|
/* Data-viz categorical (ECharts-friendly) */
|
||||||
|
--viz-1: var(--accent);
|
||||||
|
--viz-2: #4c8df0;
|
||||||
|
--viz-3: #36c780;
|
||||||
|
--viz-4: #9b7bf0;
|
||||||
|
--viz-5: #2bc2d4;
|
||||||
|
--viz-6: #e8a33c;
|
||||||
|
--viz-grid: var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Semantic: LIGHT (full parity) ---- */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--surface-canvas: #f5f6f8;
|
||||||
|
--surface-sunken: #eceef1;
|
||||||
|
--surface-base: #ffffff;
|
||||||
|
--surface-raised: #ffffff;
|
||||||
|
--surface-raised-2:#fbfcfd;
|
||||||
|
--surface-overlay: #ffffff;
|
||||||
|
--surface-inset: #f1f3f5;
|
||||||
|
--surface-hover: rgba(12, 16, 22, 0.04);
|
||||||
|
--surface-active: rgba(12, 16, 22, 0.07);
|
||||||
|
--surface-selected:rgba(12, 16, 22, 0.05);
|
||||||
|
|
||||||
|
--border-subtle: rgba(12, 16, 22, 0.07);
|
||||||
|
--border-default: rgba(12, 16, 22, 0.12);
|
||||||
|
--border-strong: rgba(12, 16, 22, 0.20);
|
||||||
|
|
||||||
|
--text-primary: #14171c;
|
||||||
|
--text-secondary: #474e58;
|
||||||
|
--text-tertiary: #6b727e;
|
||||||
|
--text-muted: #969ca6;
|
||||||
|
--text-inverse: #ffffff;
|
||||||
|
|
||||||
|
--scrim: rgba(16, 20, 28, 0.45);
|
||||||
|
|
||||||
|
/* Status soft fills need a touch more alpha on light */
|
||||||
|
--status-online-soft: rgba(33, 160, 98, 0.12);
|
||||||
|
--status-offline-soft: rgba(206, 44, 49, 0.10);
|
||||||
|
--status-warn-soft: rgba(193, 124, 18, 0.13);
|
||||||
|
--status-info-soft: rgba(43, 105, 214, 0.10);
|
||||||
|
--status-starting-soft: rgba(18, 150, 168, 0.12);
|
||||||
|
--status-wiping-soft: rgba(118, 86, 214, 0.12);
|
||||||
|
}
|
||||||
40
frontend/src/styles/tokens/elevation.css
Normal file
40
frontend/src/styles/tokens/elevation.css
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Elevation, Borders, Glows
|
||||||
|
Shadows are quiet on dark surfaces; depth comes from layered
|
||||||
|
fills + hairline borders. Accent "glow" is reserved for
|
||||||
|
active/live states and game-themed focus.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Hairline rings (use as box-shadow inset or border) */
|
||||||
|
--ring-subtle: inset 0 0 0 1px var(--border-subtle);
|
||||||
|
--ring-default: inset 0 0 0 1px var(--border-default);
|
||||||
|
--ring-strong: inset 0 0 0 1px var(--border-strong);
|
||||||
|
|
||||||
|
/* Drop shadows — tuned for dark; subtle and tight */
|
||||||
|
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.40);
|
||||||
|
--shadow-md: 0 6px 18px -4px rgba(0, 0, 0, 0.50);
|
||||||
|
--shadow-lg: 0 18px 40px -10px rgba(0, 0, 0, 0.60);
|
||||||
|
--shadow-xl: 0 32px 70px -16px rgba(0, 0, 0, 0.70);
|
||||||
|
|
||||||
|
/* Popover / menu — combines ring + drop for crisp edges on dark */
|
||||||
|
--shadow-pop: 0 0 0 1px var(--border-default), 0 12px 36px -8px rgba(0, 0, 0, 0.66);
|
||||||
|
|
||||||
|
/* Accent glow — game-themed; used on live/active controls & focus */
|
||||||
|
--glow-accent: 0 0 0 1px var(--accent-border), 0 0 22px -2px var(--accent-glow);
|
||||||
|
--glow-accent-sm: 0 0 14px -2px var(--accent-glow);
|
||||||
|
--glow-online: 0 0 12px -1px rgba(54, 199, 128, 0.55);
|
||||||
|
|
||||||
|
/* Focus ring */
|
||||||
|
--focus-ring: 0 0 0 2px var(--surface-canvas), 0 0 0 4px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--shadow-xs: 0 1px 2px rgba(16, 20, 28, 0.06);
|
||||||
|
--shadow-sm: 0 2px 6px rgba(16, 20, 28, 0.08);
|
||||||
|
--shadow-md: 0 8px 20px -6px rgba(16, 20, 28, 0.12);
|
||||||
|
--shadow-lg: 0 20px 44px -12px rgba(16, 20, 28, 0.16);
|
||||||
|
--shadow-xl: 0 32px 70px -18px rgba(16, 20, 28, 0.20);
|
||||||
|
--shadow-pop: 0 0 0 1px var(--border-default), 0 14px 38px -10px rgba(16, 20, 28, 0.20);
|
||||||
|
}
|
||||||
21
frontend/src/styles/tokens/fonts.css
Normal file
21
frontend/src/styles/tokens/fonts.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Fonts
|
||||||
|
Geist — UI / body / app headings
|
||||||
|
JetBrains Mono — console, data, IDs, telemetry
|
||||||
|
Oxanium — brand wordmark + marketing display (game-ops flavor)
|
||||||
|
------------------------------------------------------------
|
||||||
|
NOTE: Loaded from Google Fonts CDN. If you want these self-
|
||||||
|
hosted (offline), send the woff2 files and these @imports
|
||||||
|
become @font-face rules.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;800&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Oxanium:wght@500;600;700;800&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-sans: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
|
||||||
|
/* Brand wordmark + big marketing display — squared, technical, gamey */
|
||||||
|
--font-brand: 'Oxanium', 'Geist', system-ui, sans-serif;
|
||||||
|
/* App-level display headings stay on the neutral sans */
|
||||||
|
--font-display: var(--font-sans);
|
||||||
|
}
|
||||||
150
frontend/src/styles/tokens/game-themes.css
Normal file
150
frontend/src/styles/tokens/game-themes.css
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Game Themes (the re-skin layer)
|
||||||
|
------------------------------------------------------------
|
||||||
|
The shell is neutral. Each game declares an ACCENT ramp + an
|
||||||
|
ATMOSPHERE (backdrop hues for hero headers, login, glows).
|
||||||
|
Set on <html data-game="rust"> (or dune / ark / valheim / ...).
|
||||||
|
Default (no data-game) = Corrosion brand = Oxide Orange.
|
||||||
|
|
||||||
|
Accent contract every game must define:
|
||||||
|
--accent base fill
|
||||||
|
--accent-hover hover fill
|
||||||
|
--accent-press pressed / darker
|
||||||
|
--accent-contrast text/icon ON the accent fill
|
||||||
|
--accent-text accent used AS text on dark surfaces (lightened for AA)
|
||||||
|
--accent-soft low-alpha tint background
|
||||||
|
--accent-soft-strong
|
||||||
|
--accent-border accent hairline
|
||||||
|
--accent-glow glow color (box-shadow)
|
||||||
|
--game-label printable name
|
||||||
|
--atmo-1 / --atmo-2 backdrop gradient stops
|
||||||
|
--atmo-haze radial haze color
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- Default brand / RUST — Oxide Orange #F26622 ---------- */
|
||||||
|
:root,
|
||||||
|
[data-game="rust"] {
|
||||||
|
--accent: #f26622;
|
||||||
|
--accent-hover: #ff7a38;
|
||||||
|
--accent-press: #d9550f;
|
||||||
|
--accent-contrast: #190d05;
|
||||||
|
--accent-text: #ff8a4c;
|
||||||
|
--accent-soft: rgba(242, 102, 34, 0.13);
|
||||||
|
--accent-soft-strong: rgba(242, 102, 34, 0.22);
|
||||||
|
--accent-border: rgba(242, 102, 34, 0.42);
|
||||||
|
--accent-glow: rgba(242, 102, 34, 0.50);
|
||||||
|
--game-label: "Rust"; /* @kind other */
|
||||||
|
--atmo-1: #2c1206;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(242, 102, 34, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- DUNE: AWAKENING — Spice Amber / desert gold ---------- */
|
||||||
|
[data-game="dune"] {
|
||||||
|
--accent: #e9a53a;
|
||||||
|
--accent-hover: #f7b94f;
|
||||||
|
--accent-press: #c9851f;
|
||||||
|
--accent-contrast: #1c1303;
|
||||||
|
--accent-text: #f0bc5e;
|
||||||
|
--accent-soft: rgba(233, 165, 58, 0.14);
|
||||||
|
--accent-soft-strong: rgba(233, 165, 58, 0.24);
|
||||||
|
--accent-border: rgba(233, 165, 58, 0.44);
|
||||||
|
--accent-glow: rgba(233, 165, 58, 0.46);
|
||||||
|
--game-label: "Dune: Awakening"; /* @kind other */
|
||||||
|
--atmo-1: #2c1e08;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(233, 165, 58, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- SOULMASK: Ritual Jade ---------- */
|
||||||
|
[data-game="soulmask"] {
|
||||||
|
--accent: #43c47e;
|
||||||
|
--accent-hover: #59d792;
|
||||||
|
--accent-press: #2c9c5f;
|
||||||
|
--accent-contrast: #04140c;
|
||||||
|
--accent-text: #63d894;
|
||||||
|
--accent-soft: rgba(67, 196, 126, 0.14);
|
||||||
|
--accent-soft-strong: rgba(67, 196, 126, 0.24);
|
||||||
|
--accent-border: rgba(67, 196, 126, 0.42);
|
||||||
|
--accent-glow: rgba(67, 196, 126, 0.46);
|
||||||
|
--game-label: "Soulmask"; /* @kind other */
|
||||||
|
--atmo-1: #08231a;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(67, 196, 126, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- CONAN EXILES: Hyborian Bronze ---------- */
|
||||||
|
[data-game="conan"] {
|
||||||
|
--accent: #bb7637;
|
||||||
|
--accent-hover: #d28d4b;
|
||||||
|
--accent-press: #985c24;
|
||||||
|
--accent-contrast: #160d03;
|
||||||
|
--accent-text: #d59a5e;
|
||||||
|
--accent-soft: rgba(187, 118, 55, 0.15);
|
||||||
|
--accent-soft-strong: rgba(187, 118, 55, 0.26);
|
||||||
|
--accent-border: rgba(187, 118, 55, 0.44);
|
||||||
|
--accent-glow: rgba(187, 118, 55, 0.46);
|
||||||
|
--game-label: "Conan Exiles"; /* @kind other */
|
||||||
|
--atmo-1: #2a1b0b;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(187, 118, 55, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Room to add more (stubs, ready to ship) ---------- */
|
||||||
|
[data-game="ark"] {
|
||||||
|
--accent: #36c2a8;
|
||||||
|
--accent-hover: #4ad7bd;
|
||||||
|
--accent-press: #1f9c86;
|
||||||
|
--accent-contrast: #04140f;
|
||||||
|
--accent-text: #54dcc2;
|
||||||
|
--accent-soft: rgba(54, 194, 168, 0.14);
|
||||||
|
--accent-soft-strong: rgba(54, 194, 168, 0.24);
|
||||||
|
--accent-border: rgba(54, 194, 168, 0.42);
|
||||||
|
--accent-glow: rgba(54, 194, 168, 0.46);
|
||||||
|
--game-label: "ARK"; /* @kind other */
|
||||||
|
--atmo-1: #07241f;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(54, 194, 168, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-game="valheim"] {
|
||||||
|
--accent: #5b9bf0;
|
||||||
|
--accent-hover: #74afff;
|
||||||
|
--accent-press: #3c7ad4;
|
||||||
|
--accent-contrast: #050d1a;
|
||||||
|
--accent-text: #7fb2ff;
|
||||||
|
--accent-soft: rgba(91, 155, 240, 0.14);
|
||||||
|
--accent-soft-strong: rgba(91, 155, 240, 0.24);
|
||||||
|
--accent-border: rgba(91, 155, 240, 0.42);
|
||||||
|
--accent-glow: rgba(91, 155, 240, 0.46);
|
||||||
|
--game-label: "Valheim"; /* @kind other */
|
||||||
|
--atmo-1: #0a1c2e;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(91, 155, 240, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-game="palworld"] {
|
||||||
|
--accent: #58b6e8;
|
||||||
|
--accent-hover: #71c8f6;
|
||||||
|
--accent-press: #3a92c4;
|
||||||
|
--accent-contrast: #04121b;
|
||||||
|
--accent-text: #7fcaf2;
|
||||||
|
--accent-soft: rgba(88, 182, 232, 0.14);
|
||||||
|
--accent-soft-strong: rgba(88, 182, 232, 0.24);
|
||||||
|
--accent-border: rgba(88, 182, 232, 0.42);
|
||||||
|
--accent-glow: rgba(88, 182, 232, 0.46);
|
||||||
|
--game-label: "Palworld"; /* @kind other */
|
||||||
|
--atmo-1: #08222e;
|
||||||
|
--atmo-2: #0a0b0e;
|
||||||
|
--atmo-haze: rgba(88, 182, 232, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Light-theme accent legibility ----------
|
||||||
|
On light surfaces, accent-as-text reads better at the pressed
|
||||||
|
(darker) value. Placed last so it wins for --accent-text when
|
||||||
|
data-theme="light" and data-game=* are both on <html>. */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--accent-text: var(--accent-press);
|
||||||
|
--accent-soft: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
--accent-soft-strong: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
|
}
|
||||||
27
frontend/src/styles/tokens/motion.css
Normal file
27
frontend/src/styles/tokens/motion.css
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Motion
|
||||||
|
Fast, mechanical, precise. No bounce on chrome. Subtle spring
|
||||||
|
reserved for "live" telemetry (pulses, meters). Respect
|
||||||
|
prefers-reduced-motion in components.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--dur-instant: 80ms; /* @kind other */
|
||||||
|
--dur-fast: 120ms; /* @kind other */
|
||||||
|
--dur-base: 170ms; /* @kind other */
|
||||||
|
--dur-slow: 240ms; /* @kind other */
|
||||||
|
--dur-slower: 360ms; /* @kind other */
|
||||||
|
|
||||||
|
/* Easings */
|
||||||
|
--ease-standard: cubic-bezier(0.2, 0, 0, 1); /* @kind other */
|
||||||
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* @kind other */
|
||||||
|
--ease-in: cubic-bezier(0.5, 0, 0.84, 0); /* @kind other */
|
||||||
|
--ease-emphasized: cubic-bezier(0.34, 1.4, 0.5, 1); /* @kind other */
|
||||||
|
|
||||||
|
/* Common transition bundles */
|
||||||
|
--transition-colors: color var(--dur-fast) var(--ease-standard),
|
||||||
|
background-color var(--dur-fast) var(--ease-standard),
|
||||||
|
border-color var(--dur-fast) var(--ease-standard),
|
||||||
|
box-shadow var(--dur-fast) var(--ease-standard);
|
||||||
|
--transition-transform: transform var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
50
frontend/src/styles/tokens/spacing.css
Normal file
50
frontend/src/styles/tokens/spacing.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Spacing, Radius, Sizing
|
||||||
|
Dense ops-cockpit scale. 4px base grid, tightened.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Space scale (px) — used for padding, gap, margins */
|
||||||
|
--space-0: 0;
|
||||||
|
--space-px: 1px;
|
||||||
|
--space-0-5: 2px;
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-1-5: 6px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-2-5: 10px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-7: 28px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
--space-12: 48px;
|
||||||
|
--space-14: 56px;
|
||||||
|
--space-16: 64px;
|
||||||
|
--space-20: 80px;
|
||||||
|
--space-24: 96px;
|
||||||
|
--space-32: 128px;
|
||||||
|
|
||||||
|
/* Radius — small & technical; cards stay crisp, not rounded-blobby */
|
||||||
|
--radius-xs: 3px;
|
||||||
|
--radius-sm: 5px;
|
||||||
|
--radius-md: 7px;
|
||||||
|
--radius-lg: 10px;
|
||||||
|
--radius-xl: 14px;
|
||||||
|
--radius-2xl: 20px;
|
||||||
|
--radius-pill: 999px;
|
||||||
|
|
||||||
|
/* Control heights — compact rows for data-dense UI */
|
||||||
|
--control-h-xs: 24px;
|
||||||
|
--control-h-sm: 30px;
|
||||||
|
--control-h-md: 36px;
|
||||||
|
--control-h-lg: 44px;
|
||||||
|
|
||||||
|
/* Layout primitives */
|
||||||
|
--sidebar-w: 248px;
|
||||||
|
--sidebar-w-collapsed: 64px;
|
||||||
|
--topbar-h: 56px;
|
||||||
|
--content-max: 1440px;
|
||||||
|
--container-pad: var(--space-6);
|
||||||
|
}
|
||||||
75
frontend/src/styles/tokens/typography.css
Normal file
75
frontend/src/styles/tokens/typography.css
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Corrosion Control — Typography
|
||||||
|
Geist for UI & display; JetBrains Mono for telemetry, IDs,
|
||||||
|
console, and any numeric/code data. Dense reading sizes.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Font sizes (px) — base UI is 14 (dense) */
|
||||||
|
--text-2xs: 11px;
|
||||||
|
--text-xs: 12px;
|
||||||
|
--text-sm: 13px;
|
||||||
|
--text-base: 14px;
|
||||||
|
--text-md: 15px;
|
||||||
|
--text-lg: 17px;
|
||||||
|
--text-xl: 20px;
|
||||||
|
--text-2xl: 24px;
|
||||||
|
--text-3xl: 30px;
|
||||||
|
--text-4xl: 38px;
|
||||||
|
--text-5xl: 50px;
|
||||||
|
--text-6xl: 66px;
|
||||||
|
--text-7xl: 88px;
|
||||||
|
|
||||||
|
/* Line heights */
|
||||||
|
--leading-none: 1;
|
||||||
|
--leading-tight: 1.15;
|
||||||
|
--leading-snug: 1.3;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.65;
|
||||||
|
|
||||||
|
/* Weights */
|
||||||
|
--weight-light: 300;
|
||||||
|
--weight-regular: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
--weight-semibold: 600;
|
||||||
|
--weight-bold: 700;
|
||||||
|
--weight-black: 800;
|
||||||
|
|
||||||
|
/* Letter spacing */
|
||||||
|
--tracking-tighter: -0.03em;
|
||||||
|
--tracking-tight: -0.015em;
|
||||||
|
--tracking-normal: 0;
|
||||||
|
--tracking-wide: 0.02em;
|
||||||
|
--tracking-wider: 0.06em;
|
||||||
|
--tracking-caps: 0.10em; /* eyebrows / overlines / labels */
|
||||||
|
|
||||||
|
/* ---- Semantic roles (font shorthands via custom props) ---- */
|
||||||
|
--font-display: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Optional helper classes (cards/kits can use these) ---- */
|
||||||
|
.t-display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: var(--weight-bold);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
}
|
||||||
|
.t-eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: var(--tracking-caps);
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.t-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: var(--tracking-normal);
|
||||||
|
}
|
||||||
|
.t-metric {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { AlertTriangle, Save, Loader2 } from 'lucide-vue-next'
|
|
||||||
import { safeDate } from '@/utils/formatters'
|
import { safeDate } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Checkbox from '@/components/ds/forms/Checkbox.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
interface AlertConfig {
|
interface AlertConfig {
|
||||||
population_drop_enabled: boolean
|
population_drop_enabled: boolean
|
||||||
@@ -62,13 +68,27 @@ async function saveConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSeverityColor(severity: string): string {
|
function severityTone(severity: string): 'info' | 'warn' | 'offline' | 'neutral' {
|
||||||
switch (severity) {
|
if (severity === 'info') return 'info'
|
||||||
case 'info': return 'bg-blue-500/10 text-blue-400'
|
if (severity === 'warning') return 'warn'
|
||||||
case 'warning': return 'bg-yellow-500/10 text-yellow-400'
|
if (severity === 'critical') return 'offline'
|
||||||
case 'critical': return 'bg-red-500/10 text-red-400'
|
return 'neutral'
|
||||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Range input handler (range emits string, keep numeric in config)
|
||||||
|
function onThresholdInput(e: Event) {
|
||||||
|
const val = parseInt((e.target as HTMLInputElement).value, 10)
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
config.value.population_drop_threshold_percent = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FPS threshold: DS Input binds string; sync via a string ref
|
||||||
|
const fpsStr = ref(String(config.value.fps_threshold))
|
||||||
|
function onFpsUpdate(val: string | undefined) {
|
||||||
|
fpsStr.value = val ?? ''
|
||||||
|
const n = parseInt(val ?? '', 10)
|
||||||
|
if (!isNaN(n)) config.value.fps_threshold = n
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -78,156 +98,258 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="alerts">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="page__head">
|
||||||
<AlertTriangle class="w-5 h-5 text-oxide-500" />
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Alerts</h1>
|
<div class="t-eyebrow">Monitoring</div>
|
||||||
|
<h1 class="page__title">Alerts</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alert Configuration -->
|
<!-- Alert configuration -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Alert configuration" subtitle="Trigger conditions and notification channels">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Alert Configuration</h2>
|
<div v-if="isLoading" class="loading-row">
|
||||||
|
<span class="cc-btn__spin" style="width:20px;height:20px;border-width:2.5px;" />
|
||||||
<div v-if="isLoading" class="py-8 flex justify-center">
|
|
||||||
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-6">
|
<div v-else class="config-body">
|
||||||
<!-- Population Drop Alert -->
|
<!-- Triggers section -->
|
||||||
<div class="space-y-3">
|
<div class="config-section">
|
||||||
<div class="flex items-center justify-between">
|
<div class="t-eyebrow config-section__eyebrow">Triggers</div>
|
||||||
<label class="text-sm font-medium text-neutral-200">Population Drop Alert</label>
|
|
||||||
<button
|
<!-- Population drop -->
|
||||||
@click="config.population_drop_enabled = !config.population_drop_enabled"
|
<div class="alert-rule">
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
<div class="alert-rule__head">
|
||||||
:class="config.population_drop_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
<div>
|
||||||
>
|
<div class="alert-rule__name">Population drop alert</div>
|
||||||
<span
|
<div class="alert-rule__desc">Fire when player count falls by this percentage</div>
|
||||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
|
||||||
:class="config.population_drop_enabled ? 'translate-x-6' : 'translate-x-1'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="config.population_drop_enabled">
|
<Switch v-model="config.population_drop_enabled" />
|
||||||
<label class="block text-xs text-neutral-500 mb-2">Threshold (%)</label>
|
</div>
|
||||||
|
<div v-if="config.population_drop_enabled" class="alert-rule__body">
|
||||||
|
<div class="range-field">
|
||||||
<input
|
<input
|
||||||
v-model.number="config.population_drop_threshold_percent"
|
|
||||||
type="range"
|
type="range"
|
||||||
|
:value="config.population_drop_threshold_percent"
|
||||||
min="10"
|
min="10"
|
||||||
max="100"
|
max="100"
|
||||||
class="w-full h-2 bg-neutral-700 rounded-lg appearance-none cursor-pointer accent-oxide-500"
|
class="cc-range"
|
||||||
|
@input="onThresholdInput"
|
||||||
/>
|
/>
|
||||||
<div class="text-xs text-neutral-400 mt-1">{{ config.population_drop_threshold_percent }}%</div>
|
<span class="range-value">{{ config.population_drop_threshold_percent }}%</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FPS Degradation Alert -->
|
<!-- FPS degradation -->
|
||||||
<div class="space-y-3">
|
<div class="alert-rule">
|
||||||
<div class="flex items-center justify-between">
|
<div class="alert-rule__head">
|
||||||
<label class="text-sm font-medium text-neutral-200">FPS Degradation Alert</label>
|
<div>
|
||||||
<button
|
<div class="alert-rule__name">FPS degradation alert</div>
|
||||||
@click="config.fps_degradation_enabled = !config.fps_degradation_enabled"
|
<div class="alert-rule__desc">Fire when server FPS drops below threshold</div>
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
|
||||||
:class="config.fps_degradation_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
|
||||||
:class="config.fps_degradation_enabled ? 'translate-x-6' : 'translate-x-1'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="config.fps_degradation_enabled">
|
<Switch v-model="config.fps_degradation_enabled" />
|
||||||
<label class="block text-xs text-neutral-500 mb-2">FPS Threshold</label>
|
</div>
|
||||||
<input
|
<div v-if="config.fps_degradation_enabled" class="alert-rule__body">
|
||||||
v-model.number="config.fps_threshold"
|
<Input
|
||||||
|
:model-value="fpsStr"
|
||||||
|
label="FPS threshold"
|
||||||
type="number"
|
type="number"
|
||||||
min="10"
|
:mono="true"
|
||||||
max="60"
|
hint="Minimum acceptable FPS (10–60)"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
@update:model-value="onFpsUpdate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Notification Channels -->
|
<!-- Notification channels -->
|
||||||
<div class="border-t border-neutral-800 pt-4">
|
<div class="config-section">
|
||||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-3">Notification Channels</h3>
|
<div class="t-eyebrow config-section__eyebrow">Notification channels</div>
|
||||||
<div class="space-y-2">
|
<div class="channels">
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
<Checkbox v-model="config.notify_discord" label="Discord" />
|
||||||
<input
|
<Checkbox v-model="config.notify_pushbullet" label="Pushbullet" />
|
||||||
v-model="config.notify_discord"
|
<Checkbox v-model="config.notify_email" label="Email" />
|
||||||
type="checkbox"
|
|
||||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-neutral-200">Discord</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
v-model="config.notify_pushbullet"
|
|
||||||
type="checkbox"
|
|
||||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-neutral-200">Pushbullet</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
v-model="config.notify_email"
|
|
||||||
type="checkbox"
|
|
||||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-500 focus:ring-2 focus:ring-oxide-500"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-neutral-200">Email</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save Button -->
|
<!-- Save -->
|
||||||
<button
|
<Button icon="save" :loading="isSaving" @click="saveConfig">
|
||||||
@click="saveConfig"
|
Save configuration
|
||||||
:disabled="isSaving"
|
</Button>
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="isSaving" class="w-4 h-4 animate-spin" />
|
|
||||||
<Save v-else class="w-4 h-4" />
|
|
||||||
Save Configuration
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Alert History -->
|
<!-- Alert history -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel :flush-body="true" title="Alert history">
|
||||||
<div class="p-5 border-b border-neutral-800">
|
<EmptyState
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Alert History</h2>
|
v-if="history.length === 0"
|
||||||
</div>
|
icon="triangle-alert"
|
||||||
<div v-if="history.length === 0" class="p-8 text-center text-neutral-500">
|
title="No alerts triggered"
|
||||||
No alerts triggered yet.
|
description="Alerts will appear here when trigger conditions are met."
|
||||||
</div>
|
/>
|
||||||
<table v-else class="w-full">
|
<table v-else class="cc-table">
|
||||||
<thead class="bg-neutral-800/50 border-b border-neutral-800">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Triggered</th>
|
<th>Triggered</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th>
|
<th>Type</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Severity</th>
|
<th>Severity</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Title</th>
|
<th>Title</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Message</th>
|
<th>Message</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-for="alert in history" :key="alert.id" class="hover:bg-neutral-800/30">
|
<tr v-for="entry in history" :key="entry.id">
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(alert.triggered_at) }}</td>
|
<td class="td-mono">{{ safeDate(entry.triggered_at) }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ alert.alert_type }}</td>
|
<td>{{ entry.alert_type }}</td>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<span
|
<Badge :tone="severityTone(entry.severity)">{{ entry.severity }}</Badge>
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full capitalize"
|
|
||||||
:class="getSeverityColor(alert.severity)"
|
|
||||||
>
|
|
||||||
{{ alert.severity }}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-200">{{ alert.title }}</td>
|
<td class="td-primary">{{ entry.title }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ alert.message }}</td>
|
<td>{{ entry.message }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.alerts {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.page__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading row */
|
||||||
|
.loading-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config body */
|
||||||
|
.config-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.config-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.config-section__eyebrow { margin-bottom: 4px; }
|
||||||
|
|
||||||
|
/* Alert rule card */
|
||||||
|
.alert-rule {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.alert-rule__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 13px 15px;
|
||||||
|
}
|
||||||
|
.alert-rule__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.alert-rule__desc {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.alert-rule__body {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
padding: 13px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Range slider */
|
||||||
|
.range-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.cc-range {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
appearance: none;
|
||||||
|
background: var(--surface-active);
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
.range-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--accent-text);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channels */
|
||||||
|
.channels {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.cc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.cc-table thead tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.cc-table th {
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cc-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||||
|
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||||
|
.cc-table td {
|
||||||
|
padding: 11px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.td-primary { color: var(--text-primary); font-weight: 500; }
|
||||||
|
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
import { BarChart3, TrendingUp, Users, Clock, Download } from 'lucide-vue-next'
|
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import type { ECharts } from 'echarts'
|
import type { ECharts } from 'echarts'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
@@ -8,6 +7,11 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { AnalyticsSummary, TimeseriesData } from '@/types'
|
import type { AnalyticsSummary, TimeseriesData } from '@/types'
|
||||||
import { safeFixed } from '@/utils/formatters'
|
import { safeFixed } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -54,9 +58,22 @@ const loadAnalytics = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssVar(name: string): string {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
|
}
|
||||||
|
|
||||||
const renderCharts = () => {
|
const renderCharts = () => {
|
||||||
if (!timeseries.value) return
|
if (!timeseries.value) return
|
||||||
|
|
||||||
|
const accent = cssVar('--accent') || '#CE422B'
|
||||||
|
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||||
|
const axisLine = cssVar('--border-default') || '#404040'
|
||||||
|
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||||
|
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||||
|
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||||
|
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||||
|
const mono = 'JetBrains Mono, monospace'
|
||||||
|
|
||||||
// Player count chart
|
// Player count chart
|
||||||
if (playerChart.value) {
|
if (playerChart.value) {
|
||||||
if (playerChartInstance) {
|
if (playerChartInstance) {
|
||||||
@@ -68,9 +85,9 @@ const renderCharts = () => {
|
|||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: tooltipBg,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: tooltipBorder,
|
||||||
textStyle: { color: '#e5e5e5' }
|
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
left: '3%',
|
left: '3%',
|
||||||
@@ -86,14 +103,14 @@ const renderCharts = () => {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit'
|
hour: '2-digit'
|
||||||
})),
|
})),
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
axisLabel: { color: '#808080', rotate: 45 }
|
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
splitLine: { lineStyle: { color: grid } },
|
||||||
axisLabel: { color: '#808080' }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@@ -101,14 +118,14 @@ const renderCharts = () => {
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
data: timeseries.value.player_count,
|
data: timeseries.value.player_count,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
lineStyle: { color: '#CE422B', width: 2 },
|
lineStyle: { color: accent, width: 2 },
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
{ offset: 0, color: 'rgba(206, 66, 43, 0.3)' },
|
{ offset: 0, color: accent + '55' },
|
||||||
{ offset: 1, color: 'rgba(206, 66, 43, 0)' }
|
{ offset: 1, color: accent + '00' }
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
itemStyle: { color: '#CE422B' }
|
itemStyle: { color: accent }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -125,13 +142,13 @@ const renderCharts = () => {
|
|||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: tooltipBg,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: tooltipBorder,
|
||||||
textStyle: { color: '#e5e5e5' }
|
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
data: ['FPS', 'Entities'],
|
data: ['FPS', 'Entities'],
|
||||||
textStyle: { color: '#a3a3a3' },
|
textStyle: { color: labelColor, fontFamily: mono },
|
||||||
top: 0
|
top: 0
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
@@ -148,25 +165,25 @@ const renderCharts = () => {
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit'
|
hour: '2-digit'
|
||||||
})),
|
})),
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
axisLabel: { color: '#808080', rotate: 45 }
|
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
yAxis: [
|
yAxis: [
|
||||||
{
|
{
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: 'FPS',
|
name: 'FPS',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
splitLine: { lineStyle: { color: grid } },
|
||||||
axisLabel: { color: '#808080' }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: 'Entities',
|
name: 'Entities',
|
||||||
position: 'right',
|
position: 'right',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { show: false },
|
splitLine: { show: false },
|
||||||
axisLabel: { color: '#808080' }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
series: [
|
series: [
|
||||||
@@ -223,114 +240,209 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="analytics-view">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="analytics-view__header">
|
||||||
<div class="flex items-center gap-3">
|
<h1 class="analytics-view__title">Analytics</h1>
|
||||||
<BarChart3 class="w-5 h-5 text-oxide-500" />
|
<div class="analytics-view__controls">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Analytics</h1>
|
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
@click="downloadCSV"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</Button>
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
<Tabs
|
||||||
<button
|
:items="(['24h', '7d', '30d'] as const).map(v => ({ value: v, label: v }))"
|
||||||
v-for="opt in (['24h', '7d', '30d'] as const)"
|
v-model="timeRange"
|
||||||
:key="opt"
|
variant="pill"
|
||||||
@click="timeRange = opt"
|
/>
|
||||||
class="px-3 py-2 text-sm font-medium transition-colors"
|
|
||||||
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
{{ opt }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
<div v-if="loading" class="analytics-view__loading">
|
||||||
<div class="text-neutral-500">Loading analytics...</div>
|
<span class="analytics-view__loading-text">Loading analytics...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="summary">
|
<template v-else-if="summary">
|
||||||
<!-- Stat cards -->
|
<!-- Stat cards -->
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="analytics-view__stats">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<StatCard
|
||||||
<div class="flex items-center gap-2 mb-2">
|
label="Peak players"
|
||||||
<Users class="w-4 h-4 text-neutral-500" />
|
:value="summary.peak_players ?? '—'"
|
||||||
<p class="text-sm text-neutral-400">Peak Players</p>
|
icon="users"
|
||||||
</div>
|
:note="`Last ${timeRange}`"
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ summary.peak_players }}</p>
|
/>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
<StatCard
|
||||||
</div>
|
label="Avg players"
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
:value="safeFixed(summary?.avg_players, 1)"
|
||||||
<div class="flex items-center gap-2 mb-2">
|
icon="trending-up"
|
||||||
<TrendingUp class="w-4 h-4 text-neutral-500" />
|
:note="`Last ${timeRange}`"
|
||||||
<p class="text-sm text-neutral-400">Avg Players</p>
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.avg_players, 1) }}</p>
|
label="Uptime"
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
:value="safeFixed(summary?.uptime_percentage, 1)"
|
||||||
</div>
|
unit="%"
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
icon="clock"
|
||||||
<div class="flex items-center gap-2 mb-2">
|
:note="`Last ${timeRange}`"
|
||||||
<Clock class="w-4 h-4 text-neutral-500" />
|
/>
|
||||||
<p class="text-sm text-neutral-400">Uptime</p>
|
<StatCard
|
||||||
</div>
|
label="Unique players"
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.uptime_percentage, 1) }}%</p>
|
:value="summary.unique_players ?? '—'"
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
icon="bar-chart-3"
|
||||||
</div>
|
note="Phase 2.2"
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
/>
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<BarChart3 class="w-4 h-4 text-neutral-500" />
|
|
||||||
<p class="text-sm text-neutral-400">Unique Players</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ summary.unique_players ?? '—' }}</p>
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">Phase 2.2</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Charts -->
|
<!-- Charts -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div class="analytics-view__charts">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Player count over time">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Player Count Over Time</h2>
|
<div ref="playerChart" class="analytics-view__chart-area"></div>
|
||||||
<div ref="playerChart" class="h-64"></div>
|
</Panel>
|
||||||
</div>
|
<Panel title="Server performance">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<div ref="perfChart" class="analytics-view__chart-area"></div>
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Server Performance</h2>
|
</Panel>
|
||||||
<div ref="perfChart" class="h-64"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Player Retention -->
|
<!-- Player Retention placeholder -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel eyebrow="Coming in phase 2" title="Player retention">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<template #title-append>
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Player Retention</h2>
|
<Badge tone="neutral">Phase 2</Badge>
|
||||||
<span class="text-xs font-medium px-2 py-0.5 bg-neutral-800 text-neutral-500 rounded-full border border-neutral-700">Phase 2</span>
|
</template>
|
||||||
|
<div class="analytics-view__retention-grid">
|
||||||
|
<div class="analytics-view__retention-cell">
|
||||||
|
<p class="analytics-view__retention-label">New players</p>
|
||||||
|
<p class="analytics-view__retention-value">—</p>
|
||||||
|
<p class="analytics-view__retention-note">First-time visitors</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div class="analytics-view__retention-cell">
|
||||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
<p class="analytics-view__retention-label">Returning players</p>
|
||||||
<p class="text-xs text-neutral-500 mb-1">New Players</p>
|
<p class="analytics-view__retention-value">—</p>
|
||||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
<p class="analytics-view__retention-note">Seen more than once</p>
|
||||||
<p class="text-xs text-neutral-600 mt-1">First-time visitors</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
<div class="analytics-view__retention-cell">
|
||||||
<p class="text-xs text-neutral-500 mb-1">Returning Players</p>
|
<p class="analytics-view__retention-label">Avg session duration</p>
|
||||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
<p class="analytics-view__retention-value">—</p>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Seen more than once</p>
|
<p class="analytics-view__retention-note">Per visit</p>
|
||||||
</div>
|
|
||||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
|
||||||
<p class="text-xs text-neutral-500 mb-1">Avg Session Duration</p>
|
|
||||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">Per visit</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-neutral-600 mt-4 text-center">Player retention analytics will be available in Phase 2</p>
|
<p class="analytics-view__retention-footer">
|
||||||
</div>
|
Player retention analytics will be available in phase 2.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.analytics-view {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__loading-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.analytics-view__stats {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__charts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.analytics-view__charts {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__chart-area {
|
||||||
|
height: 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.analytics-view__retention-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-cell {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-note {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analytics-view__retention-footer {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAutoDoorsStore } from '@/stores/autodoors'
|
import { useAutoDoorsStore } from '@/stores/autodoors'
|
||||||
import {
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
Save,
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
Play,
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
Download,
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
Plus,
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
Trash2,
|
|
||||||
DoorOpen,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const store = useAutoDoorsStore()
|
const store = useAutoDoorsStore()
|
||||||
|
|
||||||
@@ -21,13 +17,13 @@ const importConfigName = ref('')
|
|||||||
|
|
||||||
// Door types from the AutoDoors plugin
|
// Door types from the AutoDoors plugin
|
||||||
const doorTypes = [
|
const doorTypes = [
|
||||||
{ key: 'door.hinged.wood', label: 'Wooden Door', displayName: 'Wooden Door' },
|
{ key: 'door.hinged.wood', label: 'Wooden door', displayName: 'Wooden Door' },
|
||||||
{ key: 'door.hinged.metal', label: 'Sheet Metal Door', displayName: 'Sheet Metal Door' },
|
{ key: 'door.hinged.metal', label: 'Sheet metal door', displayName: 'Sheet Metal Door' },
|
||||||
{ key: 'door.hinged.toptier', label: 'Armored Door', displayName: 'Armored Door' },
|
{ key: 'door.hinged.toptier', label: 'Armored door', displayName: 'Armored Door' },
|
||||||
{ key: 'door.double.hinged.wood', label: 'Double Wooden Door', displayName: 'Double Wooden Door' },
|
{ key: 'door.double.hinged.wood', label: 'Double wooden door', displayName: 'Double Wooden Door' },
|
||||||
{ key: 'door.double.hinged.metal', label: 'Double Sheet Metal Door', displayName: 'Double Sheet Metal Door' },
|
{ key: 'door.double.hinged.metal', label: 'Double sheet metal door', displayName: 'Double Sheet Metal Door' },
|
||||||
{ key: 'door.double.hinged.toptier', label: 'Double Armored Door', displayName: 'Double Armored Door' },
|
{ key: 'door.double.hinged.toptier', label: 'Double armored door', displayName: 'Double Armored Door' },
|
||||||
{ key: 'floor.ladder.hatch', label: 'Ladder Hatch', displayName: 'Ladder Hatch' },
|
{ key: 'floor.ladder.hatch', label: 'Ladder hatch', displayName: 'Ladder Hatch' },
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -154,442 +150,472 @@ async function handleImport() {
|
|||||||
importConfigName.value = ''
|
importConfigName.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: coerce getConfigValue result to boolean for Switch
|
||||||
|
function getBool(path: string, def: boolean): boolean {
|
||||||
|
return !!getConfigValue(path, def)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="adv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="adv__head">
|
||||||
<h1 class="text-2xl font-bold text-white">Auto Doors</h1>
|
<div class="adv__head-id">
|
||||||
<div class="flex items-center gap-3">
|
<div class="adv__head-chip">
|
||||||
<button
|
<Icon name="door-open" :size="20" :stroke-width="2" />
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
New Config
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Plugin config</div>
|
||||||
|
<h1 class="adv__title">Auto doors</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" icon="plus" @click="showCreateModal = true">New config</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config Selector + Action Bar -->
|
<!-- Config action bar -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel>
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="adv__bar">
|
||||||
<!-- Config Selector -->
|
|
||||||
<select
|
<select
|
||||||
v-if="store.configs.length > 0"
|
v-if="store.configs.length > 0"
|
||||||
:value="store.currentConfig?.id || ''"
|
:value="store.currentConfig?.id ?? ''"
|
||||||
|
class="adv__config-select"
|
||||||
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
|
||||||
>
|
>
|
||||||
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
{{ c.config_name }}
|
{{ c.config_name }}{{ c.is_active ? ' (Active)' : '' }}
|
||||||
<template v-if="c.is_active"> (Active)</template>
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
<span v-else class="adv__no-configs">No configs yet</span>
|
||||||
|
|
||||||
<!-- Save -->
|
<Button
|
||||||
<button
|
icon="save"
|
||||||
@click="store.saveCurrentConfig()"
|
size="sm"
|
||||||
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isSaving"
|
||||||
>
|
@click="store.saveCurrentConfig()"
|
||||||
<Save class="w-4 h-4" />
|
>{{ store.isSaving ? 'Saving…' : 'Save' }}</Button>
|
||||||
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Apply to Server -->
|
<Button
|
||||||
<button
|
variant="outline"
|
||||||
@click="handleApply"
|
icon="play"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig || store.isApplying"
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isApplying"
|
||||||
>
|
@click="handleApply"
|
||||||
<Play class="w-4 h-4" />
|
>{{ store.isApplying ? 'Applying…' : 'Apply to server' }}</Button>
|
||||||
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Import from Server -->
|
<Button
|
||||||
<button
|
variant="secondary"
|
||||||
|
icon="download"
|
||||||
|
size="sm"
|
||||||
@click="showImportModal = true"
|
@click="showImportModal = true"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
>Import from server</Button>
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Import from Server
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Delete -->
|
<Button
|
||||||
<button
|
variant="danger-soft"
|
||||||
@click="handleDeleteConfig"
|
icon="trash-2"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig"
|
:disabled="!store.currentConfig"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
class="adv__bar-delete"
|
||||||
|
@click="handleDeleteConfig"
|
||||||
|
>Delete</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.isLoading" class="adv__loading">
|
||||||
|
<span class="adv__spinner" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<Panel v-else-if="!store.currentConfig">
|
||||||
|
<EmptyState
|
||||||
|
icon="door-open"
|
||||||
|
title="No AutoDoors config selected"
|
||||||
|
description="Create a new config, import from server, or select one from the dropdown above."
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4" />
|
<template #action>
|
||||||
Delete
|
<Button icon="plus" @click="showCreateModal = true">Create first config</Button>
|
||||||
</button>
|
</template>
|
||||||
</div>
|
</EmptyState>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Config editor -->
|
||||||
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
<template v-else>
|
||||||
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
<!-- Global settings -->
|
||||||
</div>
|
<Panel title="Global settings">
|
||||||
|
<!-- Delay sliders -->
|
||||||
<!-- No Config Selected -->
|
<div class="adv__delay-grid">
|
||||||
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
<div class="adv__field">
|
||||||
<DoorOpen class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
<div class="adv__field-label">Default delay (seconds)</div>
|
||||||
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No AutoDoors Config Selected</h2>
|
<div class="adv__field-hint">Time before door auto-closes</div>
|
||||||
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
<div class="adv__slider-row">
|
||||||
<button
|
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
|
||||||
>
|
|
||||||
Create First Config
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Config Editor -->
|
|
||||||
<div v-else class="space-y-6">
|
|
||||||
<!-- Settings Section -->
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<SettingsIcon class="w-4 h-4 text-neutral-400" />
|
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Global Settings</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delay Settings -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-neutral-200 mb-1">Default Delay (seconds)</label>
|
|
||||||
<p class="text-xs text-neutral-500 mb-2">Time before door auto-closes</p>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue('DefaultDelay', 5)"
|
:value="getConfigValue('DefaultDelay', 5)"
|
||||||
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="30"
|
max="30"
|
||||||
step="1"
|
step="1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="adv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue('DefaultDelay', 5)"
|
:value="getConfigValue('DefaultDelay', 5)"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
class="cc-num-input adv__delay-num"
|
||||||
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
|
@input="setConfigValue('DefaultDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
min="1"
|
|
||||||
max="30"
|
|
||||||
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500">sec</span>
|
<span class="adv__unit">sec</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="adv__field">
|
||||||
<label class="block text-sm text-neutral-200 mb-1">Minimum Delay (seconds)</label>
|
<div class="adv__field-label">Minimum delay (seconds)</div>
|
||||||
<p class="text-xs text-neutral-500 mb-2">Lowest delay a player can set</p>
|
<div class="adv__field-hint">Lowest delay a player can set</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="adv__slider-row">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue('MinimumDelay', 5)"
|
:value="getConfigValue('MinimumDelay', 5)"
|
||||||
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="30"
|
max="30"
|
||||||
step="1"
|
step="1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="adv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue('MinimumDelay', 5)"
|
:value="getConfigValue('MinimumDelay', 5)"
|
||||||
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="30"
|
max="30"
|
||||||
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input adv__delay-num"
|
||||||
|
@input="setConfigValue('MinimumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500">sec</span>
|
<span class="adv__unit">sec</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="adv__field">
|
||||||
<label class="block text-sm text-neutral-200 mb-1">Maximum Delay (seconds)</label>
|
<div class="adv__field-label">Maximum delay (seconds)</div>
|
||||||
<p class="text-xs text-neutral-500 mb-2">Highest delay a player can set</p>
|
<div class="adv__field-hint">Highest delay a player can set</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="adv__slider-row">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue('MaximumDelay', 30)"
|
:value="getConfigValue('MaximumDelay', 30)"
|
||||||
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="60"
|
max="60"
|
||||||
step="1"
|
step="1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="adv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue('MaximumDelay', 30)"
|
:value="getConfigValue('MaximumDelay', 30)"
|
||||||
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="60"
|
max="60"
|
||||||
class="w-16 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input adv__delay-num"
|
||||||
|
@input="setConfigValue('MaximumDelay', Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500">sec</span>
|
<span class="adv__unit">sec</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global Toggles -->
|
<!-- Global toggles -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="adv__toggles adv__mt">
|
||||||
<div class="flex items-center justify-between">
|
<div class="adv__toggle-row">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Default Enabled</label>
|
<div class="adv__toggle-label">Default enabled</div>
|
||||||
<p class="text-xs text-neutral-500">Auto-close enabled for new players by default</p>
|
<div class="adv__toggle-sub">Auto-close enabled for new players by default</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('GlobalSettings.defaultEnabled', !getConfigValue('GlobalSettings.defaultEnabled', true))"
|
:model-value="getBool('GlobalSettings.defaultEnabled', true)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('GlobalSettings.defaultEnabled', v)"
|
||||||
:class="getConfigValue('GlobalSettings.defaultEnabled', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('GlobalSettings.defaultEnabled', true) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__toggle-row">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Allow Unowned Doors</label>
|
<div class="adv__toggle-label">Allow unowned doors</div>
|
||||||
<p class="text-xs text-neutral-500">Auto-close doors that the player does not own</p>
|
<div class="adv__toggle-sub">Auto-close doors that the player does not own</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('GlobalSettings.useUnownedDoor', !getConfigValue('GlobalSettings.useUnownedDoor', false))"
|
:model-value="getBool('GlobalSettings.useUnownedDoor', false)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('GlobalSettings.useUnownedDoor', v)"
|
||||||
:class="getConfigValue('GlobalSettings.useUnownedDoor', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('GlobalSettings.useUnownedDoor', false) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__toggle-row">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Exclude Door Controller</label>
|
<div class="adv__toggle-label">Exclude door controller</div>
|
||||||
<p class="text-xs text-neutral-500">Skip doors that have a Code Lock or Key Lock</p>
|
<div class="adv__toggle-sub">Skip doors that have a code lock or key lock</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('GlobalSettings.excludeDoorController', !getConfigValue('GlobalSettings.excludeDoorController', false))"
|
:model-value="getBool('GlobalSettings.excludeDoorController', false)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('GlobalSettings.excludeDoorController', v)"
|
||||||
:class="getConfigValue('GlobalSettings.excludeDoorController', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('GlobalSettings.excludeDoorController', false) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__toggle-row">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Cancel on Player Death</label>
|
<div class="adv__toggle-label">Cancel on player death</div>
|
||||||
<p class="text-xs text-neutral-500">Cancel auto-close if the player dies</p>
|
<div class="adv__toggle-sub">Cancel auto-close if the player dies</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('GlobalSettings.cancelOnKill', !getConfigValue('GlobalSettings.cancelOnKill', false))"
|
:model-value="getBool('GlobalSettings.cancelOnKill', false)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('GlobalSettings.cancelOnKill', v)"
|
||||||
:class="getConfigValue('GlobalSettings.cancelOnKill', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('GlobalSettings.cancelOnKill', false) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__toggle-row">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Use Permissions</label>
|
<div class="adv__toggle-label">Use permissions</div>
|
||||||
<p class="text-xs text-neutral-500">Require Oxide permission to use auto-close</p>
|
<div class="adv__toggle-sub">Require Oxide permission to use auto-close</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('UsePermissions', !getConfigValue('UsePermissions', false))"
|
:model-value="getBool('UsePermissions', false)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('UsePermissions', v)"
|
||||||
:class="getConfigValue('UsePermissions', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('UsePermissions', false) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__toggle-row">
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Clear Data on Map Wipe</label>
|
<div class="adv__toggle-label">Clear data on map wipe</div>
|
||||||
<p class="text-xs text-neutral-500">Reset all player preferences on map wipe</p>
|
<div class="adv__toggle-sub">Reset all player preferences on map wipe</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('ClearDataOnWipe', !getConfigValue('ClearDataOnWipe', false))"
|
:model-value="getBool('ClearDataOnWipe', false)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('ClearDataOnWipe', v)"
|
||||||
:class="getConfigValue('ClearDataOnWipe', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('ClearDataOnWipe', false) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Door Types Section -->
|
<!-- Door types -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Door types" subtitle="Enable or disable auto-close for each door type.">
|
||||||
<div class="flex items-center gap-2">
|
<div class="adv__toggles">
|
||||||
<DoorOpen class="w-4 h-4 text-neutral-400" />
|
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Door Types</h3>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-500">Enable or disable auto-close for each door type.</p>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div
|
<div
|
||||||
v-for="door in doorTypes"
|
v-for="door in doorTypes"
|
||||||
:key="door.key"
|
:key="door.key"
|
||||||
class="flex items-center justify-between py-2 border-b border-neutral-800 last:border-0"
|
class="adv__toggle-row"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">{{ door.label }}</label>
|
<div class="adv__toggle-label">{{ door.label }}</div>
|
||||||
<p class="text-xs text-neutral-500 font-mono">{{ door.key }}</p>
|
<div class="adv__toggle-sub adv__mono">{{ door.key }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue(`DoorSettings.${door.key}.enabled`, !getConfigValue(`DoorSettings.${door.key}.enabled`, true))"
|
:model-value="getBool(`DoorSettings.${door.key}.enabled`, true)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue(`DoorSettings.${door.key}.enabled`, v)"
|
||||||
:class="getConfigValue(`DoorSettings.${door.key}.enabled`, true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue(`DoorSettings.${door.key}.enabled`, true) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Permission Groups Section -->
|
<!-- Permission group overrides -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Permission group overrides" subtitle="Override the default delay for specific Oxide permission groups.">
|
||||||
<div class="flex items-center justify-between">
|
<template #actions>
|
||||||
<div class="flex items-center gap-2">
|
<Button size="sm" icon="plus" variant="secondary" @click="addPermissionGroup">Add group</Button>
|
||||||
<SettingsIcon class="w-4 h-4 text-neutral-400" />
|
</template>
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Permission Group Overrides</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="addPermissionGroup"
|
|
||||||
class="flex items-center gap-1 px-3 py-1 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-3 h-3" />
|
|
||||||
Add Group
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-500">Override the default delay for specific Oxide permission groups.</p>
|
|
||||||
|
|
||||||
<div v-if="getPermissionGroups().length === 0" class="text-sm text-neutral-500 text-center py-4">
|
<div v-if="getPermissionGroups().length === 0" class="adv__perm-empty">
|
||||||
No permission group overrides configured.
|
No permission group overrides configured.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="adv__perm-list">
|
||||||
<div
|
<div
|
||||||
v-for="(group, index) in getPermissionGroups()"
|
v-for="(group, index) in getPermissionGroups()"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center gap-3"
|
class="adv__perm-row"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
:value="group.name"
|
:value="group.name"
|
||||||
@input="updatePermissionGroupName(group.name, ($event.target as HTMLInputElement).value)"
|
type="text"
|
||||||
|
class="cc-text-input adv__perm-name"
|
||||||
placeholder="Group name (e.g. vip)"
|
placeholder="Group name (e.g. vip)"
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
@input="updatePermissionGroupName(group.name, ($event.target as HTMLInputElement).value)"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="group.delay"
|
:value="group.delay"
|
||||||
@input="updatePermissionGroupDelay(group.name, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="1"
|
min="1"
|
||||||
max="60"
|
max="60"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-2 text-neutral-200 text-sm text-center"
|
class="cc-num-input adv__perm-delay"
|
||||||
|
@input="updatePermissionGroupDelay(group.name, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500">sec</span>
|
<span class="adv__unit">sec</span>
|
||||||
<button
|
<button class="adv__del-btn" title="Remove group" @click="removePermissionGroup(group.name)">
|
||||||
@click="removePermissionGroup(group.name)"
|
<Icon name="trash-2" :size="15" />
|
||||||
class="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Create Config Modal -->
|
<!-- Create config modal -->
|
||||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
<div v-if="showCreateModal" class="adv__modal-backdrop" @click.self="showCreateModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="adv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New AutoDoors Config</h2>
|
<h2 class="adv__modal-title">New AutoDoors config</h2>
|
||||||
<div class="space-y-4">
|
<div class="adv__modal-body">
|
||||||
<div>
|
<div class="adv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
<label class="adv__field-label">Config name</label>
|
||||||
<input
|
<input
|
||||||
v-model="newConfigName"
|
v-model="newConfigName"
|
||||||
placeholder="e.g. 5 Second Close"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. 5 second close"
|
||||||
@keydown.enter="handleCreateConfig"
|
@keydown.enter="handleCreateConfig"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="adv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
<label class="adv__field-label">Description (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newConfigDesc"
|
v-model="newConfigDesc"
|
||||||
rows="2"
|
rows="2"
|
||||||
|
class="cc-textarea"
|
||||||
placeholder="What is this config for?"
|
placeholder="What is this config for?"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
|
||||||
<button
|
|
||||||
@click="handleCreateConfig"
|
|
||||||
:disabled="!newConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__modal-footer">
|
||||||
|
<Button variant="ghost" @click="showCreateModal = false">Cancel</Button>
|
||||||
|
<Button :disabled="!newConfigName.trim()" @click="handleCreateConfig">Create</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import from Server Modal -->
|
<!-- Import from server modal -->
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
<div v-if="showImportModal" class="adv__modal-backdrop" @click.self="showImportModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="adv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
<h2 class="adv__modal-title">Import from server</h2>
|
||||||
<p class="text-sm text-neutral-400 mb-4">
|
<p class="adv__modal-desc">Import the current AutoDoors config from your live server. This will create a new config profile.</p>
|
||||||
Import the current AutoDoors config from your live server. This will create a new config profile.
|
<div class="adv__modal-body">
|
||||||
</p>
|
<div class="adv__field">
|
||||||
<div class="space-y-4">
|
<label class="adv__field-label">Config name</label>
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
|
||||||
<input
|
<input
|
||||||
v-model="importConfigName"
|
v-model="importConfigName"
|
||||||
placeholder="e.g. Imported Server Config"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. Imported server config"
|
||||||
@keydown.enter="handleImport"
|
@keydown.enter="handleImport"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
|
||||||
<button
|
|
||||||
@click="handleImport"
|
|
||||||
:disabled="!importConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adv__modal-footer">
|
||||||
|
<Button variant="ghost" @click="showImportModal = false">Cancel</Button>
|
||||||
|
<Button :disabled="!importConfigName.trim()" @click="handleImport">Import</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.adv { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.adv__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.adv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.adv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.adv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config action bar */
|
||||||
|
.adv__bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.adv__config-select {
|
||||||
|
appearance: none; height: var(--control-h-md); padding: 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;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.adv__config-select:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.adv__no-configs { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
.adv__bar-delete { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.adv__loading { display: flex; align-items: center; justify-content: center; padding: 60px; }
|
||||||
|
.adv__spinner {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--accent);
|
||||||
|
border-top-color: transparent; animation: adv-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes adv-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Delay grid */
|
||||||
|
.adv__delay-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||||
|
@media (max-width: 700px) { .adv__delay-grid { grid-template-columns: 1fr; } }
|
||||||
|
.adv__slider-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||||||
|
.adv__slider { flex: 1; cursor: pointer; }
|
||||||
|
.adv__delay-num { width: 60px; flex: none; text-align: center; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.adv__unit { font-size: var(--text-xs); color: var(--text-tertiary); flex: none; }
|
||||||
|
.adv__mt { margin-top: 20px; }
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
.adv__field { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.adv__field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.adv__field-hint { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
/* Toggle rows */
|
||||||
|
.adv__toggles { display: flex; flex-direction: column; }
|
||||||
|
.adv__toggle-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.adv__toggle-row:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||||
|
.adv__toggle-row:first-child { padding-top: 0; }
|
||||||
|
.adv__toggle-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.adv__toggle-sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||||
|
.adv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Permission groups */
|
||||||
|
.adv__perm-empty { font-size: var(--text-sm); color: var(--text-tertiary); text-align: center; padding: 20px 0; }
|
||||||
|
.adv__perm-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.adv__perm-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.adv__perm-name { flex: 1; }
|
||||||
|
.adv__perm-delay { width: 72px; flex: none; text-align: center; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.adv__del-btn {
|
||||||
|
width: 34px; height: 34px; border-radius: var(--radius-sm); border: none; background: transparent;
|
||||||
|
color: var(--danger); display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; transition: var(--transition-colors); flex: none;
|
||||||
|
}
|
||||||
|
.adv__del-btn:hover { background: var(--status-offline-soft); }
|
||||||
|
|
||||||
|
/* Shared token inputs */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%; background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 11px; font-family: var(--font-sans); font-size: var(--text-sm);
|
||||||
|
resize: none; outline: 0; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.cc-textarea:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-text-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-text-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-num-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-num-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.adv__modal-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 50;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||||
|
}
|
||||||
|
.adv__modal {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default);
|
||||||
|
width: 100%; max-width: 440px; padding: 24px; display: flex; flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
|
.adv__modal-title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
|
||||||
|
.adv__modal-desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.5; }
|
||||||
|
.adv__modal-body { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.adv__modal-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { FileText, Tag, Loader2 } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
interface ChangelogEntry {
|
interface ChangelogEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -38,13 +42,13 @@ function loadMore() {
|
|||||||
fetchChangelog()
|
fetchChangelog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCategoryColor(category: string): string {
|
function categoryTone(category: string): 'online' | 'offline' | 'info' | 'warn' | 'neutral' {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 'feature': return 'bg-green-500/10 text-green-400'
|
case 'feature': return 'online'
|
||||||
case 'bugfix': return 'bg-red-500/10 text-red-400'
|
case 'bugfix': return 'offline'
|
||||||
case 'module': return 'bg-blue-500/10 text-blue-400'
|
case 'module': return 'info'
|
||||||
case 'security': return 'bg-yellow-500/10 text-yellow-400'
|
case 'security': return 'warn'
|
||||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
default: return 'neutral'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,60 +58,127 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="cl">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="cl__head">
|
||||||
<FileText class="w-5 h-5 text-oxide-500" />
|
<div class="cl__head-id">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Changelog</h1>
|
<div class="cl__head-chip">
|
||||||
|
<Icon name="file-text" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Platform</div>
|
||||||
|
<h1 class="cl__title">Changelog</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Changelog Feed -->
|
<!-- Loading state — first load -->
|
||||||
<div class="space-y-4">
|
<div v-if="isLoading && entries.length === 0" class="cl__loading">
|
||||||
<div
|
<Icon name="loader" :size="22" class="cl__spin" />
|
||||||
|
<span>Loading changelog…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<Panel v-else-if="!isLoading && entries.length === 0" title="Changelog">
|
||||||
|
<EmptyState
|
||||||
|
icon="file-text"
|
||||||
|
title="No entries yet"
|
||||||
|
description="Platform changelog entries will appear here."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Entry feed -->
|
||||||
|
<template v-else>
|
||||||
|
<Panel
|
||||||
v-for="entry in entries"
|
v-for="entry in entries"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"
|
:title="entry.title"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between mb-3">
|
<template #actions>
|
||||||
<div class="flex items-center gap-3">
|
<Badge tone="accent" :mono="true">{{ entry.version }}</Badge>
|
||||||
<div class="flex items-center gap-2 px-2 py-1 bg-oxide-500/10 border border-oxide-500/20 rounded-lg">
|
<Badge :tone="categoryTone(entry.category)">{{ entry.category }}</Badge>
|
||||||
<Tag class="w-3 h-3 text-oxide-400" />
|
<span class="cl__date">{{ new Date(entry.published_at).toLocaleDateString() }}</span>
|
||||||
<span class="text-xs font-mono text-oxide-400">{{ entry.version }}</span>
|
</template>
|
||||||
</div>
|
<div class="cl__body">{{ entry.body }}</div>
|
||||||
<span
|
</Panel>
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full capitalize"
|
|
||||||
:class="getCategoryColor(entry.category)"
|
<!-- Load more spinner -->
|
||||||
>
|
<div v-if="isLoading" class="cl__loading">
|
||||||
{{ entry.category }}
|
<Icon name="loader" :size="20" class="cl__spin" />
|
||||||
</span>
|
<span>Loading more…</span>
|
||||||
</div>
|
|
||||||
<span class="text-xs text-neutral-500">{{ new Date(entry.published_at).toLocaleDateString() }}</span>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-bold text-neutral-100 mb-2">{{ entry.title }}</h3>
|
|
||||||
<div class="text-sm text-neutral-300 whitespace-pre-line leading-relaxed">
|
|
||||||
{{ entry.body }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Load more button -->
|
||||||
<div v-if="isLoading" class="flex justify-center py-6">
|
<div v-else-if="hasMore" class="cl__more">
|
||||||
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
|
<Button variant="secondary" @click="loadMore">Load more</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Load More -->
|
<!-- End of list -->
|
||||||
<div v-else-if="hasMore" class="flex justify-center">
|
<div v-else class="cl__end">No more changelog entries</div>
|
||||||
<button
|
</template>
|
||||||
@click="loadMore"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Load More
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- End of List -->
|
|
||||||
<div v-else class="text-center py-6 text-sm text-neutral-500">
|
|
||||||
No more changelog entries
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cl {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.cl__head { display: flex; align-items: center; }
|
||||||
|
.cl__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.cl__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.cl__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entry body */
|
||||||
|
.cl__body {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-line;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date label in actions slot */
|
||||||
|
.cl__date {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading / footer states */
|
||||||
|
.cl__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 28px 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
@keyframes cl-spin { to { transform: rotate(360deg); } }
|
||||||
|
.cl__spin { animation: cl-spin 0.7s linear infinite; }
|
||||||
|
|
||||||
|
.cl__more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.cl__end {
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { ChatMessage } from '@/types'
|
import type { ChatMessage } from '@/types'
|
||||||
import { MessageSquare, Search, Flag, RefreshCw } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.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 Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
@@ -32,12 +39,18 @@ const filteredMessages = computed(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
function channelBadgeClass(channel: string): string {
|
const channelTabItems = computed(() => [
|
||||||
|
{ value: 'all', label: 'All', count: messages.value.length },
|
||||||
|
{ value: 'global', label: 'Global' },
|
||||||
|
{ value: 'team', label: 'Team' },
|
||||||
|
{ value: 'server', label: 'Server' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function channelTone(channel: string): 'accent' | 'info' | 'neutral' {
|
||||||
switch (channel) {
|
switch (channel) {
|
||||||
case 'global': return 'bg-oxide-500/15 text-oxide-400'
|
case 'global': return 'accent'
|
||||||
case 'team': return 'bg-blue-500/15 text-blue-400'
|
case 'team': return 'info'
|
||||||
case 'server': return 'bg-neutral-700/50 text-neutral-400'
|
default: return 'neutral'
|
||||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,89 +89,163 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="clv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="clv__head">
|
||||||
<div class="flex items-center gap-3">
|
<div class="clv__head-id">
|
||||||
<MessageSquare class="w-5 h-5 text-oxide-500" />
|
<div class="clv__head-chip">
|
||||||
|
<Icon name="message-square" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Chat Log</h1>
|
<div class="t-eyebrow">Chat log</div>
|
||||||
<p class="text-sm text-neutral-500 mt-0.5">{{ messages.length }} messages</p>
|
<h1 class="clv__title">Chat log</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="clv__head-actions">
|
||||||
@click="fetchMessages"
|
<div class="clv__stat-pill">
|
||||||
|
<span class="clv__stat-num">{{ messages.length }}</span>
|
||||||
|
<span class="clv__stat-label">messages</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="refresh-cw"
|
||||||
|
:loading="isLoading"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 rounded-lg transition-colors"
|
@click="fetchMessages"
|
||||||
>
|
>Refresh</Button>
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
</div>
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="clv__filters">
|
||||||
<div class="relative flex-1 max-w-sm">
|
<Input
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
icon="search"
|
||||||
placeholder="Search messages, players, or Steam IDs..."
|
placeholder="Search messages, players, or Steam IDs…"
|
||||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
size="sm"
|
||||||
|
style="max-width: 340px;"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Tabs v-model="channelFilter" :items="channelTabItems" />
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
|
||||||
<button
|
|
||||||
v-for="opt in (['all', 'global', 'team', 'server'] as const)"
|
|
||||||
:key="opt"
|
|
||||||
@click="channelFilter = opt"
|
|
||||||
class="px-3 py-2 text-sm font-medium transition-colors capitalize"
|
|
||||||
:class="channelFilter === opt
|
|
||||||
? 'bg-oxide-500/15 text-oxide-400'
|
|
||||||
: 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
{{ opt }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages panel -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg divide-y divide-neutral-800">
|
<Panel :flush-body="true">
|
||||||
<div v-if="filteredMessages.length === 0" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
<!-- Empty state -->
|
||||||
<template v-if="isLoading">Loading chat messages...</template>
|
<EmptyState
|
||||||
<template v-else-if="searchQuery">No messages matching "{{ searchQuery }}"</template>
|
v-if="filteredMessages.length === 0 && !isLoading"
|
||||||
<template v-else>No chat messages yet. Messages will appear when the server is active.</template>
|
icon="message-square"
|
||||||
|
:title="searchQuery ? 'No messages found' : 'No chat messages'"
|
||||||
|
:description="searchQuery
|
||||||
|
? `No messages matching "${searchQuery}".`
|
||||||
|
: 'No chat messages yet. Messages will appear when the server is active.'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-else-if="isLoading && filteredMessages.length === 0" class="clv__loading">
|
||||||
|
<Icon name="loader" :size="20" class="clv__spin" />
|
||||||
|
<span>Loading chat messages…</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Message list -->
|
||||||
|
<div v-else class="clv__messages">
|
||||||
<div
|
<div
|
||||||
v-for="msg in filteredMessages"
|
v-for="msg in filteredMessages"
|
||||||
:key="msg.id"
|
:key="msg.id"
|
||||||
class="flex items-start gap-4 px-4 py-3 hover:bg-neutral-800/50 transition-colors"
|
class="clv__row"
|
||||||
:class="{ 'bg-red-500/5 border-l-2 border-l-red-500/30': msg.flagged }"
|
:class="{ 'clv__row--flagged': msg.flagged }"
|
||||||
>
|
>
|
||||||
<div class="shrink-0 text-right w-20">
|
<!-- Timestamp -->
|
||||||
<p class="text-xs text-neutral-500">{{ formatDate(msg.created_at) }}</p>
|
<div class="clv__ts">
|
||||||
<p class="text-xs text-neutral-600">{{ formatTime(msg.created_at) }}</p>
|
<span class="clv__date">{{ formatDate(msg.created_at) }}</span>
|
||||||
|
<span class="clv__time">{{ formatTime(msg.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
class="shrink-0 text-xs font-medium px-2 py-0.5 rounded-full mt-0.5"
|
<!-- Channel badge -->
|
||||||
:class="channelBadgeClass(msg.channel)"
|
<Badge :tone="channelTone(msg.channel)" size="md">{{ msg.channel }}</Badge>
|
||||||
>
|
|
||||||
{{ msg.channel }}
|
<!-- Message body -->
|
||||||
</span>
|
<div class="clv__body">
|
||||||
<div class="flex-1 min-w-0">
|
<span class="clv__player">{{ msg.player_name }}</span>
|
||||||
<span class="text-sm font-medium text-oxide-400">{{ msg.player_name }}</span>
|
<span class="clv__steam">{{ msg.steam_id }}</span>
|
||||||
<span class="text-sm text-neutral-500 ml-2 font-mono">{{ msg.steam_id }}</span>
|
<p class="clv__text">{{ msg.message }}</p>
|
||||||
<p class="text-sm text-neutral-300 mt-0.5 break-words">{{ msg.message }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
|
<!-- Flag toggle -->
|
||||||
|
<IconButton
|
||||||
|
icon="bookmark"
|
||||||
|
:variant="msg.flagged ? 'accent' : 'ghost'"
|
||||||
|
size="sm"
|
||||||
|
:label="msg.flagged ? 'Unflag message' : 'Flag message'"
|
||||||
@click="toggleFlag(msg)"
|
@click="toggleFlag(msg)"
|
||||||
class="shrink-0 p-1 rounded transition-colors"
|
/>
|
||||||
:class="msg.flagged ? 'text-red-400 hover:text-red-300' : 'text-neutral-600 hover:text-neutral-400'"
|
|
||||||
:title="msg.flagged ? 'Unflag message' : 'Flag message'"
|
|
||||||
>
|
|
||||||
<Flag class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.clv { max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.clv__head {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
flex-wrap: wrap; gap: 12px;
|
||||||
|
}
|
||||||
|
.clv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.clv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.clv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
.clv__head-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
/* Stat pill */
|
||||||
|
.clv__stat-pill { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.clv__stat-num { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 700; color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||||
|
.clv__stat-label { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.clv__filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.clv__loading {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||||
|
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
@keyframes clv-spin { to { transform: rotate(360deg); } }
|
||||||
|
.clv__spin { animation: clv-spin 0.7s linear infinite; }
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.clv__messages { display: flex; flex-direction: column; }
|
||||||
|
.clv__row {
|
||||||
|
display: flex; align-items: flex-start; gap: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.clv__row:last-child { border-bottom: 0; }
|
||||||
|
.clv__row:hover { background: var(--surface-hover); }
|
||||||
|
.clv__row--flagged {
|
||||||
|
background: var(--status-offline-soft);
|
||||||
|
border-left: 3px solid var(--status-offline-border);
|
||||||
|
}
|
||||||
|
.clv__row--flagged:hover { background: var(--status-offline-soft); filter: brightness(1.04); }
|
||||||
|
|
||||||
|
/* Timestamp */
|
||||||
|
.clv__ts { display: flex; flex-direction: column; align-items: flex-end; min-width: 68px; flex: none; padding-top: 1px; }
|
||||||
|
.clv__date { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
.clv__time { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Message body */
|
||||||
|
.clv__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.clv__player { font-size: var(--text-sm); font-weight: 600; color: var(--accent-text); }
|
||||||
|
.clv__steam { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-left: 8px; }
|
||||||
|
.clv__text { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.5; word-break: break-word; margin: 0; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,18 +2,23 @@
|
|||||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
|
||||||
import { Send, Terminal, Trash2 } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import ConsoleLine from '@/components/ds/data/ConsoleLine.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
const ws = useWebSocket()
|
const ws = useWebSocket()
|
||||||
|
|
||||||
interface ConsoleLine {
|
interface LogLine {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
text: string
|
text: string
|
||||||
type: 'info' | 'warning' | 'error' | 'command' | 'system'
|
type: 'info' | 'warning' | 'error' | 'command' | 'system'
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = ref<ConsoleLine[]>([])
|
const lines = ref<LogLine[]>([])
|
||||||
const commandInput = ref('')
|
const commandInput = ref('')
|
||||||
const consoleEl = ref<HTMLElement | null>(null)
|
const consoleEl = ref<HTMLElement | null>(null)
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
@@ -22,7 +27,7 @@ function now(): string {
|
|||||||
return new Date().toLocaleTimeString('en-US', { hour12: false })
|
return new Date().toLocaleTimeString('en-US', { hour12: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
function addLine(text: string, type: ConsoleLine['type'] = 'info') {
|
function addLine(text: string, type: LogLine['type'] = 'info') {
|
||||||
lines.value.push({ timestamp: now(), text, type })
|
lines.value.push({ timestamp: now(), text, type })
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
@@ -60,13 +65,12 @@ function clearConsole() {
|
|||||||
lines.value = []
|
lines.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineColor(type: ConsoleLine['type']): string {
|
function lineLevel(type: LogLine['type']): 'cmd' | 'warn' | 'error' | 'info' {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'command': return 'text-oxide-400'
|
case 'command': return 'cmd'
|
||||||
case 'warning': return 'text-yellow-400'
|
case 'warning': return 'warn'
|
||||||
case 'error': return 'text-red-400'
|
case 'error': return 'error'
|
||||||
case 'system': return 'text-neutral-500'
|
default: return 'info'
|
||||||
default: return 'text-neutral-300'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,10 +87,10 @@ function handleWebSocketMessage(message: WebSocketMessage) {
|
|||||||
let unsubscribe: (() => void) | null = null
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
addLine('Corrosion Console initialized.', 'system')
|
addLine('Corrosion console initialized.', 'system')
|
||||||
addLine('Type a command and press Enter to send it to the server.', 'system')
|
addLine('Type a command and press Enter to send it to the server.', 'system')
|
||||||
if (server.connection?.connection_status !== 'connected') {
|
if (server.connection?.connection_status !== 'connected') {
|
||||||
addLine('WARNING: Server is not connected. Commands will fail.', 'warning')
|
addLine('Warning: server is not connected. Commands will fail.', 'warning')
|
||||||
}
|
}
|
||||||
unsubscribe = ws.subscribe(handleWebSocketMessage)
|
unsubscribe = ws.subscribe(handleWebSocketMessage)
|
||||||
})
|
})
|
||||||
@@ -100,70 +104,127 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 h-full flex flex-col">
|
<div class="cv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="cv__head">
|
||||||
<div class="flex items-center gap-3">
|
<div class="cv__head-id">
|
||||||
<Terminal class="w-5 h-5 text-oxide-500" />
|
<div class="cv__head-chip">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Server Console</h1>
|
<Icon name="terminal" :size="20" :stroke-width="2" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="t-eyebrow">Server management</div>
|
||||||
<span
|
<h1 class="cv__title">Console</h1>
|
||||||
class="h-2 w-2 rounded-full"
|
|
||||||
:class="server.connection?.connection_status === 'connected' ? 'bg-green-500' : 'bg-red-500'"
|
|
||||||
/>
|
|
||||||
<span class="text-neutral-400">
|
|
||||||
{{ server.connection?.connection_status === 'connected' ? 'Connected' : 'Disconnected' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
@click="clearConsole"
|
<div class="cv__head-actions">
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
<Badge
|
||||||
|
:tone="server.connection?.connection_status === 'connected' ? 'online' : 'offline'"
|
||||||
|
:dot="true"
|
||||||
|
:pulse="server.connection?.connection_status === 'connected'"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3.5 h-3.5" />
|
{{ server.connection?.connection_status === 'connected' ? 'Connected' : 'Disconnected' }}
|
||||||
Clear
|
</Badge>
|
||||||
</button>
|
<Button variant="ghost" size="sm" icon="trash-2" @click="clearConsole">Clear</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Console output -->
|
<!-- Console panel -->
|
||||||
<div
|
<Panel :flush-body="true" title="Output">
|
||||||
ref="consoleEl"
|
<div ref="consoleEl" class="cv__output">
|
||||||
class="flex-1 bg-black/80 border border-neutral-800 rounded-t-lg p-4 overflow-y-auto font-mono text-sm leading-relaxed min-h-0"
|
<div v-if="lines.length === 0" class="cv__empty">
|
||||||
>
|
|
||||||
<div v-if="lines.length === 0" class="text-neutral-600 italic">
|
|
||||||
No output yet. Send a command to get started.
|
No output yet. Send a command to get started.
|
||||||
</div>
|
</div>
|
||||||
<div
|
<ConsoleLine
|
||||||
v-for="(line, i) in lines"
|
v-for="(line, i) in lines"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="flex gap-3"
|
:time="line.timestamp"
|
||||||
>
|
:level="lineLevel(line.type)"
|
||||||
<span class="text-neutral-600 shrink-0 select-none">{{ line.timestamp }}</span>
|
>{{ line.text }}</ConsoleLine>
|
||||||
<span :class="lineColor(line.type)" class="whitespace-pre-wrap break-all">{{ line.text }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="cv__bar">
|
||||||
|
<span class="cv__bar-prompt">$</span>
|
||||||
<!-- Command input -->
|
<Input
|
||||||
<div class="flex bg-neutral-900 border border-t-0 border-neutral-800 rounded-b-lg overflow-hidden">
|
|
||||||
<span class="flex items-center px-3 text-oxide-500 font-mono text-sm select-none">$</span>
|
|
||||||
<input
|
|
||||||
v-model="commandInput"
|
v-model="commandInput"
|
||||||
@keydown.enter="handleSend"
|
:mono="true"
|
||||||
type="text"
|
size="sm"
|
||||||
placeholder="say Hello everyone..."
|
placeholder="say Hello everyone…"
|
||||||
:disabled="sending"
|
:disabled="sending"
|
||||||
class="flex-1 bg-transparent py-3 text-neutral-100 placeholder-neutral-600 font-mono text-sm focus:outline-none disabled:opacity-50"
|
style="flex: 1"
|
||||||
|
@keydown.enter="handleSend"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
@click="handleSend"
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="corner-down-left"
|
||||||
|
:loading="sending"
|
||||||
:disabled="!commandInput.trim() || sending"
|
:disabled="!commandInput.trim() || sending"
|
||||||
class="flex items-center gap-2 px-4 text-sm font-medium text-oxide-400 hover:text-oxide-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
@click="handleSend"
|
||||||
>
|
>Send</Button>
|
||||||
<Send class="w-4 h-4" />
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cv {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.cv__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.cv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.cv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.cv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
.cv__head-actions { display: flex; align-items: center; gap: 9px; }
|
||||||
|
|
||||||
|
/* Output area */
|
||||||
|
.cv__output {
|
||||||
|
min-height: 420px;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.cv__empty {
|
||||||
|
padding: 16px 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Command bar */
|
||||||
|
.cv__bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
background: var(--surface-base);
|
||||||
|
}
|
||||||
|
.cv__bar-prompt {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent-text);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,155 +1,497 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
/**
|
||||||
|
* DashboardView — Fleet / Solo dashboard.
|
||||||
|
* Fleet: multi-game server cockpit (representative mock data — pending multi-instance backend).
|
||||||
|
* Solo: single-server detail wired to the real useServerStore where data exists.
|
||||||
|
*
|
||||||
|
* View toggle (Fleet / Solo) lives inside the page so the shell (DashboardLayout) stays clean.
|
||||||
|
* Routing stays at path '/', no new routes added.
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useWipeStore } from '@/stores/wipe'
|
import { useThemeGame } from '@/composables/useThemeGame'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import ServerCard from '@/components/ds/data/ServerCard.vue'
|
||||||
|
import ConsoleLine from '@/components/ds/data/ConsoleLine.vue'
|
||||||
|
import ResourceMeter from '@/components/ds/data/ResourceMeter.vue'
|
||||||
|
import PlayersChart from '@/components/ds/data/PlayersChart.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
|
import {
|
||||||
|
MOCK_SERVERS, MOCK_FEED, MOCK_WIPES, buildStats,
|
||||||
|
type MockServer, type GameKey,
|
||||||
|
} from './_dashboardMock'
|
||||||
|
|
||||||
const router = useRouter()
|
// ---- Stores / composables ----
|
||||||
const auth = useAuthStore()
|
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
const wipe = useWipeStore()
|
const router = useRouter()
|
||||||
|
const { activeGame } = useThemeGame()
|
||||||
|
|
||||||
onMounted(async () => {
|
// ---- View toggle ----
|
||||||
|
const VIEW_KEY = 'cc-dash-view'
|
||||||
|
const view = ref<'fleet' | 'solo'>((localStorage.getItem(VIEW_KEY) as 'fleet' | 'solo') ?? 'fleet')
|
||||||
|
function setView(v: string) {
|
||||||
|
view.value = v as 'fleet' | 'solo'
|
||||||
|
localStorage.setItem(VIEW_KEY, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewItems = [
|
||||||
|
{ value: 'fleet', label: 'Fleet', icon: 'layout-grid' },
|
||||||
|
{ value: 'solo', label: 'Solo', icon: 'square-dashed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---- Fleet: filter servers by activeGame ----
|
||||||
|
const serverStatus = ref<'all' | 'online' | 'offline'>('all')
|
||||||
|
const statusItems = computed(() => [
|
||||||
|
{ value: 'all', label: 'All', count: inGame.value.length },
|
||||||
|
{ value: 'online', label: 'Running', count: inGame.value.filter((s) => s.status !== 'offline').length },
|
||||||
|
{ value: 'offline', label: 'Stopped', count: inGame.value.filter((s) => s.status === 'offline').length },
|
||||||
|
])
|
||||||
|
|
||||||
|
const GAME_LABEL: Record<string, string> = { rust: 'Rust', dune: 'Dune', conan: 'Conan Exiles', soulmask: 'Soulmask' }
|
||||||
|
|
||||||
|
const inGame = computed<MockServer[]>(() =>
|
||||||
|
activeGame.value === 'all'
|
||||||
|
? MOCK_SERVERS
|
||||||
|
: MOCK_SERVERS.filter((s) => s.game === (activeGame.value as GameKey)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const shownServers = computed<MockServer[]>(() => {
|
||||||
|
const sv = serverStatus.value
|
||||||
|
return inGame.value.filter((s) => {
|
||||||
|
if (sv === 'all') return true
|
||||||
|
if (sv === 'online') return s.status !== 'offline'
|
||||||
|
return s.status === 'offline'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Fleet KPIs ----
|
||||||
|
const runningCount = computed(() => inGame.value.filter((s) => s.status !== 'offline').length)
|
||||||
|
const playersCur = computed(() => inGame.value.reduce((a, s) => a + (s.players?.cur ?? 0), 0))
|
||||||
|
const playersMax = computed(() => inGame.value.reduce((a, s) => a + (s.players?.max ?? 0), 0))
|
||||||
|
const cpuValues = computed(() => inGame.value.filter((s) => s.cpu != null).map((s) => s.cpu as number))
|
||||||
|
const avgCpu = computed<string>(() =>
|
||||||
|
cpuValues.value.length
|
||||||
|
? String(Math.round(cpuValues.value.reduce((a, b) => a + b, 0) / cpuValues.value.length))
|
||||||
|
: '—',
|
||||||
|
)
|
||||||
|
|
||||||
|
const scopeLabel = computed(() =>
|
||||||
|
activeGame.value === 'all'
|
||||||
|
? 'Fleet overview'
|
||||||
|
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} fleet`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const fleetTitle = computed(() => {
|
||||||
|
if (activeGame.value === 'all') {
|
||||||
|
const games = new Set(MOCK_SERVERS.map((s) => s.game)).size
|
||||||
|
return `${MOCK_SERVERS.length} servers · ${games} games`
|
||||||
|
}
|
||||||
|
const n = inGame.value.length
|
||||||
|
const label = GAME_LABEL[activeGame.value as string] ?? activeGame.value
|
||||||
|
return `${n} ${label} server${n === 1 ? '' : 's'}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartSubtitle = computed(() =>
|
||||||
|
activeGame.value === 'all'
|
||||||
|
? 'All servers · last 24 hours'
|
||||||
|
: `${GAME_LABEL[activeGame.value as string] ?? activeGame.value} servers · last 24 hours`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Chart period toggle ----
|
||||||
|
const chartPeriod = ref('24h')
|
||||||
|
const periodItems = [
|
||||||
|
{ value: '24h', label: '24h' },
|
||||||
|
{ value: '7d', label: '7d' },
|
||||||
|
{ value: '30d', label: '30d' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---- Solo: real store data + representative fallbacks ----
|
||||||
|
const soloName = computed(() => server.config?.server_name ?? 'Main · 2x Vanilla')
|
||||||
|
const soloPlayers = computed(() => server.stats?.player_count ?? 0)
|
||||||
|
const soloMaxPlayers = computed(() => server.stats?.max_players ?? server.config?.max_players ?? 200)
|
||||||
|
const soloFps = computed(() => server.stats?.fps ?? 59.8)
|
||||||
|
// Memory: store gives memory_usage_mb (no max), use 8192 MB representative max for %
|
||||||
|
const soloRamMb = computed(() => server.stats?.memory_usage_mb ?? 0)
|
||||||
|
const soloRamPct = computed(() => soloRamMb.value > 0 ? Math.round((soloRamMb.value / 8192) * 100) : 68)
|
||||||
|
const soloRamSub = computed(() => soloRamMb.value > 0 ? `${(soloRamMb.value / 1024).toFixed(1)} / 8 GB` : '5.4 / 8 GB')
|
||||||
|
// CPU: not in ServerStats; use representative value
|
||||||
|
const soloCpuPct = 41
|
||||||
|
// Status badge derived from connection_status
|
||||||
|
const soloStatus = computed<'online' | 'offline' | 'starting' | 'wiping'>(() => {
|
||||||
|
const cs = server.connection?.connection_status
|
||||||
|
if (cs === 'connected') return 'online'
|
||||||
|
if (cs === 'degraded') return 'starting'
|
||||||
|
return 'offline'
|
||||||
|
})
|
||||||
|
const soloStatusTone = computed<'online' | 'offline' | 'starting' | 'warn'>(() => {
|
||||||
|
if (soloStatus.value === 'online') return 'online'
|
||||||
|
if (soloStatus.value === 'starting') return 'warn'
|
||||||
|
return 'offline'
|
||||||
|
})
|
||||||
|
const soloStatusLabel = computed(() => {
|
||||||
|
if (soloStatus.value === 'online') return 'Online'
|
||||||
|
if (soloStatus.value === 'starting') return 'Degraded'
|
||||||
|
return 'Offline'
|
||||||
|
})
|
||||||
|
const soloRegion = computed(() => {
|
||||||
|
const ip = server.connection?.server_ip
|
||||||
|
return ip ? 'Bare metal' : 'US-East'
|
||||||
|
})
|
||||||
|
const soloIp = computed(() => {
|
||||||
|
const ip = server.connection?.server_ip
|
||||||
|
const port = server.connection?.game_port ?? server.connection?.server_port
|
||||||
|
if (ip && port) return `${ip}:${port}`
|
||||||
|
return '89.142.0.7:28015'
|
||||||
|
})
|
||||||
|
const soloUptime = computed(() => {
|
||||||
|
const sec = server.stats?.uptime_seconds ?? 0
|
||||||
|
if (sec === 0) return '—'
|
||||||
|
const d = Math.floor(sec / 86400)
|
||||||
|
const h = Math.floor((sec % 86400) / 3600)
|
||||||
|
return `${d}d ${h}h`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Representative plugin list (uMod plugin state not in backend store)
|
||||||
|
const pluginStates = ref([
|
||||||
|
{ name: 'RaidableBases', ver: '2.7.4', on: true },
|
||||||
|
{ name: 'Kits', ver: '4.3.1', on: true },
|
||||||
|
{ name: 'CorrosionTeleportGUI', ver: '1.2.0', on: true },
|
||||||
|
{ name: 'Economics', ver: '3.9.6', on: true },
|
||||||
|
{ name: 'ZLevels Remastered', ver: '3.2.0', on: false },
|
||||||
|
])
|
||||||
|
|
||||||
|
// Console input
|
||||||
|
const consoleInput = ref('')
|
||||||
|
|
||||||
|
function sendConsoleCommand() {
|
||||||
|
if (!consoleInput.value.trim()) return
|
||||||
|
server.sendCommand(consoleInput.value.trim()).catch(() => {})
|
||||||
|
consoleInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation helpers
|
||||||
|
function navConsole() { router.push('/console') }
|
||||||
|
function navWipes() { router.push('/wipes') }
|
||||||
|
|
||||||
|
// ---- Lifecycle ----
|
||||||
|
onMounted(() => {
|
||||||
server.fetchServer()
|
server.fetchServer()
|
||||||
try {
|
|
||||||
await wipe.fetchSchedules()
|
|
||||||
} catch {
|
|
||||||
// Non-critical — dashboard still loads without wipe data
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const nextWipeDate = computed<string>(() => {
|
|
||||||
const upcoming = wipe.schedules
|
|
||||||
.filter(s => s.is_active && s.next_scheduled_run)
|
|
||||||
.map(s => new Date(s.next_scheduled_run!))
|
|
||||||
.sort((a, b) => a.getTime() - b.getTime())
|
|
||||||
|
|
||||||
if (upcoming.length === 0) return 'Not Scheduled'
|
|
||||||
|
|
||||||
return upcoming[0]!.toLocaleDateString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function statusColor(status: string | undefined): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'connected': return 'bg-green-500'
|
|
||||||
case 'degraded': return 'bg-yellow-500'
|
|
||||||
default: return 'bg-red-500'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusLabel(status: string | undefined): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'connected': return 'Online'
|
|
||||||
case 'degraded': return 'Degraded'
|
|
||||||
default: return 'Offline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUptime(seconds: number | undefined): string {
|
|
||||||
if (!seconds) return '\u2014'
|
|
||||||
const h = Math.floor(seconds / 3600)
|
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
|
||||||
if (h > 0) return `${h}h ${m}m`
|
|
||||||
return `${m}m`
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-8">
|
<div class="dash">
|
||||||
<!-- Welcome header -->
|
<!-- ===== FLEET VIEW ===== -->
|
||||||
|
<template v-if="view === 'fleet'">
|
||||||
|
<!-- Page head -->
|
||||||
|
<div class="page__head">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">
|
<div class="t-eyebrow">{{ scopeLabel }}</div>
|
||||||
Welcome back, {{ auth.user?.username }}
|
<h1 class="page__title">{{ fleetTitle }}</h1>
|
||||||
</h1>
|
|
||||||
<p class="text-sm text-neutral-500 mt-1">Here's what's happening with your server.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="page__actions">
|
||||||
<!-- Stat cards grid -->
|
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<Button variant="secondary" size="sm" icon="download">Export</Button>
|
||||||
<!-- Server Status -->
|
<Button size="sm" icon="rocket">Deploy server</Button>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<p class="text-sm text-neutral-400 mb-2">Server Status</p>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="h-2.5 w-2.5 rounded-full" :class="statusColor(server.connection?.connection_status)"></span>
|
|
||||||
<span class="text-2xl font-bold text-neutral-100">{{ statusLabel(server.connection?.connection_status) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Players Online -->
|
<!-- KPIs -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<div class="dash__kpis">
|
||||||
<p class="text-sm text-neutral-400 mb-2">Players Online</p>
|
<StatCard icon="server" label="Servers running" :value="String(runningCount)" :unit="'/' + inGame.length" delta="+1" note="today" />
|
||||||
<p class="text-2xl font-bold text-neutral-100">
|
<StatCard icon="users" label="Players online" :value="String(playersCur)" :unit="'/' + playersMax" delta="+38" note="since wipe" />
|
||||||
{{ server.stats?.player_count ?? 0 }}/{{ server.stats?.max_players ?? server.config?.max_players ?? 0 }}
|
<StatCard icon="cpu" :label="activeGame === 'all' ? 'Fleet CPU' : 'Avg CPU'" :value="avgCpu" :unit="avgCpu === '—' ? '' : '%'" note="reporting agents" />
|
||||||
</p>
|
<StatCard icon="server-cog" label="Agent nodes" value="2" unit="/2" note="all reporting" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Next Wipe -->
|
<!-- Main grid -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<div class="dash__grid">
|
||||||
<p class="text-sm text-neutral-400 mb-2">Next Wipe</p>
|
<!-- Left column -->
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ nextWipeDate }}</p>
|
<div class="dash__col">
|
||||||
|
<!-- Players chart panel — themed ECharts -->
|
||||||
|
<Panel title="Players online" :subtitle="chartSubtitle">
|
||||||
|
<template #actions>
|
||||||
|
<Tabs v-model="chartPeriod" :items="periodItems" />
|
||||||
|
</template>
|
||||||
|
<PlayersChart :height="200" :max="200" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Servers list -->
|
||||||
|
<Panel :flush-body="true" title="Servers">
|
||||||
|
<template #actions>
|
||||||
|
<Tabs v-model="serverStatus" :items="statusItems" />
|
||||||
|
</template>
|
||||||
|
<div class="server__list">
|
||||||
|
<ServerCard
|
||||||
|
v-for="(s, i) in shownServers"
|
||||||
|
:key="i"
|
||||||
|
:game="s.game"
|
||||||
|
:game-icon="s.gameIcon"
|
||||||
|
:name="s.name"
|
||||||
|
:region="s.region"
|
||||||
|
:map="s.map"
|
||||||
|
:version="s.version"
|
||||||
|
:status="s.status"
|
||||||
|
:players="s.players"
|
||||||
|
:cpu="s.cpu"
|
||||||
|
:ram="s.ram"
|
||||||
|
:ram-sub="s.ramSub"
|
||||||
|
:ip="s.ip"
|
||||||
|
:stats="buildStats(s)"
|
||||||
|
/>
|
||||||
|
<div v-if="shownServers.length === 0" class="server__empty">
|
||||||
|
No servers match the current filter.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uptime -->
|
<!-- Right sidebar column -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<div class="dash__col dash__col--side">
|
||||||
<p class="text-sm text-neutral-400 mb-2">Uptime</p>
|
<!-- Live activity -->
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ formatUptime(server.stats?.uptime_seconds) }}</p>
|
<Panel :flush-body="true" title="Live activity">
|
||||||
</div>
|
<template #actions>
|
||||||
|
<Badge tone="online" :dot="true" :pulse="true">Live</Badge>
|
||||||
|
</template>
|
||||||
|
<div class="feed">
|
||||||
|
<ConsoleLine
|
||||||
|
v-for="(f, i) in MOCK_FEED"
|
||||||
|
:key="i"
|
||||||
|
:time="f.time"
|
||||||
|
:level="f.level"
|
||||||
|
:who="f.who"
|
||||||
|
>{{ f.msg }}</ConsoleLine>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Upcoming wipes -->
|
||||||
<div>
|
<Panel title="Upcoming wipes">
|
||||||
<h2 class="text-lg font-semibold text-neutral-200 mb-4">Quick Actions</h2>
|
<div class="wipes">
|
||||||
<div class="flex flex-wrap gap-3">
|
<div
|
||||||
<button
|
v-for="(w, i) in MOCK_WIPES"
|
||||||
:disabled="server.connection?.connection_status === 'connected'"
|
:key="i"
|
||||||
@click="server.startServer()"
|
class="wipe"
|
||||||
class="px-4 py-2.5 bg-green-600/20 hover:bg-green-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-green-400 border border-green-600/30 rounded-lg text-sm font-medium transition-colors"
|
:data-game="w.game"
|
||||||
>
|
>
|
||||||
Start Server
|
<div class="wipe__dot" />
|
||||||
</button>
|
<div class="wipe__body">
|
||||||
<button
|
<div class="wipe__name">{{ w.name }}</div>
|
||||||
:disabled="server.connection?.connection_status !== 'connected'"
|
<div class="wipe__when">{{ w.when }}</div>
|
||||||
@click="server.stopServer()"
|
</div>
|
||||||
class="px-4 py-2.5 bg-red-600/20 hover:bg-red-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-red-400 border border-red-600/30 rounded-lg text-sm font-medium transition-colors"
|
<Badge :tone="w.tone" size="md">{{ w.label }}</Badge>
|
||||||
>
|
|
||||||
Stop Server
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="router.push('/wipes')"
|
|
||||||
class="px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-lg text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Trigger Wipe
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Server Info (if configured) -->
|
|
||||||
<div v-if="server.config" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<h2 class="text-lg font-semibold text-neutral-200 mb-3">Server Configuration</h2>
|
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<span class="text-neutral-500">Server Name</span>
|
|
||||||
<p class="text-neutral-200 mt-0.5">{{ server.config.server_name || 'Not set' }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-neutral-500">Max Players</span>
|
|
||||||
<p class="text-neutral-200 mt-0.5">{{ server.config.max_players ?? 'Not set' }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-neutral-500">World Size</span>
|
|
||||||
<p class="text-neutral-200 mt-0.5">{{ server.config.world_size ?? 'Not set' }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-neutral-500">Current Seed</span>
|
|
||||||
<p class="text-neutral-200 mt-0.5">{{ server.config.current_seed ?? 'Not set' }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ===== SOLO VIEW ===== -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Page head -->
|
||||||
|
<div class="page__head">
|
||||||
|
<div class="solo-id">
|
||||||
|
<div class="solo-id__chip">
|
||||||
|
<Icon name="box" :size="21" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="solo-id__name">
|
||||||
|
{{ soloName }}
|
||||||
|
<Badge :tone="soloStatusTone" :dot="true" :pulse="soloStatus === 'online'">{{ soloStatusLabel }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="solo-id__meta">
|
||||||
|
{{ soloRegion }} · {{ soloIp }}
|
||||||
|
<span v-if="soloUptime !== '—'"> · up {{ soloUptime }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page__actions">
|
||||||
|
<Tabs v-model="view" :items="viewItems" @update:model-value="setView" />
|
||||||
|
<Button variant="secondary" size="sm" icon="refresh-cw" @click="server.restartServer()">Restart</Button>
|
||||||
|
<Button variant="danger-soft" size="sm" icon="power" @click="server.stopServer()">Stop</Button>
|
||||||
|
<Button size="sm" icon="terminal" @click="navConsole">Console</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPIs -->
|
||||||
|
<div class="dash__kpis">
|
||||||
|
<StatCard icon="users" label="Players online" :value="String(soloPlayers)" :unit="'/' + soloMaxPlayers" delta="+12" note="since wipe" />
|
||||||
|
<StatCard icon="cpu" label="CPU" :value="String(soloCpuPct)" unit="%" delta="3%" delta-dir="down" note="agent · representative" />
|
||||||
|
<StatCard icon="memory-stick" label="Memory" :value="String(soloRamPct)" unit="%" :note="soloRamSub" />
|
||||||
|
<StatCard icon="gauge" label="Server FPS" :value="String(soloFps)" delta-dir="flat" note="stable" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Solo grid -->
|
||||||
|
<div class="dash__grid">
|
||||||
|
<!-- Left column -->
|
||||||
|
<div class="dash__col">
|
||||||
|
<!-- Players chart — themed ECharts -->
|
||||||
|
<Panel title="Players online" :subtitle="soloName + ' · last 24 hours'">
|
||||||
|
<PlayersChart :height="196" :max="soloMaxPlayers" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Console panel -->
|
||||||
|
<Panel :flush-body="true" title="Console">
|
||||||
|
<template #actions>
|
||||||
|
<Badge tone="online" :dot="true" :pulse="soloStatus === 'online'">Live</Badge>
|
||||||
|
</template>
|
||||||
|
<div class="feed feed--solo">
|
||||||
|
<ConsoleLine
|
||||||
|
v-for="(f, i) in MOCK_FEED"
|
||||||
|
:key="i"
|
||||||
|
:time="f.time"
|
||||||
|
:level="f.level"
|
||||||
|
:who="f.who"
|
||||||
|
>{{ f.msg }}</ConsoleLine>
|
||||||
|
</div>
|
||||||
|
<div class="console-bar">
|
||||||
|
<span class="console-bar__prompt">></span>
|
||||||
|
<Input
|
||||||
|
v-model="consoleInput"
|
||||||
|
:mono="true"
|
||||||
|
size="sm"
|
||||||
|
placeholder="say, kick, ban, oxide.reload …"
|
||||||
|
style="flex: 1"
|
||||||
|
@keydown.enter="sendConsoleCommand"
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="secondary" icon="corner-down-left" @click="sendConsoleCommand">Send</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right sidebar -->
|
||||||
|
<div class="dash__col dash__col--side">
|
||||||
|
<!-- Resources -->
|
||||||
|
<Panel title="Resources" subtitle="Companion agent telemetry">
|
||||||
|
<div class="solo-meters">
|
||||||
|
<ResourceMeter label="CPU" :value="soloCpuPct" sub="representative" />
|
||||||
|
<ResourceMeter label="Memory" :value="soloRamPct" :sub="soloRamSub" />
|
||||||
|
<ResourceMeter label="Disk" :value="64" sub="representative" />
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Plugins -->
|
||||||
|
<Panel :flush-body="true" title="Plugins" subtitle="uMod / Oxide">
|
||||||
|
<template #actions>
|
||||||
|
<Button size="sm" variant="ghost" icon="plus" @click="router.push('/plugins')">Add</Button>
|
||||||
|
</template>
|
||||||
|
<div class="plugs">
|
||||||
|
<div
|
||||||
|
v-for="(p, i) in pluginStates"
|
||||||
|
:key="i"
|
||||||
|
class="plug"
|
||||||
|
>
|
||||||
|
<div class="plug__id">
|
||||||
|
<span class="plug__name">{{ p.name }}</span>
|
||||||
|
<span class="plug__ver">{{ p.ver }}</span>
|
||||||
|
</div>
|
||||||
|
<Switch v-model="p.on" size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Next wipe -->
|
||||||
|
<Panel title="Next wipe">
|
||||||
|
<div class="solo-wipe">
|
||||||
|
<div>
|
||||||
|
<div class="solo-wipe__when">Thu · 18:00 UTC</div>
|
||||||
|
<div class="solo-wipe__sub">representative — configure in wipe manager</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" icon="calendar-clock" @click="navWipes">Edit</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ---------- Shared shell ---------- */
|
||||||
|
.dash { max-width: 1480px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
|
||||||
|
.page__head {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
gap: 16px; flex-wrap: wrap; row-gap: 12px;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 5px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.page__actions { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.dash__kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; }
|
||||||
|
.dash__grid { display: grid; grid-template-columns: minmax(0, 1fr) 366px; gap: 16px; align-items: start; }
|
||||||
|
.dash__col { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
||||||
|
|
||||||
|
/* ---------- Servers list ---------- */
|
||||||
|
.server__list { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 14px; }
|
||||||
|
.server__empty { grid-column: 1 / -1; font-size: var(--text-sm); color: var(--text-muted); text-align: center; padding: 24px; }
|
||||||
|
|
||||||
|
/* ---------- Live feed ---------- */
|
||||||
|
.feed { padding: 8px 2px; max-height: 340px; overflow-y: auto; }
|
||||||
|
.feed--solo { max-height: 230px; }
|
||||||
|
|
||||||
|
/* ---------- Upcoming wipes ---------- */
|
||||||
|
.wipes { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.wipe { display: flex; align-items: center; gap: 11px; padding: 9px 6px; border-radius: var(--radius-md); }
|
||||||
|
.wipe:hover { background: var(--surface-hover); }
|
||||||
|
.wipe__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; box-shadow: 0 0 10px -1px var(--accent-glow); }
|
||||||
|
.wipe__body { flex: 1; min-width: 0; }
|
||||||
|
.wipe__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.wipe__when { font-family: var(--font-mono); font-size: 11px; color: var(--text-tertiary); margin-top: 1px; }
|
||||||
|
|
||||||
|
/* ---------- Solo identity header ---------- */
|
||||||
|
.solo-id { display: flex; align-items: center; gap: 13px; }
|
||||||
|
.solo-id__chip {
|
||||||
|
width: 42px; height: 42px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.solo-id__name {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
font-size: var(--text-xl); font-weight: 700; letter-spacing: -0.01em;
|
||||||
|
color: var(--text-primary); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.solo-id__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 3px; }
|
||||||
|
|
||||||
|
/* ---------- Console bar ---------- */
|
||||||
|
.console-bar {
|
||||||
|
display: flex; align-items: center; gap: 9px; padding: 11px 12px;
|
||||||
|
border-top: 1px solid var(--border-subtle); background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.console-bar__prompt { font-family: var(--font-mono); color: var(--accent-text); font-weight: 700; }
|
||||||
|
|
||||||
|
/* ---------- Resources ---------- */
|
||||||
|
.solo-meters { display: flex; flex-direction: column; gap: 13px; }
|
||||||
|
|
||||||
|
/* ---------- Plugin list ---------- */
|
||||||
|
.plugs { display: flex; flex-direction: column; }
|
||||||
|
.plug { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.plug:last-child { border-bottom: 0; }
|
||||||
|
.plug__id { display: flex; align-items: baseline; gap: 9px; min-width: 0; }
|
||||||
|
.plug__name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.plug__ver { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ---------- Next wipe ---------- */
|
||||||
|
.solo-wipe { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.solo-wipe__when { font-family: var(--font-mono); font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
|
||||||
|
.solo-wipe__sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ---------- Responsive ---------- */
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.dash__grid { grid-template-columns: 1fr; }
|
||||||
|
.server__list { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dash__kpis { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { VueFinder, RemoteDriver } from 'vuefinder'
|
import { VueFinder, RemoteDriver } from 'vuefinder'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
@@ -26,18 +27,22 @@ const finderConfig = {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 p-6">
|
<div class="fm">
|
||||||
<div class="flex items-center justify-between">
|
<!-- Page head -->
|
||||||
|
<div class="fm__head">
|
||||||
|
<div class="fm__head-id">
|
||||||
|
<div class="fm__head-chip">
|
||||||
|
<Icon name="folder-open" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-white">File Manager</h1>
|
<div class="t-eyebrow">Server management</div>
|
||||||
<p class="text-sm text-gray-400 mt-1">Browse and edit your server files</p>
|
<h1 class="fm__title">File manager</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<!-- VueFinder wrapper — only the outer chrome is re-skinned; internals untouched -->
|
||||||
class="bg-neutral-900 rounded-lg border border-neutral-800 overflow-hidden"
|
<div class="fm__finder">
|
||||||
style="min-height: 640px;"
|
|
||||||
>
|
|
||||||
<VueFinder
|
<VueFinder
|
||||||
id="corrosion-filemanager"
|
id="corrosion-filemanager"
|
||||||
:driver="driver"
|
:driver="driver"
|
||||||
@@ -47,3 +52,36 @@ const finderConfig = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fm {
|
||||||
|
max-width: 1480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.fm__head { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.fm__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.fm__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.fm__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Finder container — surface panel chrome, VueFinder renders inside */
|
||||||
|
.fm__finder {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 640px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
|
import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
|
||||||
import {
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
Save,
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
Play,
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
Download,
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
Plus,
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
Trash2,
|
|
||||||
Flame,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const store = useFurnaceSplitterStore()
|
const store = useFurnaceSplitterStore()
|
||||||
|
|
||||||
@@ -57,13 +53,13 @@ function setConfigValue(path: string, value: any) {
|
|||||||
|
|
||||||
// Furnace types with display names
|
// Furnace types with display names
|
||||||
const furnaceTypes = [
|
const furnaceTypes = [
|
||||||
{ key: 'furnace', label: 'Small Furnace', description: 'Standard furnace for smelting ores' },
|
{ key: 'furnace', label: 'Small furnace', description: 'Standard furnace for smelting ores' },
|
||||||
{ key: 'furnace.large', label: 'Large Furnace', description: 'Large furnace with more slots' },
|
{ key: 'furnace.large', label: 'Large furnace', description: 'Large furnace with more slots' },
|
||||||
{ key: 'campfire', label: 'Campfire', description: 'Basic campfire for cooking' },
|
{ key: 'campfire', label: 'Campfire', description: 'Basic campfire for cooking' },
|
||||||
{ key: 'refinery_small_deployed', label: 'Small Oil Refinery', description: 'Refines crude oil into low grade fuel' },
|
{ key: 'refinery_small_deployed', label: 'Small oil refinery', description: 'Refines crude oil into low grade fuel' },
|
||||||
{ key: 'skull_fire_pit', label: 'Skull Fire Pit', description: 'Decorative fire pit for cooking' },
|
{ key: 'skull_fire_pit', label: 'Skull fire pit', description: 'Decorative fire pit for cooking' },
|
||||||
{ key: 'hobobarrel_static', label: 'Hobo Barrel', description: 'Barrel fire for cooking' },
|
{ key: 'hobobarrel_static', label: 'Hobo barrel', description: 'Barrel fire for cooking' },
|
||||||
{ key: 'electricfurnace.deployed', label: 'Electric Furnace', description: 'Electricity-powered furnace' },
|
{ key: 'electricfurnace.deployed', label: 'Electric furnace', description: 'Electricity-powered furnace' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// --- Action handlers ---
|
// --- Action handlers ---
|
||||||
@@ -111,261 +107,334 @@ async function handleImport() {
|
|||||||
importConfigName.value = ''
|
importConfigName.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: coerce getConfigValue result to boolean for Switch
|
||||||
|
function getBool(path: string, def: boolean): boolean {
|
||||||
|
return !!getConfigValue(path, def)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="fsv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="fsv__head">
|
||||||
<h1 class="text-2xl font-bold text-white">Furnace Splitter Config</h1>
|
<div class="fsv__head-id">
|
||||||
<div class="flex items-center gap-3">
|
<div class="fsv__head-chip">
|
||||||
<button
|
<Icon name="flame" :size="20" :stroke-width="2" />
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
New Config
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Plugin config</div>
|
||||||
|
<h1 class="fsv__title">Furnace splitter</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" icon="plus" @click="showCreateModal = true">New config</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config Selector + Action Bar -->
|
<!-- Config action bar -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel>
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="fsv__bar">
|
||||||
<!-- Config Selector -->
|
|
||||||
<select
|
<select
|
||||||
v-if="store.configs.length > 0"
|
v-if="store.configs.length > 0"
|
||||||
:value="store.currentConfig?.id || ''"
|
:value="store.currentConfig?.id ?? ''"
|
||||||
|
class="fsv__config-select"
|
||||||
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
|
||||||
>
|
>
|
||||||
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
{{ c.config_name }}
|
{{ c.config_name }}{{ c.is_active ? ' (Active)' : '' }}
|
||||||
<template v-if="c.is_active"> (Active)</template>
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
<span v-else class="fsv__no-configs">No configs yet</span>
|
||||||
|
|
||||||
<!-- Save -->
|
<Button
|
||||||
<button
|
icon="save"
|
||||||
@click="store.saveCurrentConfig()"
|
size="sm"
|
||||||
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isSaving"
|
||||||
>
|
@click="store.saveCurrentConfig()"
|
||||||
<Save class="w-4 h-4" />
|
>{{ store.isSaving ? 'Saving…' : 'Save' }}</Button>
|
||||||
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Apply to Server -->
|
<Button
|
||||||
<button
|
variant="outline"
|
||||||
@click="handleApply"
|
icon="play"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig || store.isApplying"
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isApplying"
|
||||||
>
|
@click="handleApply"
|
||||||
<Play class="w-4 h-4" />
|
>{{ store.isApplying ? 'Applying…' : 'Apply to server' }}</Button>
|
||||||
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Import from Server -->
|
<Button
|
||||||
<button
|
variant="secondary"
|
||||||
|
icon="download"
|
||||||
|
size="sm"
|
||||||
@click="showImportModal = true"
|
@click="showImportModal = true"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
>Import from server</Button>
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Import from Server
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Delete -->
|
<Button
|
||||||
<button
|
variant="danger-soft"
|
||||||
@click="handleDeleteConfig"
|
icon="trash-2"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig"
|
:disabled="!store.currentConfig"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
class="fsv__bar-delete"
|
||||||
|
@click="handleDeleteConfig"
|
||||||
|
>Delete</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.isLoading" class="fsv__loading">
|
||||||
|
<span class="fsv__spinner" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<Panel v-else-if="!store.currentConfig">
|
||||||
|
<EmptyState
|
||||||
|
icon="flame"
|
||||||
|
title="No FurnaceSplitter config selected"
|
||||||
|
description="Create a new config, import from server, or select one from the dropdown above."
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4" />
|
<template #action>
|
||||||
Delete
|
<Button icon="plus" @click="showCreateModal = true">Create first config</Button>
|
||||||
</button>
|
</template>
|
||||||
</div>
|
</EmptyState>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Config editor -->
|
||||||
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
<template v-else>
|
||||||
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
<!-- Splitter settings -->
|
||||||
</div>
|
<Panel title="Splitter settings">
|
||||||
|
<div class="fsv__toggles">
|
||||||
<!-- No Config Selected -->
|
<div class="fsv__toggle-row">
|
||||||
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
|
||||||
<Flame class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
|
||||||
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No FurnaceSplitter Config Selected</h2>
|
|
||||||
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
|
||||||
<button
|
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
|
||||||
>
|
|
||||||
Create First Config
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Config Editor -->
|
|
||||||
<div v-else class="space-y-6">
|
|
||||||
<!-- Furnace Splitter Settings -->
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<SettingsIcon class="w-5 h-5 text-neutral-400" />
|
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Splitter Settings</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Global enabled -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm text-neutral-200">Enabled</label>
|
<div class="fsv__toggle-label">Enabled</div>
|
||||||
<p class="text-xs text-neutral-500">Globally enable or disable furnace splitting</p>
|
<div class="fsv__toggle-sub">Globally enable or disable furnace splitting</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue('Enabled', !getConfigValue('Enabled', true))"
|
:model-value="getBool('Enabled', true)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue('Enabled', v)"
|
||||||
:class="getConfigValue('Enabled', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('Enabled', true) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Per-Furnace Type Settings -->
|
<!-- Per-furnace type settings -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
<Panel title="Furnace type settings">
|
||||||
<div class="flex items-center gap-2">
|
<div class="fsv__furnace-list">
|
||||||
<Flame class="w-5 h-5 text-neutral-400" />
|
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Furnace Type Settings</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="furnace in furnaceTypes"
|
v-for="furnace in furnaceTypes"
|
||||||
:key="furnace.key"
|
:key="furnace.key"
|
||||||
class="bg-neutral-800/50 border border-neutral-700/50 rounded-lg p-4"
|
class="fsv__furnace-card"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="fsv__furnace-head">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-medium text-neutral-200">{{ furnace.label }}</h4>
|
<div class="fsv__furnace-name">{{ furnace.label }}</div>
|
||||||
<p class="text-xs text-neutral-500">{{ furnace.description }}</p>
|
<div class="fsv__furnace-desc">{{ furnace.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch
|
||||||
@click="setConfigValue(`Furnaces.${furnace.key}.Enabled`, !getConfigValue(`Furnaces.${furnace.key}.Enabled`, true))"
|
:model-value="getBool(`Furnaces.${furnace.key}.Enabled`, true)"
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
@update:model-value="v => setConfigValue(`Furnaces.${furnace.key}.Enabled`, v)"
|
||||||
:class="getConfigValue(`Furnaces.${furnace.key}.Enabled`, true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue(`Furnaces.${furnace.key}.Enabled`, true) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="fsv__furnace-fields">
|
||||||
<div>
|
<div class="fsv__field">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Default Split Stacks</label>
|
<label class="fsv__field-label">Default split stacks</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
class="cc-num-input"
|
||||||
:value="getConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, 0)"
|
:value="getConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, 0)"
|
||||||
@input="setConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0"
|
min="0"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-neutral-200 text-sm"
|
|
||||||
placeholder="0 = fill all slots"
|
placeholder="0 = fill all slots"
|
||||||
|
@input="setConfigValue(`Furnaces.${furnace.key}.DefaultSplitCount`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="fsv__field">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Fuel Multiplier</label>
|
<label class="fsv__field-label">Fuel multiplier</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
|
class="cc-num-input fsv__mono"
|
||||||
:value="getConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, 1.0)"
|
:value="getConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, 1.0)"
|
||||||
@input="setConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0"
|
min="0"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-neutral-200 text-sm"
|
@input="setConfigValue(`Furnaces.${furnace.key}.FuelMultiplier`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Permission Groups -->
|
<!-- Permission -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Permission">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Permission</h3>
|
<p class="fsv__perm-text">
|
||||||
<p class="text-xs text-neutral-500">
|
The permission <code class="fsv__code">furnacesplitter.use</code> controls which players can use the furnace splitting feature. Assign this permission via your Oxide permission system.
|
||||||
The permission <code class="text-neutral-300 bg-neutral-800 px-1 rounded">furnacesplitter.use</code> controls which players can use the furnace splitting feature. Assign this permission via your Oxide permission system.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- Create Config Modal -->
|
<!-- Create config modal -->
|
||||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
<div v-if="showCreateModal" class="fsv__modal-backdrop" @click.self="showCreateModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="fsv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New FurnaceSplitter Config</h2>
|
<h2 class="fsv__modal-title">New FurnaceSplitter config</h2>
|
||||||
<div class="space-y-4">
|
<div class="fsv__modal-body">
|
||||||
<div>
|
<div class="fsv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
<label class="fsv__field-label">Config name</label>
|
||||||
<input
|
<input
|
||||||
v-model="newConfigName"
|
v-model="newConfigName"
|
||||||
placeholder="e.g. Default Furnace Settings"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. Default furnace settings"
|
||||||
@keydown.enter="handleCreateConfig"
|
@keydown.enter="handleCreateConfig"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="fsv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
<label class="fsv__field-label">Description (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newConfigDesc"
|
v-model="newConfigDesc"
|
||||||
rows="2"
|
rows="2"
|
||||||
|
class="cc-textarea"
|
||||||
placeholder="What is this config for?"
|
placeholder="What is this config for?"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
|
||||||
<button
|
|
||||||
@click="handleCreateConfig"
|
|
||||||
:disabled="!newConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fsv__modal-footer">
|
||||||
|
<Button variant="ghost" @click="showCreateModal = false">Cancel</Button>
|
||||||
|
<Button :disabled="!newConfigName.trim()" @click="handleCreateConfig">Create</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import from Server Modal -->
|
<!-- Import from server modal -->
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
<div v-if="showImportModal" class="fsv__modal-backdrop" @click.self="showImportModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="fsv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
<h2 class="fsv__modal-title">Import from server</h2>
|
||||||
<p class="text-sm text-neutral-400 mb-4">
|
<p class="fsv__modal-desc">Import the current FurnaceSplitter config from your live server. This will create a new config profile.</p>
|
||||||
Import the current FurnaceSplitter config from your live server. This will create a new config profile.
|
<div class="fsv__modal-body">
|
||||||
</p>
|
<div class="fsv__field">
|
||||||
<div class="space-y-4">
|
<label class="fsv__field-label">Config name</label>
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
|
||||||
<input
|
<input
|
||||||
v-model="importConfigName"
|
v-model="importConfigName"
|
||||||
placeholder="e.g. Imported Server Config"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. Imported server config"
|
||||||
@keydown.enter="handleImport"
|
@keydown.enter="handleImport"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
|
||||||
<button
|
|
||||||
@click="handleImport"
|
|
||||||
:disabled="!importConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fsv__modal-footer">
|
||||||
|
<Button variant="ghost" @click="showImportModal = false">Cancel</Button>
|
||||||
|
<Button :disabled="!importConfigName.trim()" @click="handleImport">Import</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fsv { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.fsv__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.fsv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.fsv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.fsv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config action bar */
|
||||||
|
.fsv__bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.fsv__config-select {
|
||||||
|
appearance: none; height: var(--control-h-md); padding: 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;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.fsv__config-select:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.fsv__no-configs { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
.fsv__bar-delete { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.fsv__loading { display: flex; align-items: center; justify-content: center; padding: 60px; }
|
||||||
|
.fsv__spinner {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--accent);
|
||||||
|
border-top-color: transparent; animation: fsv-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes fsv-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Toggles */
|
||||||
|
.fsv__toggles { display: flex; flex-direction: column; }
|
||||||
|
.fsv__toggle-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.fsv__toggle-row:last-child { border-bottom: 0; padding-bottom: 0; }
|
||||||
|
.fsv__toggle-row:first-child { padding-top: 0; }
|
||||||
|
.fsv__toggle-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.fsv__toggle-sub { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Furnace cards */
|
||||||
|
.fsv__furnace-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.fsv__furnace-card {
|
||||||
|
background: var(--surface-raised-2); border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default); padding: 14px; display: flex; flex-direction: column; gap: 12px;
|
||||||
|
}
|
||||||
|
.fsv__furnace-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||||
|
.fsv__furnace-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.fsv__furnace-desc { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||||
|
.fsv__furnace-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
@media (max-width: 500px) { .fsv__furnace-fields { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
.fsv__field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.fsv__field-label { font-size: var(--text-xs); font-weight: 600; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Permission text */
|
||||||
|
.fsv__perm-text { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
|
||||||
|
.fsv__code {
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-xs); font-variant-numeric: tabular-nums;
|
||||||
|
background: var(--surface-raised-2); color: var(--text-primary); padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm); box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.fsv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Shared token inputs */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%; background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 11px; font-family: var(--font-sans); font-size: var(--text-sm);
|
||||||
|
resize: none; outline: 0; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.cc-textarea:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-text-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-text-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-num-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-num-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.fsv__modal-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 50;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||||
|
}
|
||||||
|
.fsv__modal {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default);
|
||||||
|
width: 100%; max-width: 440px; padding: 24px; display: flex; flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
|
.fsv__modal-title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
|
||||||
|
.fsv__modal-desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.5; }
|
||||||
|
.fsv__modal-body { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.fsv__modal-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useGatherStore } from '@/stores/gather'
|
import { useGatherStore } from '@/stores/gather'
|
||||||
import {
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
Save,
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
Play,
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
Download,
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
Plus,
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
Trash2,
|
|
||||||
Pickaxe,
|
|
||||||
Settings as SettingsIcon,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const store = useGatherStore()
|
const store = useGatherStore()
|
||||||
|
|
||||||
@@ -20,51 +16,51 @@ const newConfigName = ref('')
|
|||||||
const newConfigDesc = ref('')
|
const newConfigDesc = ref('')
|
||||||
const importConfigName = ref('')
|
const importConfigName = ref('')
|
||||||
|
|
||||||
const tabs = [
|
const tabItems = [
|
||||||
{ key: 'resources', label: 'Resource Rates', icon: Pickaxe },
|
{ value: 'resources', label: 'Resource rates', icon: 'pickaxe' },
|
||||||
{ key: 'advanced', label: 'Advanced', icon: SettingsIcon },
|
{ value: 'advanced', label: 'Advanced', icon: 'settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Resource definitions for the main gather tab
|
// Resource definitions for the main gather tab
|
||||||
const gatherResources = [
|
const gatherResources = [
|
||||||
{ key: 'Wood', label: 'Wood' },
|
{ key: 'Wood', label: 'Wood' },
|
||||||
{ key: 'Stones', label: 'Stones' },
|
{ key: 'Stones', label: 'Stones' },
|
||||||
{ key: 'Metal Ore', label: 'Metal Ore' },
|
{ key: 'Metal Ore', label: 'Metal ore' },
|
||||||
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
{ key: 'Sulfur Ore', label: 'Sulfur ore' },
|
||||||
{ key: 'HQM Ore', label: 'HQM Ore' },
|
{ key: 'HQM Ore', label: 'HQM ore' },
|
||||||
{ key: 'Cloth', label: 'Cloth' },
|
{ key: 'Cloth', label: 'Cloth' },
|
||||||
{ key: 'Leather', label: 'Leather' },
|
{ key: 'Leather', label: 'Leather' },
|
||||||
{ key: 'Animal Fat', label: 'Animal Fat' },
|
{ key: 'Animal Fat', label: 'Animal fat' },
|
||||||
{ key: 'Bone Fragments', label: 'Bone Fragments' },
|
{ key: 'Bone Fragments', label: 'Bone fragments' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Advanced resource categories
|
// Advanced resource categories
|
||||||
const pickupResources = [
|
const pickupResources = [
|
||||||
{ key: 'Wood', label: 'Wood' },
|
{ key: 'Wood', label: 'Wood' },
|
||||||
{ key: 'Stones', label: 'Stones' },
|
{ key: 'Stones', label: 'Stones' },
|
||||||
{ key: 'Metal Ore', label: 'Metal Ore' },
|
{ key: 'Metal Ore', label: 'Metal ore' },
|
||||||
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
{ key: 'Sulfur Ore', label: 'Sulfur ore' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const quarryResources = [
|
const quarryResources = [
|
||||||
{ key: 'HQM Ore', label: 'HQM Ore' },
|
{ key: 'HQM Ore', label: 'HQM ore' },
|
||||||
{ key: 'Metal Ore', label: 'Metal Ore' },
|
{ key: 'Metal Ore', label: 'Metal ore' },
|
||||||
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
{ key: 'Sulfur Ore', label: 'Sulfur ore' },
|
||||||
{ key: 'Stones', label: 'Stones' },
|
{ key: 'Stones', label: 'Stones' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const excavatorResources = [
|
const excavatorResources = [
|
||||||
{ key: 'HQM Ore', label: 'HQM Ore' },
|
{ key: 'HQM Ore', label: 'HQM ore' },
|
||||||
{ key: 'Metal Ore', label: 'Metal Ore' },
|
{ key: 'Metal Ore', label: 'Metal ore' },
|
||||||
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
{ key: 'Sulfur Ore', label: 'Sulfur ore' },
|
||||||
{ key: 'Stones', label: 'Stones' },
|
{ key: 'Stones', label: 'Stones' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const surveyResources = [
|
const surveyResources = [
|
||||||
{ key: 'Metal Ore', label: 'Metal Ore' },
|
{ key: 'Metal Ore', label: 'Metal ore' },
|
||||||
{ key: 'Sulfur Ore', label: 'Sulfur Ore' },
|
{ key: 'Sulfur Ore', label: 'Sulfur ore' },
|
||||||
{ key: 'Stones', label: 'Stones' },
|
{ key: 'Stones', label: 'Stones' },
|
||||||
{ key: 'HQM Ore', label: 'HQM Ore' },
|
{ key: 'HQM Ore', label: 'HQM ore' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const presets = [
|
const presets = [
|
||||||
@@ -167,368 +163,428 @@ async function handleImport() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="gv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="gv__head">
|
||||||
<h1 class="text-2xl font-bold text-white">Gather Rates</h1>
|
<div class="gv__head-id">
|
||||||
<div class="flex items-center gap-3">
|
<div class="gv__head-chip">
|
||||||
<button
|
<Icon name="pickaxe" :size="20" :stroke-width="2" />
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
New Config
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Plugin config</div>
|
||||||
|
<h1 class="gv__title">Gather rates</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" icon="plus" @click="showCreateModal = true">New config</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config Selector + Action Bar -->
|
<!-- Config action bar -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel>
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="gv__bar">
|
||||||
<!-- Config Selector -->
|
|
||||||
<select
|
<select
|
||||||
v-if="store.configs.length > 0"
|
v-if="store.configs.length > 0"
|
||||||
:value="store.currentConfig?.id || ''"
|
:value="store.currentConfig?.id ?? ''"
|
||||||
|
class="gv__config-select"
|
||||||
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
||||||
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
|
||||||
>
|
>
|
||||||
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
||||||
{{ c.config_name }}
|
{{ c.config_name }}{{ c.is_active ? ' (Active)' : '' }}
|
||||||
<template v-if="c.is_active"> (Active)</template>
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
<span v-else class="gv__no-configs">No configs yet</span>
|
||||||
|
|
||||||
<!-- Save -->
|
<Button
|
||||||
<button
|
icon="save"
|
||||||
@click="store.saveCurrentConfig()"
|
size="sm"
|
||||||
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isSaving"
|
||||||
>
|
@click="store.saveCurrentConfig()"
|
||||||
<Save class="w-4 h-4" />
|
>{{ store.isSaving ? 'Saving…' : 'Save' }}</Button>
|
||||||
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Apply to Server -->
|
<Button
|
||||||
<button
|
variant="outline"
|
||||||
@click="handleApply"
|
icon="play"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig || store.isApplying"
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="store.isApplying"
|
||||||
>
|
@click="handleApply"
|
||||||
<Play class="w-4 h-4" />
|
>{{ store.isApplying ? 'Applying…' : 'Apply to server' }}</Button>
|
||||||
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Import from Server -->
|
<Button
|
||||||
<button
|
variant="secondary"
|
||||||
|
icon="download"
|
||||||
|
size="sm"
|
||||||
@click="showImportModal = true"
|
@click="showImportModal = true"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
>Import from server</Button>
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Import from Server
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Delete -->
|
<Button
|
||||||
<button
|
variant="danger-soft"
|
||||||
@click="handleDeleteConfig"
|
icon="trash-2"
|
||||||
|
size="sm"
|
||||||
:disabled="!store.currentConfig"
|
:disabled="!store.currentConfig"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
class="gv__bar-delete"
|
||||||
|
@click="handleDeleteConfig"
|
||||||
|
>Delete</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.isLoading" class="gv__loading">
|
||||||
|
<span class="gv__spinner" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<Panel v-else-if="!store.currentConfig">
|
||||||
|
<EmptyState
|
||||||
|
icon="pickaxe"
|
||||||
|
title="No gather config selected"
|
||||||
|
description="Create a new config, import from server, or select one from the dropdown above."
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4" />
|
<template #action>
|
||||||
Delete
|
<Button icon="plus" @click="showCreateModal = true">Create first config</Button>
|
||||||
</button>
|
</template>
|
||||||
</div>
|
</EmptyState>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Config editor -->
|
||||||
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
<template v-else>
|
||||||
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
<Tabs v-model="activeTab" :items="tabItems" variant="line" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No Config Selected -->
|
<!-- Resource rates tab -->
|
||||||
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
<Panel v-if="activeTab === 'resources'" title="Gather resource modifiers">
|
||||||
<Pickaxe class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
<template #actions>
|
||||||
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Gather Config Selected</h2>
|
<div class="gv__presets">
|
||||||
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
<span class="gv__presets-label">Presets:</span>
|
||||||
<button
|
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
|
||||||
>
|
|
||||||
Create First Config
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Config Editor -->
|
|
||||||
<div v-else class="space-y-6">
|
|
||||||
<!-- Tab Bar -->
|
|
||||||
<div class="flex border-b border-neutral-800">
|
|
||||||
<button
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.key"
|
|
||||||
@click="activeTab = tab.key as typeof activeTab"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
|
||||||
:class="activeTab === tab.key
|
|
||||||
? 'border-oxide-500 text-oxide-400'
|
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
|
||||||
>
|
|
||||||
<component :is="tab.icon" class="w-4 h-4" />
|
|
||||||
{{ tab.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resource Rates Tab -->
|
|
||||||
<div v-if="activeTab === 'resources'" class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Gather Resource Modifiers</h3>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="text-xs text-neutral-500 mr-2">Presets:</span>
|
|
||||||
<button
|
<button
|
||||||
v-for="preset in presets"
|
v-for="preset in presets"
|
||||||
:key="preset.value"
|
:key="preset.value"
|
||||||
|
class="gv__preset-btn"
|
||||||
@click="applyPreset(preset.value)"
|
@click="applyPreset(preset.value)"
|
||||||
class="px-3 py-1 text-xs bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 hover:text-white transition-colors"
|
>{{ preset.label }}</button>
|
||||||
>
|
|
||||||
{{ preset.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="gv__rate-list">
|
||||||
<div
|
<div
|
||||||
v-for="resource in gatherResources"
|
v-for="resource in gatherResources"
|
||||||
:key="resource.key"
|
:key="resource.key"
|
||||||
class="flex items-center gap-4"
|
class="gv__rate-row"
|
||||||
>
|
>
|
||||||
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
<label class="gv__rate-label">{{ resource.label }}</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="gv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`GatherResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input gv__rate-num"
|
||||||
|
@input="setConfigValue(`GatherResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500 w-4">x</span>
|
<span class="gv__rate-unit">x</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Advanced Tab -->
|
<!-- Advanced tab -->
|
||||||
<div v-else-if="activeTab === 'advanced'" class="space-y-6">
|
<template v-else-if="activeTab === 'advanced'">
|
||||||
<!-- Pickup Resource Modifiers -->
|
<!-- Pickup -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Pickup resource modifiers" subtitle="Modify rates for resources picked up from the ground (small rocks, wood piles).">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Pickup Resource Modifiers</h3>
|
<div class="gv__rate-list">
|
||||||
<p class="text-xs text-neutral-500">Modify rates for resources picked up from the ground (small rocks, wood piles).</p>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="resource in pickupResources"
|
v-for="resource in pickupResources"
|
||||||
:key="resource.key"
|
:key="resource.key"
|
||||||
class="flex items-center gap-4"
|
class="gv__rate-row"
|
||||||
>
|
>
|
||||||
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
<label class="gv__rate-label">{{ resource.label }}</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="gv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`PickupResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input gv__rate-num"
|
||||||
|
@input="setConfigValue(`PickupResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500 w-4">x</span>
|
<span class="gv__rate-unit">x</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Quarry Resource Modifiers -->
|
<!-- Quarry -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Quarry resource modifiers" subtitle="Scale resource output from mining quarries.">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Quarry Resource Modifiers</h3>
|
<div class="gv__rate-list">
|
||||||
<p class="text-xs text-neutral-500">Scale resource output from Mining Quarries.</p>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="resource in quarryResources"
|
v-for="resource in quarryResources"
|
||||||
:key="resource.key"
|
:key="resource.key"
|
||||||
class="flex items-center gap-4"
|
class="gv__rate-row"
|
||||||
>
|
>
|
||||||
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
<label class="gv__rate-label">{{ resource.label }}</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="gv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`QuarryResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input gv__rate-num"
|
||||||
|
@input="setConfigValue(`QuarryResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500 w-4">x</span>
|
<span class="gv__rate-unit">x</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Excavator Resource Modifiers -->
|
<!-- Excavator -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Excavator resource modifiers" subtitle="Scale resource output from the giant excavator.">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Excavator Resource Modifiers</h3>
|
<div class="gv__rate-list">
|
||||||
<p class="text-xs text-neutral-500">Scale resource output from the Giant Excavator.</p>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="resource in excavatorResources"
|
v-for="resource in excavatorResources"
|
||||||
:key="resource.key"
|
:key="resource.key"
|
||||||
class="flex items-center gap-4"
|
class="gv__rate-row"
|
||||||
>
|
>
|
||||||
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
<label class="gv__rate-label">{{ resource.label }}</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="gv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`ExcavatorResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input gv__rate-num"
|
||||||
|
@input="setConfigValue(`ExcavatorResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500 w-4">x</span>
|
<span class="gv__rate-unit">x</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Survey Resource Modifiers -->
|
<!-- Survey -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<Panel title="Survey charge resource modifiers" subtitle="Modify resource amounts from survey charge grenades.">
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Survey Charge Resource Modifiers</h3>
|
<div class="gv__rate-list">
|
||||||
<p class="text-xs text-neutral-500">Modify resource amounts from Survey Charge grenades.</p>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="resource in surveyResources"
|
v-for="resource in surveyResources"
|
||||||
:key="resource.key"
|
:key="resource.key"
|
||||||
class="flex items-center gap-4"
|
class="gv__rate-row"
|
||||||
>
|
>
|
||||||
<label class="text-sm text-neutral-200 w-32 flex-shrink-0">{{ resource.label }}</label>
|
<label class="gv__rate-label">{{ resource.label }}</label>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="100"
|
max="100"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="flex-1 accent-oxide-500"
|
class="gv__slider"
|
||||||
|
style="accent-color: var(--accent)"
|
||||||
|
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
|
:value="getConfigValue(`SurveyResourceModifiers.${resource.key}`, 1)"
|
||||||
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
|
||||||
min="0.1"
|
min="0.1"
|
||||||
max="1000"
|
max="1000"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
class="w-20 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1 text-neutral-200 text-sm text-center"
|
class="cc-num-input gv__rate-num"
|
||||||
|
@input="setConfigValue(`SurveyResourceModifiers.${resource.key}`, Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500 w-4">x</span>
|
<span class="gv__rate-unit">x</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Create Config Modal -->
|
<!-- Create config modal -->
|
||||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
<div v-if="showCreateModal" class="gv__modal-backdrop" @click.self="showCreateModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="gv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Gather Config</h2>
|
<h2 class="gv__modal-title">New gather config</h2>
|
||||||
<div class="space-y-4">
|
<div class="gv__modal-body">
|
||||||
<div>
|
<div class="gv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
<label class="gv__field-label">Config name</label>
|
||||||
<input
|
<input
|
||||||
v-model="newConfigName"
|
v-model="newConfigName"
|
||||||
placeholder="e.g. 3x Gather Rates"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. 3x gather rates"
|
||||||
@keydown.enter="handleCreateConfig"
|
@keydown.enter="handleCreateConfig"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="gv__field">
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
<label class="gv__field-label">Description (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newConfigDesc"
|
v-model="newConfigDesc"
|
||||||
rows="2"
|
rows="2"
|
||||||
|
class="cc-textarea"
|
||||||
placeholder="What is this config for?"
|
placeholder="What is this config for?"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
|
||||||
<button
|
|
||||||
@click="handleCreateConfig"
|
|
||||||
:disabled="!newConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="gv__modal-footer">
|
||||||
|
<Button variant="ghost" @click="showCreateModal = false">Cancel</Button>
|
||||||
|
<Button :disabled="!newConfigName.trim()" @click="handleCreateConfig">Create</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import from Server Modal -->
|
<!-- Import from server modal -->
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
<div v-if="showImportModal" class="gv__modal-backdrop" @click.self="showImportModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="gv__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
<h2 class="gv__modal-title">Import from server</h2>
|
||||||
<p class="text-sm text-neutral-400 mb-4">
|
<p class="gv__modal-desc">Import the current GatherManager config from your live server. This will create a new config profile.</p>
|
||||||
Import the current GatherManager config from your live server. This will create a new config profile.
|
<div class="gv__modal-body">
|
||||||
</p>
|
<div class="gv__field">
|
||||||
<div class="space-y-4">
|
<label class="gv__field-label">Config name</label>
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
|
||||||
<input
|
<input
|
||||||
v-model="importConfigName"
|
v-model="importConfigName"
|
||||||
placeholder="e.g. Imported Server Config"
|
type="text"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
class="cc-text-input"
|
||||||
|
placeholder="e.g. Imported server config"
|
||||||
@keydown.enter="handleImport"
|
@keydown.enter="handleImport"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
|
||||||
<button
|
|
||||||
@click="handleImport"
|
|
||||||
:disabled="!importConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="gv__modal-footer">
|
||||||
|
<Button variant="ghost" @click="showImportModal = false">Cancel</Button>
|
||||||
|
<Button :disabled="!importConfigName.trim()" @click="handleImport">Import</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gv { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.gv__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.gv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.gv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.gv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config action bar */
|
||||||
|
.gv__bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.gv__config-select {
|
||||||
|
appearance: none; height: var(--control-h-md); padding: 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;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
.gv__config-select:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.gv__no-configs { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
.gv__bar-delete { margin-left: auto; }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.gv__loading { display: flex; align-items: center; justify-content: center; padding: 60px; }
|
||||||
|
.gv__spinner {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%; border: 2px solid var(--accent);
|
||||||
|
border-top-color: transparent; animation: gv-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes gv-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Presets */
|
||||||
|
.gv__presets { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.gv__presets-label { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
.gv__preset-btn {
|
||||||
|
height: 26px; padding: 0 10px; border: 0; border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-raised-2); color: var(--text-secondary);
|
||||||
|
font-size: var(--text-xs); font-weight: 600; cursor: pointer;
|
||||||
|
box-shadow: var(--ring-default); transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.gv__preset-btn:hover { background: var(--surface-active); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* Rate rows */
|
||||||
|
.gv__rate-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.gv__rate-row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.gv__rate-label { font-size: var(--text-sm); color: var(--text-primary); width: 120px; flex: none; }
|
||||||
|
.gv__slider { flex: 1; cursor: pointer; }
|
||||||
|
.gv__rate-num {
|
||||||
|
width: 72px; height: var(--control-h-sm); flex: none;
|
||||||
|
text-align: center; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.gv__rate-unit { font-size: var(--text-xs); color: var(--text-tertiary); width: 12px; flex: none; }
|
||||||
|
|
||||||
|
/* Shared token inputs */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%; background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 11px; font-family: var(--font-sans); font-size: var(--text-sm);
|
||||||
|
resize: none; outline: 0; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.cc-textarea:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-text-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-text-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-num-input {
|
||||||
|
width: 100%; height: var(--control-h-md); background: var(--surface-inset); color: var(--text-primary);
|
||||||
|
border: 0; border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
padding: 0 11px; font-family: var(--font-sans); font-size: var(--text-sm); outline: 0;
|
||||||
|
}
|
||||||
|
.cc-num-input:focus-visible { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
.gv__field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.gv__field-label { font-size: var(--text-xs); font-weight: 600; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.gv__modal-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.55); z-index: 50;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||||
|
}
|
||||||
|
.gv__modal {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default);
|
||||||
|
width: 100%; max-width: 440px; padding: 24px; display: flex; flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
|
.gv__modal-title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
|
||||||
|
.gv__modal-desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.5; }
|
||||||
|
.gv__modal-body { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.gv__modal-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useLootStore } from '@/stores/loot'
|
import { useLootStore } from '@/stores/loot'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import LootContainerSidebar from '@/components/loot/LootContainerSidebar.vue'
|
import LootContainerSidebar from '@/components/loot/LootContainerSidebar.vue'
|
||||||
import LootItemEditor from '@/components/loot/LootItemEditor.vue'
|
import LootItemEditor from '@/components/loot/LootItemEditor.vue'
|
||||||
import LootGroupEditor from '@/components/loot/LootGroupEditor.vue'
|
import LootGroupEditor from '@/components/loot/LootGroupEditor.vue'
|
||||||
import LootItemPicker from '@/components/loot/LootItemPicker.vue'
|
import LootItemPicker from '@/components/loot/LootItemPicker.vue'
|
||||||
import { Save, Upload, Download, Play, Copy, Trash2, Plus, Layers } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
import DsSelect from '@/components/ds/forms/Select.vue'
|
||||||
|
import DsInput from '@/components/ds/forms/Input.vue'
|
||||||
|
|
||||||
const loot = useLootStore()
|
const loot = useLootStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
@@ -24,6 +31,20 @@ const activeTab = ref<'items' | 'groups'>('items')
|
|||||||
|
|
||||||
const multipliers = [1, 2, 5, 10]
|
const multipliers = [1, 2, 5, 10]
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{ value: 'items', label: 'Container items' },
|
||||||
|
{ value: 'groups', label: 'Loot groups', icon: 'layers' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Profile selector options for DS Select
|
||||||
|
const profileOptions = computed(() =>
|
||||||
|
loot.profiles.map(p => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.profile_name + (p.is_active ? ' (active)' : ''),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
const currentProfileId = computed(() => loot.currentProfile?.id ?? '')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loot.fetchProfiles()
|
await loot.fetchProfiles()
|
||||||
if (loot.profiles.length > 0 && loot.profiles[0]) {
|
if (loot.profiles.length > 0 && loot.profiles[0]) {
|
||||||
@@ -130,145 +151,139 @@ function handleAddItem(shortname: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-4">
|
<div class="lb-root">
|
||||||
<!-- Header -->
|
<!-- Page header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="lb-header">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Loot Builder</h1>
|
<div class="lb-header__left">
|
||||||
<div class="flex items-center gap-2">
|
<h1 class="lb-title">Loot builder</h1>
|
||||||
<button
|
<Badge v-if="loot.isDirty" tone="warn">Unsaved changes</Badge>
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
New Profile
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button size="sm" variant="secondary" icon="plus" @click="showCreateModal = true">
|
||||||
|
New profile
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Profile Bar -->
|
<!-- Profile toolbar -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
<Panel>
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="lb-toolbar">
|
||||||
<!-- Profile Selector -->
|
<!-- Profile selector -->
|
||||||
<select
|
<div class="lb-toolbar__profile">
|
||||||
|
<DsSelect
|
||||||
v-if="loot.profiles.length > 0"
|
v-if="loot.profiles.length > 0"
|
||||||
:value="loot.currentProfile?.id || ''"
|
:options="profileOptions"
|
||||||
@change="handleProfileChange(($event.target as HTMLSelectElement).value)"
|
:model-value="currentProfileId"
|
||||||
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
@update:model-value="(v: string | undefined) => { if (v) handleProfileChange(v) }"
|
||||||
>
|
/>
|
||||||
<option v-for="p in loot.profiles" :key="p.id" :value="p.id">
|
<span v-else class="lb-toolbar__empty">No profiles yet</span>
|
||||||
{{ p.profile_name }}
|
</div>
|
||||||
<template v-if="p.is_active"> (Active)</template>
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<span v-else class="text-neutral-500 text-sm">No profiles yet</span>
|
|
||||||
|
|
||||||
|
<div class="lb-toolbar__actions">
|
||||||
<!-- Save -->
|
<!-- Save -->
|
||||||
<button
|
<Button
|
||||||
@click="loot.saveCurrentProfile()"
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
icon="save"
|
||||||
:disabled="!loot.currentProfile || !loot.isDirty || loot.isSaving"
|
:disabled="!loot.currentProfile || !loot.isDirty || loot.isSaving"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="loot.isSaving"
|
||||||
|
@click="loot.saveCurrentProfile()"
|
||||||
>
|
>
|
||||||
<Save class="w-4 h-4" />
|
{{ loot.isSaving ? 'Saving…' : 'Save' }}
|
||||||
{{ loot.isSaving ? 'Saving...' : 'Save' }}
|
</Button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Apply Dropdown -->
|
<!-- Apply to server with multiplier dropdown -->
|
||||||
<div class="relative">
|
<div class="lb-apply-wrap">
|
||||||
<button
|
<Button
|
||||||
@click="showApplyDropdown = !showApplyDropdown"
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
icon="play"
|
||||||
:disabled="!loot.currentProfile || loot.isApplying"
|
:disabled="!loot.currentProfile || loot.isApplying"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
:loading="loot.isApplying"
|
||||||
>
|
@click="showApplyDropdown = !showApplyDropdown"
|
||||||
<Play class="w-4 h-4" />
|
|
||||||
{{ loot.isApplying ? 'Applying...' : 'Apply to Server' }}
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="showApplyDropdown"
|
|
||||||
class="absolute top-full mt-1 right-0 bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl z-10 py-1 min-w-[140px]"
|
|
||||||
>
|
>
|
||||||
|
{{ loot.isApplying ? 'Applying…' : 'Apply to server' }}
|
||||||
|
</Button>
|
||||||
|
<div v-if="showApplyDropdown" class="lb-dropdown">
|
||||||
<button
|
<button
|
||||||
v-for="m in multipliers"
|
v-for="m in multipliers"
|
||||||
:key="m"
|
:key="m"
|
||||||
|
class="lb-dropdown__item"
|
||||||
@click="handleApply(m)"
|
@click="handleApply(m)"
|
||||||
class="w-full text-left px-4 py-2 text-sm text-neutral-300 hover:bg-neutral-700"
|
|
||||||
>
|
>
|
||||||
{{ m }}x Multiplier
|
{{ m }}x multiplier
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Duplicate -->
|
<!-- Duplicate -->
|
||||||
<button
|
<Button
|
||||||
@click="handleDuplicate"
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon="copy"
|
||||||
:disabled="!loot.currentProfile"
|
:disabled="!loot.currentProfile"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 disabled:opacity-50 text-sm"
|
@click="handleDuplicate"
|
||||||
>
|
>
|
||||||
<Copy class="w-4 h-4" />
|
|
||||||
Duplicate
|
Duplicate
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<!-- Import -->
|
<!-- Import -->
|
||||||
<button
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon="upload"
|
||||||
@click="showImportModal = true"
|
@click="showImportModal = true"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
>
|
||||||
<Upload class="w-4 h-4" />
|
|
||||||
Import
|
Import
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<!-- Export -->
|
<!-- Export -->
|
||||||
<button
|
<Button
|
||||||
@click="handleExport"
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon="download"
|
||||||
:disabled="!loot.currentProfile"
|
:disabled="!loot.currentProfile"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 disabled:opacity-50 text-sm"
|
@click="handleExport"
|
||||||
>
|
>
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Export
|
Export
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<!-- Delete -->
|
<!-- Delete -->
|
||||||
<button
|
<Button
|
||||||
@click="handleDeleteProfile"
|
size="sm"
|
||||||
|
variant="danger-soft"
|
||||||
|
icon="trash-2"
|
||||||
:disabled="!loot.currentProfile"
|
:disabled="!loot.currentProfile"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
@click="handleDeleteProfile"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loot.isLoading" class="lb-loading">
|
||||||
|
<Icon name="loader" :size="28" class="lb-spin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main editor layout -->
|
||||||
<div v-if="loot.currentProfile" class="flex gap-4" style="height: calc(100vh - 250px)">
|
<div v-else-if="loot.currentProfile" class="lb-workspace">
|
||||||
<!-- Sidebar -->
|
<!-- Container sidebar -->
|
||||||
<LootContainerSidebar
|
<LootContainerSidebar
|
||||||
:loot-table="loot.currentProfile.loot_table"
|
:loot-table="loot.currentProfile.loot_table"
|
||||||
:selected="loot.selectedContainer"
|
:selected="loot.selectedContainer"
|
||||||
@select="loot.selectedContainer = $event"
|
@select="loot.selectedContainer = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Editor Area -->
|
<!-- Editor area -->
|
||||||
<div class="flex-1 flex flex-col min-w-0">
|
<div class="lb-editor">
|
||||||
<!-- Tabs -->
|
<Tabs
|
||||||
<div class="flex border-b border-neutral-800 mb-4">
|
v-model="activeTab"
|
||||||
<button
|
:items="tabItems"
|
||||||
@click="activeTab = 'items'"
|
variant="line"
|
||||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
class="lb-tabs"
|
||||||
:class="activeTab === 'items' ? 'border-oxide-500 text-oxide-400' : 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
/>
|
||||||
>
|
|
||||||
Container Items
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="activeTab = 'groups'"
|
|
||||||
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-2"
|
|
||||||
:class="activeTab === 'groups' ? 'border-oxide-500 text-oxide-400' : 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
|
||||||
>
|
|
||||||
<Layers class="w-4 h-4" />
|
|
||||||
Loot Groups
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="lb-editor__body">
|
||||||
<LootItemEditor
|
<LootItemEditor
|
||||||
v-if="activeTab === 'items' && loot.selectedContainer"
|
v-if="activeTab === 'items' && loot.selectedContainer"
|
||||||
:container-key="loot.selectedContainer"
|
:container-key="loot.selectedContainer"
|
||||||
@@ -276,8 +291,12 @@ function handleAddItem(shortname: string) {
|
|||||||
@dirty="loot.markDirty()"
|
@dirty="loot.markDirty()"
|
||||||
@add-item="showItemPicker = true"
|
@add-item="showItemPicker = true"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="activeTab === 'items'" class="flex items-center justify-center h-full text-neutral-500">
|
<div v-else-if="activeTab === 'items'" class="lb-editor__placeholder">
|
||||||
Select a container from the sidebar
|
<EmptyState
|
||||||
|
icon="box"
|
||||||
|
title="No container selected"
|
||||||
|
description="Select a container from the sidebar to configure its loot."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LootGroupEditor
|
<LootGroupEditor
|
||||||
@@ -289,98 +308,103 @@ function handleAddItem(shortname: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty state — no profile -->
|
||||||
<div v-else-if="!loot.isLoading" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
<div v-else class="lb-empty-wrap">
|
||||||
<Layers class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
<Panel>
|
||||||
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Loot Profile Selected</h2>
|
<EmptyState
|
||||||
<p class="text-neutral-500 mb-4">Create a new profile or select one from the dropdown above.</p>
|
icon="layers"
|
||||||
<button
|
title="No loot profile selected"
|
||||||
@click="showCreateModal = true"
|
description="Create a new profile or select one above."
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
|
||||||
>
|
>
|
||||||
Create First Profile
|
<template #action>
|
||||||
|
<Button icon="plus" @click="showCreateModal = true">Create first profile</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create profile modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showCreateModal" class="lb-overlay" @click.self="showCreateModal = false">
|
||||||
|
<div class="lb-modal">
|
||||||
|
<div class="lb-modal__head">
|
||||||
|
<span class="lb-modal__title">New loot profile</span>
|
||||||
|
<button class="lb-modal__close" @click="showCreateModal = false" aria-label="Close">
|
||||||
|
<Icon name="x" :size="16" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="lb-modal__body">
|
||||||
<!-- Loading -->
|
<DsInput
|
||||||
<div v-if="loot.isLoading" class="flex items-center justify-center py-20">
|
|
||||||
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create Modal -->
|
|
||||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Loot Profile</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Profile Name</label>
|
|
||||||
<input
|
|
||||||
v-model="newProfileName"
|
v-model="newProfileName"
|
||||||
|
label="Profile name"
|
||||||
placeholder="e.g. Vanilla 2x"
|
placeholder="e.g. Vanilla 2x"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
|
||||||
@keydown.enter="handleCreateProfile"
|
@keydown.enter="handleCreateProfile"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div class="lb-field">
|
||||||
<div>
|
<label class="lb-field__label">Description <span class="lb-field__opt">(optional)</span></label>
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newProfileDesc"
|
v-model="newProfileDesc"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="What is this profile for?"
|
placeholder="What is this profile for?"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
class="cc-textarea"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
</div>
|
||||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
<div class="lb-modal__foot">
|
||||||
<button
|
<Button variant="ghost" size="sm" @click="showCreateModal = false">Cancel</Button>
|
||||||
@click="handleCreateProfile"
|
<Button
|
||||||
|
size="sm"
|
||||||
:disabled="!newProfileName.trim()"
|
:disabled="!newProfileName.trim()"
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
@click="handleCreateProfile"
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Import modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showImportModal" class="lb-overlay" @click.self="showImportModal = false">
|
||||||
|
<div class="lb-modal lb-modal--wide">
|
||||||
|
<div class="lb-modal__head">
|
||||||
|
<span class="lb-modal__title">Import loot profile</span>
|
||||||
|
<button class="lb-modal__close" @click="showImportModal = false" aria-label="Close">
|
||||||
|
<Icon name="x" :size="16" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="lb-modal__body">
|
||||||
</div>
|
<DsInput
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Import Modal -->
|
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-lg">
|
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import Loot Profile</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Profile Name</label>
|
|
||||||
<input
|
|
||||||
v-model="importName"
|
v-model="importName"
|
||||||
|
label="Profile name"
|
||||||
placeholder="Name for imported profile"
|
placeholder="Name for imported profile"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<div class="lb-field">
|
||||||
<div>
|
<label class="lb-field__label">BetterLoot JSON</label>
|
||||||
<label class="block text-sm text-neutral-400 mb-1">BetterLoot JSON</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="importJson"
|
v-model="importJson"
|
||||||
rows="10"
|
rows="10"
|
||||||
placeholder="Paste LootTables.json content here..."
|
placeholder="Paste LootTables.json content here…"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm font-mono resize-none"
|
class="cc-textarea cc-textarea--mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
</div>
|
||||||
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
<div class="lb-modal__foot">
|
||||||
<button
|
<Button variant="ghost" size="sm" @click="showImportModal = false">Cancel</Button>
|
||||||
@click="handleImport"
|
<Button
|
||||||
|
size="sm"
|
||||||
:disabled="!importName.trim() || !importJson.trim()"
|
:disabled="!importName.trim() || !importJson.trim()"
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
@click="handleImport"
|
||||||
>
|
>
|
||||||
Import
|
Import
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- Item Picker Modal -->
|
<!-- Item picker modal -->
|
||||||
<LootItemPicker
|
<LootItemPicker
|
||||||
v-if="showItemPicker"
|
v-if="showItemPicker"
|
||||||
@select="handleAddItem"
|
@select="handleAddItem"
|
||||||
@@ -388,6 +412,255 @@ function handleAddItem(shortname: string) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Click-away for apply dropdown -->
|
<!-- Click-away for apply dropdown -->
|
||||||
<div v-if="showApplyDropdown" class="fixed inset-0 z-0" @click="showApplyDropdown = false" />
|
<div v-if="showApplyDropdown" class="lb-clickaway" @click="showApplyDropdown = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lb-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.lb-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.lb-header__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.lb-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar inside Panel */
|
||||||
|
.lb-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.lb-toolbar__profile {
|
||||||
|
min-width: 200px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.lb-toolbar__empty {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.lb-toolbar__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apply dropdown */
|
||||||
|
.lb-apply-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.lb-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 150px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.lb-dropdown__item {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.lb-dropdown__item:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.lb-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.lb-spin {
|
||||||
|
animation: lb-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes lb-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Workspace (sidebar + editor) */
|
||||||
|
.lb-workspace {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: calc(100vh - 260px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor column */
|
||||||
|
.lb-editor {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.lb-tabs {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.lb-editor__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.lb-editor__placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state wrapper */
|
||||||
|
.lb-empty-wrap {}
|
||||||
|
|
||||||
|
/* Click-away backdrop */
|
||||||
|
.lb-clickaway {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal overlay */
|
||||||
|
.lb-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.lb-modal {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.lb-modal--wide {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
.lb-modal__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.lb-modal__title {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lb-modal__close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.lb-modal__close:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.lb-modal__body {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.lb-modal__foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 20px 16px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Field (textarea wrapper) */
|
||||||
|
.lb-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.lb-field__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.lb-field__opt {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bare textarea with token styling */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
padding: 9px 11px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
}
|
||||||
|
.cc-textarea::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.cc-textarea:focus-within,
|
||||||
|
.cc-textarea:focus {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||||
|
}
|
||||||
|
.cc-textarea--mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, nextTick, computed } from 'vue'
|
import { ref, onMounted, watch, nextTick, computed } from 'vue'
|
||||||
import { Map, TrendingUp, Award, Target, Download } from 'lucide-vue-next'
|
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import type { ECharts } from 'echarts'
|
import type { ECharts } from 'echarts'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import type { MapAnalyticsSummary } from '@/types'
|
import type { MapAnalyticsSummary } from '@/types'
|
||||||
import { safeFixed } from '@/utils/formatters'
|
import { safeFixed } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -30,6 +36,10 @@ const loadMapAnalytics = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssVar(name: string): string {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
|
}
|
||||||
|
|
||||||
const renderCharts = () => {
|
const renderCharts = () => {
|
||||||
if (!analytics.value || analytics.value.maps.length === 0) return
|
if (!analytics.value || analytics.value.maps.length === 0) return
|
||||||
|
|
||||||
@@ -44,20 +54,29 @@ const renderCharts = () => {
|
|||||||
const avgPlayers = analytics.value.maps.map(m => m.avg_players)
|
const avgPlayers = analytics.value.maps.map(m => m.avg_players)
|
||||||
const peakPlayers = analytics.value.maps.map(m => m.peak_players)
|
const peakPlayers = analytics.value.maps.map(m => m.peak_players)
|
||||||
|
|
||||||
|
const accent = cssVar('--accent') || '#CE422B'
|
||||||
|
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||||
|
const axisLine = cssVar('--border-default') || '#404040'
|
||||||
|
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||||
|
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||||
|
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||||
|
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||||
|
const mono = 'JetBrains Mono, monospace'
|
||||||
|
|
||||||
performanceChartInstance.setOption({
|
performanceChartInstance.setOption({
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: tooltipBg,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: tooltipBorder,
|
||||||
textStyle: { color: '#e5e5e5' },
|
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
type: 'shadow'
|
type: 'shadow'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
data: ['Avg Players', 'Peak Players'],
|
data: ['Avg Players', 'Peak Players'],
|
||||||
textStyle: { color: '#a3a3a3' },
|
textStyle: { color: labelColor, fontFamily: mono },
|
||||||
top: 0
|
top: 0
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
@@ -70,22 +89,22 @@ const renderCharts = () => {
|
|||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: mapNames,
|
data: mapNames,
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
axisLabel: { color: '#808080', rotate: 45 }
|
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: 'Players',
|
name: 'Players',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
splitLine: { lineStyle: { color: grid } },
|
||||||
axisLabel: { color: '#808080' }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: 'Avg Players',
|
name: 'Avg Players',
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: avgPlayers,
|
data: avgPlayers,
|
||||||
itemStyle: { color: '#CE422B' },
|
itemStyle: { color: accent },
|
||||||
barGap: '10%'
|
barGap: '10%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -138,182 +157,263 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="map-analytics-view">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="map-analytics-view__header">
|
||||||
<div class="flex items-center gap-3">
|
<h1 class="map-analytics-view__title">Map analytics</h1>
|
||||||
<Map class="w-5 h-5 text-oxide-500" />
|
<div class="map-analytics-view__controls">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Map Analytics</h1>
|
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
@click="downloadCSV"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</Button>
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
<Tabs
|
||||||
<button
|
:items="[
|
||||||
v-for="opt in (['30d', '90d', 'all'] as const)"
|
{ value: '30d', label: '30d' },
|
||||||
:key="opt"
|
{ value: '90d', label: '90d' },
|
||||||
@click="timeRange = opt"
|
{ value: 'all', label: 'All' }
|
||||||
class="px-3 py-2 text-sm font-medium transition-colors"
|
]"
|
||||||
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
v-model="timeRange"
|
||||||
>
|
variant="pill"
|
||||||
{{ opt }}
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
<div v-if="loading" class="map-analytics-view__loading">
|
||||||
<div class="text-neutral-500">Loading map analytics...</div>
|
<span class="map-analytics-view__loading-text">Loading map analytics...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="analytics">
|
<template v-else-if="analytics">
|
||||||
<!-- Summary cards -->
|
<!-- Summary cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="map-analytics-view__stats">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<StatCard
|
||||||
<div class="flex items-center gap-2 mb-2">
|
label="Best performing map"
|
||||||
<Award class="w-4 h-4 text-oxide-400" />
|
:value="analytics.best_performing_map ?? 'No data'"
|
||||||
<p class="text-sm text-neutral-400">Best Performing Map</p>
|
icon="award"
|
||||||
</div>
|
:note="analytics.maps.length > 0 ? `Avg ${safeFixed(analytics?.maps?.[0]?.avg_players, 1)} players` : undefined"
|
||||||
<p class="text-xl font-bold text-neutral-100">
|
/>
|
||||||
{{ analytics.best_performing_map ?? 'No data' }}
|
<StatCard
|
||||||
</p>
|
label="Rotation effectiveness"
|
||||||
<p class="text-xs text-neutral-600 mt-1" v-if="analytics.maps.length > 0">
|
:value="safeFixed(analytics?.rotation_effectiveness, 1)"
|
||||||
Avg {{ safeFixed(analytics?.maps?.[0]?.avg_players, 1) }} players
|
unit="%"
|
||||||
</p>
|
icon="target"
|
||||||
</div>
|
note="Overall rotation health"
|
||||||
|
/>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<StatCard
|
||||||
<div class="flex items-center gap-2 mb-2">
|
label="Total maps tracked"
|
||||||
<Target class="w-4 h-4 text-green-400" />
|
:value="analytics.maps.length"
|
||||||
<p class="text-sm text-neutral-400">Rotation Effectiveness</p>
|
icon="trending-up"
|
||||||
</div>
|
:note="`Last ${timeRange}`"
|
||||||
<p class="text-xl font-bold text-neutral-100">
|
/>
|
||||||
{{ safeFixed(analytics?.rotation_effectiveness, 1) }}%
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">Overall rotation health</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<TrendingUp class="w-4 h-4 text-blue-400" />
|
|
||||||
<p class="text-sm text-neutral-400">Total Maps Tracked</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-xl font-bold text-neutral-100">{{ analytics.maps.length }}</p>
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Performance chart -->
|
<!-- Performance chart -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Map performance comparison">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
<div v-if="analytics.maps.length > 0" ref="performanceChart" class="map-analytics-view__chart-area"></div>
|
||||||
Map Performance Comparison
|
<EmptyState
|
||||||
</h2>
|
v-else
|
||||||
<div v-if="analytics.maps.length > 0" ref="performanceChart" class="h-80"></div>
|
icon="map"
|
||||||
<div v-else class="h-80 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
|
title="No map data"
|
||||||
<p class="text-sm text-neutral-600">No map data available for this time range</p>
|
description="No map data available for this time range."
|
||||||
</div>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Map performance table -->
|
<!-- Map performance table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Detailed map metrics" :flush-body="sortedMaps.length > 0">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
<div v-if="sortedMaps.length > 0" class="map-analytics-view__table-wrap">
|
||||||
Detailed Map Metrics
|
<table class="map-analytics-view__table">
|
||||||
</h2>
|
<thead>
|
||||||
<div v-if="sortedMaps.length > 0" class="overflow-x-auto">
|
<tr class="map-analytics-view__thead-row">
|
||||||
<table class="w-full text-sm">
|
<th class="map-analytics-view__th map-analytics-view__th--left">Map name</th>
|
||||||
<thead class="border-b border-neutral-800">
|
<th class="map-analytics-view__th map-analytics-view__th--left">Seed</th>
|
||||||
<tr>
|
<th class="map-analytics-view__th map-analytics-view__th--right">Times used</th>
|
||||||
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Map Name</th>
|
<th class="map-analytics-view__th map-analytics-view__th--right">Avg players</th>
|
||||||
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Seed</th>
|
<th class="map-analytics-view__th map-analytics-view__th--right">Peak players</th>
|
||||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Times Used</th>
|
<th class="map-analytics-view__th map-analytics-view__th--right">Effectiveness</th>
|
||||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Avg Players</th>
|
|
||||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Peak Players</th>
|
|
||||||
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Effectiveness</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="map in sortedMaps"
|
v-for="map in sortedMaps"
|
||||||
:key="map.map_id"
|
:key="map.map_id"
|
||||||
class="border-b border-neutral-800/50 hover:bg-neutral-800/30 transition-colors"
|
class="map-analytics-view__row"
|
||||||
>
|
>
|
||||||
<td class="py-3 px-4 text-neutral-200 font-medium">{{ map.map_name }}</td>
|
<td class="map-analytics-view__td map-analytics-view__td--primary">{{ map.map_name }}</td>
|
||||||
<td class="py-3 px-4 text-neutral-400">{{ map.seed ?? '—' }}</td>
|
<td class="map-analytics-view__td">{{ map.seed ?? '—' }}</td>
|
||||||
<td class="py-3 px-4 text-right text-neutral-300">{{ map.times_used }}</td>
|
<td class="map-analytics-view__td map-analytics-view__td--num">{{ map.times_used }}</td>
|
||||||
<td class="py-3 px-4 text-right text-neutral-300">{{ safeFixed(map.avg_players, 1) }}</td>
|
<td class="map-analytics-view__td map-analytics-view__td--num">{{ safeFixed(map.avg_players, 1) }}</td>
|
||||||
<td class="py-3 px-4 text-right text-neutral-300">{{ map.peak_players }}</td>
|
<td class="map-analytics-view__td map-analytics-view__td--num">{{ map.peak_players }}</td>
|
||||||
<td class="py-3 px-4 text-right">
|
<td class="map-analytics-view__td map-analytics-view__td--right">
|
||||||
<span
|
<Badge
|
||||||
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium"
|
:tone="map.effectiveness_score >= 80 ? 'online' : map.effectiveness_score >= 60 ? 'warn' : 'offline'"
|
||||||
:class="{
|
mono
|
||||||
'bg-green-500/10 text-green-400': map.effectiveness_score >= 80,
|
|
||||||
'bg-yellow-500/10 text-yellow-400': map.effectiveness_score >= 60 && map.effectiveness_score < 80,
|
|
||||||
'bg-red-500/10 text-red-400': map.effectiveness_score < 60
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
{{ safeFixed(map.effectiveness_score, 1) }}%
|
{{ safeFixed(map.effectiveness_score, 1) }}%
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="py-8 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
|
<EmptyState
|
||||||
<p class="text-sm text-neutral-600">No map data available</p>
|
v-else
|
||||||
</div>
|
icon="map"
|
||||||
</div>
|
title="No map data"
|
||||||
|
description="No map data available."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Insights section -->
|
<!-- Insights section -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Actionable insights">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
<div class="map-analytics-view__insights">
|
||||||
Actionable Insights
|
<Alert
|
||||||
</h2>
|
v-if="analytics.best_performing_map"
|
||||||
<div class="space-y-3">
|
tone="accent"
|
||||||
<div v-if="analytics.best_performing_map" class="flex items-start gap-3 p-3 bg-neutral-800/50 rounded-lg">
|
:title="`Best map: ${analytics.best_performing_map}`"
|
||||||
<Award class="w-5 h-5 text-oxide-400 mt-0.5 flex-shrink-0" />
|
>
|
||||||
<div>
|
|
||||||
<p class="text-sm text-neutral-200 font-medium">
|
|
||||||
Your best map is <span class="text-oxide-400">{{ analytics.best_performing_map }}</span>
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-1">
|
|
||||||
Consider featuring this map more frequently in your rotation for maximum player engagement.
|
Consider featuring this map more frequently in your rotation for maximum player engagement.
|
||||||
</p>
|
</Alert>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<Alert
|
||||||
v-if="analytics.rotation_effectiveness < 70"
|
v-if="analytics.rotation_effectiveness < 70"
|
||||||
class="flex items-start gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg"
|
tone="warn"
|
||||||
|
title="Rotation effectiveness is below optimal"
|
||||||
>
|
>
|
||||||
<Target class="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-neutral-200 font-medium">Rotation effectiveness is below optimal</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-1">
|
|
||||||
Consider removing low-performing maps (effectiveness < 60%) and testing new maps to improve overall rotation health.
|
Consider removing low-performing maps (effectiveness < 60%) and testing new maps to improve overall rotation health.
|
||||||
</p>
|
</Alert>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<Alert
|
||||||
v-else-if="analytics.rotation_effectiveness >= 80"
|
v-else-if="analytics.rotation_effectiveness >= 80"
|
||||||
class="flex items-start gap-3 p-3 bg-green-500/5 border border-green-500/20 rounded-lg"
|
tone="online"
|
||||||
|
title="Excellent rotation effectiveness"
|
||||||
>
|
>
|
||||||
<TrendingUp class="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-neutral-200 font-medium">Excellent rotation effectiveness!</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-1">
|
|
||||||
Your current map rotation is driving strong player engagement. Keep monitoring for any changes.
|
Your current map rotation is driving strong player engagement. Keep monitoring for any changes.
|
||||||
</p>
|
</Alert>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-analytics-view {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__loading-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.map-analytics-view__stats {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__chart-area {
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__thead-row {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__th {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__th--left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__th--right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__row {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: background var(--dur-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__row:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__td--primary {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__td--num {
|
||||||
|
text-align: right;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__td--right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-analytics-view__insights {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { useApi } from '@/composables/useApi'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { MapEntry } from '@/types'
|
import type { MapEntry } from '@/types'
|
||||||
import { Map, Upload, Trash2, RefreshCw, Loader2 } from 'lucide-vue-next'
|
|
||||||
import { safeFileSize } from '@/utils/formatters'
|
import { safeFileSize } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -20,8 +24,8 @@ function formatSize(bytes: number): string {
|
|||||||
return safeFileSize(bytes)
|
return safeFileSize(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeBadgeClass(type: string): string {
|
function mapTypeTone(type: string): 'accent' | 'info' {
|
||||||
return type === 'custom' ? 'bg-oxide-500/15 text-oxide-400' : 'bg-blue-500/15 text-blue-400'
|
return type === 'custom' ? 'accent' : 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMaps() {
|
async function fetchMaps() {
|
||||||
@@ -92,84 +96,211 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="maps">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="page__head">
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Map class="w-5 h-5 text-oxide-500" />
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Map Library</h1>
|
<div class="t-eyebrow">Operations</div>
|
||||||
<p class="text-sm text-neutral-500 mt-0.5">{{ maps.length }} maps</p>
|
<h1 class="page__title">
|
||||||
|
Map library
|
||||||
|
<span v-if="maps.length > 0" class="page__count">{{ maps.length }} maps</span>
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="page__actions">
|
||||||
<div class="flex items-center gap-3">
|
<IconButton
|
||||||
<button
|
icon="refresh-cw"
|
||||||
@click="fetchMaps"
|
label="Refresh"
|
||||||
|
:class="isLoading && 'spin'"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
@click="fetchMaps"
|
||||||
>
|
/>
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".map"
|
accept=".map"
|
||||||
class="hidden"
|
class="hidden-input"
|
||||||
@change="handleFileSelected"
|
@change="handleFileSelected"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
|
icon="upload"
|
||||||
|
:loading="isUploading"
|
||||||
@click="triggerFileInput"
|
@click="triggerFileInput"
|
||||||
:disabled="isUploading"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
|
{{ isUploading ? 'Uploading…' : 'Upload map' }}
|
||||||
<Upload v-else class="w-4 h-4" />
|
</Button>
|
||||||
{{ isUploading ? 'Uploading...' : 'Upload Map' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<Panel v-if="maps.length === 0">
|
||||||
|
<EmptyState
|
||||||
|
icon="map"
|
||||||
|
title="No maps"
|
||||||
|
description="Upload custom maps or they will appear here when procedural maps are generated."
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button icon="upload" size="sm" @click="triggerFileInput">Upload map</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Maps grid -->
|
<!-- Maps grid -->
|
||||||
<div v-if="maps.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
<div v-else class="maps-grid">
|
||||||
<Map class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
|
||||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">No Maps</h3>
|
|
||||||
<p class="text-sm text-neutral-500">Upload custom maps or they'll appear here when procedural maps are generated.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="map in maps"
|
v-for="map in maps"
|
||||||
:key="map.id"
|
:key="map.id"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden hover:border-neutral-700 transition-colors"
|
class="map-card"
|
||||||
>
|
>
|
||||||
<!-- Thumbnail or placeholder -->
|
<!-- Thumbnail -->
|
||||||
<div class="h-32 bg-neutral-800 flex items-center justify-center">
|
<div class="map-card__thumb">
|
||||||
<img v-if="map.thumbnail_path" :src="map.thumbnail_path" :alt="map.display_name" class="w-full h-full object-cover" />
|
<img
|
||||||
<Map v-else class="w-8 h-8 text-neutral-600" />
|
v-if="map.thumbnail_path"
|
||||||
|
:src="map.thumbnail_path"
|
||||||
|
:alt="map.display_name"
|
||||||
|
class="map-card__img"
|
||||||
|
/>
|
||||||
|
<svg v-else class="map-card__placeholder" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" /><line x1="9" y1="3" x2="9" y2="18" /><line x1="15" y1="6" x2="15" y2="21" />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
<!-- Card body -->
|
||||||
<h3 class="text-sm font-medium text-neutral-100 truncate">{{ map.display_name }}</h3>
|
<div class="map-card__body">
|
||||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full shrink-0 ml-2" :class="typeBadgeClass(map.map_type)">
|
<div class="map-card__row">
|
||||||
{{ map.map_type }}
|
<span class="map-card__name">{{ map.display_name }}</span>
|
||||||
</span>
|
<Badge :tone="mapTypeTone(map.map_type)" size="md">{{ map.map_type }}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between text-xs text-neutral-500">
|
<div class="map-card__meta">
|
||||||
<span>{{ formatSize(map.file_size_bytes) }}</span>
|
<span>{{ formatSize(map.file_size_bytes) }}</span>
|
||||||
<span v-if="map.world_size">{{ map.world_size }}m</span>
|
<span v-if="map.world_size">{{ map.world_size }}m</span>
|
||||||
<span v-if="map.seed">Seed: {{ map.seed }}</span>
|
<span v-if="map.seed" class="mono">Seed: {{ map.seed }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end mt-3">
|
<div class="map-card__foot">
|
||||||
<button
|
<IconButton
|
||||||
|
icon="trash-2"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
label="Delete map"
|
||||||
@click="deleteMap(map)"
|
@click="deleteMap(map)"
|
||||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
/>
|
||||||
title="Delete map"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.maps {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.page__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.page__count {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.page__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.hidden-input { display: none; }
|
||||||
|
|
||||||
|
/* Spin utility */
|
||||||
|
.spin { animation: spin 0.7s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Maps grid */
|
||||||
|
.maps-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map card */
|
||||||
|
.map-card {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
.map-card:hover {
|
||||||
|
box-shadow: var(--ring-default), 0 0 0 1px var(--border-default);
|
||||||
|
}
|
||||||
|
.map-card__thumb {
|
||||||
|
height: 128px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.map-card__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.map-card__placeholder { color: var(--text-muted); }
|
||||||
|
.map-card__body { padding: 12px 14px; }
|
||||||
|
.map-card__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.map-card__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.map-card__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.map-card__foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.maps-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { Download, Upload, FileText, Loader2 } from 'lucide-vue-next'
|
|
||||||
import { safeFileSize, safeDate } from '@/utils/formatters'
|
import { safeFileSize, safeDate } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
interface ExportRecord {
|
interface ExportRecord {
|
||||||
id: string
|
id: string
|
||||||
@@ -20,6 +25,8 @@ const isExporting = ref(false)
|
|||||||
const isImporting = ref(false)
|
const isImporting = ref(false)
|
||||||
const exportType = ref<'full' | 'config_only' | 'store_only'>('full')
|
const exportType = ref<'full' | 'config_only' | 'store_only'>('full')
|
||||||
const uploadFile = ref<File | null>(null)
|
const uploadFile = ref<File | null>(null)
|
||||||
|
const importError = ref<string | null>(null)
|
||||||
|
const importSuccess = ref(false)
|
||||||
|
|
||||||
async function fetchExports() {
|
async function fetchExports() {
|
||||||
exports.value = await api.get<ExportRecord[]>('/migration/exports')
|
exports.value = await api.get<ExportRecord[]>('/migration/exports')
|
||||||
@@ -49,6 +56,8 @@ async function importData() {
|
|||||||
if (!confirm('Import data? This will overwrite existing configuration.')) return
|
if (!confirm('Import data? This will overwrite existing configuration.')) return
|
||||||
|
|
||||||
isImporting.value = true
|
isImporting.value = true
|
||||||
|
importError.value = null
|
||||||
|
importSuccess.value = false
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', uploadFile.value)
|
formData.append('file', uploadFile.value)
|
||||||
@@ -63,18 +72,20 @@ async function importData() {
|
|||||||
throw new Error('Import failed')
|
throw new Error('Import failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
alert('Import successful')
|
importSuccess.value = true
|
||||||
uploadFile.value = null
|
uploadFile.value = null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : 'Import failed')
|
importError.value = err instanceof Error ? err.message : 'Import failed'
|
||||||
} finally {
|
} finally {
|
||||||
isImporting.value = false
|
isImporting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
const EXPORT_TYPE_OPTIONS = [
|
||||||
return safeFileSize(bytes)
|
{ value: 'full' as const, label: 'Full' },
|
||||||
}
|
{ value: 'config_only' as const, label: 'Config only' },
|
||||||
|
{ value: 'store_only' as const, label: 'Store only' },
|
||||||
|
]
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchExports()
|
fetchExports()
|
||||||
@@ -82,110 +93,251 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="mv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="mv__head">
|
||||||
<FileText class="w-5 h-5 text-oxide-500" />
|
<div class="mv__head-id">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Migration</h1>
|
<div class="mv__head-chip">
|
||||||
|
<Icon name="upload" :size="20" :stroke-width="2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export Section -->
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Export Data</h2>
|
|
||||||
<div class="flex items-end gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-neutral-500 mb-2">Export Type</label>
|
<div class="t-eyebrow">Platform</div>
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
<h1 class="mv__title">Migration</h1>
|
||||||
<button
|
|
||||||
v-for="opt in (['full', 'config_only', 'store_only'] as const)"
|
|
||||||
:key="opt"
|
|
||||||
@click="exportType = opt"
|
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors capitalize"
|
|
||||||
:class="exportType === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
{{ opt.replace('_', ' ') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
@click="createExport"
|
|
||||||
:disabled="isExporting"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="isExporting" class="w-4 h-4 animate-spin" />
|
|
||||||
<Download v-else class="w-4 h-4" />
|
|
||||||
Export
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export History -->
|
<!-- Export section -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel title="Export data" subtitle="Create a portable backup of your configuration">
|
||||||
<div class="p-5 border-b border-neutral-800">
|
<div class="mv__export-row">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Export History</h2>
|
<div class="mv__export-field">
|
||||||
|
<div class="mv__field-label">Export type</div>
|
||||||
|
<div class="mv__seg">
|
||||||
|
<button
|
||||||
|
v-for="opt in EXPORT_TYPE_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
class="mv__seg-btn"
|
||||||
|
:class="exportType === opt.value && 'mv__seg-btn--active'"
|
||||||
|
@click="exportType = opt.value"
|
||||||
|
>{{ opt.label }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="exports.length === 0" class="p-8 text-center text-neutral-500">
|
|
||||||
No exports yet.
|
|
||||||
</div>
|
</div>
|
||||||
<table v-else class="w-full">
|
<Button
|
||||||
<thead class="bg-neutral-800/50 border-b border-neutral-800">
|
icon="download"
|
||||||
|
:loading="isExporting"
|
||||||
|
:disabled="isExporting"
|
||||||
|
@click="createExport"
|
||||||
|
>Export</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Export history -->
|
||||||
|
<Panel :flush-body="true" title="Export history">
|
||||||
|
<EmptyState
|
||||||
|
v-if="exports.length === 0"
|
||||||
|
icon="file-text"
|
||||||
|
title="No exports yet"
|
||||||
|
description="Create an export above to see it listed here."
|
||||||
|
/>
|
||||||
|
<table v-else class="cc-table">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th>
|
<th>Type</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Created</th>
|
<th>Created</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Size</th>
|
<th>Size</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-for="exp in exports" :key="exp.id" class="hover:bg-neutral-800/30">
|
<tr v-for="exp in exports" :key="exp.id">
|
||||||
<td class="px-4 py-3 text-sm text-neutral-200 capitalize">{{ exp.export_type.replace('_', ' ') }}</td>
|
<td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(exp.created_at) }}</td>
|
<Badge tone="neutral">{{ exp.export_type.replace('_', ' ') }}</Badge>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatBytes(exp.file_size_bytes) }}</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="td-mono">{{ safeDate(exp.created_at) }}</td>
|
||||||
|
<td class="td-mono">{{ safeFileSize(exp.file_size_bytes) }}</td>
|
||||||
|
<td>
|
||||||
<a
|
<a
|
||||||
v-if="exp.download_url"
|
v-if="exp.download_url"
|
||||||
:href="exp.download_url"
|
:href="exp.download_url"
|
||||||
class="text-oxide-400 hover:text-oxide-300 text-sm transition-colors"
|
class="mv__dl-link"
|
||||||
>
|
>
|
||||||
|
<Icon name="download" :size="13" />
|
||||||
Download
|
Download
|
||||||
</a>
|
</a>
|
||||||
<span v-else class="text-sm text-neutral-600">Preparing...</span>
|
<span v-else class="mv__preparing">Preparing…</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Import Section -->
|
<!-- Import section -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Import data" subtitle="Restore from a JSON or ZIP export file">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Import Data</h2>
|
<div class="mv__import-body">
|
||||||
<div class="space-y-4">
|
<Alert v-if="importSuccess" tone="online">Import completed successfully.</Alert>
|
||||||
<div class="border-2 border-dashed border-neutral-700 rounded-lg p-6 text-center">
|
<Alert v-if="importError" tone="danger">{{ importError }}</Alert>
|
||||||
<Upload class="w-8 h-8 text-neutral-500 mx-auto mb-2" />
|
|
||||||
<input
|
<label for="file-upload" class="mv__dropzone">
|
||||||
type="file"
|
<Icon name="upload" :size="28" class="mv__dropzone-icon" />
|
||||||
accept=".json,.zip"
|
<span class="mv__dropzone-label">
|
||||||
@change="handleFileSelect"
|
|
||||||
class="hidden"
|
|
||||||
id="file-upload"
|
|
||||||
/>
|
|
||||||
<label for="file-upload" class="cursor-pointer">
|
|
||||||
<span class="text-sm text-oxide-400 hover:text-oxide-300 transition-colors">
|
|
||||||
{{ uploadFile ? uploadFile.name : 'Click to select file' }}
|
{{ uploadFile ? uploadFile.name : 'Click to select file' }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="mv__dropzone-hint">JSON or ZIP exports</span>
|
||||||
|
<input
|
||||||
|
id="file-upload"
|
||||||
|
type="file"
|
||||||
|
accept=".json,.zip"
|
||||||
|
class="mv__file-input"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-neutral-500 mt-1">JSON or ZIP exports</p>
|
|
||||||
</div>
|
<Button
|
||||||
<button
|
:block="true"
|
||||||
@click="importData"
|
icon="upload"
|
||||||
|
:loading="isImporting"
|
||||||
:disabled="!uploadFile || isImporting"
|
:disabled="!uploadFile || isImporting"
|
||||||
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
@click="importData"
|
||||||
>
|
>Import</Button>
|
||||||
<Loader2 v-if="isImporting" class="w-4 h-4 animate-spin" />
|
|
||||||
<Upload v-else class="w-4 h-4" />
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mv {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.mv__head { display: flex; align-items: center; }
|
||||||
|
.mv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.mv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.mv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Export row */
|
||||||
|
.mv__export-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mv__export-field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.mv__field-label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segment control (mirrors WipesView pattern) */
|
||||||
|
.mv__seg {
|
||||||
|
display: flex;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mv__seg-btn {
|
||||||
|
height: var(--control-h-md);
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.mv__seg-btn:hover { color: var(--text-primary); background: var(--surface-hover); }
|
||||||
|
.mv__seg-btn--active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.cc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.cc-table thead tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.cc-table th {
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cc-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||||
|
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||||
|
.cc-table td {
|
||||||
|
padding: 11px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Download link */
|
||||||
|
.mv__dl-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.mv__dl-link:hover { color: var(--accent); }
|
||||||
|
.mv__preparing { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Import body */
|
||||||
|
.mv__import-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropzone */
|
||||||
|
.mv__dropzone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
border: 1.5px dashed var(--border-default);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.mv__dropzone:hover { border-color: var(--accent-border); background: var(--accent-soft); }
|
||||||
|
.mv__dropzone-icon { color: var(--text-tertiary); }
|
||||||
|
.mv__dropzone:hover .mv__dropzone-icon { color: var(--accent-text); }
|
||||||
|
.mv__dropzone-label { font-size: var(--text-sm); font-weight: 500; color: var(--accent-text); }
|
||||||
|
.mv__dropzone-hint { font-size: var(--text-xs); color: var(--text-muted); }
|
||||||
|
.mv__file-input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,8 +3,17 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { Module } from '@/types'
|
import type { Module } from '@/types'
|
||||||
import { ShoppingCart, Package, Search, Filter, X, Check, Download, AlertCircle } from 'lucide-vue-next'
|
|
||||||
import { safeFixed } from '@/utils/formatters'
|
import { safeFixed } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.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 Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -22,17 +31,22 @@ const isPurchasing = ref(false)
|
|||||||
const purchaseError = ref('')
|
const purchaseError = ref('')
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ value: 'all', label: 'All Modules' },
|
{ value: 'all', label: 'All modules' },
|
||||||
{ value: 'loot', label: 'Loot' },
|
{ value: 'loot', label: 'Loot' },
|
||||||
{ value: 'events', label: 'Events' },
|
{ value: 'events', label: 'Events' },
|
||||||
{ value: 'economy', label: 'Economy' },
|
{ value: 'economy', label: 'Economy' },
|
||||||
{ value: 'kits', label: 'Kits' },
|
{ value: 'kits', label: 'Kits' },
|
||||||
{ value: 'admin', label: 'Admin Tools' },
|
{ value: 'admin', label: 'Admin tools' },
|
||||||
{ value: 'pvp', label: 'PVP' },
|
{ value: 'pvp', label: 'PVP' },
|
||||||
{ value: 'pve', label: 'PVE' },
|
{ value: 'pve', label: 'PVE' },
|
||||||
{ value: 'building', label: 'Building' },
|
{ value: 'building', label: 'Building' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const tabItems = computed(() => [
|
||||||
|
{ value: 'catalog', label: 'Catalog', icon: 'package' },
|
||||||
|
{ value: 'my-modules', label: `My modules (${myModules.value.length})`, icon: 'download' },
|
||||||
|
])
|
||||||
|
|
||||||
const filteredModules = computed(() => {
|
const filteredModules = computed(() => {
|
||||||
let result = activeTab.value === 'catalog' ? modules.value : myModules.value
|
let result = activeTab.value === 'catalog' ? modules.value : myModules.value
|
||||||
|
|
||||||
@@ -51,6 +65,21 @@ const filteredModules = computed(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type BadgeTone = 'info' | 'accent' | 'warn' | 'online' | 'neutral' | 'offline'
|
||||||
|
function categoryTone(category: string): BadgeTone {
|
||||||
|
const map: Record<string, BadgeTone> = {
|
||||||
|
loot: 'warn',
|
||||||
|
events: 'accent',
|
||||||
|
economy: 'online',
|
||||||
|
kits: 'info',
|
||||||
|
admin: 'accent',
|
||||||
|
pvp: 'offline',
|
||||||
|
pve: 'info',
|
||||||
|
building: 'warn',
|
||||||
|
}
|
||||||
|
return map[category] ?? 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCatalog() {
|
async function loadCatalog() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -94,10 +123,8 @@ async function confirmPurchase() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (response.payment_url) {
|
if (response.payment_url) {
|
||||||
// Redirect to external payment provider
|
|
||||||
window.location.href = response.payment_url
|
window.location.href = response.payment_url
|
||||||
} else if (response.success) {
|
} else if (response.success) {
|
||||||
// Instant purchase confirmed
|
|
||||||
showPurchaseModal.value = false
|
showPurchaseModal.value = false
|
||||||
selectedModule.value = null
|
selectedModule.value = null
|
||||||
await loadCatalog()
|
await loadCatalog()
|
||||||
@@ -131,20 +158,6 @@ function closeModals() {
|
|||||||
purchaseError.value = ''
|
purchaseError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryBadgeClass(category: string): string {
|
|
||||||
const colors: Record<string, string> = {
|
|
||||||
loot: 'bg-yellow-500/15 text-yellow-400',
|
|
||||||
events: 'bg-purple-500/15 text-purple-400',
|
|
||||||
economy: 'bg-green-500/15 text-green-400',
|
|
||||||
kits: 'bg-blue-500/15 text-blue-400',
|
|
||||||
admin: 'bg-oxide-500/15 text-oxide-400',
|
|
||||||
pvp: 'bg-red-500/15 text-red-400',
|
|
||||||
pve: 'bg-indigo-500/15 text-indigo-400',
|
|
||||||
building: 'bg-orange-500/15 text-orange-400',
|
|
||||||
}
|
|
||||||
return colors[category] || 'bg-neutral-700/50 text-neutral-400'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadCatalog()
|
loadCatalog()
|
||||||
loadMyModules()
|
loadMyModules()
|
||||||
@@ -152,323 +165,371 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="ms-page">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="page__head">
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<ShoppingCart class="w-5 h-5 text-oxide-500" />
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Module Store</h1>
|
<div class="t-eyebrow">Management</div>
|
||||||
<p class="text-sm text-neutral-500 mt-0.5">
|
<h1 class="page__title">Module store</h1>
|
||||||
Extend your server with premium gameplay modules
|
<p class="page__sub">Extend your server with premium gameplay modules.</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
|
||||||
<button
|
|
||||||
@click="activeTab = 'catalog'"
|
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
:class="activeTab === 'catalog' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<Package class="w-4 h-4" />
|
|
||||||
Catalog
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="activeTab = 'my-modules'"
|
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
:class="activeTab === 'my-modules' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
My Modules ({{ myModules.length }})
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Tabs v-model="activeTab" :items="tabItems" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters bar -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="ms-filters">
|
||||||
<div class="relative flex-1 max-w-md">
|
<Input
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
icon="search"
|
||||||
placeholder="Search modules..."
|
placeholder="Search modules…"
|
||||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
class="ms-filters__search"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Select
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Filter class="w-4 h-4 text-neutral-500" />
|
|
||||||
<select
|
|
||||||
v-model="selectedCategory"
|
v-model="selectedCategory"
|
||||||
class="px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
:options="categories"
|
||||||
>
|
size="md"
|
||||||
<option v-for="cat in categories" :key="cat.value" :value="cat.value">
|
class="ms-filters__cat"
|
||||||
{{ cat.label }}
|
/>
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
<div v-if="isLoading" class="ms-loading">
|
||||||
<div class="text-neutral-500">Loading modules...</div>
|
<EmptyState icon="loader" title="Loading modules…" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Module grid -->
|
<!-- Module grid -->
|
||||||
<div v-else-if="filteredModules.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div v-else-if="filteredModules.length > 0" class="ms-grid">
|
||||||
<div
|
<article
|
||||||
v-for="module in filteredModules"
|
v-for="module in filteredModules"
|
||||||
:key="module.id"
|
:key="module.id"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden hover:border-oxide-500/30 transition-all group"
|
class="ms-card"
|
||||||
>
|
>
|
||||||
<!-- Preview Image -->
|
<!-- Preview image -->
|
||||||
<div class="relative h-40 bg-neutral-800 overflow-hidden">
|
<div class="ms-card__img">
|
||||||
<img
|
<img
|
||||||
v-if="module.preview_image_url"
|
v-if="module.preview_image_url"
|
||||||
:src="module.preview_image_url"
|
:src="module.preview_image_url"
|
||||||
:alt="module.name"
|
:alt="module.name"
|
||||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
class="ms-card__img-el"
|
||||||
/>
|
/>
|
||||||
<div v-else class="w-full h-full flex items-center justify-center">
|
<div v-else class="ms-card__img-placeholder">
|
||||||
<Package class="w-12 h-12 text-neutral-700" />
|
<Icon name="package" :size="36" :stroke-width="1.5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-2 right-2">
|
<div class="ms-card__img-badges">
|
||||||
<span class="text-xs font-medium px-2 py-1 rounded-full" :class="categoryBadgeClass(module.category)">
|
<Badge :tone="categoryTone(module.category)" size="md">{{ module.category }}</Badge>
|
||||||
{{ module.category }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="module.is_purchased" class="absolute top-2 left-2">
|
<div v-if="module.is_purchased" class="ms-card__img-owned">
|
||||||
<span class="text-xs font-medium px-2 py-1 rounded-full bg-green-500/20 text-green-400 flex items-center gap-1">
|
<Badge tone="online" icon="check" size="md">Purchased</Badge>
|
||||||
<Check class="w-3 h-3" />
|
|
||||||
Purchased
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="p-4 space-y-3">
|
<div class="ms-card__body">
|
||||||
<div>
|
<div class="ms-card__header">
|
||||||
<div class="flex items-start justify-between gap-2 mb-1">
|
<span class="ms-card__name">{{ module.name }}</span>
|
||||||
<h3 class="text-base font-semibold text-neutral-100">{{ module.name }}</h3>
|
<span class="ms-price">${{ safeFixed(module.price, 2) }}</span>
|
||||||
<span class="text-lg font-bold text-oxide-400 shrink-0">${{ safeFixed(module.price, 2) }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-neutral-500 line-clamp-2">{{ module.description }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="ms-card__desc">{{ module.description }}</p>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="ms-card__features">
|
||||||
<span
|
<Badge
|
||||||
v-for="(feature, idx) in module.features.slice(0, 3)"
|
v-for="(feature, idx) in module.features.slice(0, 3)"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="text-xs text-neutral-400 bg-neutral-800 px-2 py-0.5 rounded"
|
tone="neutral"
|
||||||
>
|
>{{ feature }}</Badge>
|
||||||
{{ feature }}
|
<span v-if="module.features.length > 3" class="ms-card__more">+{{ module.features.length - 3 }} more</span>
|
||||||
</span>
|
|
||||||
<span v-if="module.features.length > 3" class="text-xs text-neutral-500">
|
|
||||||
+{{ module.features.length - 3 }} more
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex gap-2 pt-2">
|
<div class="ms-card__actions">
|
||||||
<button
|
<Button variant="secondary" size="sm" :block="false" @click="openDetailModal(module)">Details</Button>
|
||||||
@click="openDetailModal(module)"
|
<Button
|
||||||
class="flex-1 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="!module.is_purchased"
|
v-if="!module.is_purchased"
|
||||||
|
size="sm"
|
||||||
@click="initiatePurchase(module)"
|
@click="initiatePurchase(module)"
|
||||||
class="flex-1 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
>Purchase</Button>
|
||||||
>
|
<Button
|
||||||
Purchase
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else-if="!module.is_installed"
|
v-else-if="!module.is_installed"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
icon="download"
|
||||||
@click="installModule(module)"
|
@click="installModule(module)"
|
||||||
class="flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium text-green-400 bg-green-500/10 hover:bg-green-500/20 border border-green-500/20 rounded-lg transition-colors"
|
>Install</Button>
|
||||||
>
|
<Button
|
||||||
<Download class="w-3.5 h-3.5" />
|
|
||||||
Install
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
v-else
|
||||||
disabled
|
size="sm"
|
||||||
class="flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium text-green-400 bg-green-500/10 border border-green-500/20 rounded-lg cursor-default"
|
variant="outline"
|
||||||
>
|
icon="check"
|
||||||
<Check class="w-3.5 h-3.5" />
|
:disabled="true"
|
||||||
Installed
|
>Installed</Button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
<Panel v-else>
|
||||||
<Package class="w-12 h-12 text-neutral-600 mx-auto mb-3" />
|
<EmptyState
|
||||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">
|
icon="package"
|
||||||
{{ activeTab === 'catalog' ? 'No modules found' : 'No purchased modules' }}
|
:title="activeTab === 'catalog' ? 'No modules found' : 'No purchased modules'"
|
||||||
</h3>
|
:description="activeTab === 'catalog' ? 'Try adjusting your search or category filter.' : 'Browse the catalog to purchase modules.'"
|
||||||
<p class="text-sm text-neutral-500">
|
/>
|
||||||
{{ activeTab === 'catalog' ? 'Try adjusting your filters.' : 'Browse the catalog to purchase modules.' }}
|
</Panel>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Detail Modal -->
|
<!-- ===== Detail modal ===== -->
|
||||||
<div
|
<div
|
||||||
v-if="showDetailModal && selectedModule"
|
v-if="showDetailModal && selectedModule"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
class="ms-modal-backdrop"
|
||||||
@click.self="closeModals"
|
@click.self="closeModals"
|
||||||
>
|
>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
<div class="ms-modal ms-modal--wide">
|
||||||
<!-- Modal Header -->
|
<div class="ms-modal__head ms-modal__head--sticky">
|
||||||
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-start justify-between">
|
<div class="ms-modal__head-content">
|
||||||
<div class="flex-1">
|
<div class="ms-modal__title-row">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<h2 class="ms-modal__title">{{ selectedModule.name }}</h2>
|
||||||
<h2 class="text-xl font-bold text-neutral-100">{{ selectedModule.name }}</h2>
|
<Badge :tone="categoryTone(selectedModule.category)" size="md">{{ selectedModule.category }}</Badge>
|
||||||
<span class="text-xs font-medium px-2 py-1 rounded-full" :class="categoryBadgeClass(selectedModule.category)">
|
|
||||||
{{ selectedModule.category }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-neutral-400">Version {{ selectedModule.version }}</p>
|
<p class="ms-modal__ver">Version {{ selectedModule.version }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<IconButton icon="x" label="Close" @click="closeModals" />
|
||||||
@click="closeModals"
|
|
||||||
class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<X class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Body -->
|
<div class="ms-modal__body">
|
||||||
<div class="p-6 space-y-6">
|
|
||||||
<!-- Screenshots -->
|
<!-- Screenshots -->
|
||||||
<div v-if="selectedModule.screenshots.length > 0" class="space-y-3">
|
<div v-if="selectedModule.screenshots.length > 0" class="ms-detail-section">
|
||||||
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Screenshots</h3>
|
<div class="ms-detail-section__label">Screenshots</div>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="ms-screenshots">
|
||||||
<img
|
<img
|
||||||
v-for="(screenshot, idx) in selectedModule.screenshots"
|
v-for="(screenshot, idx) in selectedModule.screenshots"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
:src="screenshot"
|
:src="screenshot"
|
||||||
:alt="`Screenshot ${idx + 1}`"
|
:alt="`Screenshot ${idx + 1}`"
|
||||||
class="w-full h-48 object-cover rounded-lg border border-neutral-800"
|
class="ms-screenshot"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="space-y-3">
|
<div class="ms-detail-section">
|
||||||
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Description</h3>
|
<div class="ms-detail-section__label">Description</div>
|
||||||
<p class="text-sm text-neutral-400 leading-relaxed">{{ selectedModule.description }}</p>
|
<p class="ms-detail-desc">{{ selectedModule.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Features -->
|
<!-- Features -->
|
||||||
<div class="space-y-3">
|
<div class="ms-detail-section">
|
||||||
<h3 class="text-sm font-medium text-neutral-300 uppercase tracking-wider">Features</h3>
|
<div class="ms-detail-section__label">Features</div>
|
||||||
<ul class="space-y-2">
|
<ul class="ms-features">
|
||||||
<li
|
<li v-for="(feature, idx) in selectedModule.features" :key="idx" class="ms-feature">
|
||||||
v-for="(feature, idx) in selectedModule.features"
|
<Icon name="check" :size="14" :stroke-width="2.5" class="ms-feature__icon" />
|
||||||
:key="idx"
|
|
||||||
class="flex items-start gap-2 text-sm text-neutral-400"
|
|
||||||
>
|
|
||||||
<Check class="w-4 h-4 text-oxide-500 shrink-0 mt-0.5" />
|
|
||||||
{{ feature }}
|
{{ feature }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Price and Purchase -->
|
<!-- Price and action -->
|
||||||
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 flex items-center justify-between">
|
<div class="ms-detail-cta">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-neutral-400 mb-1">One-time purchase</p>
|
<p class="ms-detail-cta__label">One-time purchase</p>
|
||||||
<p class="text-2xl font-bold text-oxide-400">${{ safeFixed(selectedModule.price, 2) }}</p>
|
<p class="ms-price ms-price--lg">${{ safeFixed(selectedModule.price, 2) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
v-if="!selectedModule.is_purchased"
|
v-if="!selectedModule.is_purchased"
|
||||||
@click="initiatePurchase(selectedModule)"
|
@click="initiatePurchase(selectedModule)"
|
||||||
class="px-6 py-3 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
>Purchase now</Button>
|
||||||
>
|
<Button
|
||||||
Purchase Now
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else-if="!selectedModule.is_installed"
|
v-else-if="!selectedModule.is_installed"
|
||||||
|
variant="outline"
|
||||||
|
icon="download"
|
||||||
@click="installModule(selectedModule)"
|
@click="installModule(selectedModule)"
|
||||||
class="flex items-center gap-2 px-6 py-3 text-sm font-medium text-green-400 bg-green-500/10 hover:bg-green-500/20 border border-green-500/20 rounded-lg transition-colors"
|
>Install</Button>
|
||||||
>
|
<div v-else class="ms-installed">
|
||||||
<Download class="w-4 h-4" />
|
<Icon name="check" :size="18" :stroke-width="2.5" />
|
||||||
Install
|
<span>Installed</span>
|
||||||
</button>
|
|
||||||
<div v-else class="flex items-center gap-2 text-green-400">
|
|
||||||
<Check class="w-5 h-5" />
|
|
||||||
<span class="text-sm font-medium">Installed</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Purchase Confirmation Modal -->
|
<!-- ===== Purchase confirmation modal ===== -->
|
||||||
<div
|
<div
|
||||||
v-if="showPurchaseModal && selectedModule"
|
v-if="showPurchaseModal && selectedModule"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
class="ms-modal-backdrop"
|
||||||
@click.self="closeModals"
|
@click.self="closeModals"
|
||||||
>
|
>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-md w-full">
|
<div class="ms-modal">
|
||||||
<!-- Header -->
|
<div class="ms-modal__head">
|
||||||
<div class="border-b border-neutral-800 px-6 py-4">
|
<h2 class="ms-modal__title">Confirm purchase</h2>
|
||||||
<h2 class="text-xl font-bold text-neutral-100">Confirm Purchase</h2>
|
<IconButton icon="x" label="Close" @click="closeModals" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<div class="ms-modal__body">
|
||||||
<div class="p-6 space-y-4">
|
<div class="ms-purchase-summary">
|
||||||
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 space-y-2">
|
<div class="ms-purchase-row">
|
||||||
<div class="flex items-center justify-between">
|
<span class="ms-purchase-row__label">Module</span>
|
||||||
<span class="text-sm text-neutral-400">Module</span>
|
<span class="ms-purchase-row__value">{{ selectedModule.name }}</span>
|
||||||
<span class="text-sm font-medium text-neutral-100">{{ selectedModule.name }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="ms-purchase-row">
|
||||||
<span class="text-sm text-neutral-400">License</span>
|
<span class="ms-purchase-row__label">License</span>
|
||||||
<span class="text-sm font-medium text-neutral-100">{{ auth.license?.license_key }}</span>
|
<span class="ms-purchase-row__value ms-mono">{{ auth.license?.license_key }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-neutral-700 pt-2 mt-2 flex items-center justify-between">
|
<div class="ms-purchase-divider" />
|
||||||
<span class="text-base font-medium text-neutral-300">Total</span>
|
<div class="ms-purchase-row ms-purchase-row--total">
|
||||||
<span class="text-2xl font-bold text-oxide-400">${{ safeFixed(selectedModule.price, 2) }}</span>
|
<span class="ms-purchase-row__label">Total</span>
|
||||||
|
<span class="ms-price ms-price--lg">${{ safeFixed(selectedModule.price, 2) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="purchaseError" class="flex items-start gap-2 bg-red-500/10 border border-red-500/20 rounded-lg p-3">
|
<Alert v-if="purchaseError" tone="danger">{{ purchaseError }}</Alert>
|
||||||
<AlertCircle class="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
|
||||||
<p class="text-sm text-red-400">{{ purchaseError }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-xs text-neutral-500 leading-relaxed">
|
<p class="ms-purchase-terms">
|
||||||
By confirming this purchase, you agree to the module license terms. This purchase is non-refundable once the module is installed.
|
By confirming this purchase, you agree to the module license terms. This purchase is non-refundable once the module is installed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<div class="ms-modal__foot">
|
||||||
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
|
<Button variant="secondary" :disabled="isPurchasing" @click="closeModals">Cancel</Button>
|
||||||
<button
|
<Button :loading="isPurchasing" @click="confirmPurchase">Confirm purchase</Button>
|
||||||
@click="closeModals"
|
|
||||||
:disabled="isPurchasing"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="confirmPurchase"
|
|
||||||
:disabled="isPurchasing"
|
|
||||||
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ isPurchasing ? 'Processing...' : 'Confirm Purchase' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ms-page { max-width: 1320px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
|
||||||
|
.page__head {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
gap: 16px; flex-wrap: wrap; row-gap: 12px;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 5px;
|
||||||
|
}
|
||||||
|
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.ms-filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.ms-filters__search { flex: 1; min-width: 200px; max-width: 380px; }
|
||||||
|
.ms-filters__cat { min-width: 160px; }
|
||||||
|
|
||||||
|
.ms-loading { padding: 20px 0; }
|
||||||
|
|
||||||
|
/* Module grid */
|
||||||
|
.ms-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||||
|
|
||||||
|
/* Module card */
|
||||||
|
.ms-card {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default); overflow: hidden; display: flex; flex-direction: column;
|
||||||
|
transition: box-shadow var(--dur-base) var(--ease-standard);
|
||||||
|
}
|
||||||
|
.ms-card:hover { box-shadow: inset 0 0 0 1px var(--accent-border), var(--ring-default); }
|
||||||
|
|
||||||
|
.ms-card__img {
|
||||||
|
position: relative; height: 160px; overflow: hidden;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.ms-card__img-el {
|
||||||
|
width: 100%; height: 100%; object-fit: cover;
|
||||||
|
transition: transform 300ms var(--ease-standard);
|
||||||
|
}
|
||||||
|
.ms-card:hover .ms-card__img-el { transform: scale(1.04); }
|
||||||
|
.ms-card__img-placeholder {
|
||||||
|
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.ms-card__img-badges { position: absolute; top: 8px; right: 8px; }
|
||||||
|
.ms-card__img-owned { position: absolute; top: 8px; left: 8px; }
|
||||||
|
|
||||||
|
.ms-card__body { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
|
||||||
|
.ms-card__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||||
|
.ms-card__name { font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
|
||||||
|
.ms-card__desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.ms-card__features { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||||
|
.ms-card__more { font-size: var(--text-xs); color: var(--text-muted); }
|
||||||
|
.ms-card__actions { display: flex; gap: 8px; margin-top: auto; padding-top: 4px; }
|
||||||
|
|
||||||
|
/* Price — mono + tabular */
|
||||||
|
.ms-price { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-weight: 700; font-size: var(--text-base); color: var(--accent-text); }
|
||||||
|
.ms-price--lg { font-size: var(--text-2xl); }
|
||||||
|
.ms-mono { font-family: var(--font-mono); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.ms-modal-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 60;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,.6); backdrop-filter: blur(4px); padding: 16px;
|
||||||
|
}
|
||||||
|
.ms-modal {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xl, var(--ring-default)); max-width: 480px; width: 100%;
|
||||||
|
display: flex; flex-direction: column; max-height: 90vh;
|
||||||
|
}
|
||||||
|
.ms-modal--wide { max-width: 760px; }
|
||||||
|
|
||||||
|
.ms-modal__head {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--border-subtle); flex: none;
|
||||||
|
}
|
||||||
|
.ms-modal__head--sticky { position: sticky; top: 0; background: var(--surface-base); z-index: 1; }
|
||||||
|
.ms-modal__head-content { flex: 1; min-width: 0; }
|
||||||
|
.ms-modal__title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.ms-modal__title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
|
||||||
|
.ms-modal__ver { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-top: 3px; }
|
||||||
|
|
||||||
|
.ms-modal__body { padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.ms-modal__foot {
|
||||||
|
display: flex; align-items: center; justify-content: flex-end;
|
||||||
|
gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border-subtle); flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail modal content */
|
||||||
|
.ms-detail-section { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.ms-detail-section__label {
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
}
|
||||||
|
.ms-detail-desc { font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
|
||||||
|
|
||||||
|
.ms-screenshots { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.ms-screenshot { width: 100%; height: 180px; object-fit: cover; border-radius: var(--radius-md); box-shadow: var(--ring-default); }
|
||||||
|
|
||||||
|
.ms-features { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.ms-feature { display: flex; align-items: flex-start; gap: 8px; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
|
.ms-feature__icon { flex: none; color: var(--accent-text); margin-top: 2px; }
|
||||||
|
|
||||||
|
.ms-detail-cta {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||||
|
background: var(--surface-raised); border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default); padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.ms-detail-cta__label { font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: 4px; }
|
||||||
|
|
||||||
|
.ms-installed {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: var(--text-sm); font-weight: 500; color: var(--status-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Purchase summary */
|
||||||
|
.ms-purchase-summary {
|
||||||
|
background: var(--surface-raised); border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default); padding: 14px 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.ms-purchase-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.ms-purchase-row--total { margin-top: 2px; }
|
||||||
|
.ms-purchase-row__label { font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||||
|
.ms-purchase-row__value { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.ms-purchase-divider { height: 1px; background: var(--border-subtle); }
|
||||||
|
|
||||||
|
.ms-purchase-terms { font-size: var(--text-xs); color: var(--text-muted); line-height: 1.5; }
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ms-grid { grid-template-columns: 1fr; }
|
||||||
|
.ms-filters__search { max-width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { NotificationConfig } from '@/types'
|
import type { NotificationConfig } from '@/types'
|
||||||
import { Bell, Save, Loader2 } from 'lucide-vue-next'
|
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
@@ -26,12 +30,12 @@ const saving = ref(false)
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
const eventToggles = [
|
const eventToggles = [
|
||||||
{ key: 'notify_wipe_start' as const, label: 'Wipe Started', desc: 'When a wipe begins' },
|
{ key: 'notify_wipe_start' as const, label: 'Wipe started', desc: 'When a wipe begins' },
|
||||||
{ key: 'notify_wipe_complete' as const, label: 'Wipe Complete', desc: 'When a wipe finishes successfully' },
|
{ key: 'notify_wipe_complete' as const, label: 'Wipe complete', desc: 'When a wipe finishes successfully' },
|
||||||
{ key: 'notify_wipe_failed' as const, label: 'Wipe Failed', desc: 'When a wipe fails or rolls back' },
|
{ key: 'notify_wipe_failed' as const, label: 'Wipe failed', desc: 'When a wipe fails or rolls back' },
|
||||||
{ key: 'notify_server_crash' as const, label: 'Server Crash', desc: 'When the server process crashes' },
|
{ key: 'notify_server_crash' as const, label: 'Server crash', desc: 'When the server process crashes' },
|
||||||
{ key: 'notify_server_offline' as const, label: 'Server Offline', desc: 'When the server goes unreachable' },
|
{ key: 'notify_server_offline' as const, label: 'Server offline', desc: 'When the server goes unreachable' },
|
||||||
{ key: 'notify_store_purchase' as const, label: 'Store Purchase', desc: 'When a player buys from the store' },
|
{ key: 'notify_store_purchase' as const, label: 'Store purchase', desc: 'When a player buys from the store' },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function fetchConfig() {
|
async function fetchConfig() {
|
||||||
@@ -58,129 +62,114 @@ async function saveConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coerce nullable string fields to string for DS Input (which expects string, not string|null)
|
||||||
|
const discordWebhookUrl = computed<string>({
|
||||||
|
get: () => config.value.discord_webhook_url ?? '',
|
||||||
|
set: (v) => { config.value.discord_webhook_url = v || null },
|
||||||
|
})
|
||||||
|
const pushbulletApiKey = computed<string>({
|
||||||
|
get: () => config.value.pushbullet_api_key ?? '',
|
||||||
|
set: (v) => { config.value.pushbullet_api_key = v || null },
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchConfig()
|
fetchConfig()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="notifs">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="page__head">
|
||||||
<div class="flex items-center gap-3">
|
<div>
|
||||||
<Bell class="w-5 h-5 text-oxide-500" />
|
<div class="t-eyebrow">Monitoring</div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Notifications</h1>
|
<h1 class="page__title">Notifications</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="save"
|
||||||
|
:loading="saving"
|
||||||
@click="saveConfig"
|
@click="saveConfig"
|
||||||
:disabled="saving"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" />
|
{{ saving ? 'Saving...' : 'Save changes' }}
|
||||||
<Save v-else class="w-4 h-4" />
|
</Button>
|
||||||
{{ saving ? 'Saving...' : 'Save Changes' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Discord -->
|
<!-- Discord webhook -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Discord webhook" subtitle="Send notifications to a Discord channel" eyebrow="Channel">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<template #actions>
|
||||||
<div>
|
<Switch v-model="config.discord_enabled" />
|
||||||
<h2 class="text-sm font-medium text-neutral-200">Discord Webhook</h2>
|
</template>
|
||||||
<p class="text-xs text-neutral-500 mt-0.5">Send notifications to a Discord channel</p>
|
<Input
|
||||||
</div>
|
v-model="discordWebhookUrl"
|
||||||
<button
|
|
||||||
@click="config.discord_enabled = !config.discord_enabled"
|
|
||||||
class="w-9 h-5 rounded-full transition-colors"
|
|
||||||
:class="config.discord_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
|
||||||
:class="config.discord_enabled ? 'translate-x-4.5' : 'translate-x-0.5'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-model="config.discord_webhook_url"
|
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
:disabled="!config.discord_enabled"
|
:disabled="!config.discord_enabled"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors disabled:opacity-40"
|
:mono="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Pushbullet -->
|
<!-- Pushbullet -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Pushbullet" subtitle="Push notifications to your devices" eyebrow="Channel">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<template #actions>
|
||||||
<div>
|
<Switch v-model="config.pushbullet_enabled" />
|
||||||
<h2 class="text-sm font-medium text-neutral-200">Pushbullet</h2>
|
</template>
|
||||||
<p class="text-xs text-neutral-500 mt-0.5">Push notifications to your devices</p>
|
<Input
|
||||||
</div>
|
v-model="pushbulletApiKey"
|
||||||
<button
|
|
||||||
@click="config.pushbullet_enabled = !config.pushbullet_enabled"
|
|
||||||
class="w-9 h-5 rounded-full transition-colors"
|
|
||||||
:class="config.pushbullet_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
|
||||||
:class="config.pushbullet_enabled ? 'translate-x-4.5' : 'translate-x-0.5'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
v-model="config.pushbullet_api_key"
|
|
||||||
type="text"
|
|
||||||
placeholder="Pushbullet API key"
|
placeholder="Pushbullet API key"
|
||||||
:disabled="!config.pushbullet_enabled"
|
:disabled="!config.pushbullet_enabled"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors disabled:opacity-40"
|
:mono="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Email -->
|
<!-- Email alerts -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Email alerts" subtitle="Send critical alerts to your registered email" eyebrow="Channel">
|
||||||
<div class="flex items-center justify-between">
|
<template #actions>
|
||||||
<div>
|
<Switch v-model="config.email_alerts_enabled" />
|
||||||
<h2 class="text-sm font-medium text-neutral-200">Email Alerts</h2>
|
</template>
|
||||||
<p class="text-xs text-neutral-500 mt-0.5">Send critical alerts to your registered email</p>
|
<!-- No additional fields for email — toggle only -->
|
||||||
</div>
|
</Panel>
|
||||||
<button
|
|
||||||
@click="config.email_alerts_enabled = !config.email_alerts_enabled"
|
|
||||||
class="w-9 h-5 rounded-full transition-colors"
|
|
||||||
:class="config.email_alerts_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
|
||||||
:class="config.email_alerts_enabled ? 'translate-x-4.5' : 'translate-x-0.5'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Event Toggles -->
|
<!-- Event triggers -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Event triggers" subtitle="Choose which events fire notifications" eyebrow="Routing">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Event Triggers</h2>
|
<div class="triggers">
|
||||||
<div class="space-y-4">
|
|
||||||
<div
|
<div
|
||||||
v-for="toggle in eventToggles"
|
v-for="toggle in eventToggles"
|
||||||
:key="toggle.key"
|
:key="toggle.key"
|
||||||
class="flex items-center justify-between"
|
class="trigger-row"
|
||||||
>
|
>
|
||||||
<div>
|
<div class="trigger-text">
|
||||||
<p class="text-sm text-neutral-200">{{ toggle.label }}</p>
|
<span class="trigger-label">{{ toggle.label }}</span>
|
||||||
<p class="text-xs text-neutral-500">{{ toggle.desc }}</p>
|
<span class="trigger-desc">{{ toggle.desc }}</span>
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="config[toggle.key] = !config[toggle.key]"
|
|
||||||
class="w-9 h-5 rounded-full transition-colors"
|
|
||||||
:class="config[toggle.key] ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
|
||||||
:class="config[toggle.key] ? 'translate-x-4.5' : 'translate-x-0.5'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Switch v-model="config[toggle.key]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notifs { max-width: 780px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
|
||||||
|
.page__head {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
gap: 16px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event triggers */
|
||||||
|
.triggers { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.trigger-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||||
|
padding: 11px 4px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.trigger-row:last-child { border-bottom: 0; }
|
||||||
|
.trigger-text { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.trigger-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.trigger-desc { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
import { Users, TrendingUp, Clock, Download, BarChart3 } from 'lucide-vue-next'
|
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import type { ECharts } from 'echarts'
|
import type { ECharts } from 'echarts'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { safeFixed } from '@/utils/formatters'
|
import { safeFixed } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -60,6 +64,10 @@ const loadRetentionData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssVar(name: string): string {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
|
}
|
||||||
|
|
||||||
const renderCharts = () => {
|
const renderCharts = () => {
|
||||||
if (!retentionData.value || !retentionData.value.wipe_metrics.length) return
|
if (!retentionData.value || !retentionData.value.wipe_metrics.length) return
|
||||||
|
|
||||||
@@ -81,13 +89,22 @@ const renderCharts = () => {
|
|||||||
const retention48h = retentionData.value.wipe_metrics.map((w) => w.retention_48h_percent)
|
const retention48h = retentionData.value.wipe_metrics.map((w) => w.retention_48h_percent)
|
||||||
const retention72h = retentionData.value.wipe_metrics.map((w) => w.retention_72h_percent)
|
const retention72h = retentionData.value.wipe_metrics.map((w) => w.retention_72h_percent)
|
||||||
|
|
||||||
|
const accent = cssVar('--accent') || '#CE422B'
|
||||||
|
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||||
|
const axisLine = cssVar('--border-default') || '#404040'
|
||||||
|
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||||
|
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||||
|
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||||
|
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||||
|
const mono = 'JetBrains Mono, monospace'
|
||||||
|
|
||||||
retentionChartInstance.setOption({
|
retentionChartInstance.setOption({
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: tooltipBg,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: tooltipBorder,
|
||||||
textStyle: { color: '#e5e5e5' },
|
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
|
||||||
formatter: (params: any) => {
|
formatter: (params: any) => {
|
||||||
let tooltip = `<strong>${params[0].axisValue}</strong><br/>`
|
let tooltip = `<strong>${params[0].axisValue}</strong><br/>`
|
||||||
params.forEach((param: any) => {
|
params.forEach((param: any) => {
|
||||||
@@ -98,7 +115,7 @@ const renderCharts = () => {
|
|||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
data: ['24h Return', '48h Return', '72h Return'],
|
data: ['24h Return', '48h Return', '72h Return'],
|
||||||
textStyle: { color: '#a3a3a3' },
|
textStyle: { color: labelColor, fontFamily: mono },
|
||||||
top: 0
|
top: 0
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
@@ -111,16 +128,18 @@ const renderCharts = () => {
|
|||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: wipeLabels,
|
data: wipeLabels,
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
axisLabel: { color: '#808080', rotate: 45 }
|
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: 'Retention %',
|
name: 'Retention %',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
splitLine: { lineStyle: { color: grid } },
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#808080',
|
color: labelColor,
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
formatter: (value: number) => `${value}%`
|
formatter: (value: number) => `${value}%`
|
||||||
},
|
},
|
||||||
max: 100
|
max: 100
|
||||||
@@ -131,8 +150,8 @@ const renderCharts = () => {
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
data: retention24h,
|
data: retention24h,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
lineStyle: { color: '#CE422B', width: 3 },
|
lineStyle: { color: accent, width: 3 },
|
||||||
itemStyle: { color: '#CE422B' },
|
itemStyle: { color: accent },
|
||||||
symbolSize: 8
|
symbolSize: 8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -187,117 +206,91 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="retention-view">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="retention-view__header">
|
||||||
<div class="flex items-center gap-3">
|
<h1 class="retention-view__title">Player retention</h1>
|
||||||
<Users class="w-5 h-5 text-oxide-500" />
|
<div class="retention-view__controls">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Player Retention</h1>
|
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
@click="downloadCSV"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</Button>
|
||||||
<div class="flex items-center gap-2 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2">
|
<Select
|
||||||
<label class="text-sm text-neutral-400">Wipes:</label>
|
:options="[
|
||||||
<select
|
{ value: '3', label: 'Last 3 wipes' },
|
||||||
v-model.number="wipeCount"
|
{ value: '6', label: 'Last 6 wipes' },
|
||||||
class="bg-transparent text-neutral-100 text-sm focus:outline-none"
|
{ value: '10', label: 'Last 10 wipes' },
|
||||||
>
|
{ value: '20', label: 'Last 20 wipes' }
|
||||||
<option :value="3">Last 3</option>
|
]"
|
||||||
<option :value="6">Last 6</option>
|
:model-value="String(wipeCount)"
|
||||||
<option :value="10">Last 10</option>
|
size="sm"
|
||||||
<option :value="20">Last 20</option>
|
@update:model-value="wipeCount = Number($event)"
|
||||||
</select>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
<div v-if="loading" class="retention-view__loading">
|
||||||
<div class="text-neutral-500">Loading retention data...</div>
|
<span class="retention-view__loading-text">Loading retention data...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="retentionData && retentionData.wipe_metrics.length > 0">
|
<template v-else-if="retentionData && retentionData.wipe_metrics.length > 0">
|
||||||
<!-- Summary cards -->
|
<!-- Summary cards -->
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="retention-view__stats">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<StatCard
|
||||||
<div class="flex items-center gap-2 mb-2">
|
label="Unique players"
|
||||||
<Users class="w-4 h-4 text-neutral-500" />
|
:value="retentionData.summary.unique_players"
|
||||||
<p class="text-sm text-neutral-400">Unique Players</p>
|
icon="users"
|
||||||
</div>
|
note="Last 30 days"
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.unique_players }}</p>
|
/>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
|
<StatCard
|
||||||
</div>
|
label="Avg session"
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
:value="safeFixed(retentionData?.summary?.avg_session_duration_minutes, 0)"
|
||||||
<div class="flex items-center gap-2 mb-2">
|
unit="m"
|
||||||
<Clock class="w-4 h-4 text-neutral-500" />
|
icon="clock"
|
||||||
<p class="text-sm text-neutral-400">Avg Session</p>
|
note="Duration"
|
||||||
</div>
|
/>
|
||||||
<p class="text-2xl font-bold text-neutral-100">
|
<StatCard
|
||||||
{{ safeFixed(retentionData?.summary?.avg_session_duration_minutes, 0) }}m
|
label="New players"
|
||||||
</p>
|
:value="retentionData.summary.new_players"
|
||||||
<p class="text-xs text-neutral-600 mt-1">Duration</p>
|
icon="trending-up"
|
||||||
</div>
|
note="Last 30 days"
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
/>
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<StatCard
|
||||||
<TrendingUp class="w-4 h-4 text-neutral-500" />
|
label="Returning"
|
||||||
<p class="text-sm text-neutral-400">New Players</p>
|
:value="retentionData.summary.returning_players"
|
||||||
</div>
|
icon="bar-chart-3"
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.new_players }}</p>
|
note="Last 30 days"
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
|
/>
|
||||||
</div>
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<BarChart3 class="w-4 h-4 text-neutral-500" />
|
|
||||||
<p class="text-sm text-neutral-400">Returning</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ retentionData.summary.returning_players }}</p>
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last 30 days</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Retention curve chart -->
|
<!-- Retention curve chart -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Retention curve (post-wipe return rates)">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
<div ref="retentionChart" class="retention-view__chart-area"></div>
|
||||||
Retention Curve (Post-Wipe Return Rates)
|
<p class="retention-view__chart-note">
|
||||||
</h2>
|
<strong>How to read:</strong> Percentage of players who played in the 7 days before a wipe and returned within 24h/48h/72h after the wipe.
|
||||||
<div ref="retentionChart" class="h-96"></div>
|
|
||||||
<div class="mt-4 text-xs text-neutral-500">
|
|
||||||
<p>
|
|
||||||
<strong>How to read:</strong> Percentage of players who played in the 7 days before a wipe and
|
|
||||||
returned within 24h/48h/72h after the wipe.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Wipe details table -->
|
<!-- Wipe details table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Wipe details" :flush-body="true">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
|
<div class="retention-view__table-wrap">
|
||||||
Wipe Details
|
<table class="retention-view__table">
|
||||||
</h2>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800">
|
<tr class="retention-view__thead-row">
|
||||||
<th class="text-left py-2 px-3 text-neutral-400 font-medium">Wipe Date</th>
|
<th class="retention-view__th retention-view__th--left">Wipe date</th>
|
||||||
<th class="text-right py-2 px-3 text-neutral-400 font-medium">Pre-Wipe Players</th>
|
<th class="retention-view__th retention-view__th--right">Pre-wipe players</th>
|
||||||
<th class="text-right py-2 px-3 text-neutral-400 font-medium">24h Return</th>
|
<th class="retention-view__th retention-view__th--right">24h return</th>
|
||||||
<th class="text-right py-2 px-3 text-neutral-400 font-medium">48h Return</th>
|
<th class="retention-view__th retention-view__th--right">48h return</th>
|
||||||
<th class="text-right py-2 px-3 text-neutral-400 font-medium">72h Return</th>
|
<th class="retention-view__th retention-view__th--right">72h return</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="wipe in retentionData.wipe_metrics"
|
v-for="wipe in retentionData.wipe_metrics"
|
||||||
:key="wipe.wipe_id"
|
:key="wipe.wipe_id"
|
||||||
class="border-b border-neutral-800 hover:bg-neutral-800/50 transition-colors"
|
class="retention-view__row"
|
||||||
>
|
>
|
||||||
<td class="py-3 px-3 text-neutral-300">
|
<td class="retention-view__td retention-view__td--date">
|
||||||
{{ new Date(wipe.wipe_date).toLocaleString('en-US', {
|
{{ new Date(wipe.wipe_date).toLocaleString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -306,38 +299,165 @@ onMounted(() => {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
}) }}
|
}) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-3 text-right text-neutral-300">
|
<td class="retention-view__td retention-view__td--num">
|
||||||
{{ wipe.total_players_before_wipe }}
|
{{ wipe.total_players_before_wipe }}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-3 text-right">
|
<td class="retention-view__td retention-view__td--num">
|
||||||
<span class="text-neutral-100 font-medium">{{ wipe.returned_24h }}</span>
|
<span class="retention-view__count">{{ wipe.returned_24h }}</span>
|
||||||
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_24h_percent, 1) }}%)</span>
|
<span class="retention-view__pct">({{ safeFixed(wipe.retention_24h_percent, 1) }}%)</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-3 text-right">
|
<td class="retention-view__td retention-view__td--num">
|
||||||
<span class="text-neutral-100 font-medium">{{ wipe.returned_48h }}</span>
|
<span class="retention-view__count">{{ wipe.returned_48h }}</span>
|
||||||
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_48h_percent, 1) }}%)</span>
|
<span class="retention-view__pct">({{ safeFixed(wipe.retention_48h_percent, 1) }}%)</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-3 text-right">
|
<td class="retention-view__td retention-view__td--num">
|
||||||
<span class="text-neutral-100 font-medium">{{ wipe.returned_72h }}</span>
|
<span class="retention-view__count">{{ wipe.returned_72h }}</span>
|
||||||
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_72h_percent, 1) }}%)</span>
|
<span class="retention-view__pct">({{ safeFixed(wipe.retention_72h_percent, 1) }}%)</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div
|
<Panel v-else>
|
||||||
v-else
|
<EmptyState
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"
|
icon="users"
|
||||||
>
|
title="No retention data available"
|
||||||
<Users class="w-12 h-12 text-neutral-700 mx-auto mb-4" />
|
description="Player retention metrics will appear here after wipes are tracked and players join/leave."
|
||||||
<p class="text-neutral-500 mb-2">No retention data available</p>
|
/>
|
||||||
<p class="text-sm text-neutral-600">
|
</Panel>
|
||||||
Player retention metrics will appear here after wipes are tracked and players join/leave.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.retention-view {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__loading-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.retention-view__stats {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__chart-area {
|
||||||
|
height: 384px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__chart-note {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__thead-row {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__th {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__th--left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__th--right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__row {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: background var(--dur-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__row:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__td {
|
||||||
|
padding: 12px 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__td--date {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__td--num {
|
||||||
|
text-align: right;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__count {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retention-view__pct {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { Users, Search, Ban, LogOut, Shield, RefreshCw } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.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 Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -50,6 +57,12 @@ const filteredPlayers = computed(() => {
|
|||||||
|
|
||||||
const onlineCount = computed(() => players.value.filter(p => p.is_online).length)
|
const onlineCount = computed(() => players.value.filter(p => p.is_online).length)
|
||||||
|
|
||||||
|
const statusTabItems = computed(() => [
|
||||||
|
{ value: 'all', label: 'All', count: players.value.length },
|
||||||
|
{ value: 'online', label: 'Online', count: players.value.filter(p => p.is_online).length },
|
||||||
|
{ value: 'offline', label: 'Offline', count: players.value.filter(p => !p.is_online).length },
|
||||||
|
])
|
||||||
|
|
||||||
function formatPlaytime(seconds: number): string {
|
function formatPlaytime(seconds: number): string {
|
||||||
const h = Math.floor(seconds / 3600)
|
const h = Math.floor(seconds / 3600)
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
@@ -58,7 +71,7 @@ function formatPlaytime(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatConnectedTime(iso: string | null): string {
|
function formatConnectedTime(iso: string | null): string {
|
||||||
if (!iso) return '\u2014'
|
if (!iso) return '—'
|
||||||
const diff = Date.now() - new Date(iso).getTime()
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
const m = Math.floor(diff / 60000)
|
const m = Math.floor(diff / 60000)
|
||||||
const h = Math.floor(m / 60)
|
const h = Math.floor(m / 60)
|
||||||
@@ -66,6 +79,18 @@ function formatConnectedTime(iso: string | null): string {
|
|||||||
return `${m}m`
|
return `${m}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playerStatusTone(player: Player): 'online' | 'offline' | 'warn' {
|
||||||
|
if (player.is_banned) return 'warn'
|
||||||
|
if (player.is_online) return 'online'
|
||||||
|
return 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerStatusLabel(player: Player): string {
|
||||||
|
if (player.is_banned) return 'Banned'
|
||||||
|
if (player.is_online) return 'Online'
|
||||||
|
return 'Offline'
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPlayers() {
|
async function fetchPlayers() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -106,132 +131,197 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="pv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="pv__head">
|
||||||
<div class="flex items-center gap-3">
|
<div class="pv__head-id">
|
||||||
<Users class="w-5 h-5 text-oxide-500" />
|
<div class="pv__head-chip">
|
||||||
|
<Icon name="users" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Player Management</h1>
|
<div class="t-eyebrow">Player management</div>
|
||||||
<p class="text-sm text-neutral-500 mt-0.5">
|
<h1 class="pv__title">Players</h1>
|
||||||
{{ onlineCount }} online / {{ players.length }} total
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="pv__head-actions">
|
||||||
@click="fetchPlayers"
|
<div class="pv__stat-pill">
|
||||||
|
<span class="pv__stat-num">{{ onlineCount }}</span>
|
||||||
|
<span class="pv__stat-sep">/</span>
|
||||||
|
<span class="pv__stat-total">{{ players.length }}</span>
|
||||||
|
<Badge tone="online" :dot="true" :pulse="onlineCount > 0">Online</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="refresh-cw"
|
||||||
|
:loading="isLoading"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 rounded-lg transition-colors"
|
@click="fetchPlayers"
|
||||||
>
|
>Refresh</Button>
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
</div>
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="pv__filters">
|
||||||
<div class="relative flex-1 max-w-sm">
|
<Input
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
icon="search"
|
||||||
placeholder="Search by name or Steam ID..."
|
placeholder="Search by name or Steam ID…"
|
||||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
size="sm"
|
||||||
|
:mono="false"
|
||||||
|
style="max-width: 320px;"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Tabs v-model="filterStatus" :items="statusTabItems" />
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
|
||||||
<button
|
|
||||||
v-for="opt in (['all', 'online', 'offline'] as const)"
|
|
||||||
:key="opt"
|
|
||||||
@click="filterStatus = opt"
|
|
||||||
class="px-3 py-2 text-sm font-medium transition-colors capitalize"
|
|
||||||
:class="filterStatus === opt
|
|
||||||
? 'bg-oxide-500/15 text-oxide-400'
|
|
||||||
: 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
{{ opt }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Player table -->
|
<!-- Player table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel :flush-body="true">
|
||||||
<table class="w-full">
|
<!-- Empty state -->
|
||||||
|
<EmptyState
|
||||||
|
v-if="filteredPlayers.length === 0 && !isLoading"
|
||||||
|
icon="users"
|
||||||
|
:title="searchQuery ? 'No players found' : 'No players'"
|
||||||
|
:description="searchQuery
|
||||||
|
? `No players matching "${searchQuery}".`
|
||||||
|
: 'No players found. Server may be offline or API not connected.'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-else-if="isLoading && filteredPlayers.length === 0" class="pv__loading">
|
||||||
|
<Icon name="loader" :size="20" class="pv__spin" />
|
||||||
|
<span>Loading players…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<table v-else class="pv__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Player</th>
|
<th>Player</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Steam ID</th>
|
<th>Steam ID</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
<th>Status</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Session</th>
|
<th>Session</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Playtime</th>
|
<th>Playtime</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Ping</th>
|
<th>Ping</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
<th class="pv__th-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-if="filteredPlayers.length === 0">
|
|
||||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
|
||||||
<template v-if="isLoading">Loading players...</template>
|
|
||||||
<template v-else-if="searchQuery">No players matching "{{ searchQuery }}"</template>
|
|
||||||
<template v-else>No players found. Server may be offline or API not connected.</template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
<tr
|
||||||
v-for="player in filteredPlayers"
|
v-for="player in filteredPlayers"
|
||||||
:key="player.steam_id"
|
:key="player.steam_id"
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="pv__player-name">
|
||||||
<span class="text-sm font-medium text-neutral-100">{{ player.display_name }}</span>
|
<span class="pv__name">{{ player.display_name }}</span>
|
||||||
<Shield v-if="player.is_admin" class="w-3.5 h-3.5 text-oxide-400" title="Admin" />
|
<Badge v-if="player.is_admin" tone="accent" size="md">Admin</Badge>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ player.steam_id }}</td>
|
<td class="pv__mono">{{ player.steam_id }}</td>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<span
|
<Badge
|
||||||
class="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full"
|
:tone="playerStatusTone(player)"
|
||||||
:class="player.is_online
|
:dot="true"
|
||||||
? 'bg-green-500/10 text-green-400'
|
:pulse="player.is_online && !player.is_banned"
|
||||||
: player.is_banned
|
>{{ playerStatusLabel(player) }}</Badge>
|
||||||
? 'bg-red-500/10 text-red-400'
|
|
||||||
: 'bg-neutral-700/50 text-neutral-400'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="h-1.5 w-1.5 rounded-full"
|
|
||||||
:class="player.is_online ? 'bg-green-500' : player.is_banned ? 'bg-red-500' : 'bg-neutral-500'"
|
|
||||||
/>
|
|
||||||
{{ player.is_banned ? 'Banned' : player.is_online ? 'Online' : 'Offline' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">
|
<td class="pv__secondary">
|
||||||
{{ player.is_online ? formatConnectedTime(player.connected_at) : '\u2014' }}
|
{{ player.is_online ? formatConnectedTime(player.connected_at) : '—' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatPlaytime(player.playtime_seconds) }}</td>
|
<td class="pv__secondary pv__mono">{{ formatPlaytime(player.playtime_seconds) }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">
|
<td class="pv__secondary pv__mono">
|
||||||
{{ player.is_online && player.ping_ms ? `${player.ping_ms}ms` : '\u2014' }}
|
{{ player.is_online && player.ping_ms ? `${player.ping_ms}ms` : '—' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="pv__td-right">
|
||||||
<div class="flex items-center justify-end gap-1" v-if="player.is_online">
|
<div v-if="player.is_online" class="pv__row-actions">
|
||||||
<button
|
<IconButton
|
||||||
|
icon="log-out"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
label="Kick"
|
||||||
@click="kickPlayer(player.steam_id, player.display_name)"
|
@click="kickPlayer(player.steam_id, player.display_name)"
|
||||||
class="p-1.5 text-neutral-500 hover:text-yellow-400 rounded transition-colors"
|
/>
|
||||||
title="Kick"
|
<IconButton
|
||||||
>
|
icon="ban"
|
||||||
<LogOut class="w-4 h-4" />
|
variant="danger"
|
||||||
</button>
|
size="sm"
|
||||||
<button
|
label="Ban"
|
||||||
@click="banPlayer(player.steam_id, player.display_name)"
|
@click="banPlayer(player.steam_id, player.display_name)"
|
||||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
/>
|
||||||
title="Ban"
|
|
||||||
>
|
|
||||||
<Ban class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pv { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.pv__head {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
flex-wrap: wrap; gap: 12px;
|
||||||
|
}
|
||||||
|
.pv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.pv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.pv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
.pv__head-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
/* Stat pill */
|
||||||
|
.pv__stat-pill {
|
||||||
|
display: flex; align-items: center; gap: 7px;
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.pv__stat-num { font-weight: 700; color: var(--status-online); }
|
||||||
|
.pv__stat-sep { color: var(--text-muted); }
|
||||||
|
.pv__stat-total { color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.pv__filters { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.pv__loading {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||||
|
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
@keyframes pv-spin { to { transform: rotate(360deg); } }
|
||||||
|
.pv__spin { animation: pv-spin 0.7s linear infinite; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.pv__table { width: 100%; border-collapse: collapse; }
|
||||||
|
.pv__table th {
|
||||||
|
padding: 10px 14px; text-align: left;
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.pv__table td {
|
||||||
|
padding: 10px 14px; font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary); border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.pv__table tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
.pv__table tbody tr:hover td { background: var(--surface-hover); }
|
||||||
|
|
||||||
|
/* Col helpers */
|
||||||
|
.pv__th-right { text-align: right; }
|
||||||
|
.pv__td-right { text-align: right; }
|
||||||
|
.pv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs) !important; color: var(--text-secondary) !important; }
|
||||||
|
.pv__secondary { color: var(--text-secondary) !important; }
|
||||||
|
|
||||||
|
/* Player name cell */
|
||||||
|
.pv__player-name { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.pv__name { font-weight: 500; }
|
||||||
|
|
||||||
|
/* Row actions */
|
||||||
|
.pv__row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 2px; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -11,19 +11,13 @@ import { useFurnaceSplitterStore } from '@/stores/furnacesplitter'
|
|||||||
import { useBetterChatStore } from '@/stores/betterchat'
|
import { useBetterChatStore } from '@/stores/betterchat'
|
||||||
import { useTimedExecuteStore } from '@/stores/timedexecute'
|
import { useTimedExecuteStore } from '@/stores/timedexecute'
|
||||||
import { useRaidableBasesStore } from '@/stores/raidablebases'
|
import { useRaidableBasesStore } from '@/stores/raidablebases'
|
||||||
import {
|
|
||||||
Crosshair,
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
Navigation2,
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
Pickaxe,
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
DoorOpen,
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
Gift,
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
Flame,
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
MessageSquare,
|
|
||||||
Clock,
|
|
||||||
Swords,
|
|
||||||
Search,
|
|
||||||
ArrowRight,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -45,7 +39,7 @@ interface PluginDef {
|
|||||||
key: string
|
key: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
icon: any
|
icon: string
|
||||||
path: string
|
path: string
|
||||||
permission: string
|
permission: string
|
||||||
getConfigs: () => any[]
|
getConfigs: () => any[]
|
||||||
@@ -53,15 +47,15 @@ interface PluginDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const plugins: PluginDef[] = [
|
const plugins: PluginDef[] = [
|
||||||
{ key: 'loot', name: 'Loot Tables', description: 'Configure loot container drop tables and item probabilities', icon: Crosshair, path: '/loot-builder', permission: 'loot.view', getConfigs: () => lootStore.profiles, fetchFn: () => lootStore.fetchProfiles() },
|
{ key: 'loot', name: 'Loot tables', description: 'Configure loot container drop tables and item probabilities', icon: 'crosshair', path: '/loot-builder', permission: 'loot.view', getConfigs: () => lootStore.profiles, fetchFn: () => lootStore.fetchProfiles() },
|
||||||
{ key: 'teleport', name: 'Teleport', description: 'Home locations, TPR cooldowns, and VIP teleport settings', icon: Navigation2, path: '/teleport-config', permission: 'teleport.view', getConfigs: () => teleportStore.configs, fetchFn: () => teleportStore.fetchConfigs() },
|
{ key: 'teleport', name: 'Teleport', description: 'Home locations, TPR cooldowns, and VIP teleport settings', icon: 'navigation', path: '/teleport-config', permission: 'teleport.view', getConfigs: () => teleportStore.configs, fetchFn: () => teleportStore.fetchConfigs() },
|
||||||
{ key: 'gather', name: 'Gather Rates', description: 'Resource gathering multipliers and pickup rates', icon: Pickaxe, path: '/gather-manager', permission: 'gather.view', getConfigs: () => gatherStore.configs, fetchFn: () => gatherStore.fetchConfigs() },
|
{ key: 'gather', name: 'Gather rates', description: 'Resource gathering multipliers and pickup rates', icon: 'pickaxe', path: '/gather-manager', permission: 'gather.view', getConfigs: () => gatherStore.configs, fetchFn: () => gatherStore.fetchConfigs() },
|
||||||
{ key: 'autodoors', name: 'Auto Doors', description: 'Automatic door closing delays and permissions', icon: DoorOpen, path: '/autodoors', permission: 'autodoors.view', getConfigs: () => autoDoorsStore.configs, fetchFn: () => autoDoorsStore.fetchConfigs() },
|
{ key: 'autodoors', name: 'Auto doors', description: 'Automatic door closing delays and permissions', icon: 'door-open', path: '/autodoors', permission: 'autodoors.view', getConfigs: () => autoDoorsStore.configs, fetchFn: () => autoDoorsStore.fetchConfigs() },
|
||||||
{ key: 'kits', name: 'Kits', description: 'Player kits with items, cooldowns, and permissions', icon: Gift, path: '/kits', permission: 'kits.view', getConfigs: () => kitsStore.configs, fetchFn: () => kitsStore.fetchConfigs() },
|
{ key: 'kits', name: 'Kits', description: 'Player kits with items, cooldowns, and permissions', icon: 'gift', path: '/kits', permission: 'kits.view', getConfigs: () => kitsStore.configs, fetchFn: () => kitsStore.fetchConfigs() },
|
||||||
{ key: 'furnacesplitter', name: 'Furnace Splitter', description: 'Automatic furnace ore splitting and smelting config', icon: Flame, path: '/furnace-splitter', permission: 'furnacesplitter.view', getConfigs: () => furnaceSplitterStore.configs, fetchFn: () => furnaceSplitterStore.fetchConfigs() },
|
{ key: 'furnacesplitter', name: 'Furnace splitter', description: 'Automatic furnace ore splitting and smelting config', icon: 'flame', path: '/furnace-splitter', permission: 'furnacesplitter.view', getConfigs: () => furnaceSplitterStore.configs, fetchFn: () => furnaceSplitterStore.fetchConfigs() },
|
||||||
{ key: 'betterchat', name: 'Better Chat', description: 'Chat formatting, group colors, and title prefixes', icon: MessageSquare, path: '/better-chat', permission: 'betterchat.view', getConfigs: () => betterChatStore.configs, fetchFn: () => betterChatStore.fetchConfigs() },
|
{ key: 'betterchat', name: 'Better Chat', description: 'Chat formatting, group colors, and title prefixes', icon: 'message-square', path: '/better-chat', permission: 'betterchat.view', getConfigs: () => betterChatStore.configs, fetchFn: () => betterChatStore.fetchConfigs() },
|
||||||
{ key: 'timedexecute', name: 'Timed Execute', description: 'Scheduled, real-time, and event-driven command execution', icon: Clock, path: '/timed-execute', permission: 'timedexecute.view', getConfigs: () => timedExecuteStore.configs, fetchFn: () => timedExecuteStore.fetchConfigs() },
|
{ key: 'timedexecute', name: 'Timed Execute', description: 'Scheduled, real-time, and event-driven command execution', icon: 'clock', path: '/timed-execute', permission: 'timedexecute.view', getConfigs: () => timedExecuteStore.configs, fetchFn: () => timedExecuteStore.fetchConfigs() },
|
||||||
{ key: 'raidablebases', name: 'Raidable Bases', description: 'PVE raid events, difficulty, NPCs, and loot settings', icon: Swords, path: '/raidable-bases', permission: 'raidablebases.view', getConfigs: () => raidableBasesStore.configs, fetchFn: () => raidableBasesStore.fetchConfigs() },
|
{ key: 'raidablebases', name: 'Raidable Bases', description: 'PVE raid events, difficulty, NPCs, and loot settings', icon: 'swords', path: '/raidable-bases', permission: 'raidablebases.view', getConfigs: () => raidableBasesStore.configs, fetchFn: () => raidableBasesStore.fetchConfigs() },
|
||||||
]
|
]
|
||||||
|
|
||||||
const visiblePlugins = computed(() =>
|
const visiblePlugins = computed(() =>
|
||||||
@@ -76,12 +70,12 @@ const filteredPlugins = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function getStatus(plugin: PluginDef): { label: string; color: string } {
|
function getStatus(plugin: PluginDef): { label: string; tone: 'online' | 'info' | 'neutral' } {
|
||||||
const configs = plugin.getConfigs()
|
const configs = plugin.getConfigs()
|
||||||
if (!configs || configs.length === 0) return { label: 'Not Configured', color: 'neutral' }
|
if (!configs || configs.length === 0) return { label: 'Not configured', tone: 'neutral' }
|
||||||
const hasActive = configs.some((c: any) => c.is_active)
|
const hasActive = configs.some((c: any) => c.is_active)
|
||||||
if (hasActive) return { label: 'Active', color: 'green' }
|
if (hasActive) return { label: 'Active', tone: 'online' }
|
||||||
return { label: 'Configured', color: 'blue' }
|
return { label: 'Configured', tone: 'info' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfigCount(plugin: PluginDef): string {
|
function getConfigCount(plugin: PluginDef): string {
|
||||||
@@ -90,6 +84,12 @@ function getConfigCount(plugin: PluginDef): string {
|
|||||||
return `${configs.length} profile${configs.length !== 1 ? 's' : ''}`
|
return `${configs.length} profile${configs.length !== 1 ? 's' : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search model — DS Input uses string v-model
|
||||||
|
const searchModel = computed<string>({
|
||||||
|
get: () => searchQuery.value,
|
||||||
|
set: (v: string | undefined) => { searchQuery.value = v ?? '' },
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const fetches = visiblePlugins.value.map(p => p.fetchFn().catch(() => {}))
|
const fetches = visiblePlugins.value.map(p => p.fetchFn().catch(() => {}))
|
||||||
await Promise.all(fetches)
|
await Promise.all(fetches)
|
||||||
@@ -98,88 +98,142 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 max-w-7xl mx-auto">
|
<div class="pc">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="mb-6">
|
<div class="pc__head">
|
||||||
<h1 class="text-2xl font-bold text-white">Plugin Configs</h1>
|
<div class="pc__head-id">
|
||||||
<p class="text-neutral-400 mt-1">Configure and manage your server plugins</p>
|
<div class="pc__head-chip">
|
||||||
|
<Icon name="puzzle" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Plugin management</div>
|
||||||
|
<h1 class="pc__title">Plugin configs</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="relative mb-6">
|
<Input
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
v-model="searchModel"
|
||||||
<input
|
icon="search"
|
||||||
v-model="searchQuery"
|
placeholder="Search plugins…"
|
||||||
type="text"
|
|
||||||
placeholder="Search plugins..."
|
|
||||||
class="w-full pl-10 pr-4 py-2 bg-neutral-900 border border-neutral-800 rounded-lg text-sm text-neutral-200 placeholder-neutral-500 focus:outline-none focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading skeleton -->
|
||||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div v-if="loading" class="pc__grid">
|
||||||
<div
|
<div
|
||||||
v-for="i in visiblePlugins.length"
|
v-for="i in visiblePlugins.length"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 animate-pulse"
|
class="pc__skeleton"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3 mb-3">
|
<div class="pc__skel-icon" />
|
||||||
<div class="w-10 h-10 bg-neutral-800 rounded-lg" />
|
<div class="pc__skel-lines">
|
||||||
<div class="flex-1">
|
<div class="pc__skel-line pc__skel-line--name" />
|
||||||
<div class="h-4 w-24 bg-neutral-800 rounded" />
|
<div class="pc__skel-line pc__skel-line--sub" />
|
||||||
<div class="h-3 w-16 bg-neutral-800 rounded mt-2" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-3 w-full bg-neutral-800 rounded mt-3" />
|
|
||||||
<div class="h-3 w-2/3 bg-neutral-800 rounded mt-2" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cards -->
|
<!-- Cards grid -->
|
||||||
<div v-else-if="filteredPlugins.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div v-else-if="filteredPlugins.length" class="pc__grid">
|
||||||
<div
|
<div
|
||||||
v-for="plugin in filteredPlugins"
|
v-for="plugin in filteredPlugins"
|
||||||
:key="plugin.key"
|
:key="plugin.key"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 hover:border-neutral-700 transition-colors group"
|
class="pc__card"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between mb-3">
|
<!-- Card head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="pc__card-head">
|
||||||
<div class="w-10 h-10 bg-neutral-800 rounded-lg flex items-center justify-center group-hover:bg-oxide-500/10 transition-colors">
|
<div class="pc__card-id">
|
||||||
<component :is="plugin.icon" class="w-5 h-5 text-neutral-400 group-hover:text-oxide-400 transition-colors" />
|
<div class="pc__card-icon">
|
||||||
|
<Icon :name="plugin.icon" :size="18" :stroke-width="2" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-white">{{ plugin.name }}</h3>
|
<div class="pc__card-name">{{ plugin.name }}</div>
|
||||||
<span class="text-xs text-neutral-500">{{ getConfigCount(plugin) }}</span>
|
<div class="pc__card-count">{{ getConfigCount(plugin) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Status badge -->
|
<Badge :tone="getStatus(plugin).tone">{{ getStatus(plugin).label }}</Badge>
|
||||||
<span
|
|
||||||
class="text-[10px] font-medium px-2 py-0.5 rounded-full"
|
|
||||||
:class="{
|
|
||||||
'bg-green-500/10 text-green-400': getStatus(plugin).color === 'green',
|
|
||||||
'bg-blue-500/10 text-blue-400': getStatus(plugin).color === 'blue',
|
|
||||||
'bg-neutral-800 text-neutral-500': getStatus(plugin).color === 'neutral',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ getStatus(plugin).label }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-neutral-400 mb-4 line-clamp-2">{{ plugin.description }}</p>
|
<!-- Description -->
|
||||||
|
<p class="pc__card-desc">{{ plugin.description }}</p>
|
||||||
|
|
||||||
<button
|
<!-- Configure button -->
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon-right="chevron-right"
|
||||||
|
:block="true"
|
||||||
@click="router.push(plugin.path)"
|
@click="router.push(plugin.path)"
|
||||||
class="w-full flex items-center justify-center gap-2 px-3 py-2 bg-neutral-800 hover:bg-oxide-500/10 text-neutral-300 hover:text-oxide-400 text-xs font-medium rounded-lg transition-colors"
|
>Configure</Button>
|
||||||
>
|
|
||||||
Configure
|
|
||||||
<ArrowRight class="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state (search miss) -->
|
||||||
<div v-else class="text-center py-12">
|
<Panel v-else>
|
||||||
<p class="text-neutral-500">No plugins match your search.</p>
|
<EmptyState
|
||||||
</div>
|
icon="search"
|
||||||
|
title="No plugins match"
|
||||||
|
description="Try a different search term."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ---- Page shell ---- */
|
||||||
|
.pc { max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* ---- Page head ---- */
|
||||||
|
.pc__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.pc__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.pc__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.pc__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Grid ---- */
|
||||||
|
.pc__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||||
|
@media (max-width: 900px) { .pc__grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 540px) { .pc__grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* ---- Card ---- */
|
||||||
|
.pc__card {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default);
|
||||||
|
padding: 16px; display: flex; flex-direction: column; gap: 12px; min-width: 0;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.pc__card:hover { background: var(--surface-raised); }
|
||||||
|
|
||||||
|
.pc__card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||||
|
.pc__card-id { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||||
|
.pc__card-icon {
|
||||||
|
width: 36px; height: 36px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--text-tertiary); background: var(--surface-raised-2);
|
||||||
|
box-shadow: var(--ring-default); transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.pc__card:hover .pc__card-icon { color: var(--accent-text); background: var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent-border); }
|
||||||
|
.pc__card-name { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
||||||
|
.pc__card-count { font-size: var(--text-xs); color: var(--text-muted); margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||||
|
.pc__card-desc { font-size: var(--text-xs); color: var(--text-tertiary); line-height: 1.5; flex: 1; }
|
||||||
|
|
||||||
|
/* ---- Skeleton ---- */
|
||||||
|
.pc__skeleton {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default);
|
||||||
|
padding: 16px; display: flex; align-items: flex-start; gap: 12px;
|
||||||
|
animation: pc-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pc-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
|
||||||
|
.pc__skel-icon { width: 36px; height: 36px; border-radius: var(--radius-md); background: var(--surface-raised-2); flex: none; }
|
||||||
|
.pc__skel-lines { flex: 1; display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
||||||
|
.pc__skel-line { height: 10px; border-radius: var(--radius-sm); background: var(--surface-raised-2); }
|
||||||
|
.pc__skel-line--name { width: 60%; }
|
||||||
|
.pc__skel-line--sub { width: 40%; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import { usePluginStore } from '@/stores/plugins'
|
|||||||
import type { UmodPlugin } from '@/stores/plugins'
|
import type { UmodPlugin } from '@/stores/plugins'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { PluginEntry } from '@/types'
|
import type { PluginEntry } from '@/types'
|
||||||
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2, Loader2, Upload, X } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.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 Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
|
||||||
const pluginStore = usePluginStore()
|
const pluginStore = usePluginStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
@@ -35,6 +43,12 @@ const browsePlugins = computed(() => pluginStore.browseResults?.data ?? [])
|
|||||||
|
|
||||||
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
|
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{ value: 'installed', label: 'Installed' },
|
||||||
|
{ value: 'browse', label: 'Browse uMod' },
|
||||||
|
{ value: 'upload', label: 'Upload custom' },
|
||||||
|
]
|
||||||
|
|
||||||
function sourceLabel(source: string): string {
|
function sourceLabel(source: string): string {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case 'umod': return 'uMod'
|
case 'umod': return 'uMod'
|
||||||
@@ -44,11 +58,11 @@ function sourceLabel(source: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceBadgeClass(source: string): string {
|
function sourceTone(source: string): 'online' | 'accent' | 'neutral' {
|
||||||
switch (source) {
|
switch (source) {
|
||||||
case 'umod': return 'bg-green-500/10 text-green-400'
|
case 'umod': return 'online'
|
||||||
case 'corrosion_module': return 'bg-oxide-500/15 text-oxide-400'
|
case 'corrosion_module': return 'accent'
|
||||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
default: return 'neutral'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,274 +182,249 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="plv">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="plv__head">
|
||||||
<div class="flex items-center gap-3">
|
<div class="plv__head-id">
|
||||||
<Puzzle class="w-5 h-5 text-oxide-500" />
|
<div class="plv__head-chip">
|
||||||
|
<Icon name="puzzle" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Plugins</h1>
|
<div class="t-eyebrow">Plugin management</div>
|
||||||
<p class="text-sm text-neutral-500 mt-0.5">
|
<h1 class="plv__title">Plugins</h1>
|
||||||
{{ loadedCount }} loaded / {{ pluginStore.plugins.length }} installed
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="plv__head-actions">
|
||||||
@click="pluginStore.fetchPlugins()"
|
<div class="plv__stat-pill">
|
||||||
|
<span class="plv__stat-num">{{ loadedCount }}</span>
|
||||||
|
<span class="plv__stat-sep">/</span>
|
||||||
|
<span class="plv__stat-total">{{ pluginStore.plugins.length }}</span>
|
||||||
|
<span class="plv__stat-label">loaded</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="refresh-cw"
|
||||||
|
:loading="pluginStore.isLoading"
|
||||||
:disabled="pluginStore.isLoading"
|
:disabled="pluginStore.isLoading"
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 rounded-lg transition-colors"
|
@click="pluginStore.fetchPlugins()"
|
||||||
>
|
>Refresh</Button>
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': pluginStore.isLoading }" />
|
</div>
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs + Search -->
|
<!-- Tab bar + search -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="plv__toolbar">
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
<Tabs v-model="tab" :items="tabItems" />
|
||||||
<button
|
<Input
|
||||||
@click="tab = 'installed'"
|
v-if="tab === 'installed'"
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
:class="tab === 'installed' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
Installed
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="tab = 'browse'"
|
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
:class="tab === 'browse' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
Browse uMod
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="tab = 'upload'"
|
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
:class="tab === 'upload' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
Upload Custom
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="tab === 'installed'" class="relative flex-1 max-w-sm">
|
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
icon="search"
|
||||||
placeholder="Search installed plugins..."
|
placeholder="Search installed plugins…"
|
||||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
size="sm"
|
||||||
|
style="max-width: 280px;"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div v-if="tab === 'browse'" class="relative flex-1 max-w-sm">
|
v-if="tab === 'browse'"
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="browseQuery"
|
v-model="browseQuery"
|
||||||
type="text"
|
icon="search"
|
||||||
placeholder="Search uMod plugins..."
|
placeholder="Search uMod plugins…"
|
||||||
|
size="sm"
|
||||||
|
style="max-width: 280px;"
|
||||||
@input="scheduleBrowseSearch"
|
@input="scheduleBrowseSearch"
|
||||||
@keydown.enter="handleBrowseSearch(1)"
|
@keydown.enter="handleBrowseSearch(1)"
|
||||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Installed Plugins -->
|
<!-- ===== INSTALLED TAB ===== -->
|
||||||
<div v-if="tab === 'installed'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel v-if="tab === 'installed'" :flush-body="true">
|
||||||
<table class="w-full">
|
<EmptyState
|
||||||
|
v-if="filteredPlugins.length === 0 && !pluginStore.isLoading"
|
||||||
|
icon="puzzle"
|
||||||
|
title="No plugins installed"
|
||||||
|
:description="searchQuery ? `No plugins matching "${searchQuery}".` : 'Install plugins from the Browse uMod tab or upload a custom .cs file.'"
|
||||||
|
/>
|
||||||
|
<div v-else-if="pluginStore.isLoading && filteredPlugins.length === 0" class="plv__loading">
|
||||||
|
<Icon name="loader" :size="20" class="plv__spin" />
|
||||||
|
<span>Loading plugins…</span>
|
||||||
|
</div>
|
||||||
|
<table v-else class="plv__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
|
<th>Plugin</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
|
<th>Version</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Source</th>
|
<th>Source</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
<th>Status</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Wipe Behavior</th>
|
<th>Wipe behavior</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
<th class="plv__th-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-if="filteredPlugins.length === 0">
|
|
||||||
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
|
||||||
<template v-if="pluginStore.isLoading">Loading plugins...</template>
|
|
||||||
<template v-else>No plugins installed yet.</template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
<tr
|
||||||
v-for="plugin in filteredPlugins"
|
v-for="plugin in filteredPlugins"
|
||||||
:key="plugin.id"
|
:key="plugin.id"
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 text-sm font-medium text-neutral-100">{{ plugin.plugin_name }}</td>
|
<td class="plv__plugin-name">{{ plugin.plugin_name }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ plugin.plugin_version || '\u2014' }}</td>
|
<td class="plv__mono">{{ plugin.plugin_version ?? '—' }}</td>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="sourceBadgeClass(plugin.source)">
|
<Badge :tone="sourceTone(plugin.source)" size="md">{{ sourceLabel(plugin.source) }}</Badge>
|
||||||
{{ sourceLabel(plugin.source) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<span
|
<Badge
|
||||||
class="inline-flex items-center gap-1.5 text-xs font-medium"
|
:tone="plugin.is_loaded ? 'online' : 'offline'"
|
||||||
:class="plugin.is_loaded ? 'text-green-400' : 'text-neutral-500'"
|
:dot="true"
|
||||||
>
|
:pulse="plugin.is_loaded"
|
||||||
<span class="h-1.5 w-1.5 rounded-full" :class="plugin.is_loaded ? 'bg-green-500' : 'bg-neutral-600'" />
|
>{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}</Badge>
|
||||||
{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-xs text-neutral-500">
|
<td class="plv__secondary plv__wipe-cell">
|
||||||
<template v-if="plugin.never_wipe">Never wipe</template>
|
<template v-if="plugin.never_wipe">Never wipe</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ [plugin.wipe_on_map && 'Map', plugin.wipe_on_bp && 'BP', plugin.wipe_on_full && 'Full'].filter(Boolean).join(', ') || 'None' }}
|
{{ [plugin.wipe_on_map && 'Map', plugin.wipe_on_bp && 'BP', plugin.wipe_on_full && 'Full'].filter(Boolean).join(', ') || 'None' }}
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="plv__td-right">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="plv__row-actions">
|
||||||
<button
|
<IconButton
|
||||||
|
:icon="plugin.is_loaded ? 'power' : 'play'"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:label="plugin.is_loaded ? 'Unload' : 'Load'"
|
||||||
@click="handleToggleLoad(plugin)"
|
@click="handleToggleLoad(plugin)"
|
||||||
class="p-1.5 rounded transition-colors"
|
/>
|
||||||
:class="plugin.is_loaded ? 'text-neutral-500 hover:text-yellow-400' : 'text-neutral-500 hover:text-green-400'"
|
<IconButton
|
||||||
:title="plugin.is_loaded ? 'Unload' : 'Load'"
|
icon="trash-2"
|
||||||
>
|
variant="danger"
|
||||||
<component :is="plugin.is_loaded ? PowerOff : Power" class="w-4 h-4" />
|
size="sm"
|
||||||
</button>
|
label="Uninstall"
|
||||||
<button
|
|
||||||
@click="handleUninstall(plugin)"
|
@click="handleUninstall(plugin)"
|
||||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
/>
|
||||||
title="Uninstall"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Browse uMod -->
|
<!-- ===== BROWSE UMOD TAB ===== -->
|
||||||
<div v-if="tab === 'browse'">
|
<div v-if="tab === 'browse'">
|
||||||
<!-- Empty state: no search yet -->
|
<!-- No search yet -->
|
||||||
<div v-if="!browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
<Panel v-if="!browseQuery.trim() && browsePlugins.length === 0">
|
||||||
<Search class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
<EmptyState
|
||||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">Search uMod</h3>
|
icon="search"
|
||||||
<p class="text-sm text-neutral-500">Type a plugin name above to search the uMod plugin directory.</p>
|
title="Search uMod"
|
||||||
</div>
|
description="Type a plugin name above to search the uMod plugin directory."
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-else-if="pluginStore.isBrowseLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
<Panel v-else-if="pluginStore.isBrowseLoading">
|
||||||
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" />
|
<div class="plv__loading">
|
||||||
<p class="text-sm text-neutral-500">Searching uMod...</p>
|
<Icon name="loader" :size="20" class="plv__spin" />
|
||||||
|
<span>Searching uMod…</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- No results -->
|
<!-- No results -->
|
||||||
<div v-else-if="browseQuery.trim() && browsePlugins.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
<Panel v-else-if="browseQuery.trim() && browsePlugins.length === 0">
|
||||||
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
<EmptyState
|
||||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">No plugins found</h3>
|
icon="download"
|
||||||
<p class="text-sm text-neutral-500">No uMod plugins matched "{{ browseQuery }}". Try a different search term.</p>
|
title="No plugins found"
|
||||||
</div>
|
:description="`No uMod plugins matched "${browseQuery}". Try a different search term.`"
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel v-else :flush-body="true">
|
||||||
<div v-if="pluginStore.browseResults" class="px-4 py-2 border-b border-neutral-800 flex items-center justify-between">
|
<template #actions>
|
||||||
<p class="text-xs text-neutral-500">
|
<span v-if="pluginStore.browseResults" class="plv__browse-meta">
|
||||||
{{ pluginStore.browseResults.total.toLocaleString() }} plugins found
|
{{ pluginStore.browseResults.total.toLocaleString() }} plugins
|
||||||
• Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
· page {{ pluginStore.browseResults.current_page }}/{{ pluginStore.browseResults.last_page }}
|
||||||
</p>
|
</span>
|
||||||
<div class="flex items-center gap-1">
|
<Button
|
||||||
<button
|
variant="ghost"
|
||||||
@click="browsePrev"
|
size="sm"
|
||||||
|
icon="chevron-left"
|
||||||
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
||||||
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
@click="browsePrev"
|
||||||
>
|
>Prev</Button>
|
||||||
← Prev
|
<Button
|
||||||
</button>
|
variant="ghost"
|
||||||
<button
|
size="sm"
|
||||||
@click="browseNext"
|
icon-right="chevron-right"
|
||||||
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
||||||
class="px-2.5 py-1 text-xs font-medium rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
@click="browseNext"
|
||||||
>
|
>Next</Button>
|
||||||
Next →
|
</template>
|
||||||
</button>
|
<table class="plv__table">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
|
<th>Plugin</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Author</th>
|
<th>Author</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
|
<th>Version</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Downloads</th>
|
<th>Downloads</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Action</th>
|
<th class="plv__th-right">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="result in browsePlugins"
|
v-for="result in browsePlugins"
|
||||||
:key="result.name"
|
:key="result.name"
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<p class="text-sm font-medium text-neutral-100">{{ result.title }}</p>
|
<div class="plv__browse-name">{{ result.title }}</div>
|
||||||
<p v-if="result.description" class="text-xs text-neutral-500 mt-0.5 truncate max-w-xs">{{ result.description }}</p>
|
<div v-if="result.description" class="plv__browse-desc">{{ result.description }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.author ?? '\u2014' }}</td>
|
<td class="plv__secondary">{{ result.author ?? '—' }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ result.latest_release_version_formatted ?? '\u2014' }}</td>
|
<td class="plv__mono">{{ result.latest_release_version_formatted ?? '—' }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.downloads_shortened ?? '\u2014' }}</td>
|
<td class="plv__secondary plv__mono">{{ result.downloads_shortened ?? '—' }}</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="plv__td-right">
|
||||||
<button
|
<Button
|
||||||
@click="installFromBrowse(result)"
|
size="sm"
|
||||||
|
:variant="isAlreadyInstalled(result.name) ? 'secondary' : 'primary'"
|
||||||
|
:icon="installing === result.name ? 'loader' : 'download'"
|
||||||
|
:loading="installing === result.name"
|
||||||
:disabled="installing === result.name || isAlreadyInstalled(result.name)"
|
:disabled="installing === result.name || isAlreadyInstalled(result.name)"
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ml-auto"
|
@click="installFromBrowse(result)"
|
||||||
:class="isAlreadyInstalled(result.name)
|
>{{ isAlreadyInstalled(result.name) ? 'Installed' : 'Install' }}</Button>
|
||||||
? 'bg-neutral-700 text-neutral-500 cursor-not-allowed'
|
|
||||||
: 'bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white'"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="installing === result.name" class="w-3.5 h-3.5 animate-spin" />
|
|
||||||
<Download v-else class="w-3.5 h-3.5" />
|
|
||||||
{{ isAlreadyInstalled(result.name) ? 'Installed' : installing === result.name ? 'Installing...' : 'Install' }}
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<!-- Bottom pagination -->
|
<!-- Bottom pagination -->
|
||||||
<div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="px-4 py-3 border-t border-neutral-800 flex items-center justify-between">
|
<div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="plv__browse-foot">
|
||||||
<p class="text-xs text-neutral-500">
|
<span class="plv__browse-meta">
|
||||||
Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
|
||||||
</p>
|
</span>
|
||||||
<div class="flex items-center gap-1">
|
<div class="plv__browse-pag">
|
||||||
<button
|
<Button
|
||||||
@click="browsePrev"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="chevron-left"
|
||||||
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
|
||||||
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
|
@click="browsePrev"
|
||||||
>
|
>Previous</Button>
|
||||||
← Previous
|
<Button
|
||||||
</button>
|
variant="secondary"
|
||||||
<button
|
size="sm"
|
||||||
@click="browseNext"
|
icon-right="chevron-right"
|
||||||
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
|
||||||
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700"
|
@click="browseNext"
|
||||||
>
|
>Next</Button>
|
||||||
Next →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload Custom Plugin -->
|
<!-- ===== UPLOAD CUSTOM TAB ===== -->
|
||||||
<div v-if="tab === 'upload'" class="space-y-4">
|
<div v-if="tab === 'upload'" class="plv__upload-wrap">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
<Panel title="Upload custom plugin" subtitle="Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.">
|
||||||
<h2 class="text-base font-semibold text-neutral-100 mb-1">Upload Custom Plugin</h2>
|
|
||||||
<p class="text-sm text-neutral-500 mb-6">
|
|
||||||
Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.
|
|
||||||
The file will be pushed to your server via the companion agent.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Drop zone -->
|
<!-- Drop zone -->
|
||||||
<div
|
<div
|
||||||
class="relative border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer"
|
class="plv__dropzone"
|
||||||
:class="isDragOver
|
:class="{
|
||||||
? 'border-oxide-500 bg-oxide-500/5'
|
'plv__dropzone--active': isDragOver,
|
||||||
: uploadFile
|
'plv__dropzone--ready': !!uploadFile && !isDragOver,
|
||||||
? 'border-green-600 bg-green-900/10'
|
}"
|
||||||
: 'border-neutral-700 hover:border-neutral-600'"
|
|
||||||
@dragover.prevent="isDragOver = true"
|
@dragover.prevent="isDragOver = true"
|
||||||
@dragleave.prevent="isDragOver = false"
|
@dragleave.prevent="isDragOver = false"
|
||||||
@drop.prevent="handleDrop"
|
@drop.prevent="handleDrop"
|
||||||
@@ -445,65 +434,174 @@ onMounted(() => {
|
|||||||
ref="uploadInput"
|
ref="uploadInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".cs"
|
accept=".cs"
|
||||||
class="hidden"
|
class="plv__file-hidden"
|
||||||
@change="handleFilePick"
|
@change="handleFilePick"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- No file selected -->
|
<!-- No file -->
|
||||||
<template v-if="!uploadFile">
|
<template v-if="!uploadFile">
|
||||||
<Upload class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
<div class="plv__drop-icon">
|
||||||
<p class="text-sm font-medium text-neutral-300">Drop your .cs file here</p>
|
<Icon name="upload" :size="22" :stroke-width="1.75" />
|
||||||
<p class="text-xs text-neutral-500 mt-1">or click to browse</p>
|
</div>
|
||||||
|
<p class="plv__drop-label">Drop your .cs file here</p>
|
||||||
|
<p class="plv__drop-hint">or click to browse</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- File selected -->
|
<!-- File selected -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex items-center justify-center gap-3">
|
<div class="plv__file-row">
|
||||||
<Puzzle class="w-8 h-8 text-green-400 flex-shrink-0" />
|
<div class="plv__file-icon">
|
||||||
<div class="text-left">
|
<Icon name="puzzle" :size="20" :stroke-width="1.75" />
|
||||||
<p class="text-sm font-medium text-neutral-100">{{ uploadFile.name }}</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-0.5">{{ (uploadFile.size / 1024).toFixed(1) }} KB</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="plv__file-info">
|
||||||
|
<div class="plv__file-name">{{ uploadFile.name }}</div>
|
||||||
|
<div class="plv__file-size">{{ (uploadFile.size / 1024).toFixed(1) }} KB</div>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
icon="x"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
label="Remove"
|
||||||
@click.stop="clearUpload"
|
@click.stop="clearUpload"
|
||||||
class="ml-2 p-1 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
/>
|
||||||
title="Remove"
|
|
||||||
>
|
|
||||||
<X class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex items-center gap-3 mt-4">
|
<div class="plv__upload-actions">
|
||||||
<button
|
<Button
|
||||||
@click="handleUpload"
|
icon="upload"
|
||||||
|
:loading="isUploading"
|
||||||
:disabled="!uploadFile || isUploading"
|
:disabled="!uploadFile || isUploading"
|
||||||
class="flex items-center gap-2 px-5 py-2 text-sm font-medium bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
@click="handleUpload"
|
||||||
>
|
>{{ isUploading ? 'Uploading…' : 'Upload plugin' }}</Button>
|
||||||
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
|
<Button
|
||||||
<Upload v-else class="w-4 h-4" />
|
|
||||||
{{ isUploading ? 'Uploading...' : 'Upload Plugin' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="uploadFile"
|
v-if="uploadFile"
|
||||||
|
variant="ghost"
|
||||||
@click="clearUpload"
|
@click="clearUpload"
|
||||||
class="px-4 py-2 text-sm font-medium text-neutral-400 hover:text-neutral-200 transition-colors"
|
>Cancel</Button>
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Info card -->
|
<Alert tone="info">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-neutral-500 leading-relaxed">
|
|
||||||
<span class="font-medium text-neutral-400">Note:</span>
|
|
||||||
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
|
The plugin will be registered in your plugin list immediately. Your companion agent must be connected
|
||||||
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
|
||||||
</p>
|
</Alert>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plv { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.plv__head {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
flex-wrap: wrap; gap: 12px;
|
||||||
|
}
|
||||||
|
.plv__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.plv__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.plv__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
.plv__head-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
/* Stat pill */
|
||||||
|
.plv__stat-pill {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.plv__stat-num { font-weight: 700; color: var(--accent-text); }
|
||||||
|
.plv__stat-sep { color: var(--text-muted); }
|
||||||
|
.plv__stat-total { color: var(--text-tertiary); }
|
||||||
|
.plv__stat-label { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.plv__toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.plv__loading {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 10px;
|
||||||
|
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
@keyframes plv-spin { to { transform: rotate(360deg); } }
|
||||||
|
.plv__spin { animation: plv-spin 0.7s linear infinite; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.plv__table { width: 100%; border-collapse: collapse; }
|
||||||
|
.plv__table th {
|
||||||
|
padding: 10px 14px; text-align: left;
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.plv__table td {
|
||||||
|
padding: 10px 14px; font-size: var(--text-sm);
|
||||||
|
color: var(--text-primary); border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.plv__table tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
.plv__table tbody tr:hover td { background: var(--surface-hover); }
|
||||||
|
.plv__th-right { text-align: right; }
|
||||||
|
.plv__td-right { text-align: right; }
|
||||||
|
.plv__plugin-name { font-weight: 500; }
|
||||||
|
.plv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs) !important; color: var(--text-secondary) !important; }
|
||||||
|
.plv__secondary { color: var(--text-secondary) !important; }
|
||||||
|
.plv__wipe-cell { font-size: var(--text-xs) !important; }
|
||||||
|
.plv__row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 2px; }
|
||||||
|
|
||||||
|
/* Browse */
|
||||||
|
.plv__browse-meta { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
|
||||||
|
.plv__browse-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.plv__browse-desc {
|
||||||
|
font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px;
|
||||||
|
max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.plv__browse-foot {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 14px; border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.plv__browse-pag { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
/* Upload */
|
||||||
|
.plv__upload-wrap { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
.plv__dropzone {
|
||||||
|
border: 2px dashed var(--border-default); border-radius: var(--radius-md);
|
||||||
|
padding: 40px 24px; text-align: center;
|
||||||
|
cursor: pointer; transition: var(--transition-colors);
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.plv__dropzone:hover { border-color: var(--accent-border); background: var(--accent-soft); }
|
||||||
|
.plv__dropzone--active { border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.plv__dropzone--ready { border-color: var(--status-online-border); background: var(--status-online-soft); }
|
||||||
|
|
||||||
|
.plv__file-hidden { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
.plv__drop-icon {
|
||||||
|
width: 46px; height: 46px; border-radius: var(--radius-lg);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--surface-raised-2); color: var(--text-tertiary);
|
||||||
|
box-shadow: var(--ring-default); margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.plv__drop-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
||||||
|
.plv__drop-hint { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
.plv__file-row { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.plv__file-icon {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--status-online-soft); color: var(--status-online);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--status-online-border);
|
||||||
|
}
|
||||||
|
.plv__file-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.plv__file-size { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.plv__upload-actions { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { Clock, Plus, Edit, Trash2, Power, Loader2 } from 'lucide-vue-next'
|
|
||||||
import { safeDate } from '@/utils/formatters'
|
import { safeDate } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
interface ScheduledTask {
|
interface ScheduledTask {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,7 +18,7 @@ interface ScheduledTask {
|
|||||||
timezone: string
|
timezone: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
next_run: string | null
|
next_run: string | null
|
||||||
task_config: Record<string, any>
|
task_config: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -31,6 +37,13 @@ const formData = ref({
|
|||||||
|
|
||||||
const timezones = ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Asia/Tokyo']
|
const timezones = ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Asia/Tokyo']
|
||||||
|
|
||||||
|
const TASK_TYPE_OPTIONS = [
|
||||||
|
{ value: 'restart', label: 'Restart' },
|
||||||
|
{ value: 'announcement', label: 'Announcement' },
|
||||||
|
{ value: 'command', label: 'Command' },
|
||||||
|
{ value: 'plugin_reload', label: 'Plugin reload' },
|
||||||
|
]
|
||||||
|
|
||||||
async function fetchTasks() {
|
async function fetchTasks() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -95,167 +108,295 @@ async function toggleActive(task: ScheduledTask) {
|
|||||||
await fetchTasks()
|
await fetchTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function taskTypeLabel(type: string): string {
|
||||||
|
return type.replace('_', ' ')
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchTasks()
|
fetchTasks()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="schedules">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="page__head">
|
||||||
<div class="flex items-center gap-3">
|
<div>
|
||||||
<Clock class="w-5 h-5 text-oxide-500" />
|
<div class="t-eyebrow">Operations</div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Scheduled Tasks</h1>
|
<h1 class="page__title">Scheduled tasks</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button icon="plus" @click="openCreateModal">New task</Button>
|
||||||
@click="openCreateModal"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 text-white font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
New Task
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tasks Table -->
|
<!-- Tasks table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel :flush-body="true" title="Tasks">
|
||||||
<div v-if="isLoading" class="p-8 flex justify-center">
|
<div v-if="isLoading" class="loading-row">
|
||||||
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
|
<span class="cc-btn__spin" style="width:20px;height:20px;border-width:2.5px;" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tasks.length === 0" class="p-8 text-center text-neutral-500">
|
|
||||||
No scheduled tasks configured.
|
<EmptyState
|
||||||
</div>
|
v-else-if="tasks.length === 0"
|
||||||
<table v-else class="w-full">
|
icon="calendar-clock"
|
||||||
<thead class="bg-neutral-800/50 border-b border-neutral-800">
|
title="No scheduled tasks"
|
||||||
|
description="Create tasks to automate restarts, announcements, and commands."
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button icon="plus" size="sm" @click="openCreateModal">New task</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
|
||||||
|
<table v-else class="cc-table">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Task Name</th>
|
<th>Task name</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th>
|
<th>Type</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Schedule</th>
|
<th>Schedule</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Timezone</th>
|
<th>Timezone</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Next Run</th>
|
<th>Next run</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Status</th>
|
<th>Status</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-for="task in tasks" :key="task.id" class="hover:bg-neutral-800/30">
|
<tr v-for="task in tasks" :key="task.id">
|
||||||
<td class="px-4 py-3 text-sm text-neutral-200">{{ task.task_name }}</td>
|
<td class="td-primary">{{ task.task_name }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 capitalize">{{ task.task_type.replace('_', ' ') }}</td>
|
<td class="td-cap">{{ taskTypeLabel(task.task_type) }}</td>
|
||||||
<td class="px-4 py-3 text-sm font-mono text-neutral-400">{{ task.cron_expression }}</td>
|
<td class="td-mono">{{ task.cron_expression }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ task.timezone }}</td>
|
<td>{{ task.timezone }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(task.next_run, '—') }}</td>
|
<td class="td-mono">{{ safeDate(task.next_run, '—') }}</td>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<span
|
<Badge :tone="task.is_active ? 'online' : 'neutral'">
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
|
||||||
:class="task.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
|
||||||
>
|
|
||||||
{{ task.is_active ? 'Active' : 'Paused' }}
|
{{ task.is_active ? 'Active' : 'Paused' }}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="row-actions">
|
||||||
<button
|
<IconButton
|
||||||
|
icon="power"
|
||||||
|
size="sm"
|
||||||
|
:label="task.is_active ? 'Pause' : 'Activate'"
|
||||||
@click="toggleActive(task)"
|
@click="toggleActive(task)"
|
||||||
class="text-neutral-400 hover:text-oxide-400 transition-colors"
|
/>
|
||||||
:title="task.is_active ? 'Pause' : 'Activate'"
|
<IconButton
|
||||||
>
|
icon="pencil"
|
||||||
<Power class="w-4 h-4" />
|
size="sm"
|
||||||
</button>
|
label="Edit"
|
||||||
<button
|
|
||||||
@click="openEditModal(task)"
|
@click="openEditModal(task)"
|
||||||
class="text-neutral-400 hover:text-oxide-400 transition-colors"
|
/>
|
||||||
>
|
<IconButton
|
||||||
<Edit class="w-4 h-4" />
|
icon="trash-2"
|
||||||
</button>
|
variant="danger"
|
||||||
<button
|
size="sm"
|
||||||
|
label="Delete"
|
||||||
@click="deleteTask(task.id)"
|
@click="deleteTask(task.id)"
|
||||||
class="text-neutral-400 hover:text-red-400 transition-colors"
|
/>
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create / Edit Modal -->
|
<!-- Create / Edit Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
v-if="showModal"
|
v-if="showModal"
|
||||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
class="modal-backdrop"
|
||||||
@click.self="showModal = false"
|
@click.self="showModal = false"
|
||||||
>
|
>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg w-full max-w-lg">
|
<div class="modal">
|
||||||
<div class="p-5 border-b border-neutral-800">
|
<div class="modal__head">
|
||||||
<h2 class="text-lg font-bold text-neutral-100">{{ editingTask ? 'Edit Task' : 'New Task' }}</h2>
|
<h2 class="modal__title">{{ editingTask ? 'Edit task' : 'New task' }}</h2>
|
||||||
|
<IconButton icon="x" label="Close" @click="showModal = false" />
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 space-y-4">
|
|
||||||
<div>
|
<div class="modal__body">
|
||||||
<label class="block text-sm text-neutral-400 mb-2">Task Name</label>
|
<Input
|
||||||
<input
|
|
||||||
v-model="formData.task_name"
|
v-model="formData.task_name"
|
||||||
type="text"
|
label="Task name"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
|
||||||
placeholder="Daily restart"
|
placeholder="Daily restart"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
<Select
|
||||||
<label class="block text-sm text-neutral-400 mb-2">Task Type</label>
|
|
||||||
<select
|
|
||||||
v-model="formData.task_type"
|
v-model="formData.task_type"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
label="Task type"
|
||||||
>
|
:options="TASK_TYPE_OPTIONS"
|
||||||
<option value="restart">Restart</option>
|
|
||||||
<option value="announcement">Announcement</option>
|
|
||||||
<option value="command">Command</option>
|
|
||||||
<option value="plugin_reload">Plugin Reload</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-neutral-400 mb-2">Cron Expression</label>
|
|
||||||
<input
|
|
||||||
v-model="formData.cron_expression"
|
|
||||||
type="text"
|
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
|
||||||
placeholder="0 0 * * *"
|
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-neutral-500 mt-1">Example: "0 0 * * *" = daily at midnight</p>
|
|
||||||
</div>
|
<Input
|
||||||
<div>
|
v-model="formData.cron_expression"
|
||||||
<label class="block text-sm text-neutral-400 mb-2">Timezone</label>
|
label="Cron expression"
|
||||||
<select
|
placeholder="0 0 * * *"
|
||||||
|
:mono="true"
|
||||||
|
hint="Example: 0 0 * * * = daily at midnight"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
v-model="formData.timezone"
|
v-model="formData.timezone"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
label="Timezone"
|
||||||
>
|
:options="timezones"
|
||||||
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
<div class="cc-field">
|
||||||
<div>
|
<span class="cc-field__label">Task config (JSON)</span>
|
||||||
<label class="block text-sm text-neutral-400 mb-2">Task Config (JSON)</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="formData.task_config"
|
v-model="formData.task_config"
|
||||||
rows="4"
|
rows="4"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
|
||||||
placeholder='{"message": "Server restarting..."}'
|
placeholder='{"message": "Server restarting..."}'
|
||||||
|
class="cc-textarea cc-textarea--mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5 border-t border-neutral-800 flex justify-end gap-3">
|
|
||||||
<button
|
<div class="modal__foot">
|
||||||
@click="showModal = false"
|
<Button variant="secondary" @click="showModal = false">Cancel</Button>
|
||||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
<Button @click="saveTask">{{ editingTask ? 'Update' : 'Create' }}</Button>
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="saveTask"
|
|
||||||
class="px-4 py-2 text-sm font-medium bg-oxide-500 hover:bg-oxide-600 text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{{ editingTask ? 'Update' : 'Create' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.schedules {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.page__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading row */
|
||||||
|
.loading-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.cc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.cc-table thead tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.cc-table th {
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cc-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||||
|
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||||
|
.cc-table td {
|
||||||
|
padding: 11px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.td-primary { color: var(--text-primary); font-weight: 500; }
|
||||||
|
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.td-cap { text-transform: capitalize; }
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xl, 0 24px 60px rgba(0,0,0,.45));
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.modal__title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.modal__body {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal__foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 11px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
outline: 0;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||||
|
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-textarea--mono { font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,20 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { Settings, Key, Globe, User, Save, Loader2, Eye } from 'lucide-vue-next'
|
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const section = ref<'account' | 'license' | 'domain' | 'public'>('account')
|
const section = ref<string>('account')
|
||||||
|
|
||||||
const accountForm = ref({
|
const accountForm = ref({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -86,209 +92,225 @@ async function savePublicSite() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadForms()
|
loadForms()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{ value: 'account', label: 'Account', icon: 'user' },
|
||||||
|
{ value: 'license', label: 'License', icon: 'key' },
|
||||||
|
{ value: 'domain', label: 'Domain', icon: 'globe' },
|
||||||
|
{ value: 'public', label: 'Public status', icon: 'eye' },
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="settings">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="page__head">
|
||||||
<Settings class="w-5 h-5 text-oxide-500" />
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Settings</h1>
|
<div class="t-eyebrow">Management</div>
|
||||||
|
<h1 class="page__title">Settings</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Section tabs -->
|
<!-- Section tabs -->
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden w-fit">
|
<Tabs v-model="section" :items="tabItems" />
|
||||||
<button
|
|
||||||
v-for="tab in ([
|
|
||||||
{ key: 'account', label: 'Account', icon: User },
|
|
||||||
{ key: 'license', label: 'License', icon: Key },
|
|
||||||
{ key: 'domain', label: 'Domain', icon: Globe },
|
|
||||||
{ key: 'public', label: 'Public Status', icon: Eye },
|
|
||||||
] as const)"
|
|
||||||
:key="tab.key"
|
|
||||||
@click="section = tab.key"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
:class="section === tab.key
|
|
||||||
? 'bg-oxide-500/15 text-oxide-400'
|
|
||||||
: 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
<component :is="tab.icon" class="w-4 h-4" />
|
|
||||||
{{ tab.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Account -->
|
<!-- Account -->
|
||||||
<div v-if="section === 'account'" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4">
|
<Panel v-if="section === 'account'" title="Account details" eyebrow="Identity">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Account Details</h2>
|
<template #actions>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<Button size="sm" :loading="saving" icon="save" @click="saveAccount">Save</Button>
|
||||||
<div>
|
</template>
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Username</label>
|
<div class="form-grid">
|
||||||
<input
|
<Input
|
||||||
v-model="accountForm.username"
|
v-model="accountForm.username"
|
||||||
type="text"
|
label="Username"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
placeholder="your-handle"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Email</label>
|
|
||||||
<input
|
|
||||||
v-model="accountForm.email"
|
v-model="accountForm.email"
|
||||||
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
placeholder="you@example.com"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="totp-row">
|
||||||
<div class="flex items-center gap-3">
|
<span class="field-label">2FA status</span>
|
||||||
<p class="text-xs text-neutral-500">
|
<Badge
|
||||||
2FA: <span :class="auth.user?.totp_enabled ? 'text-green-400' : 'text-yellow-400'">
|
:tone="auth.user?.totp_enabled ? 'online' : 'warn'"
|
||||||
{{ auth.user?.totp_enabled ? 'Enabled' : 'Not configured' }}
|
:dot="true"
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="saveAccount"
|
|
||||||
:disabled="saving"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" />
|
{{ auth.user?.totp_enabled ? 'Enabled' : 'Not configured' }}
|
||||||
<Save v-else class="w-4 h-4" />
|
</Badge>
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- License -->
|
<!-- License -->
|
||||||
<div v-if="section === 'license'" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4">
|
<Panel v-if="section === 'license'" title="License information" eyebrow="Subscription">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">License Information</h2>
|
<div class="lic-grid">
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="lic-field">
|
||||||
<div>
|
<span class="field-label">License key</span>
|
||||||
<p class="text-xs text-neutral-500 mb-1">License Key</p>
|
<span class="field-mono">{{ auth.license?.license_key ?? '—' }}</span>
|
||||||
<p class="text-sm font-mono text-neutral-300">{{ auth.license?.license_key || '\u2014' }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="lic-field">
|
||||||
<p class="text-xs text-neutral-500 mb-1">Status</p>
|
<span class="field-label">Status</span>
|
||||||
<span
|
<Badge
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
:tone="auth.license?.status === 'active' ? 'online' : auth.license?.status === 'suspended' ? 'warn' : 'offline'"
|
||||||
:class="{
|
|
||||||
'bg-green-500/10 text-green-400': auth.license?.status === 'active',
|
|
||||||
'bg-yellow-500/10 text-yellow-400': auth.license?.status === 'suspended',
|
|
||||||
'bg-red-500/10 text-red-400': auth.license?.status === 'expired' || auth.license?.status === 'revoked',
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
{{ auth.license?.status || 'Unknown' }}
|
{{ auth.license?.status ?? 'Unknown' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="lic-field">
|
||||||
|
<span class="field-label">Expires</span>
|
||||||
|
<span class="field-value">
|
||||||
|
{{ auth.license?.expires_at ? new Date(auth.license.expires_at).toLocaleDateString() : 'Never' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="lic-field">
|
||||||
<p class="text-xs text-neutral-500 mb-1">Expires</p>
|
<span class="field-label">Server name</span>
|
||||||
<p class="text-sm text-neutral-300">
|
<span class="field-value">{{ auth.license?.server_name ?? 'Not set' }}</span>
|
||||||
{{ auth.license?.expires_at ? new Date(auth.license.expires_at).toLocaleDateString() : 'Never' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="lic-field">
|
||||||
<p class="text-xs text-neutral-500 mb-1">Server Name</p>
|
<span class="field-label">Webstore</span>
|
||||||
<p class="text-sm text-neutral-300">{{ auth.license?.server_name || 'Not set' }}</p>
|
<Badge :tone="auth.license?.webstore_active ? 'online' : 'neutral'">
|
||||||
</div>
|
{{ auth.license?.webstore_active ? 'Active' : 'Inactive' }}
|
||||||
<div>
|
</Badge>
|
||||||
<p class="text-xs text-neutral-500 mb-1">Webstore</p>
|
|
||||||
<p class="text-sm text-neutral-300">{{ auth.license?.webstore_active ? 'Active' : 'Inactive' }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs text-neutral-500 mb-1">Modules</p>
|
|
||||||
<p class="text-sm text-neutral-300">{{ auth.license?.modules_enabled?.length ?? 0 }} enabled</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="lic-field">
|
||||||
|
<span class="field-label">Modules</span>
|
||||||
|
<span class="field-mono">{{ auth.license?.modules_enabled?.length ?? 0 }} enabled</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Domain -->
|
<!-- Domain -->
|
||||||
<div v-if="section === 'domain'" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4">
|
<Panel v-if="section === 'domain'" title="Domain & subdomain" eyebrow="Routing">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Domain & Subdomain</h2>
|
<template #actions>
|
||||||
<div class="space-y-4">
|
<Button size="sm" :loading="saving" icon="save" @click="saveDomain">Save</Button>
|
||||||
<div>
|
</template>
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Subdomain</label>
|
<div class="domain-stack">
|
||||||
<div class="flex items-center gap-0">
|
<div class="cc-field">
|
||||||
<input
|
<span class="cc-field__label">Subdomain</span>
|
||||||
|
<div class="subdomain-row">
|
||||||
|
<Input
|
||||||
v-model="domainForm.subdomain"
|
v-model="domainForm.subdomain"
|
||||||
type="text"
|
|
||||||
placeholder="my-server"
|
placeholder="my-server"
|
||||||
class="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-l-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
style="flex: 1"
|
||||||
/>
|
/>
|
||||||
<span class="px-3 py-2 bg-neutral-700 border border-l-0 border-neutral-700 rounded-r-lg text-sm text-neutral-400">
|
<span class="subdomain-suffix">.corrosionmgmt.com</span>
|
||||||
.corrosionmgmt.com
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<Input
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Custom Domain (optional)</label>
|
|
||||||
<input
|
|
||||||
v-model="domainForm.custom_domain"
|
v-model="domainForm.custom_domain"
|
||||||
type="text"
|
label="Custom domain"
|
||||||
placeholder="panel.myserver.com"
|
placeholder="panel.myserver.com"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
hint="Point a CNAME record to panel.corrosionmgmt.com"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Point a CNAME record to panel.corrosionmgmt.com</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="saveDomain"
|
|
||||||
:disabled="saving"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" />
|
|
||||||
<Save v-else class="w-4 h-4" />
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Public Status Page -->
|
<!-- Public status page -->
|
||||||
<div v-if="section === 'public'" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4">
|
<Panel v-if="section === 'public'" title="Public status page" eyebrow="Visibility">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Public Status Page</h2>
|
<template #actions>
|
||||||
<p class="text-xs text-neutral-500">
|
<Button size="sm" :loading="saving" icon="save" @click="savePublicSite">Save</Button>
|
||||||
|
</template>
|
||||||
|
<div class="public-stack">
|
||||||
|
<p class="section-note">
|
||||||
Showcase your server on the public Corrosion status page at
|
Showcase your server on the public Corrosion status page at
|
||||||
<a href="https://status.corrosionmgmt.com" target="_blank" class="text-oxide-400 hover:text-oxide-300">
|
<a href="https://status.corrosionmgmt.com" target="_blank" class="link">status.corrosionmgmt.com</a>.
|
||||||
status.corrosionmgmt.com
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="toggle-row">
|
||||||
<!-- Toggle -->
|
|
||||||
<div class="flex items-center justify-between p-4 bg-neutral-800 rounded-lg border border-neutral-700">
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-neutral-200">Show on status page</p>
|
<p class="toggle-label">Show on status page</p>
|
||||||
<p class="text-xs text-neutral-500 mt-1">Display your server publicly with live stats</p>
|
<p class="toggle-desc">Display your server publicly with live stats</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch v-model="publicSiteForm.show_on_status_page" />
|
||||||
@click="publicSiteForm.show_on_status_page = !publicSiteForm.show_on_status_page"
|
|
||||||
:class="publicSiteForm.show_on_status_page ? 'bg-oxide-600' : 'bg-neutral-700'"
|
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-oxide-500 focus:ring-offset-2 focus:ring-offset-neutral-900"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:class="publicSiteForm.show_on_status_page ? 'translate-x-6' : 'translate-x-1'"
|
|
||||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<div class="cc-field">
|
||||||
<div>
|
<span class="cc-field__label">Description <span class="optional">(optional)</span></span>
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Description (optional)</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="publicSiteForm.status_page_description"
|
v-model="publicSiteForm.status_page_description"
|
||||||
placeholder="Friendly 10x modded server with custom plugins..."
|
placeholder="Friendly 10x modded server with custom plugins..."
|
||||||
rows="3"
|
rows="3"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
class="cc-textarea"
|
||||||
></textarea>
|
/>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Brief description shown on the status page</p>
|
<span class="cc-field__hint">Brief description shown on the status page</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
<button
|
|
||||||
@click="savePublicSite"
|
|
||||||
:disabled="saving"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" />
|
|
||||||
<Save v-else class="w-4 h-4" />
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.page__title { font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em; color: var(--text-primary); margin-top: 5px; }
|
||||||
|
|
||||||
|
/* Account tab */
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
.totp-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border-subtle); }
|
||||||
|
|
||||||
|
/* License tab */
|
||||||
|
.lic-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px 24px; }
|
||||||
|
.lic-field { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
|
||||||
|
/* Domain tab */
|
||||||
|
.domain-stack { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.subdomain-row { display: flex; align-items: center; gap: 0; }
|
||||||
|
.subdomain-suffix {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
height: var(--control-h-md); padding: 0 11px;
|
||||||
|
background: var(--surface-raised-2); color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono); font-size: var(--text-xs);
|
||||||
|
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
white-space: nowrap; flex: none;
|
||||||
|
}
|
||||||
|
/* Remove the right-radius from the preceding Input's inner element */
|
||||||
|
.subdomain-row :deep(.cc-input) { border-radius: var(--radius-md) 0 0 var(--radius-md); }
|
||||||
|
|
||||||
|
/* Public status tab */
|
||||||
|
.public-stack { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.section-note { font-size: var(--text-sm); color: var(--text-tertiary); margin: 0; }
|
||||||
|
.link { color: var(--accent-text); text-decoration: none; }
|
||||||
|
.link:hover { text-decoration: underline; }
|
||||||
|
.toggle-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
background: var(--surface-raised-2); border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.toggle-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); margin: 0; }
|
||||||
|
.toggle-desc { font-size: var(--text-xs); color: var(--text-tertiary); margin: 3px 0 0; }
|
||||||
|
|
||||||
|
/* Shared field helpers */
|
||||||
|
.field-label { font-size: var(--text-xs); font-weight: 600; color: var(--text-secondary); }
|
||||||
|
.field-mono { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
||||||
|
.field-value { font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
|
.optional { font-weight: 400; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Textarea (no DS component for textarea — minimal consistent styling) */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%; padding: 9px 11px; resize: vertical;
|
||||||
|
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); line-height: 1.5;
|
||||||
|
outline: 0; transition: var(--transition-colors);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||||
|
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
|
.lic-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { ref, onMounted, computed } from 'vue'
|
|||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { StoreConfig } from '@/types'
|
import type { StoreConfig } from '@/types'
|
||||||
import { Store, Save, Loader2, AlertTriangle, DollarSign } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
@@ -22,10 +28,18 @@ const isLoading = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const isConfigured = ref(false)
|
const isConfigured = ref(false)
|
||||||
|
|
||||||
|
// DS Input uses defineModel<string>() which emits string | undefined, but
|
||||||
|
// paypal_client_id is string | null in the DB type. Bridge null ↔ undefined
|
||||||
|
// so we never change what gets saved to the API.
|
||||||
|
const paypalClientId = computed({
|
||||||
|
get: () => config.value.paypal_client_id ?? undefined,
|
||||||
|
set: (v: string | undefined) => { config.value.paypal_client_id = v ?? null },
|
||||||
|
})
|
||||||
|
|
||||||
const currencyOptions = [
|
const currencyOptions = [
|
||||||
{ value: 'USD', label: 'USD - US Dollar' },
|
{ value: 'USD', label: 'USD — US Dollar' },
|
||||||
{ value: 'EUR', label: 'EUR - Euro' },
|
{ value: 'EUR', label: 'EUR — Euro' },
|
||||||
{ value: 'GBP', label: 'GBP - British Pound' },
|
{ value: 'GBP', label: 'GBP — British Pound' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const showPayPalWarning = computed(() => {
|
const showPayPalWarning = computed(() => {
|
||||||
@@ -40,7 +54,6 @@ async function fetchConfig() {
|
|||||||
isConfigured.value = !!data.config.store_name
|
isConfigured.value = !!data.config.store_name
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.response?.status === 404) {
|
if (err.response?.status === 404) {
|
||||||
// No config yet, use defaults
|
|
||||||
isConfigured.value = false
|
isConfigured.value = false
|
||||||
} else {
|
} else {
|
||||||
toast.error('Failed to load store configuration')
|
toast.error('Failed to load store configuration')
|
||||||
@@ -51,7 +64,6 @@ async function fetchConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
// Validation
|
|
||||||
if (!config.value.store_name.trim()) {
|
if (!config.value.store_name.trim()) {
|
||||||
toast.error('Store name is required')
|
toast.error('Store name is required')
|
||||||
return
|
return
|
||||||
@@ -73,7 +85,6 @@ async function saveConfig() {
|
|||||||
enabled: config.value.enabled,
|
enabled: config.value.enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include secret if it was entered
|
|
||||||
if (paypalSecret.value.trim()) {
|
if (paypalSecret.value.trim()) {
|
||||||
payload.paypal_client_secret = paypalSecret.value
|
payload.paypal_client_secret = paypalSecret.value
|
||||||
}
|
}
|
||||||
@@ -81,7 +92,7 @@ async function saveConfig() {
|
|||||||
await api.put('/webstore/config', payload)
|
await api.put('/webstore/config', payload)
|
||||||
toast.success('Store configuration saved successfully')
|
toast.success('Store configuration saved successfully')
|
||||||
isConfigured.value = true
|
isConfigured.value = true
|
||||||
paypalSecret.value = '' // Clear after save
|
paypalSecret.value = ''
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const message = err.response?.data?.error || 'Failed to save configuration'
|
const message = err.response?.data?.error || 'Failed to save configuration'
|
||||||
toast.error(message)
|
toast.error(message)
|
||||||
@@ -96,202 +107,181 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="sc-page">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="page__head">
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Store class="w-5 h-5 text-oxide-500" />
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Store Configuration</h1>
|
<div class="t-eyebrow">Management</div>
|
||||||
<p class="text-sm text-neutral-500 mt-0.5">
|
<h1 class="page__title">Store configuration</h1>
|
||||||
Configure your integrated webstore and PayPal settings
|
<p class="page__sub">Configure your integrated webstore and PayPal settings.</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Button :loading="saving" :disabled="isLoading" icon="save" @click="saveConfig">
|
||||||
<button
|
Save configuration
|
||||||
@click="saveConfig"
|
</Button>
|
||||||
:disabled="saving || isLoading"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" />
|
|
||||||
<Save v-else class="w-4 h-4" />
|
|
||||||
{{ saving ? 'Saving...' : 'Save Configuration' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading skeleton -->
|
||||||
<div v-if="isLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
<div v-if="isLoading" class="sc-loading">
|
||||||
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" />
|
<EmptyState icon="loader" title="Loading configuration" description="Fetching your store settings…" />
|
||||||
<p class="text-sm text-neutral-500">Loading configuration...</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state — no config yet -->
|
||||||
<div
|
<Panel v-else-if="!isConfigured && !config.store_name">
|
||||||
v-else-if="!isConfigured && !config.store_name"
|
<EmptyState
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center"
|
icon="shopping-cart"
|
||||||
|
title="No store configured"
|
||||||
|
description="Set up your integrated webstore to start selling in-game items, ranks, and currency to your players. Fill out the form below to get started."
|
||||||
>
|
>
|
||||||
<Store class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
<template #action>
|
||||||
<h3 class="text-lg font-medium text-neutral-200 mb-2">No Store Configured</h3>
|
<Button size="sm" variant="outline" icon="arrow-down">Complete the form below</Button>
|
||||||
<p class="text-sm text-neutral-500 mb-6 max-w-md mx-auto">
|
</template>
|
||||||
Set up your integrated webstore to start selling in-game items, ranks, and currency to your players.
|
</EmptyState>
|
||||||
</p>
|
</Panel>
|
||||||
<p class="text-xs text-neutral-600">
|
|
||||||
Fill out the form below to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Configuration form -->
|
<!-- Configuration form -->
|
||||||
<div v-else class="space-y-6">
|
<div v-else class="sc-form">
|
||||||
<!-- Store enable toggle with warning -->
|
<!-- Enable toggle -->
|
||||||
<div
|
<Panel title="Webstore status" subtitle="Allow players to purchase items from your store">
|
||||||
class="bg-neutral-900 border rounded-lg p-5"
|
<div class="sc-toggle-row">
|
||||||
:class="config.enabled ? 'border-oxide-500/50' : 'border-neutral-800'"
|
<div class="sc-toggle-row__info">
|
||||||
>
|
<span class="sc-toggle-row__label">Enable webstore</span>
|
||||||
<div class="flex items-center justify-between">
|
<span class="sc-toggle-row__hint">Players will be able to browse and purchase items when enabled.</span>
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-medium text-neutral-200">Enable Webstore</h2>
|
|
||||||
<p class="text-xs text-neutral-500 mt-1">
|
|
||||||
Allow players to purchase items from your store
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Switch v-model="config.enabled" />
|
||||||
@click="config.enabled = !config.enabled"
|
|
||||||
:class="config.enabled ? 'bg-oxide-600' : 'bg-neutral-700'"
|
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-oxide-500 focus:ring-offset-2 focus:ring-offset-neutral-900"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:class="config.enabled ? 'translate-x-6' : 'translate-x-1'"
|
|
||||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Warning when enabled without PayPal -->
|
<Alert
|
||||||
<div
|
|
||||||
v-if="showPayPalWarning"
|
v-if="showPayPalWarning"
|
||||||
class="mt-4 flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg"
|
tone="warn"
|
||||||
|
title="PayPal configuration required"
|
||||||
|
class="sc-alert"
|
||||||
>
|
>
|
||||||
<AlertTriangle class="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
|
Configure PayPal credentials in the section below before the store can process transactions.
|
||||||
<div class="text-xs text-yellow-300">
|
</Alert>
|
||||||
<strong>PayPal configuration required.</strong> You must configure PayPal credentials below before the store can process transactions.
|
</Panel>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Basic store info -->
|
<!-- Store information -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4">
|
<Panel title="Store information" eyebrow="General">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Store Information</h2>
|
<div class="sc-fields">
|
||||||
|
<Input
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Store Name *</label>
|
|
||||||
<input
|
|
||||||
v-model="config.store_name"
|
v-model="config.store_name"
|
||||||
type="text"
|
label="Store name"
|
||||||
placeholder="My Rust Server Store"
|
placeholder="My Rust Server Store"
|
||||||
maxlength="200"
|
:required="true"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
hint="Displayed to players on the store page"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Displayed to players on the store page</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<label class="cc-field">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Description (optional)</label>
|
<span class="cc-field__label">Description <span class="sc-opt">(optional)</span></span>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="config.description"
|
v-model="config.description"
|
||||||
placeholder="Welcome to our server store! Support us and get awesome in-game items..."
|
class="cc-textarea"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
placeholder="Welcome to our server store! Support us and get awesome in-game items…"
|
||||||
></textarea>
|
/>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Brief description shown on the store page</p>
|
<span class="cc-field__hint">Brief description shown on the store page.</span>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<div>
|
<Select
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Currency</label>
|
|
||||||
<div class="relative">
|
|
||||||
<DollarSign class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500 pointer-events-none" />
|
|
||||||
<select
|
|
||||||
v-model="config.currency"
|
v-model="config.currency"
|
||||||
class="w-full pl-9 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors appearance-none cursor-pointer"
|
label="Currency"
|
||||||
>
|
:options="currencyOptions"
|
||||||
<option v-for="opt in currencyOptions" :key="opt.value" :value="opt.value">
|
hint="Currency used for all transactions"
|
||||||
{{ opt.label }}
|
/>
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">Currency used for all transactions</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- PayPal configuration -->
|
<!-- PayPal configuration -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 space-y-4">
|
<Panel title="PayPal configuration" eyebrow="Payments" subtitle="Get your API credentials from the PayPal Developer Dashboard.">
|
||||||
<div>
|
<div class="sc-fields">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">PayPal Configuration</h2>
|
<Input
|
||||||
<p class="text-xs text-neutral-500 mt-1">
|
v-model="paypalClientId"
|
||||||
Get your API credentials from the
|
label="PayPal Client ID"
|
||||||
<a
|
|
||||||
href="https://developer.paypal.com/dashboard/"
|
|
||||||
target="_blank"
|
|
||||||
class="text-oxide-400 hover:text-oxide-300 underline"
|
|
||||||
>
|
|
||||||
PayPal Developer Dashboard
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-neutral-500 mb-1">PayPal Client ID *</label>
|
|
||||||
<input
|
|
||||||
v-model="config.paypal_client_id"
|
|
||||||
type="text"
|
|
||||||
placeholder="AXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
placeholder="AXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
:required="true"
|
||||||
|
:mono="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Input
|
||||||
<label class="block text-xs text-neutral-500 mb-1">PayPal Client Secret *</label>
|
|
||||||
<input
|
|
||||||
v-model="paypalSecret"
|
v-model="paypalSecret"
|
||||||
|
label="PayPal Client Secret"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Enter to update (stored encrypted)"
|
placeholder="Enter to update (stored encrypted)"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
:mono="true"
|
||||||
|
hint="Stored encrypted. Leave blank to keep existing secret."
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-neutral-600 mt-1">
|
|
||||||
Stored encrypted. Leave blank to keep existing secret.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sandbox mode toggle -->
|
<!-- Sandbox mode toggle -->
|
||||||
<div class="flex items-center justify-between p-4 bg-neutral-800 rounded-lg border border-neutral-700">
|
<div class="sc-sandbox">
|
||||||
<div>
|
<div class="sc-toggle-row">
|
||||||
<p class="text-sm font-medium text-neutral-200">Sandbox Mode</p>
|
<div class="sc-toggle-row__info">
|
||||||
<p class="text-xs text-neutral-500 mt-1">
|
<span class="sc-toggle-row__label">Sandbox mode</span>
|
||||||
Use PayPal sandbox for testing (no real transactions)
|
<span class="sc-toggle-row__hint">Use PayPal sandbox for testing — no real transactions.</span>
|
||||||
</p>
|
</div>
|
||||||
|
<Switch v-model="config.sandbox_mode" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
@click="config.sandbox_mode = !config.sandbox_mode"
|
|
||||||
:class="config.sandbox_mode ? 'bg-yellow-600' : 'bg-green-600'"
|
|
||||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-oxide-500 focus:ring-offset-2 focus:ring-offset-neutral-900"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:class="config.sandbox_mode ? 'translate-x-6' : 'translate-x-1'"
|
|
||||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Production warning -->
|
<Alert
|
||||||
<div
|
|
||||||
v-if="!config.sandbox_mode && config.enabled"
|
v-if="!config.sandbox_mode && config.enabled"
|
||||||
class="flex items-start gap-3 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"
|
tone="danger"
|
||||||
|
title="Production mode active"
|
||||||
>
|
>
|
||||||
<AlertTriangle class="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
|
Real transactions will be processed. Ensure your PayPal credentials are correct.
|
||||||
<div class="text-xs text-red-300">
|
</Alert>
|
||||||
<strong>Production mode enabled.</strong> Real transactions will be processed. Ensure your PayPal credentials are correct.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sc-page { max-width: 860px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
|
||||||
|
.page__head {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: 16px; flex-wrap: wrap; row-gap: 12px;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 5px;
|
||||||
|
}
|
||||||
|
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
|
||||||
|
|
||||||
|
.sc-loading { padding: 20px 0; }
|
||||||
|
|
||||||
|
.sc-form { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
.sc-fields { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Shared toggle row */
|
||||||
|
.sc-toggle-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.sc-toggle-row__info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.sc-toggle-row__label { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.sc-toggle-row__hint { font-size: var(--text-xs); color: var(--text-tertiary); }
|
||||||
|
|
||||||
|
.sc-sandbox {
|
||||||
|
background: var(--surface-raised); border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default); padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sc-alert { margin-top: 14px; }
|
||||||
|
|
||||||
|
.sc-opt { font-weight: 400; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Textarea token style */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%; min-height: 80px; padding: 9px 11px;
|
||||||
|
background: var(--surface-inset); border: none; border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default); resize: vertical;
|
||||||
|
font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-primary);
|
||||||
|
line-height: var(--leading-normal); outline: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||||
|
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,16 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import type { StoreCategory, StoreItem } from '@/types'
|
import type { StoreCategory, StoreItem } from '@/types'
|
||||||
import { ShoppingBag, Plus, Trash2, RefreshCw, Edit2, DollarSign, X } from 'lucide-vue-next'
|
|
||||||
import { safeFixed } from '@/utils/formatters'
|
import { safeFixed } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
|
import Checkbox from '@/components/ds/forms/Checkbox.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -42,17 +50,23 @@ const itemTypes = [
|
|||||||
{ value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' },
|
{ value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' },
|
||||||
{ value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' },
|
{ value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' },
|
||||||
{ value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' },
|
{ value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' },
|
||||||
{ value: 'command', label: 'Custom Command', example: 'yourplugin.givereward {steam_id}' }
|
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' }
|
||||||
]
|
]
|
||||||
|
|
||||||
function typeBadgeClass(type: string): string {
|
const tabItems = computed(() => [
|
||||||
switch (type) {
|
{ value: 'categories', label: 'Categories', count: categories.value.length },
|
||||||
case 'kit': return 'bg-blue-500/15 text-blue-400'
|
{ value: 'items', label: 'Items', count: items.value.length },
|
||||||
case 'rank': return 'bg-purple-500/15 text-purple-400'
|
])
|
||||||
case 'currency': return 'bg-yellow-500/15 text-yellow-400'
|
|
||||||
case 'command': return 'bg-oxide-500/15 text-oxide-400'
|
type ItemTypeTone = 'info' | 'accent' | 'warn' | 'neutral'
|
||||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
function typeTone(type: string): ItemTypeTone {
|
||||||
|
const map: Record<string, ItemTypeTone> = {
|
||||||
|
kit: 'info',
|
||||||
|
rank: 'accent',
|
||||||
|
currency: 'warn',
|
||||||
|
command: 'accent',
|
||||||
}
|
}
|
||||||
|
return map[type] ?? 'neutral'
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoGenerateSlug(name: string): string {
|
function autoGenerateSlug(name: string): string {
|
||||||
@@ -188,7 +202,6 @@ function removeCommand(index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveItem() {
|
async function saveItem() {
|
||||||
// Validate
|
|
||||||
if (!itemForm.value.name.trim()) {
|
if (!itemForm.value.name.trim()) {
|
||||||
alert('Item name is required')
|
alert('Item name is required')
|
||||||
return
|
return
|
||||||
@@ -234,12 +247,22 @@ async function deleteItem(item: StoreItem) {
|
|||||||
function getCategoryName(categoryId: string | null): string {
|
function getCategoryName(categoryId: string | null): string {
|
||||||
if (!categoryId) return 'Uncategorized'
|
if (!categoryId) return 'Uncategorized'
|
||||||
const cat = categories.value.find(c => c.id === categoryId)
|
const cat = categories.value.find(c => c.id === categoryId)
|
||||||
return cat?.name || 'Unknown'
|
return cat?.name ?? 'Unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedTypeExample = computed(() => {
|
const selectedTypeExample = computed(() => {
|
||||||
const type = itemTypes.find(t => t.value === itemForm.value.item_type)
|
const type = itemTypes.find(t => t.value === itemForm.value.item_type)
|
||||||
return type?.example || ''
|
return type?.example ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const categorySelectOptions = computed(() => [
|
||||||
|
{ value: '', label: 'Uncategorized' },
|
||||||
|
...categories.value.map(c => ({ value: c.id, label: c.name }))
|
||||||
|
])
|
||||||
|
|
||||||
|
const categorySelectValue = computed({
|
||||||
|
get: () => itemForm.value.category_id ?? '',
|
||||||
|
set: (v: string) => { itemForm.value.category_id = v || null }
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -249,447 +272,408 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="si-page">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="page__head">
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<ShoppingBag class="w-5 h-5 text-oxide-500" />
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Store Items</h1>
|
<div class="t-eyebrow">Management · Store</div>
|
||||||
<p class="text-sm text-neutral-500 mt-0.5">
|
<h1 class="page__title">Store items</h1>
|
||||||
{{ categories.length }} categories, {{ items.length }} items
|
<p class="page__sub">{{ categories.length }} categories · {{ items.length }} items</p>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="page__actions">
|
||||||
<div class="flex items-center gap-3">
|
<IconButton icon="refresh-cw" label="Refresh" :class="{ 'si-spin': isLoading }" @click="tab === 'categories' ? fetchCategories() : fetchItems()" />
|
||||||
<button
|
<Button v-if="tab === 'categories'" icon="plus" @click="openCategoryModal()">Add category</Button>
|
||||||
@click="tab === 'categories' ? fetchCategories() : fetchItems()"
|
<Button v-else icon="plus" @click="openItemModal()">Add item</Button>
|
||||||
:disabled="isLoading"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="tab === 'categories'"
|
|
||||||
@click="openCategoryModal()"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
Add Category
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
@click="openItemModal()"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
Add Item
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tab bar + content panel -->
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden w-fit">
|
<Panel :flush-body="true">
|
||||||
<button
|
<template #actions>
|
||||||
@click="tab = 'categories'"
|
<Tabs v-model="tab" :items="tabItems" />
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
</template>
|
||||||
:class="tab === 'categories' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
Categories
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="tab = 'items'"
|
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
|
||||||
:class="tab === 'items' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
|
||||||
Items
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Categories Tab -->
|
<!-- Categories table -->
|
||||||
<div v-if="tab === 'categories'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<table v-if="tab === 'categories'" class="si-table">
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Name</th>
|
<th>Name</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Slug</th>
|
<th>Slug</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Display Order</th>
|
<th class="si-col-num">Order</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Visible</th>
|
<th>Visibility</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
<th class="si-col-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-if="categories.length === 0">
|
<tr v-if="categories.length === 0">
|
||||||
<td colspan="5" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
<td colspan="5" class="si-empty-cell">
|
||||||
<template v-if="isLoading">Loading categories...</template>
|
<EmptyState
|
||||||
<template v-else>No categories yet. Add one to organize your store items.</template>
|
icon="folder-open"
|
||||||
|
:title="isLoading ? 'Loading…' : 'No categories'"
|
||||||
|
:description="isLoading ? '' : 'Add a category to organize your store items.'"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr v-for="category in categories" :key="category.id">
|
||||||
v-for="category in categories"
|
<td class="si-cell-primary">
|
||||||
:key="category.id"
|
<span class="si-name">{{ category.name }}</span>
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
<span v-if="category.description" class="si-sub">{{ category.description }}</span>
|
||||||
>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<p class="text-sm font-medium text-neutral-100">{{ category.name }}</p>
|
|
||||||
<p v-if="category.description" class="text-xs text-neutral-500 truncate max-w-md">{{ category.description }}</p>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ category.slug }}</td>
|
<td><span class="si-mono">{{ category.slug }}</span></td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ category.display_order }}</td>
|
<td class="si-col-num si-mono">{{ category.display_order }}</td>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<span
|
<Badge :tone="category.visible ? 'online' : 'neutral'">
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
|
||||||
:class="category.visible ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
|
||||||
>
|
|
||||||
{{ category.visible ? 'Visible' : 'Hidden' }}
|
{{ category.visible ? 'Visible' : 'Hidden' }}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="si-col-actions">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="si-row-actions">
|
||||||
<button
|
<IconButton icon="pencil" label="Edit" size="sm" @click="openCategoryModal(category)" />
|
||||||
@click="openCategoryModal(category)"
|
<IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteCategory(category)" />
|
||||||
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="deleteCategory(category)"
|
|
||||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Items Tab -->
|
<!-- Items table -->
|
||||||
<div v-if="tab === 'items'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<table v-if="tab === 'items'" class="si-table">
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Item</th>
|
<th>Item</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Category</th>
|
<th>Category</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Type</th>
|
<th>Type</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Price</th>
|
<th class="si-col-price">Price</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Commands</th>
|
<th>Commands</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
<th>Status</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
<th class="si-col-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-if="items.length === 0">
|
<tr v-if="items.length === 0">
|
||||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
<td colspan="7" class="si-empty-cell">
|
||||||
<template v-if="isLoading">Loading items...</template>
|
<EmptyState
|
||||||
<template v-else>No items yet. Add items to start selling.</template>
|
icon="shopping-bag"
|
||||||
|
:title="isLoading ? 'Loading…' : 'No items'"
|
||||||
|
:description="isLoading ? '' : 'Add items to start selling.'"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr v-for="item in items" :key="item.id">
|
||||||
v-for="item in items"
|
<td class="si-cell-primary">
|
||||||
:key="item.id"
|
<span class="si-name">{{ item.name }}</span>
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
<span v-if="item.description" class="si-sub">{{ item.description }}</span>
|
||||||
>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<p class="text-sm font-medium text-neutral-100">{{ item.name }}</p>
|
|
||||||
<p v-if="item.description" class="text-xs text-neutral-500 truncate max-w-xs">{{ item.description }}</p>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ getCategoryName(item.category_id) }}</td>
|
<td class="si-text-secondary">{{ getCategoryName(item.category_id) }}</td>
|
||||||
<td class="px-4 py-3">
|
<td><Badge :tone="typeTone(item.item_type)">{{ item.item_type }}</Badge></td>
|
||||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="typeBadgeClass(item.item_type)">
|
<td class="si-col-price">
|
||||||
{{ item.item_type }}
|
<span class="si-price">${{ safeFixed(item.price, 2) }}</span>
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td><span class="si-mono si-text-secondary">{{ item.delivery_commands.length }} cmd{{ item.delivery_commands.length !== 1 ? 's' : '' }}</span></td>
|
||||||
<div class="flex items-center gap-1 text-sm text-neutral-200">
|
<td>
|
||||||
<DollarSign class="w-3.5 h-3.5 text-neutral-500" />
|
<Badge :tone="item.enabled ? 'online' : 'neutral'">
|
||||||
{{ safeFixed(item.price, 2) }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
|
|
||||||
{{ item.delivery_commands.length }} cmd{{ item.delivery_commands.length !== 1 ? 's' : '' }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span
|
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
|
||||||
:class="item.enabled ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
|
||||||
>
|
|
||||||
{{ item.enabled ? 'Enabled' : 'Disabled' }}
|
{{ item.enabled ? 'Enabled' : 'Disabled' }}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="si-col-actions">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="si-row-actions">
|
||||||
<button
|
<IconButton icon="pencil" label="Edit" size="sm" @click="openItemModal(item)" />
|
||||||
@click="openItemModal(item)"
|
<IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteItem(item)" />
|
||||||
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="deleteItem(item)"
|
|
||||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Category Modal -->
|
<!-- ===== Category modal ===== -->
|
||||||
<div
|
<div
|
||||||
v-if="showCategoryModal"
|
v-if="showCategoryModal"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
class="si-modal-backdrop"
|
||||||
@click.self="closeCategoryModal"
|
@click.self="closeCategoryModal"
|
||||||
>
|
>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-lg w-full">
|
<div class="si-modal">
|
||||||
<div class="border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
|
<div class="si-modal__head">
|
||||||
<h2 class="text-xl font-bold text-neutral-100">{{ editingCategory ? 'Edit Category' : 'Add Category' }}</h2>
|
<h2 class="si-modal__title">{{ editingCategory ? 'Edit category' : 'Add category' }}</h2>
|
||||||
<button @click="closeCategoryModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
|
<IconButton icon="x" label="Close" @click="closeCategoryModal" />
|
||||||
<X class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-4">
|
<div class="si-modal__body">
|
||||||
<div>
|
<Input
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Name</label>
|
|
||||||
<input
|
|
||||||
v-model="categoryForm.name"
|
v-model="categoryForm.name"
|
||||||
@input="categoryForm.slug = autoGenerateSlug(categoryForm.name)"
|
label="Name"
|
||||||
type="text"
|
|
||||||
placeholder="VIP Kits"
|
placeholder="VIP Kits"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
@input="categoryForm.slug = autoGenerateSlug(categoryForm.name)"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Slug (URL-safe)</label>
|
|
||||||
<input
|
|
||||||
v-model="categoryForm.slug"
|
v-model="categoryForm.slug"
|
||||||
type="text"
|
label="Slug (URL-safe)"
|
||||||
placeholder="vip-kits"
|
placeholder="vip-kits"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
:mono="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
<label class="cc-field">
|
||||||
<div>
|
<span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="categoryForm.description"
|
v-model="categoryForm.description"
|
||||||
|
class="cc-textarea"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="Premium kits for VIP players"
|
placeholder="Premium kits for VIP players"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
<div>
|
<Input
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Display Order</label>
|
v-model="categoryForm.display_order as any"
|
||||||
<input
|
label="Display order"
|
||||||
v-model.number="categoryForm.display_order"
|
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
|
<Checkbox v-model="categoryForm.visible" label="Visible to customers" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="si-modal__foot">
|
||||||
<input
|
<Button variant="secondary" @click="closeCategoryModal">Cancel</Button>
|
||||||
v-model="categoryForm.visible"
|
<Button @click="saveCategory">{{ editingCategory ? 'Save changes' : 'Create category' }}</Button>
|
||||||
type="checkbox"
|
|
||||||
id="category-visible"
|
|
||||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50 transition-colors"
|
|
||||||
/>
|
|
||||||
<label for="category-visible" class="text-sm text-neutral-300">Visible to customers</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
|
|
||||||
<button
|
|
||||||
@click="closeCategoryModal"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="saveCategory"
|
|
||||||
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{{ editingCategory ? 'Save Changes' : 'Create Category' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Item Modal -->
|
<!-- ===== Item modal ===== -->
|
||||||
<div
|
<div
|
||||||
v-if="showItemModal"
|
v-if="showItemModal"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
class="si-modal-backdrop"
|
||||||
@click.self="closeItemModal"
|
@click.self="closeItemModal"
|
||||||
>
|
>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
<div class="si-modal si-modal--wide">
|
||||||
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
|
<div class="si-modal__head si-modal__head--sticky">
|
||||||
<h2 class="text-xl font-bold text-neutral-100">{{ editingItem ? 'Edit Item' : 'Add Item' }}</h2>
|
<h2 class="si-modal__title">{{ editingItem ? 'Edit item' : 'Add item' }}</h2>
|
||||||
<button @click="closeItemModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
|
<IconButton icon="x" label="Close" @click="closeItemModal" />
|
||||||
<X class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-6">
|
<div class="si-modal__body">
|
||||||
<!-- Basic Info -->
|
<!-- Basic info section -->
|
||||||
<div class="space-y-4">
|
<div class="si-section">
|
||||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3>
|
<div class="si-section__label">Basic information</div>
|
||||||
<div>
|
<Input v-model="itemForm.name" label="Item name" placeholder="VIP Starter Kit" />
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Item Name</label>
|
<label class="cc-field">
|
||||||
<input
|
<span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
|
||||||
v-model="itemForm.name"
|
|
||||||
type="text"
|
|
||||||
placeholder="VIP Starter Kit"
|
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="itemForm.description"
|
v-model="itemForm.description"
|
||||||
|
class="cc-textarea"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="Get started with essential gear and resources"
|
placeholder="Get started with essential gear and resources"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
/>
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
v-model="categorySelectValue"
|
||||||
|
label="Category"
|
||||||
|
:options="categorySelectOptions"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Category</label>
|
|
||||||
<select
|
|
||||||
v-model="itemForm.category_id"
|
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
>
|
|
||||||
<option :value="null">Uncategorized</option>
|
|
||||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pricing -->
|
<!-- Pricing section -->
|
||||||
<div class="space-y-4">
|
<div class="si-section">
|
||||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Pricing</h3>
|
<div class="si-section__label">Pricing</div>
|
||||||
<div>
|
<Input
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Price (USD)</label>
|
v-model="itemForm.price as any"
|
||||||
<div class="relative">
|
label="Price (USD)"
|
||||||
<DollarSign class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model.number="itemForm.price"
|
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
prefix="$"
|
||||||
min="0.01"
|
|
||||||
placeholder="9.99"
|
placeholder="9.99"
|
||||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Item Type -->
|
<!-- Item type section -->
|
||||||
<div class="space-y-4">
|
<div class="si-section">
|
||||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Item Type</h3>
|
<div class="si-section__label">Item type</div>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="si-type-grid">
|
||||||
<button
|
<button
|
||||||
v-for="type in itemTypes"
|
v-for="type in itemTypes"
|
||||||
:key="type.value"
|
:key="type.value"
|
||||||
|
type="button"
|
||||||
|
class="si-type-btn"
|
||||||
|
:class="itemForm.item_type === type.value ? 'si-type-btn--active' : ''"
|
||||||
@click="itemForm.item_type = type.value as any"
|
@click="itemForm.item_type = type.value as any"
|
||||||
class="px-3 py-2 text-sm font-medium rounded-lg border transition-colors"
|
|
||||||
:class="itemForm.item_type === type.value
|
|
||||||
? 'bg-oxide-500/15 border-oxide-500 text-oxide-400'
|
|
||||||
: 'bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
>
|
||||||
{{ type.label }}
|
{{ type.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delivery Commands -->
|
<!-- Delivery commands section -->
|
||||||
<div class="space-y-4">
|
<div class="si-section">
|
||||||
<div class="flex items-center justify-between">
|
<div class="si-section__label-row">
|
||||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Delivery Commands</h3>
|
<div class="si-section__label">Delivery commands</div>
|
||||||
<button
|
<Button size="sm" variant="ghost" icon="plus" @click="addCommand">Add command</Button>
|
||||||
@click="addCommand"
|
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Plus class="w-3 h-3" />
|
|
||||||
Add Command
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-3 space-y-1.5 text-xs">
|
<div class="si-cmd-hint">
|
||||||
<p class="text-neutral-400">Use placeholders:</p>
|
<p class="si-cmd-hint__row"><span class="si-cmd-hint__mono">{'{steam_id}'}</span> Player's Steam ID</p>
|
||||||
<p class="text-neutral-300 font-mono">{'{steam_id}'} - Player's Steam ID</p>
|
<p class="si-cmd-hint__row"><span class="si-cmd-hint__mono">{'{player_name}'}</span> Player's name</p>
|
||||||
<p class="text-neutral-300 font-mono">{'{player_name}'} - Player's name</p>
|
<p class="si-cmd-hint__example">Example: {{ selectedTypeExample }}</p>
|
||||||
<p class="text-neutral-500 mt-2">Example: {{ selectedTypeExample }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="si-commands">
|
||||||
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="flex gap-2">
|
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="si-command-row">
|
||||||
<input
|
<Input
|
||||||
v-model="itemForm.delivery_commands[index]"
|
v-model="itemForm.delivery_commands[index]"
|
||||||
type="text"
|
:mono="true"
|
||||||
placeholder="inventory.giveto {steam_id} rifle.ak 1"
|
placeholder="inventory.giveto {steam_id} rifle.ak 1"
|
||||||
class="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
<button
|
<IconButton
|
||||||
v-if="itemForm.delivery_commands.length > 1"
|
v-if="itemForm.delivery_commands.length > 1"
|
||||||
|
icon="trash-2"
|
||||||
|
label="Remove"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
@click="removeCommand(index)"
|
@click="removeCommand(index)"
|
||||||
class="p-2 text-neutral-500 hover:text-red-400 rounded-lg transition-colors"
|
/>
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Settings -->
|
<!-- Additional settings section -->
|
||||||
<div class="space-y-4">
|
<div class="si-section">
|
||||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Additional Settings</h3>
|
<div class="si-section__label">Additional settings</div>
|
||||||
<div>
|
<Input
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Image URL (optional)</label>
|
|
||||||
<input
|
|
||||||
v-model="itemForm.image_url"
|
v-model="itemForm.image_url"
|
||||||
type="text"
|
label="Image URL"
|
||||||
placeholder="https://example.com/image.png"
|
placeholder="https://example.com/image.png"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
hint="Optional preview image for the store page."
|
||||||
/>
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div>
|
v-model="itemForm.limit_per_player as any"
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Purchase Limit Per Player (optional)</label>
|
label="Purchase limit per player"
|
||||||
<input
|
|
||||||
v-model.number="itemForm.limit_per_player"
|
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
|
||||||
placeholder="Leave empty for unlimited"
|
placeholder="Leave empty for unlimited"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
hint="Optional. Restricts how many times a player can purchase this item."
|
||||||
/>
|
/>
|
||||||
</div>
|
<Checkbox v-model="itemForm.enabled" label="Enabled and available for purchase" />
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
v-model="itemForm.enabled"
|
|
||||||
type="checkbox"
|
|
||||||
id="item-enabled"
|
|
||||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50 transition-colors"
|
|
||||||
/>
|
|
||||||
<label for="item-enabled" class="text-sm text-neutral-300">Enabled and available for purchase</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="si-modal__foot">
|
||||||
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
|
<Button variant="secondary" @click="closeItemModal">Cancel</Button>
|
||||||
<button
|
<Button @click="saveItem">{{ editingItem ? 'Save changes' : 'Create item' }}</Button>
|
||||||
@click="closeItemModal"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="saveItem"
|
|
||||||
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{{ editingItem ? 'Save Changes' : 'Create Item' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.si-page { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
|
||||||
|
.page__head {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: 16px; flex-wrap: wrap; row-gap: 12px;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 5px;
|
||||||
|
}
|
||||||
|
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
|
||||||
|
.page__actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.si-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.si-table thead th {
|
||||||
|
padding: 10px 16px; text-align: left;
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.si-table tbody tr { border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.si-table tbody tr:last-child { border-bottom: none; }
|
||||||
|
.si-table tbody tr:hover { background: var(--surface-hover); }
|
||||||
|
.si-table td { padding: 11px 16px; vertical-align: middle; }
|
||||||
|
|
||||||
|
.si-cell-primary { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.si-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.si-sub { font-size: var(--text-xs); color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; }
|
||||||
|
.si-mono { font-family: var(--font-mono); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
||||||
|
.si-text-secondary { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
|
.si-price { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.si-col-num { width: 80px; text-align: right; }
|
||||||
|
.si-col-num.si-mono { text-align: right; }
|
||||||
|
.si-col-price { width: 90px; }
|
||||||
|
.si-col-actions { width: 80px; }
|
||||||
|
.si-row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
|
||||||
|
|
||||||
|
.si-empty-cell { padding: 0 !important; }
|
||||||
|
|
||||||
|
.si-spin { animation: si-spin 0.8s linear infinite; }
|
||||||
|
@keyframes si-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.si-modal-backdrop {
|
||||||
|
position: fixed; inset: 0; z-index: 60;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,.6); backdrop-filter: blur(4px); padding: 16px;
|
||||||
|
}
|
||||||
|
.si-modal {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xl, var(--ring-default)); max-width: 520px; width: 100%;
|
||||||
|
display: flex; flex-direction: column; max-height: 90vh;
|
||||||
|
}
|
||||||
|
.si-modal--wide { max-width: 680px; }
|
||||||
|
|
||||||
|
.si-modal__head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--border-subtle);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.si-modal__head--sticky { position: sticky; top: 0; background: var(--surface-base); z-index: 1; }
|
||||||
|
.si-modal__title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
|
||||||
|
|
||||||
|
.si-modal__body {
|
||||||
|
padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.si-modal__foot {
|
||||||
|
display: flex; align-items: center; justify-content: flex-end;
|
||||||
|
gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border-subtle);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item modal sections */
|
||||||
|
.si-section { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.si-section__label {
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
}
|
||||||
|
.si-section__label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
|
||||||
|
/* Item type selector */
|
||||||
|
.si-type-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.si-type-btn {
|
||||||
|
padding: 9px 14px; font-size: var(--text-sm); font-weight: 500; font-family: var(--font-sans);
|
||||||
|
border-radius: var(--radius-md); border: none; cursor: pointer;
|
||||||
|
background: var(--surface-inset); color: var(--text-secondary);
|
||||||
|
box-shadow: var(--ring-default); transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.si-type-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||||
|
.si-type-btn--active {
|
||||||
|
background: var(--accent-soft); color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Command hint */
|
||||||
|
.si-cmd-hint {
|
||||||
|
background: var(--surface-inset); border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default); padding: 10px 13px;
|
||||||
|
display: flex; flex-direction: column; gap: 3px;
|
||||||
|
}
|
||||||
|
.si-cmd-hint__row { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
|
.si-cmd-hint__mono { font-family: var(--font-mono); color: var(--text-primary); margin-right: 6px; }
|
||||||
|
.si-cmd-hint__example { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-top: 4px; }
|
||||||
|
|
||||||
|
.si-commands { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.si-command-row { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
.si-opt { font-weight: 400; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Textarea token style */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%; min-height: 68px; padding: 9px 11px;
|
||||||
|
background: var(--surface-inset); border: none; border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default); resize: vertical;
|
||||||
|
font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-primary);
|
||||||
|
line-height: var(--leading-normal); outline: none;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||||
|
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
import { DollarSign, TrendingUp, Clock, AlertCircle, Download, RefreshCw } from 'lucide-vue-next'
|
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import type { ECharts } from 'echarts'
|
import type { ECharts } from 'echarts'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import type { StoreTransaction } from '@/types'
|
import type { StoreTransaction } from '@/types'
|
||||||
import { safeCurrency, safeDate } from '@/utils/formatters'
|
import { safeCurrency, safeDate } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -45,15 +50,16 @@ const formatCurrency = (amount: number, currency: string = 'USD'): string => {
|
|||||||
return safeCurrency(amount, symbol)
|
return safeCurrency(amount, symbol)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status badge color classes
|
// Status badge tone map
|
||||||
const statusBadgeClass = (status: string): string => {
|
type BadgeTone = 'online' | 'warn' | 'info' | 'offline' | 'neutral'
|
||||||
|
const statusTone = (status: string): BadgeTone => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'delivered': return 'bg-green-500/10 text-green-400'
|
case 'delivered': return 'online'
|
||||||
case 'paid': return 'bg-yellow-500/10 text-yellow-400'
|
case 'paid': return 'warn'
|
||||||
case 'pending': return 'bg-blue-500/10 text-blue-400'
|
case 'pending': return 'info'
|
||||||
case 'failed': return 'bg-red-500/10 text-red-400'
|
case 'failed': return 'offline'
|
||||||
case 'refunded': return 'bg-neutral-500/10 text-neutral-400'
|
case 'refunded': return 'neutral'
|
||||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
default: return 'neutral'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +83,10 @@ const loadTransactions = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssVar(name: string): string {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
|
}
|
||||||
|
|
||||||
// Render revenue chart (last 30 days, grouped by day)
|
// Render revenue chart (last 30 days, grouped by day)
|
||||||
const renderRevenueChart = () => {
|
const renderRevenueChart = () => {
|
||||||
if (!revenueChart.value || transactions.value.length === 0) return
|
if (!revenueChart.value || transactions.value.length === 0) return
|
||||||
@@ -106,16 +116,24 @@ const renderRevenueChart = () => {
|
|||||||
d.setDate(d.getDate() - i)
|
d.setDate(d.getDate() - i)
|
||||||
const dateKey = d.toLocaleDateString('en-US')
|
const dateKey = d.toLocaleDateString('en-US')
|
||||||
dates.push(d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }))
|
dates.push(d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }))
|
||||||
revenueData.push(revenueByDate.get(dateKey) || 0)
|
revenueData.push(revenueByDate.get(dateKey) ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||||
|
const axisLine = cssVar('--border-default') || '#404040'
|
||||||
|
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||||
|
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||||
|
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||||
|
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||||
|
const mono = 'JetBrains Mono, monospace'
|
||||||
|
|
||||||
revenueChartInstance.setOption({
|
revenueChartInstance.setOption({
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: tooltipBg,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: tooltipBorder,
|
||||||
textStyle: { color: '#e5e5e5' },
|
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
|
||||||
formatter: (params: any) => {
|
formatter: (params: any) => {
|
||||||
const value = params[0]?.data
|
const value = params[0]?.data
|
||||||
return `${params[0]?.axisValue ?? 'Unknown'}<br/>Revenue: ${safeCurrency(value, '$')}`
|
return `${params[0]?.axisValue ?? 'Unknown'}<br/>Revenue: ${safeCurrency(value, '$')}`
|
||||||
@@ -131,15 +149,15 @@ const renderRevenueChart = () => {
|
|||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: dates,
|
data: dates,
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
axisLabel: { color: '#808080', rotate: 45 }
|
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: 'Revenue ($)',
|
name: 'Revenue ($)',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
splitLine: { lineStyle: { color: grid } },
|
||||||
axisLabel: { color: '#808080', formatter: (value: number) => `$${value}` }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10, formatter: (value: number) => `$${value}` }
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@@ -186,156 +204,136 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="revenue-view">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="revenue-view__header">
|
||||||
<div class="flex items-center gap-3">
|
<h1 class="revenue-view__title">Revenue dashboard</h1>
|
||||||
<DollarSign class="w-5 h-5 text-oxide-500" />
|
<div class="revenue-view__controls">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Revenue Dashboard</h1>
|
<Button
|
||||||
</div>
|
variant="secondary"
|
||||||
<div class="flex items-center gap-3">
|
size="sm"
|
||||||
<button
|
icon="refresh-cw"
|
||||||
|
:loading="loading"
|
||||||
@click="loadTransactions"
|
@click="loadTransactions"
|
||||||
:disabled="loading"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
|
||||||
>
|
>
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': loading }" />
|
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
@click="exportCSV"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="download"
|
||||||
:disabled="transactions.length === 0"
|
:disabled="transactions.length === 0"
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
@click="exportCSV"
|
||||||
>
|
>
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
<div v-if="loading" class="revenue-view__loading">
|
||||||
<div class="text-neutral-500">Loading transaction data...</div>
|
<span class="revenue-view__loading-text">Loading transaction data...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Summary Cards -->
|
<!-- Summary Cards -->
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="revenue-view__stats">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<StatCard
|
||||||
<div class="flex items-center gap-2 mb-2">
|
label="Total revenue"
|
||||||
<DollarSign class="w-4 h-4 text-neutral-500" />
|
:value="formatCurrency(totalRevenue)"
|
||||||
<p class="text-sm text-neutral-400">Total Revenue</p>
|
icon="dollar-sign"
|
||||||
</div>
|
note="Last 100 transactions"
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ formatCurrency(totalRevenue) }}</p>
|
/>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Last 100 transactions</p>
|
<StatCard
|
||||||
</div>
|
label="Total transactions"
|
||||||
|
:value="totalTransactions"
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
icon="trending-up"
|
||||||
<div class="flex items-center gap-2 mb-2">
|
note="All time"
|
||||||
<TrendingUp class="w-4 h-4 text-neutral-500" />
|
/>
|
||||||
<p class="text-sm text-neutral-400">Total Transactions</p>
|
<StatCard
|
||||||
</div>
|
label="Pending deliveries"
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ totalTransactions }}</p>
|
:value="pendingDeliveries"
|
||||||
<p class="text-xs text-neutral-600 mt-1">All time</p>
|
icon="clock"
|
||||||
</div>
|
note="Paid, not delivered"
|
||||||
|
/>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<StatCard
|
||||||
<div class="flex items-center gap-2 mb-2">
|
label="Refunds"
|
||||||
<Clock class="w-4 h-4 text-neutral-500" />
|
:value="refunds"
|
||||||
<p class="text-sm text-neutral-400">Pending Deliveries</p>
|
icon="alert-circle"
|
||||||
</div>
|
note="Total refunded"
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ pendingDeliveries }}</p>
|
/>
|
||||||
<p class="text-xs text-neutral-600 mt-1">Paid, not delivered</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<AlertCircle class="w-4 h-4 text-neutral-500" />
|
|
||||||
<p class="text-sm text-neutral-400">Refunds</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ refunds }}</p>
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">Total refunded</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Revenue Chart -->
|
<!-- Revenue Chart -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Revenue over time (last 30 days)">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Revenue Over Time (Last 30 Days)</h2>
|
<div ref="revenueChart" class="revenue-view__chart-area"></div>
|
||||||
<div ref="revenueChart" class="h-64"></div>
|
</Panel>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Transaction Table -->
|
<!-- Transaction Table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel title="Transaction history" :flush-body="true">
|
||||||
<div class="px-4 py-3 border-b border-neutral-800 flex items-center justify-between">
|
<template #actions>
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Transaction History</h2>
|
<Select
|
||||||
<div class="flex items-center gap-2">
|
:options="[
|
||||||
<label class="text-xs text-neutral-500">Filter:</label>
|
{ value: 'all', label: 'All statuses' },
|
||||||
<select
|
{ value: 'delivered', label: 'Delivered' },
|
||||||
|
{ value: 'paid', label: 'Paid' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'failed', label: 'Failed' },
|
||||||
|
{ value: 'refunded', label: 'Refunded' }
|
||||||
|
]"
|
||||||
v-model="statusFilter"
|
v-model="statusFilter"
|
||||||
class="px-2 py-1 text-xs bg-neutral-800 border border-neutral-700 rounded text-neutral-300 focus:outline-none focus:border-oxide-500"
|
size="sm"
|
||||||
>
|
/>
|
||||||
<option value="all">All</option>
|
</template>
|
||||||
<option value="delivered">Delivered</option>
|
|
||||||
<option value="paid">Paid</option>
|
|
||||||
<option value="pending">Pending</option>
|
|
||||||
<option value="failed">Failed</option>
|
|
||||||
<option value="refunded">Refunded</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table class="w-full">
|
<div class="revenue-view__table-wrap">
|
||||||
|
<table class="revenue-view__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr class="revenue-view__thead-row">
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Date</th>
|
<th class="revenue-view__th revenue-view__th--left">Date</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Player</th>
|
<th class="revenue-view__th revenue-view__th--left">Player</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Item</th>
|
<th class="revenue-view__th revenue-view__th--left">Item</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Amount</th>
|
<th class="revenue-view__th revenue-view__th--right">Amount</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
<th class="revenue-view__th revenue-view__th--left">Status</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Delivered</th>
|
<th class="revenue-view__th revenue-view__th--left">Delivered</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-if="filteredTransactions.length === 0">
|
<tr v-if="filteredTransactions.length === 0">
|
||||||
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
<td colspan="6" class="revenue-view__td-empty">
|
||||||
<template v-if="statusFilter !== 'all'">No {{ statusFilter }} transactions found.</template>
|
<EmptyState
|
||||||
<template v-else>No transactions yet. Sales will appear here.</template>
|
icon="dollar-sign"
|
||||||
|
:title="statusFilter !== 'all' ? `No ${statusFilter} transactions` : 'No transactions yet'"
|
||||||
|
:description="statusFilter !== 'all' ? '' : 'Sales will appear here once customers make purchases.'"
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr
|
||||||
v-for="txn in filteredTransactions"
|
v-for="txn in filteredTransactions"
|
||||||
:key="txn.id"
|
:key="txn.id"
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
class="revenue-view__row"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3">
|
<td class="revenue-view__td">
|
||||||
<p class="text-sm text-neutral-300">{{ formatDate(txn.created_at) }}</p>
|
<p class="revenue-view__cell-text">{{ formatDate(txn.created_at) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="revenue-view__td">
|
||||||
<p class="text-sm font-medium text-neutral-100">{{ txn.player_name || 'Unknown' }}</p>
|
<p class="revenue-view__cell-primary">{{ txn.player_name || 'Unknown' }}</p>
|
||||||
<p class="text-xs text-neutral-500 font-mono">{{ txn.steam_id }}</p>
|
<p class="revenue-view__cell-mono">{{ txn.steam_id }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="revenue-view__td">
|
||||||
<p class="text-sm text-neutral-300">{{ txn.item_id || '—' }}</p>
|
<p class="revenue-view__cell-text">{{ txn.item_id || '—' }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="revenue-view__td revenue-view__td--right">
|
||||||
<p class="text-sm font-medium text-neutral-200">{{ formatCurrency(txn.amount, txn.currency) }}</p>
|
<p class="revenue-view__cell-primary revenue-view__cell-mono">{{ formatCurrency(txn.amount, txn.currency) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="revenue-view__td">
|
||||||
<span
|
<Badge :tone="statusTone(txn.status)" uppercase>{{ txn.status }}</Badge>
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full uppercase"
|
|
||||||
:class="statusBadgeClass(txn.status)"
|
|
||||||
>
|
|
||||||
{{ txn.status }}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="revenue-view__td">
|
||||||
<span
|
<Badge :tone="txn.delivered ? 'online' : 'neutral'">
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
|
||||||
:class="txn.delivered ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
|
||||||
>
|
|
||||||
{{ txn.delivered ? 'Yes' : 'No' }}
|
{{ txn.delivered ? 'Yes' : 'No' }}
|
||||||
</span>
|
</Badge>
|
||||||
<p v-if="txn.delivered_at" class="text-xs text-neutral-600 mt-1">
|
<p v-if="txn.delivered_at" class="revenue-view__cell-sub">
|
||||||
{{ formatDate(txn.delivered_at) }}
|
{{ formatDate(txn.delivered_at) }}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
@@ -343,6 +341,140 @@ onMounted(() => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.revenue-view {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__loading-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.revenue-view__stats {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__chart-area {
|
||||||
|
height: 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__thead-row {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__th {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__th--left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__th--right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__row {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: background var(--dur-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__row:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__td--right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__td-empty {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__cell-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__cell-primary {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__cell-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-view__cell-sub {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { TeamMember, Role } from '@/types'
|
import type { TeamMember, Role } from '@/types'
|
||||||
import { UserPlus, Shield, Mail, Trash2, RefreshCw } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
@@ -16,12 +21,11 @@ const inviteEmail = ref('')
|
|||||||
const inviteRole = ref('')
|
const inviteRole = ref('')
|
||||||
const inviting = ref(false)
|
const inviting = ref(false)
|
||||||
|
|
||||||
function roleBadgeClass(roleName: string): string {
|
function roleTone(roleName: string): 'accent' | 'info' | 'neutral' {
|
||||||
switch (roleName.toLowerCase()) {
|
switch (roleName.toLowerCase()) {
|
||||||
case 'owner': return 'bg-oxide-500/15 text-oxide-400'
|
case 'owner': return 'accent'
|
||||||
case 'admin': return 'bg-purple-500/15 text-purple-400'
|
case 'admin': return 'info'
|
||||||
case 'moderator': return 'bg-blue-500/15 text-blue-400'
|
default: return 'neutral'
|
||||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,135 +76,173 @@ async function removeMember(member: TeamMember) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchTeam()
|
fetchTeam()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const roleOptions = computed(() =>
|
||||||
|
roles.value.map((r) => ({ value: r.id, label: r.role_name }))
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="team">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="page__head">
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<UserPlus class="w-5 h-5 text-oxide-500" />
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Team Management</h1>
|
<div class="t-eyebrow">Management</div>
|
||||||
<p class="text-sm text-neutral-500 mt-0.5">{{ members.length }} members</p>
|
<h1 class="page__title">Team</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="page__actions">
|
||||||
<div class="flex items-center gap-3">
|
<Button
|
||||||
<button
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="refresh-cw"
|
||||||
|
:loading="isLoading"
|
||||||
@click="fetchTeam"
|
@click="fetchTeam"
|
||||||
:disabled="isLoading"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
Refresh
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="mail"
|
||||||
@click="showInvite = !showInvite"
|
@click="showInvite = !showInvite"
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
<Mail class="w-4 h-4" />
|
Invite member
|
||||||
Invite Member
|
</Button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invite form -->
|
<!-- Invite panel -->
|
||||||
<div v-if="showInvite" class="bg-neutral-900 border border-oxide-500/20 rounded-lg p-5">
|
<Panel v-if="showInvite" title="Invite a team member" eyebrow="New invite">
|
||||||
<h3 class="text-sm font-medium text-neutral-200 mb-3">Invite a Team Member</h3>
|
<form @submit.prevent="sendInvite" class="invite-form">
|
||||||
<form @submit.prevent="sendInvite" class="flex items-end gap-3">
|
<Input
|
||||||
<div class="flex-1">
|
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Email Address</label>
|
|
||||||
<input
|
|
||||||
v-model="inviteEmail"
|
v-model="inviteEmail"
|
||||||
|
label="Email address"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
|
||||||
placeholder="team@example.com"
|
placeholder="team@example.com"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
:required="true"
|
||||||
|
style="flex: 1"
|
||||||
|
/>
|
||||||
|
<div class="invite-role">
|
||||||
|
<span class="cc-field__label">Role</span>
|
||||||
|
<Select
|
||||||
|
v-model="inviteRole"
|
||||||
|
:options="[{ value: '', label: 'Select role...' }, ...roleOptions]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-48">
|
<div class="invite-action">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Role</label>
|
<!-- spacer to align with inputs -->
|
||||||
<select
|
<span class="cc-field__label"> </span>
|
||||||
v-model="inviteRole"
|
<Button type="submit" size="md" :loading="inviting">
|
||||||
required
|
{{ inviting ? 'Sending...' : 'Send invite' }}
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
</Button>
|
||||||
>
|
|
||||||
<option value="" disabled>Select role...</option>
|
|
||||||
<option v-for="role in roles" :key="role.id" :value="role.id">
|
|
||||||
{{ role.role_name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="inviting"
|
|
||||||
class="px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{{ inviting ? 'Sending...' : 'Send Invite' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Team table -->
|
<!-- Team table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel :flush-body="true" title="Members" :subtitle="members.length + ' total'">
|
||||||
<table class="w-full">
|
<!-- Empty state -->
|
||||||
|
<EmptyState
|
||||||
|
v-if="!isLoading && members.length === 0"
|
||||||
|
icon="users"
|
||||||
|
title="No team members"
|
||||||
|
description="Invite someone to get started."
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button size="sm" icon="mail" @click="showInvite = true">Invite member</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<div v-else-if="isLoading" class="table-loading">
|
||||||
|
<div v-for="i in 3" :key="i" class="skeleton-row" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<table v-else class="cc-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Member</th>
|
<th>Member</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Email</th>
|
<th>Email</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Role</th>
|
<th>Role</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
<th>Status</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
<th class="col-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-if="members.length === 0">
|
<tr v-for="member in members" :key="member.id">
|
||||||
<td colspan="5" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
<td>
|
||||||
<template v-if="isLoading">Loading team...</template>
|
<div class="member-id">
|
||||||
<template v-else>No team members yet. Invite someone to get started.</template>
|
<span class="member-avatar">{{ (member.username ?? '?')[0]?.toUpperCase() ?? '?' }}</span>
|
||||||
</td>
|
<span class="member-name">{{ member.username }}</span>
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-for="member in members"
|
|
||||||
:key="member.id"
|
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
|
||||||
>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Shield class="w-4 h-4 text-neutral-600" />
|
|
||||||
<span class="text-sm font-medium text-neutral-100">{{ member.username }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ member.email }}</td>
|
<td class="cell-mono">{{ member.email }}</td>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<span
|
<Badge :tone="roleTone(member.role_name)">{{ member.role_name }}</Badge>
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
|
||||||
:class="roleBadgeClass(member.role_name)"
|
|
||||||
>
|
|
||||||
{{ member.role_name }}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<span
|
<Badge :tone="member.accepted_at ? 'online' : 'warn'" :dot="true">
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
|
||||||
:class="member.accepted_at
|
|
||||||
? 'bg-green-500/10 text-green-400'
|
|
||||||
: 'bg-yellow-500/10 text-yellow-400'"
|
|
||||||
>
|
|
||||||
{{ member.accepted_at ? 'Active' : 'Pending' }}
|
{{ member.accepted_at ? 'Active' : 'Pending' }}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="col-actions">
|
||||||
<button
|
<Button
|
||||||
@click="removeMember(member)"
|
variant="ghost"
|
||||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
size="sm"
|
||||||
|
icon="trash-2"
|
||||||
title="Remove member"
|
title="Remove member"
|
||||||
>
|
@click="removeMember(member)"
|
||||||
<Trash2 class="w-4 h-4" />
|
/>
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.team { max-width: 1100px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
|
||||||
|
.page__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; row-gap: 10px; }
|
||||||
|
.page__title { font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em; color: var(--text-primary); margin-top: 5px; }
|
||||||
|
.page__actions { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Invite form */
|
||||||
|
.invite-form { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.invite-role { display: flex; flex-direction: column; gap: 6px; min-width: 180px; }
|
||||||
|
.invite-action { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.cc-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.cc-table th {
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.cc-table td {
|
||||||
|
padding: 11px 16px;
|
||||||
|
font-size: var(--text-sm); color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.cc-table tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
.cc-table tbody tr:hover td { background: var(--surface-hover); }
|
||||||
|
.col-actions { text-align: right; width: 56px; }
|
||||||
|
|
||||||
|
.member-id { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.member-avatar {
|
||||||
|
width: 28px; height: 28px; border-radius: var(--radius-sm);
|
||||||
|
display: flex; align-items: center; justify-content: center; flex: none;
|
||||||
|
background: var(--accent-soft); color: var(--accent-text);
|
||||||
|
font-size: var(--text-xs); font-weight: 700;
|
||||||
|
}
|
||||||
|
.member-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.cell-mono { font-family: var(--font-mono); font-size: var(--text-xs) !important; color: var(--text-tertiary) !important; }
|
||||||
|
|
||||||
|
/* Loading skeleton */
|
||||||
|
.table-loading { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.skeleton-row { height: 40px; border-radius: var(--radius-md); background: var(--surface-raised-2); animation: cc-skeleton 1.4s ease-in-out infinite; }
|
||||||
|
@keyframes cc-skeleton { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useTimedExecuteStore } from '@/stores/timedexecute'
|
import { useTimedExecuteStore } from '@/stores/timedexecute'
|
||||||
import {
|
|
||||||
Save,
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
Play,
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
Download,
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
Plus,
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
Trash2,
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
Clock,
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
Settings as SettingsIcon,
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
UserPlus,
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
UserMinus,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const store = useTimedExecuteStore()
|
const store = useTimedExecuteStore()
|
||||||
|
|
||||||
const activeTab = ref<'timed' | 'realtime' | 'connect' | 'disconnect'>('timed')
|
const activeTab = ref<string>('timed')
|
||||||
const showCreateModal = ref(false)
|
const showCreateModal = ref(false)
|
||||||
const showImportModal = ref(false)
|
const showImportModal = ref(false)
|
||||||
const newConfigName = ref('')
|
const newConfigName = ref('')
|
||||||
const newConfigDesc = ref('')
|
const newConfigDesc = ref('')
|
||||||
const importConfigName = ref('')
|
const importConfigName = ref('')
|
||||||
|
|
||||||
const tabs = [
|
const tabItems = [
|
||||||
{ key: 'timed', label: 'Timed Commands', icon: Clock },
|
{ value: 'timed', label: 'Timed commands', icon: 'clock' },
|
||||||
{ key: 'realtime', label: 'Real-Time', icon: SettingsIcon },
|
{ value: 'realtime', label: 'Real-time', icon: 'settings' },
|
||||||
{ key: 'connect', label: 'On Connect', icon: UserPlus },
|
{ value: 'connect', label: 'On connect', icon: 'user' },
|
||||||
{ key: 'disconnect', label: 'On Disconnect', icon: UserMinus },
|
{ value: 'disconnect', label: 'On disconnect', icon: 'user' },
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -228,428 +226,438 @@ async function handleImport() {
|
|||||||
importConfigName.value = ''
|
importConfigName.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DS Select model
|
||||||
|
const selectedConfigId = computed<string>({
|
||||||
|
get: () => store.currentConfig?.id ?? '',
|
||||||
|
set: (v: string | undefined) => { handleConfigChange(v ?? '') },
|
||||||
|
})
|
||||||
|
const configSelectOptions = computed(() =>
|
||||||
|
store.configs.map(c => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.config_name + (c.is_active ? ' (active)' : ''),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Switch wrappers
|
||||||
|
const enableTimerRepeat = computed<boolean>({
|
||||||
|
get: () => getConfigValue('EnableTimerRepeat', true) as boolean,
|
||||||
|
set: (v: boolean) => setConfigValue('EnableTimerRepeat', v),
|
||||||
|
})
|
||||||
|
const enableRealTimeTimer = computed<boolean>({
|
||||||
|
get: () => getConfigValue('EnableRealTime-Timer', false) as boolean,
|
||||||
|
set: (v: boolean) => setConfigValue('EnableRealTime-Timer', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Modal bindings
|
||||||
|
const newConfigNameModel = computed<string>({
|
||||||
|
get: () => newConfigName.value,
|
||||||
|
set: (v: string | undefined) => { newConfigName.value = v ?? '' },
|
||||||
|
})
|
||||||
|
const importConfigNameModel = computed<string>({
|
||||||
|
get: () => importConfigName.value,
|
||||||
|
set: (v: string | undefined) => { importConfigName.value = v ?? '' },
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="te">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="te__head">
|
||||||
<h1 class="text-2xl font-bold text-white">Timed Execute</h1>
|
<div class="te__head-id">
|
||||||
<div class="flex items-center gap-3">
|
<div class="te__head-chip">
|
||||||
<button
|
<Icon name="clock" :size="20" :stroke-width="2" />
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
New Config
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Config Selector + Action Bar -->
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
|
||||||
<!-- Config Selector -->
|
|
||||||
<select
|
|
||||||
v-if="store.configs.length > 0"
|
|
||||||
:value="store.currentConfig?.id || ''"
|
|
||||||
@change="handleConfigChange(($event.target as HTMLSelectElement).value)"
|
|
||||||
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
|
||||||
>
|
|
||||||
<option v-for="c in store.configs" :key="c.id" :value="c.id">
|
|
||||||
{{ c.config_name }}
|
|
||||||
<template v-if="c.is_active"> (Active)</template>
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<span v-else class="text-neutral-500 text-sm">No configs yet</span>
|
|
||||||
|
|
||||||
<!-- Save -->
|
|
||||||
<button
|
|
||||||
@click="store.saveCurrentConfig()"
|
|
||||||
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
||||||
>
|
|
||||||
<Save class="w-4 h-4" />
|
|
||||||
{{ store.isSaving ? 'Saving...' : 'Save' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Apply to Server -->
|
|
||||||
<button
|
|
||||||
@click="handleApply"
|
|
||||||
:disabled="!store.currentConfig || store.isApplying"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
||||||
>
|
|
||||||
<Play class="w-4 h-4" />
|
|
||||||
{{ store.isApplying ? 'Applying...' : 'Apply to Server' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Import from Server -->
|
|
||||||
<button
|
|
||||||
@click="showImportModal = true"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Import from Server
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Delete -->
|
|
||||||
<button
|
|
||||||
@click="handleDeleteConfig"
|
|
||||||
:disabled="!store.currentConfig"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="store.isLoading" class="flex items-center justify-center py-20">
|
|
||||||
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No Config Selected -->
|
|
||||||
<div v-else-if="!store.currentConfig" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
|
||||||
<Clock class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
|
||||||
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No TimedExecute Config Selected</h2>
|
|
||||||
<p class="text-neutral-500 mb-4">Create a new config, import from server, or select one from the dropdown above.</p>
|
|
||||||
<button
|
|
||||||
@click="showCreateModal = true"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
|
||||||
>
|
|
||||||
Create First Config
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Config Editor -->
|
|
||||||
<div v-else class="space-y-6">
|
|
||||||
<!-- Tab Bar -->
|
|
||||||
<div class="flex border-b border-neutral-800">
|
|
||||||
<button
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.key"
|
|
||||||
@click="activeTab = tab.key as typeof activeTab"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
|
||||||
:class="activeTab === tab.key
|
|
||||||
? 'border-oxide-500 text-oxide-400'
|
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
|
||||||
>
|
|
||||||
<component :is="tab.icon" class="w-4 h-4" />
|
|
||||||
{{ tab.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Timed Commands Tab -->
|
|
||||||
<div v-if="activeTab === 'timed'" class="space-y-4">
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Timer Repeat</h3>
|
<div class="t-eyebrow">Plugin configuration</div>
|
||||||
<p class="text-xs text-neutral-500 mt-1">Commands executed repeatedly at set intervals (seconds)</p>
|
<h1 class="te__title">Timed Execute</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
</div>
|
||||||
<!-- Enable toggle -->
|
<div class="te__head-actions">
|
||||||
<span class="text-xs text-neutral-400 mr-2">Enabled</span>
|
<Button size="sm" icon="plus" @click="showCreateModal = true">New config</Button>
|
||||||
<button
|
</div>
|
||||||
@click="setConfigValue('EnableTimerRepeat', !getConfigValue('EnableTimerRepeat', true))"
|
</div>
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
|
||||||
:class="getConfigValue('EnableTimerRepeat', true) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
<!-- Toolbar panel -->
|
||||||
>
|
<Panel>
|
||||||
<span
|
<div class="te__toolbar">
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
<Select
|
||||||
:class="getConfigValue('EnableTimerRepeat', true) ? 'translate-x-5' : 'translate-x-0'"
|
v-if="store.configs.length > 0"
|
||||||
|
v-model="selectedConfigId"
|
||||||
|
:options="configSelectOptions"
|
||||||
|
size="sm"
|
||||||
|
style="min-width: 200px"
|
||||||
/>
|
/>
|
||||||
</button>
|
<span v-else class="te__no-configs">No configs yet</span>
|
||||||
|
|
||||||
|
<div class="te__toolbar-actions">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="save"
|
||||||
|
:loading="store.isSaving"
|
||||||
|
:disabled="!store.currentConfig || !store.isDirty || store.isSaving"
|
||||||
|
@click="store.saveCurrentConfig()"
|
||||||
|
>{{ store.isSaving ? 'Saving…' : 'Save' }}</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
icon="play"
|
||||||
|
:loading="store.isApplying"
|
||||||
|
:disabled="!store.currentConfig || store.isApplying"
|
||||||
|
@click="handleApply"
|
||||||
|
>{{ store.isApplying ? 'Applying…' : 'Apply to server' }}</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon="download"
|
||||||
|
@click="showImportModal = true"
|
||||||
|
>Import from server</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger-soft"
|
||||||
|
icon="trash-2"
|
||||||
|
:disabled="!store.currentConfig"
|
||||||
|
@click="handleDeleteConfig"
|
||||||
|
>Delete</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.isLoading" class="te__loading">
|
||||||
|
<span class="te__spinner" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Preset Buttons -->
|
<!-- Empty — no config -->
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<Panel v-else-if="!store.currentConfig">
|
||||||
<span class="text-xs text-neutral-500">Quick add:</span>
|
<EmptyState
|
||||||
<button
|
icon="clock"
|
||||||
@click="addPresetTimer('server.save', 300)"
|
title="No TimedExecute config selected"
|
||||||
class="px-2 py-1 text-xs bg-neutral-800 text-neutral-300 rounded hover:bg-neutral-700"
|
description="Create a new config, import from server, or select one from the dropdown above."
|
||||||
>
|
>
|
||||||
server.save (5m)
|
<template #action>
|
||||||
</button>
|
<Button icon="plus" @click="showCreateModal = true">Create first config</Button>
|
||||||
<button
|
</template>
|
||||||
@click="addPresetTimer('say Server restart warning!', 3600)"
|
</EmptyState>
|
||||||
class="px-2 py-1 text-xs bg-neutral-800 text-neutral-300 rounded hover:bg-neutral-700"
|
</Panel>
|
||||||
>
|
|
||||||
Restart warning (1h)
|
<!-- Config editor -->
|
||||||
</button>
|
<template v-else>
|
||||||
<button
|
<!-- Tab bar -->
|
||||||
@click="addPresetTimer('oxide.reload *', 7200)"
|
<Tabs v-model="activeTab" :items="tabItems" variant="line" />
|
||||||
class="px-2 py-1 text-xs bg-neutral-800 text-neutral-300 rounded hover:bg-neutral-700"
|
|
||||||
>
|
<!-- Timed commands tab -->
|
||||||
Reload plugins (2h)
|
<Panel v-if="activeTab === 'timed'" title="Timer repeat" subtitle="Commands executed repeatedly at set intervals (seconds)">
|
||||||
</button>
|
<template #actions>
|
||||||
|
<Switch v-model="enableTimerRepeat" label="Enabled" size="sm" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Preset quick-add -->
|
||||||
|
<div class="te__presets">
|
||||||
|
<span class="te__presets-label">Quick add:</span>
|
||||||
|
<button class="te__preset" @click="addPresetTimer('server.save', 300)">server.save (5 min)</button>
|
||||||
|
<button class="te__preset" @click="addPresetTimer('say Server restart warning!', 3600)">Restart warning (1 h)</button>
|
||||||
|
<button class="te__preset" @click="addPresetTimer('oxide.reload *', 7200)">Reload plugins (2 h)</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Entries -->
|
<EmptyState
|
||||||
<div v-if="timerRepeatEntries.length === 0" class="py-4 text-center text-neutral-500 text-sm">
|
v-if="timerRepeatEntries.length === 0"
|
||||||
No timed commands configured. Add a command or use a preset above.
|
icon="clock"
|
||||||
</div>
|
description="No timed commands configured. Add a command or use a preset above."
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="te__entries">
|
||||||
<div
|
<div
|
||||||
v-for="(entry, index) in timerRepeatEntries"
|
v-for="(entry, index) in timerRepeatEntries"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
|
class="te__entry"
|
||||||
>
|
>
|
||||||
|
<div class="te__entry-cmd">
|
||||||
<input
|
<input
|
||||||
:value="entry.command"
|
:value="entry.command"
|
||||||
@change="updateTimerRepeatCommand(entry.command, ($event.target as HTMLInputElement).value)"
|
@change="updateTimerRepeatCommand(entry.command, ($event.target as HTMLInputElement).value)"
|
||||||
placeholder="console command"
|
placeholder="console command"
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
|
class="cc-input-raw te__input-grow"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-1">
|
</div>
|
||||||
|
<div class="te__entry-interval">
|
||||||
<input
|
<input
|
||||||
:value="entry.interval"
|
:value="entry.interval"
|
||||||
@input="updateTimerRepeatInterval(entry.command, Number(($event.target as HTMLInputElement).value))"
|
@input="updateTimerRepeatInterval(entry.command, Number(($event.target as HTMLInputElement).value))"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
class="w-24 bg-neutral-800 border border-neutral-700 rounded-lg px-2 py-1.5 text-neutral-200 text-sm text-right"
|
class="cc-input-raw cc-input-raw--mono te__input-interval"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-neutral-500">sec</span>
|
<span class="te__unit">sec</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button variant="danger-soft" size="sm" icon="trash-2" @click="removeTimerRepeat(entry.command)" />
|
||||||
@click="removeTimerRepeat(entry.command)"
|
|
||||||
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="te__add-row">
|
||||||
@click="addTimerRepeat"
|
<Button variant="secondary" size="sm" icon="plus" @click="addTimerRepeat">Add command</Button>
|
||||||
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
Add Command
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Real-Time Tab -->
|
<!-- Real-time tab -->
|
||||||
<div v-else-if="activeTab === 'realtime'" class="space-y-4">
|
<Panel v-else-if="activeTab === 'realtime'" title="Real-time timer" subtitle="Commands executed at specific times of day (HH:MM:SS)">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<template #actions>
|
||||||
<div class="flex items-center justify-between">
|
<Switch v-model="enableRealTimeTimer" label="Enabled" size="sm" />
|
||||||
<div>
|
</template>
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">Real-Time Timer</h3>
|
|
||||||
<p class="text-xs text-neutral-500 mt-1">Commands executed at specific times of day (HH:MM:SS)</p>
|
<EmptyState
|
||||||
</div>
|
v-if="realTimeEntries.length === 0"
|
||||||
<div class="flex items-center gap-2">
|
icon="clock"
|
||||||
<span class="text-xs text-neutral-400 mr-2">Enabled</span>
|
description="No real-time commands configured. Add a time-based command below."
|
||||||
<button
|
|
||||||
@click="setConfigValue('EnableRealTime-Timer', !getConfigValue('EnableRealTime-Timer', false))"
|
|
||||||
class="relative w-11 h-6 rounded-full transition-colors"
|
|
||||||
:class="getConfigValue('EnableRealTime-Timer', false) ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform"
|
|
||||||
:class="getConfigValue('EnableRealTime-Timer', false) ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="realTimeEntries.length === 0" class="py-4 text-center text-neutral-500 text-sm">
|
<div v-else class="te__entries">
|
||||||
No real-time commands configured. Add a time-based command below.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-2">
|
|
||||||
<div
|
<div
|
||||||
v-for="(entry, index) in realTimeEntries"
|
v-for="(entry, index) in realTimeEntries"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
|
class="te__entry"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
:value="entry.time"
|
:value="entry.time"
|
||||||
@change="updateRealTimeTime(entry.time, ($event.target as HTMLInputElement).value)"
|
@change="updateRealTimeTime(entry.time, ($event.target as HTMLInputElement).value)"
|
||||||
placeholder="HH:MM:SS"
|
placeholder="HH:MM:SS"
|
||||||
class="w-32 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
|
class="cc-input-raw cc-input-raw--mono te__input-time"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
:value="entry.command"
|
:value="entry.command"
|
||||||
@change="updateRealTimeCommand(entry.time, ($event.target as HTMLInputElement).value)"
|
@change="updateRealTimeCommand(entry.time, ($event.target as HTMLInputElement).value)"
|
||||||
placeholder="console command"
|
placeholder="console command"
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
|
class="cc-input-raw te__input-grow"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button variant="danger-soft" size="sm" icon="trash-2" @click="removeRealTimeEntry(entry.time)" />
|
||||||
@click="removeRealTimeEntry(entry.time)"
|
|
||||||
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="te__add-row">
|
||||||
@click="addRealTimeEntry"
|
<Button variant="secondary" size="sm" icon="plus" @click="addRealTimeEntry">Add time entry</Button>
|
||||||
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
Add Time Entry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- On Connect Tab -->
|
<!-- On connect tab -->
|
||||||
<div v-else-if="activeTab === 'connect'" class="space-y-4">
|
<Panel v-else-if="activeTab === 'connect'" title="On player connect" subtitle="Commands executed when a player joins the server">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<EmptyState
|
||||||
<div>
|
v-if="connectCommands.length === 0"
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">On Player Connect</h3>
|
icon="user"
|
||||||
<p class="text-xs text-neutral-500 mt-1">Commands executed when a player joins the server</p>
|
description="No connect commands configured. Add a command below."
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div v-if="connectCommands.length === 0" class="py-4 text-center text-neutral-500 text-sm">
|
<div v-else class="te__entries">
|
||||||
No connect commands configured. Add a command below.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-2">
|
|
||||||
<div
|
<div
|
||||||
v-for="(cmd, index) in connectCommands"
|
v-for="(cmd, index) in connectCommands"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
|
class="te__entry"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
:value="cmd"
|
:value="cmd"
|
||||||
@change="updateConnectCommand(index, ($event.target as HTMLInputElement).value)"
|
@change="updateConnectCommand(index, ($event.target as HTMLInputElement).value)"
|
||||||
placeholder='e.g. say Welcome {player.name}!'
|
placeholder="e.g. say Welcome {player.name}!"
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
|
class="cc-input-raw te__input-grow"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button variant="danger-soft" size="sm" icon="trash-2" @click="removeConnectCommand(index)" />
|
||||||
@click="removeConnectCommand(index)"
|
|
||||||
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="te__add-row">
|
||||||
@click="addConnectCommand"
|
<Button variant="secondary" size="sm" icon="plus" @click="addConnectCommand">Add command</Button>
|
||||||
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
Add Command
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- On Disconnect Tab -->
|
<!-- On disconnect tab -->
|
||||||
<div v-else-if="activeTab === 'disconnect'" class="space-y-4">
|
<Panel v-else-if="activeTab === 'disconnect'" title="On player disconnect" subtitle="Commands executed when a player leaves the server">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
<EmptyState
|
||||||
<div>
|
v-if="disconnectCommands.length === 0"
|
||||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">On Player Disconnect</h3>
|
icon="user"
|
||||||
<p class="text-xs text-neutral-500 mt-1">Commands executed when a player leaves the server</p>
|
description="No disconnect commands configured. Add a command below."
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div v-if="disconnectCommands.length === 0" class="py-4 text-center text-neutral-500 text-sm">
|
<div v-else class="te__entries">
|
||||||
No disconnect commands configured. Add a command below.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-2">
|
|
||||||
<div
|
<div
|
||||||
v-for="(cmd, index) in disconnectCommands"
|
v-for="(cmd, index) in disconnectCommands"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center gap-3 bg-neutral-800/50 rounded-lg p-3"
|
class="te__entry"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
:value="cmd"
|
:value="cmd"
|
||||||
@change="updateDisconnectCommand(index, ($event.target as HTMLInputElement).value)"
|
@change="updateDisconnectCommand(index, ($event.target as HTMLInputElement).value)"
|
||||||
placeholder='e.g. say {player.name} has left'
|
placeholder="e.g. say {player.name} has left"
|
||||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-neutral-200 text-sm"
|
class="cc-input-raw te__input-grow"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button variant="danger-soft" size="sm" icon="trash-2" @click="removeDisconnectCommand(index)" />
|
||||||
@click="removeDisconnectCommand(index)"
|
|
||||||
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="te__add-row">
|
||||||
@click="addDisconnectCommand"
|
<Button variant="secondary" size="sm" icon="plus" @click="addDisconnectCommand">Add command</Button>
|
||||||
class="flex items-center gap-2 px-3 py-1.5 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
Add Command
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Create Config Modal -->
|
<!-- Create config modal -->
|
||||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
<div v-if="showCreateModal" class="te__modal-backdrop" @click.self="showCreateModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="te__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New TimedExecute Config</h2>
|
<div class="te__modal-head">
|
||||||
<div class="space-y-4">
|
<h2 class="te__modal-title">New TimedExecute config</h2>
|
||||||
<div>
|
</div>
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
<div class="te__modal-body">
|
||||||
<input
|
<Input
|
||||||
v-model="newConfigName"
|
v-model="newConfigNameModel"
|
||||||
|
label="Config name"
|
||||||
placeholder="e.g. Default Timer Settings"
|
placeholder="e.g. Default Timer Settings"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
|
||||||
@keydown.enter="handleCreateConfig"
|
@keydown.enter="handleCreateConfig"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div class="te__field">
|
||||||
<div>
|
<span class="te__field-label">Description (optional)</span>
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newConfigDesc"
|
v-model="newConfigDesc"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="What is this config for?"
|
placeholder="What is this config for?"
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
class="cc-textarea"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
|
||||||
<button
|
|
||||||
@click="handleCreateConfig"
|
|
||||||
:disabled="!newConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="te__modal-foot">
|
||||||
|
<Button variant="ghost" @click="showCreateModal = false">Cancel</Button>
|
||||||
|
<Button :disabled="!newConfigName.trim()" @click="handleCreateConfig">Create</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import from Server Modal -->
|
<!-- Import from server modal -->
|
||||||
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
<div v-if="showImportModal" class="te__modal-backdrop" @click.self="showImportModal = false">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
<div class="te__modal">
|
||||||
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import from Server</h2>
|
<div class="te__modal-head">
|
||||||
<p class="text-sm text-neutral-400 mb-4">
|
<h2 class="te__modal-title">Import from server</h2>
|
||||||
Import the current TimedExecute config from your live server. This will create a new config profile.
|
</div>
|
||||||
</p>
|
<div class="te__modal-body">
|
||||||
<div class="space-y-4">
|
<p class="te__modal-desc">Import the current TimedExecute config from your live server. This will create a new config profile.</p>
|
||||||
<div>
|
<Input
|
||||||
<label class="block text-sm text-neutral-400 mb-1">Config Name</label>
|
v-model="importConfigNameModel"
|
||||||
<input
|
label="Config name"
|
||||||
v-model="importConfigName"
|
placeholder="e.g. Imported server config"
|
||||||
placeholder="e.g. Imported Server Config"
|
|
||||||
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
|
||||||
@keydown.enter="handleImport"
|
@keydown.enter="handleImport"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="te__modal-foot">
|
||||||
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
<Button variant="ghost" @click="showImportModal = false">Cancel</Button>
|
||||||
<button
|
<Button :disabled="!importConfigName.trim()" @click="handleImport">Import</Button>
|
||||||
@click="handleImport"
|
|
||||||
:disabled="!importConfigName.trim()"
|
|
||||||
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
Import
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ---- Page shell ---- */
|
||||||
|
.te { max-width: 900px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* ---- Page head ---- */
|
||||||
|
.te__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.te__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.te__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.te__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
.te__head-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* ---- Toolbar ---- */
|
||||||
|
.te__toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.te__toolbar-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-left: auto; }
|
||||||
|
.te__no-configs { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* ---- Loading ---- */
|
||||||
|
.te__loading { display: flex; justify-content: center; padding: 48px 0; }
|
||||||
|
.te__spinner {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
|
border: 2px solid var(--accent); border-top-color: transparent;
|
||||||
|
animation: te-spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes te-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ---- Presets ---- */
|
||||||
|
.te__presets { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||||
|
.te__presets-label { font-size: var(--text-xs); color: var(--text-muted); }
|
||||||
|
.te__preset {
|
||||||
|
height: var(--control-h-sm); padding: 0 10px; border: 0; cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm); font-size: var(--text-xs); font-family: var(--font-sans);
|
||||||
|
font-weight: 500; color: var(--text-secondary);
|
||||||
|
background: var(--surface-raised-2); box-shadow: var(--ring-default);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.te__preset:hover { background: var(--surface-active); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* ---- Entry rows ---- */
|
||||||
|
.te__entries { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
|
||||||
|
.te__entry {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 12px; border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-raised-2); box-shadow: var(--ring-default);
|
||||||
|
}
|
||||||
|
.te__entry-cmd { flex: 1; min-width: 0; }
|
||||||
|
.te__entry-interval { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.te__unit { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ---- Inline raw inputs (entries) ---- */
|
||||||
|
.cc-input-raw {
|
||||||
|
height: var(--control-h-sm); padding: 0 9px;
|
||||||
|
background: var(--surface-inset); border: 0; border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--ring-default); outline: 0;
|
||||||
|
font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-primary);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-input-raw::placeholder { color: var(--text-muted); }
|
||||||
|
.cc-input-raw:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
.cc-input-raw--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.te__input-grow { width: 100%; }
|
||||||
|
.te__input-interval { width: 72px; text-align: right; }
|
||||||
|
.te__input-time { width: 90px; }
|
||||||
|
|
||||||
|
/* ---- Add row ---- */
|
||||||
|
.te__add-row { padding-top: 2px; }
|
||||||
|
|
||||||
|
/* ---- Modal ---- */
|
||||||
|
.te__modal-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 50;
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||||
|
}
|
||||||
|
.te__modal {
|
||||||
|
background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default);
|
||||||
|
width: 100%; max-width: 440px; display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.te__modal-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.te__modal-title { font-size: var(--text-base); font-weight: 600; color: var(--text-primary); }
|
||||||
|
.te__modal-body { padding: 20px; display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.te__modal-desc { font-size: var(--text-sm); color: var(--text-tertiary); line-height: 1.5; }
|
||||||
|
.te__modal-foot {
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
padding: 14px 20px; border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.te__field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.te__field-label { font-size: var(--text-xs); font-weight: 600; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ---- Textarea ---- */
|
||||||
|
.cc-textarea {
|
||||||
|
background: var(--surface-inset); border-radius: var(--radius-md); box-shadow: var(--ring-default);
|
||||||
|
border: 0; outline: 0; padding: 9px 11px; resize: none; width: 100%;
|
||||||
|
font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-primary);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||||
|
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
import { BarChart3, TrendingUp, Clock, Target, Download, Zap } from 'lucide-vue-next'
|
|
||||||
import * as echarts from 'echarts'
|
import * as echarts from 'echarts'
|
||||||
import type { ECharts } from 'echarts'
|
import type { ECharts } from 'echarts'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import type { WipePerformanceMetrics } from '@/types'
|
import type { WipePerformanceMetrics } from '@/types'
|
||||||
import { safeFixed } from '@/utils/formatters'
|
import { safeFixed } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -42,9 +47,22 @@ const loadAnalytics = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cssVar(name: string): string {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||||
|
}
|
||||||
|
|
||||||
const renderCharts = () => {
|
const renderCharts = () => {
|
||||||
if (!metrics.value) return
|
if (!metrics.value) return
|
||||||
|
|
||||||
|
const accent = cssVar('--accent') || '#CE422B'
|
||||||
|
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
|
||||||
|
const axisLine = cssVar('--border-default') || '#404040'
|
||||||
|
const labelColor = cssVar('--text-tertiary') || '#808080'
|
||||||
|
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
|
||||||
|
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
|
||||||
|
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
|
||||||
|
const mono = 'JetBrains Mono, monospace'
|
||||||
|
|
||||||
// Success Rate Timeline
|
// Success Rate Timeline
|
||||||
if (successRateChart.value) {
|
if (successRateChart.value) {
|
||||||
if (successRateChartInstance) {
|
if (successRateChartInstance) {
|
||||||
@@ -62,9 +80,9 @@ const renderCharts = () => {
|
|||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: tooltipBg,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: tooltipBorder,
|
||||||
textStyle: { color: '#e5e5e5' },
|
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
|
||||||
formatter: (params: any) => {
|
formatter: (params: any) => {
|
||||||
const status = params[0].data === 1 ? 'Success' : 'Failed'
|
const status = params[0].data === 1 ? 'Success' : 'Failed'
|
||||||
const color = params[0].data === 1 ? '#10b981' : '#ef4444'
|
const color = params[0].data === 1 ? '#10b981' : '#ef4444'
|
||||||
@@ -81,17 +99,19 @@ const renderCharts = () => {
|
|||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: dates,
|
data: dates,
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
axisLabel: { color: '#808080', rotate: 45 }
|
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1,
|
max: 1,
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
splitLine: { lineStyle: { color: grid } },
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
color: '#808080',
|
color: labelColor,
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
formatter: (value: number) => value === 1 ? 'Success' : 'Failed'
|
formatter: (value: number) => value === 1 ? 'Success' : 'Failed'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -122,9 +142,9 @@ const renderCharts = () => {
|
|||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: tooltipBg,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: tooltipBorder,
|
||||||
textStyle: { color: '#e5e5e5' }
|
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 }
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
left: '3%',
|
left: '3%',
|
||||||
@@ -136,15 +156,15 @@ const renderCharts = () => {
|
|||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: ['Day 1', 'Day 2', 'Day 3'],
|
data: ['Day 1', 'Day 2', 'Day 3'],
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
axisLabel: { color: '#808080' }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: 'Avg Players',
|
name: 'Avg Players',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
splitLine: { lineStyle: { color: grid } },
|
||||||
axisLabel: { color: '#808080' }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@@ -157,8 +177,8 @@ const renderCharts = () => {
|
|||||||
],
|
],
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
{ offset: 0, color: '#CE422B' },
|
{ offset: 0, color: accent },
|
||||||
{ offset: 1, color: '#8B2E1F' }
|
{ offset: 1, color: accent + '99' }
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
barWidth: '50%'
|
barWidth: '50%'
|
||||||
@@ -184,9 +204,9 @@ const renderCharts = () => {
|
|||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: tooltipBg,
|
||||||
borderColor: '#2a2a2a',
|
borderColor: tooltipBorder,
|
||||||
textStyle: { color: '#e5e5e5' },
|
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
|
||||||
formatter: (params: any) => {
|
formatter: (params: any) => {
|
||||||
return `${params[0].axisValue}<br/>Duration: ${params[0].data} minutes`
|
return `${params[0].axisValue}<br/>Duration: ${params[0].data} minutes`
|
||||||
}
|
}
|
||||||
@@ -201,15 +221,15 @@ const renderCharts = () => {
|
|||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: dates,
|
data: dates,
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
axisLabel: { color: '#808080', rotate: 45 }
|
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
name: 'Minutes',
|
name: 'Minutes',
|
||||||
axisLine: { lineStyle: { color: '#404040' } },
|
axisLine: { lineStyle: { color: axisLine } },
|
||||||
splitLine: { lineStyle: { color: '#2a2a2a' } },
|
splitLine: { lineStyle: { color: grid } },
|
||||||
axisLabel: { color: '#808080' }
|
axisLabel: { color: labelColor, fontFamily: mono, fontSize: 10 }
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
@@ -264,128 +284,190 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="wipe-analytics-view">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="wipe-analytics-view__header">
|
||||||
<div class="flex items-center gap-3">
|
<h1 class="wipe-analytics-view__title">Wipe analytics</h1>
|
||||||
<Zap class="w-5 h-5 text-oxide-500" />
|
<div class="wipe-analytics-view__controls">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Wipe Analytics</h1>
|
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
@click="downloadCSV"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
|
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</Button>
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
<Tabs
|
||||||
<button
|
:items="[
|
||||||
v-for="opt in (['6', '12', 'all'] as const)"
|
{ value: '6', label: 'Last 6 wipes' },
|
||||||
:key="opt"
|
{ value: '12', label: 'Last 12 wipes' },
|
||||||
@click="timeRange = opt"
|
{ value: 'all', label: 'All time' }
|
||||||
class="px-3 py-2 text-sm font-medium transition-colors"
|
]"
|
||||||
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
v-model="timeRange"
|
||||||
>
|
variant="pill"
|
||||||
{{ opt === 'all' ? 'All Time' : `Last ${opt} Wipes` }}
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
<div v-if="loading" class="wipe-analytics-view__loading">
|
||||||
<div class="text-neutral-500">Loading wipe analytics...</div>
|
<span class="wipe-analytics-view__loading-text">Loading wipe analytics...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="metrics">
|
<template v-else-if="metrics">
|
||||||
<!-- Insight Cards -->
|
<!-- Insight Cards -->
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="wipe-analytics-view__stats">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<StatCard
|
||||||
<div class="flex items-center gap-2 mb-2">
|
label="Success rate"
|
||||||
<Target class="w-4 h-4 text-neutral-500" />
|
:value="safeFixed(metrics?.success_rate_percent, 1)"
|
||||||
<p class="text-sm text-neutral-400">Success Rate</p>
|
unit="%"
|
||||||
</div>
|
icon="target"
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(metrics?.success_rate_percent, 1) }}%</p>
|
:note="`${metrics.successful_wipes}/${metrics.total_wipes} wipes`"
|
||||||
<p class="text-xs text-neutral-600 mt-1">{{ metrics.successful_wipes }}/{{ metrics.total_wipes }} wipes</p>
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
|
label="Avg duration"
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
:value="formatDuration(metrics.avg_duration_seconds)"
|
||||||
<div class="flex items-center gap-2 mb-2">
|
icon="clock"
|
||||||
<Clock class="w-4 h-4 text-neutral-500" />
|
note="Per wipe"
|
||||||
<p class="text-sm text-neutral-400">Avg Duration</p>
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ formatDuration(metrics.avg_duration_seconds) }}</p>
|
label="Peak population"
|
||||||
<p class="text-xs text-neutral-600 mt-1">Per wipe</p>
|
value="Day 1"
|
||||||
</div>
|
icon="trending-up"
|
||||||
|
:note="`${safeFixed(metrics?.population_curve?.day_1_avg, 1)} avg players`"
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
/>
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<StatCard
|
||||||
<TrendingUp class="w-4 h-4 text-neutral-500" />
|
label="Optimal timing"
|
||||||
<p class="text-sm text-neutral-400">Peak Population</p>
|
:value="metrics.optimal_wipe_day"
|
||||||
</div>
|
icon="bar-chart-3"
|
||||||
<p class="text-2xl font-bold text-neutral-100">Day 1</p>
|
:note="`${metrics.optimal_wipe_hour}:00`"
|
||||||
<p class="text-xs text-neutral-600 mt-1">{{ safeFixed(metrics?.population_curve?.day_1_avg, 1) }} avg players</p>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<BarChart3 class="w-4 h-4 text-neutral-500" />
|
|
||||||
<p class="text-sm text-neutral-400">Optimal Timing</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-neutral-100">{{ metrics.optimal_wipe_day }}</p>
|
|
||||||
<p class="text-xs text-neutral-600 mt-1">{{ metrics.optimal_wipe_hour }}:00</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actionable Insight Banner -->
|
<!-- Actionable Insight Banner -->
|
||||||
<div v-if="metrics.total_wipes > 3" class="bg-oxide-500/10 border border-oxide-500/30 rounded-lg p-4">
|
<Alert
|
||||||
<div class="flex items-start gap-3">
|
v-if="metrics.total_wipes > 3"
|
||||||
<Target class="w-5 h-5 text-oxide-400 mt-0.5" />
|
tone="accent"
|
||||||
<div>
|
title="Recommendations"
|
||||||
<p class="text-sm font-medium text-neutral-100 mb-1">Recommendations</p>
|
>
|
||||||
<ul class="text-sm text-neutral-300 space-y-1">
|
<ul class="wipe-analytics-view__recs">
|
||||||
<li>• Your best wipe day is <span class="font-semibold text-oxide-400">{{ metrics.optimal_wipe_day }} at {{ metrics.optimal_wipe_hour }}:00</span> based on post-wipe population peaks.</li>
|
<li>Your best wipe day is <strong>{{ metrics.optimal_wipe_day }} at {{ metrics.optimal_wipe_hour }}:00</strong> based on post-wipe population peaks.</li>
|
||||||
<li v-if="metrics.population_curve.day_1_avg > metrics.population_curve.day_2_avg * 1.2">
|
<li v-if="metrics.population_curve.day_1_avg > metrics.population_curve.day_2_avg * 1.2">
|
||||||
• Players peak on Day 1 ({{ safeFixed(metrics?.population_curve?.day_1_avg, 0) }} avg). Consider <span class="font-semibold text-oxide-400">weekly wipes</span> to maintain engagement.
|
Players peak on day 1 ({{ safeFixed(metrics?.population_curve?.day_1_avg, 0) }} avg). Consider <strong>weekly wipes</strong> to maintain engagement.
|
||||||
</li>
|
</li>
|
||||||
<li v-if="metrics.avg_duration_seconds > 600">
|
<li v-if="metrics.avg_duration_seconds > 600">
|
||||||
• Average wipe duration is {{ formatDuration(metrics.avg_duration_seconds) }}. Review pre-wipe commands for optimization opportunities.
|
Average wipe duration is {{ formatDuration(metrics.avg_duration_seconds) }}. Review pre-wipe commands for optimization opportunities.
|
||||||
</li>
|
</li>
|
||||||
<li v-if="metrics.success_rate_percent < 95 && metrics.failed_wipes > 0">
|
<li v-if="metrics.success_rate_percent < 95 && metrics.failed_wipes > 0">
|
||||||
• {{ metrics.failed_wipes }} wipe(s) failed. Enable rollback protection in wipe profiles.
|
{{ metrics.failed_wipes }} wipe(s) failed. Enable rollback protection in wipe profiles.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Alert>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Charts -->
|
<!-- Charts -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div class="wipe-analytics-view__charts">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Wipe success timeline">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Wipe Success Timeline</h2>
|
<div ref="successRateChart" class="wipe-analytics-view__chart-area"></div>
|
||||||
<div ref="successRateChart" class="h-64"></div>
|
</Panel>
|
||||||
|
<Panel title="Population curve post-wipe">
|
||||||
|
<div ref="populationCurveChart" class="wipe-analytics-view__chart-area"></div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Wipe duration trend">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Population Curve Post-Wipe</h2>
|
<div ref="durationTrendChart" class="wipe-analytics-view__chart-area"></div>
|
||||||
<div ref="populationCurveChart" class="h-64"></div>
|
</Panel>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Wipe Duration Trend</h2>
|
|
||||||
<div ref="durationTrendChart" class="h-64"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No Data State -->
|
<!-- No Data State -->
|
||||||
<div v-if="metrics.total_wipes === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-8">
|
<Panel v-if="metrics.total_wipes === 0">
|
||||||
<div class="text-center">
|
<EmptyState
|
||||||
<Zap class="w-12 h-12 text-neutral-700 mx-auto mb-3" />
|
icon="zap"
|
||||||
<p class="text-neutral-400 mb-1">No wipe data yet</p>
|
title="No wipe data yet"
|
||||||
<p class="text-sm text-neutral-600">Wipe analytics will appear after your first scheduled or manual wipe.</p>
|
description="Wipe analytics will appear after your first scheduled or manual wipe."
|
||||||
</div>
|
/>
|
||||||
</div>
|
</Panel>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wipe-analytics-view {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wipe-analytics-view__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wipe-analytics-view__title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wipe-analytics-view__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wipe-analytics-view__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wipe-analytics-view__loading-text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wipe-analytics-view__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.wipe-analytics-view__stats {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wipe-analytics-view__charts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.wipe-analytics-view__charts {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wipe-analytics-view__chart-area {
|
||||||
|
height: 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wipe-analytics-view__recs {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wipe-analytics-view__recs li::before {
|
||||||
|
content: '• ';
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useWipeStore } from '@/stores/wipe'
|
import { useWipeStore } from '@/stores/wipe'
|
||||||
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const wipeStore = useWipeStore()
|
const wipeStore = useWipeStore()
|
||||||
|
|
||||||
@@ -53,6 +57,8 @@ function nextMonth() {
|
|||||||
currentMonth.value = d
|
currentMonth.value = d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeSchedules = computed(() => wipeStore.schedules.filter(s => s.is_active))
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
wipeStore.fetchHistory()
|
wipeStore.fetchHistory()
|
||||||
wipeStore.fetchSchedules()
|
wipeStore.fetchSchedules()
|
||||||
@@ -60,79 +66,177 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="wc">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="wc__head">
|
||||||
<Calendar class="w-5 h-5 text-oxide-500" />
|
<div class="wc__head-id">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Wipe Calendar</h1>
|
<div class="wc__head-chip">
|
||||||
|
<Icon name="calendar" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Auto-wiper</div>
|
||||||
|
<h1 class="wc__title">Wipe calendar</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Month navigation -->
|
<!-- Calendar panel -->
|
||||||
<div class="flex items-center justify-between">
|
<Panel :flush-body="true" title="Wipe calendar">
|
||||||
<button @click="prevMonth" class="p-2 text-neutral-400 hover:text-neutral-200 transition-colors">
|
<template #actions>
|
||||||
<ChevronLeft class="w-5 h-5" />
|
<Button variant="ghost" size="sm" icon="chevron-left" @click="prevMonth" />
|
||||||
</button>
|
<span class="wc__month-label">{{ monthLabel }}</span>
|
||||||
<h2 class="text-lg font-semibold text-neutral-200">{{ monthLabel }}</h2>
|
<Button variant="ghost" size="sm" icon="chevron-right" @click="nextMonth" />
|
||||||
<button @click="nextMonth" class="p-2 text-neutral-400 hover:text-neutral-200 transition-colors">
|
</template>
|
||||||
<ChevronRight class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Calendar grid -->
|
<!-- Day-of-week headers -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<div class="wc__dow-row">
|
||||||
<!-- Day headers -->
|
|
||||||
<div class="grid grid-cols-7 border-b border-neutral-800">
|
|
||||||
<div
|
<div
|
||||||
v-for="day in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
v-for="day in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||||
:key="day"
|
:key="day"
|
||||||
class="py-2 text-center text-xs font-medium text-neutral-500 uppercase"
|
class="wc__dow"
|
||||||
>
|
>{{ day }}</div>
|
||||||
{{ day }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Day cells -->
|
<!-- Day cells -->
|
||||||
<div class="grid grid-cols-7">
|
<div class="wc__grid">
|
||||||
<div
|
<div
|
||||||
v-for="(day, i) in calendarDays"
|
v-for="(day, i) in calendarDays"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="min-h-20 p-2 border-b border-r border-neutral-800 last:border-r-0"
|
class="wc__cell"
|
||||||
:class="{ 'bg-neutral-800/30': !day.inMonth }"
|
:class="{ 'wc__cell--out': !day.inMonth }"
|
||||||
>
|
>
|
||||||
<template v-if="day.inMonth">
|
<template v-if="day.inMonth">
|
||||||
<p class="text-sm" :class="day.hasWipe ? 'text-oxide-400 font-bold' : 'text-neutral-400'">
|
<span class="wc__date" :class="{ 'wc__date--wipe': day.hasWipe }">{{ day.date }}</span>
|
||||||
{{ day.date }}
|
<Badge
|
||||||
</p>
|
v-if="day.hasWipe"
|
||||||
<div v-if="day.hasWipe" class="mt-1">
|
tone="warn"
|
||||||
<span class="text-xs bg-oxide-500/15 text-oxide-400 px-1.5 py-0.5 rounded">
|
size="md"
|
||||||
{{ day.wipeType }}
|
class="wc__wipe-badge"
|
||||||
|
>{{ day.wipeType }}</Badge>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Active schedules panel -->
|
||||||
|
<Panel title="Active schedules" subtitle="Cron schedules currently enabled">
|
||||||
|
<EmptyState
|
||||||
|
v-if="activeSchedules.length === 0"
|
||||||
|
icon="calendar-clock"
|
||||||
|
title="No active schedules"
|
||||||
|
description="Activate a schedule in the auto-wiper to see it here."
|
||||||
|
/>
|
||||||
|
<div v-else class="wc__sched-list">
|
||||||
|
<div
|
||||||
|
v-for="schedule in activeSchedules"
|
||||||
|
:key="schedule.id"
|
||||||
|
class="wc__sched-row"
|
||||||
|
>
|
||||||
|
<div class="wc__sched-info">
|
||||||
|
<div class="wc__sched-name">{{ schedule.schedule_name }}</div>
|
||||||
|
<div class="wc__sched-meta">
|
||||||
|
<span class="wc__mono">{{ schedule.cron_expression }}</span>
|
||||||
|
· {{ schedule.timezone }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wc__sched-next">
|
||||||
|
Next:
|
||||||
|
<span class="wc__mono">
|
||||||
|
{{ schedule.next_scheduled_run ? new Date(schedule.next_scheduled_run).toLocaleDateString() : 'TBD' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upcoming schedules -->
|
<style scoped>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
.wc {
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-3">Active Schedules</h2>
|
max-width: 900px;
|
||||||
<div v-if="wipeStore.schedules.length === 0" class="text-sm text-neutral-500 text-center py-4">
|
margin: 0 auto;
|
||||||
No active schedules.
|
display: flex;
|
||||||
</div>
|
flex-direction: column;
|
||||||
<div v-else class="space-y-2">
|
gap: 16px;
|
||||||
<div
|
}
|
||||||
v-for="schedule in wipeStore.schedules.filter(s => s.is_active)"
|
|
||||||
:key="schedule.id"
|
/* Page head */
|
||||||
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
|
.wc__head { display: flex; align-items: center; }
|
||||||
>
|
.wc__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
<div>
|
.wc__head-chip {
|
||||||
<p class="text-sm text-neutral-200">{{ schedule.schedule_name }}</p>
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
<p class="text-xs text-neutral-500 font-mono">{{ schedule.cron_expression }}</p>
|
display: flex; align-items: center; justify-content: center;
|
||||||
</div>
|
color: var(--accent); background: var(--accent-soft);
|
||||||
<p class="text-xs text-neutral-400">
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
Next: {{ schedule.next_scheduled_run ? new Date(schedule.next_scheduled_run).toLocaleDateString() : 'TBD' }}
|
}
|
||||||
</p>
|
.wc__title {
|
||||||
</div>
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
</div>
|
color: var(--text-primary); margin-top: 3px;
|
||||||
</div>
|
}
|
||||||
</div>
|
.wc__month-label {
|
||||||
</template>
|
font-size: var(--text-sm); font-weight: 600; color: var(--text-primary);
|
||||||
|
padding: 0 4px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar grid */
|
||||||
|
.wc__dow-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.wc__dow {
|
||||||
|
padding: 8px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
.wc__cell {
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.wc__cell:nth-child(7n) { border-right: 0; }
|
||||||
|
.wc__cell--out { background: var(--surface-inset); }
|
||||||
|
|
||||||
|
.wc__date {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.wc__date--wipe { color: var(--accent-text); font-weight: 700; }
|
||||||
|
.wc__wipe-badge { align-self: flex-start; }
|
||||||
|
|
||||||
|
/* Active schedules list */
|
||||||
|
.wc__sched-list { display: flex; flex-direction: column; }
|
||||||
|
.wc__sched-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 2px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.wc__sched-row:last-child { border-bottom: 0; }
|
||||||
|
.wc__sched-info { flex: 1; min-width: 0; }
|
||||||
|
.wc__sched-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.wc__sched-meta { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||||
|
.wc__sched-next { font-size: var(--text-xs); color: var(--text-tertiary); flex: none; }
|
||||||
|
.wc__mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.wc__cell { min-height: 52px; padding: 4px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { useWipeStore } from '@/stores/wipe'
|
import { useWipeStore } from '@/stores/wipe'
|
||||||
import { History, RefreshCw } from 'lucide-vue-next'
|
|
||||||
import { safeDate } from '@/utils/formatters'
|
import { safeDate } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const wipeStore = useWipeStore()
|
const wipeStore = useWipeStore()
|
||||||
|
|
||||||
function statusBadgeClass(status: string): string {
|
function wipeTone(status: string): 'online' | 'offline' | 'warn' | 'neutral' {
|
||||||
switch (status) {
|
if (status === 'success') return 'online'
|
||||||
case 'success': return 'bg-green-500/10 text-green-400'
|
if (status === 'failed' || status === 'rolled_back') return 'offline'
|
||||||
case 'failed':
|
if (status === 'wiping' || status === 'pre_wipe' || status === 'post_wipe') return 'warn'
|
||||||
case 'rolled_back': return 'bg-red-500/10 text-red-400'
|
return 'neutral'
|
||||||
case 'wiping':
|
|
||||||
case 'pre_wipe':
|
|
||||||
case 'post_wipe': return 'bg-yellow-500/10 text-yellow-400'
|
|
||||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function duration(start: string | null, end: string | null): string {
|
function duration(start: string | null, end: string | null): string {
|
||||||
if (!start || !end) return '\u2014'
|
if (!start || !end) return '—'
|
||||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||||
const s = Math.floor(ms / 1000)
|
const s = Math.floor(ms / 1000)
|
||||||
const m = Math.floor(s / 60)
|
const m = Math.floor(s / 60)
|
||||||
@@ -33,68 +32,154 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="wh">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="wh__head">
|
||||||
<div class="flex items-center gap-3">
|
<div class="wh__head-id">
|
||||||
<History class="w-5 h-5 text-oxide-500" />
|
<div class="wh__head-chip">
|
||||||
|
<Icon name="clock" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Wipe History</h1>
|
<div class="t-eyebrow">Auto-wiper</div>
|
||||||
<p class="text-sm text-neutral-500 mt-0.5">{{ wipeStore.history.length }} wipes recorded</p>
|
<h1 class="wh__title">Wipe history</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
@click="wipeStore.fetchHistory()"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="refresh-cw"
|
||||||
|
:loading="wipeStore.isLoading"
|
||||||
:disabled="wipeStore.isLoading"
|
:disabled="wipeStore.isLoading"
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 rounded-lg transition-colors"
|
@click="wipeStore.fetchHistory()"
|
||||||
>
|
>Refresh</Button>
|
||||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': wipeStore.isLoading }" />
|
</div>
|
||||||
Refresh
|
|
||||||
</button>
|
<!-- Summary line -->
|
||||||
|
<div v-if="!wipeStore.isLoading" class="wh__summary">
|
||||||
|
{{ wipeStore.history.length }} wipe{{ wipeStore.history.length === 1 ? '' : 's' }} recorded
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- History table -->
|
<!-- History table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel :flush-body="true" title="All wipes">
|
||||||
<table class="w-full">
|
<EmptyState
|
||||||
|
v-if="wipeStore.history.length === 0 && !wipeStore.isLoading"
|
||||||
|
icon="trash-2"
|
||||||
|
title="No wipe history"
|
||||||
|
description="Wipes will appear here once they run."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="wipeStore.isLoading" class="wh__loading">
|
||||||
|
<Icon name="loader" :size="20" class="wh__spin" />
|
||||||
|
<span>Loading history…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="cc-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Type</th>
|
<th>Type</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Trigger</th>
|
<th>Trigger</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
<th>Status</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Started</th>
|
<th>Started</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Duration</th>
|
<th>Duration</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Map</th>
|
<th>Map</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-if="wipeStore.history.length === 0">
|
|
||||||
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
|
||||||
<template v-if="wipeStore.isLoading">Loading history...</template>
|
|
||||||
<template v-else>No wipe history yet.</template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
<tr
|
||||||
v-for="wipe in wipeStore.history"
|
v-for="wipe in wipeStore.history"
|
||||||
:key="wipe.id"
|
:key="wipe.id"
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 text-sm font-medium text-neutral-100 capitalize">{{ wipe.wipe_type }}</td>
|
<td class="td-primary">{{ wipe.wipe_type }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 capitalize">{{ wipe.trigger_type.replace('_', ' ') }}</td>
|
<td>{{ wipe.trigger_type.replace('_', ' ') }}</td>
|
||||||
<td class="px-4 py-3">
|
<td>
|
||||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="statusBadgeClass(wipe.status)">
|
<Badge :tone="wipeTone(wipe.status)">{{ wipe.status.replace('_', ' ') }}</Badge>
|
||||||
{{ wipe.status.replace('_', ' ') }}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">
|
<td class="td-mono">{{ safeDate(wipe.started_at, '—') }}</td>
|
||||||
{{ safeDate(wipe.started_at, '\u2014') }}
|
<td class="td-mono">{{ duration(wipe.started_at, wipe.completed_at) }}</td>
|
||||||
</td>
|
<td>{{ wipe.map_used || '—' }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
|
|
||||||
{{ duration(wipe.started_at, wipe.completed_at) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ wipe.map_used || '\u2014' }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wh {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.wh__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.wh__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.wh__head-chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.wh__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
.wh__summary {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.wh__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 36px 16px;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
@keyframes wh-spin { to { transform: rotate(360deg); } }
|
||||||
|
.wh__spin { animation: wh-spin 0.7s linear infinite; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.cc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.cc-table thead tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.cc-table th {
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cc-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||||
|
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||||
|
.cc-table td {
|
||||||
|
padding: 11px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.td-primary { color: var(--text-primary); font-weight: 500; text-transform: capitalize; }
|
||||||
|
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useWipeStore } from '@/stores/wipe'
|
import { useWipeStore } from '@/stores/wipe'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { WipeProfile } from '@/types'
|
import type { WipeProfile } from '@/types'
|
||||||
import { FileText, Plus, ChevronDown, ChevronRight, Edit2, Trash2, X } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Checkbox from '@/components/ds/forms/Checkbox.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const wipeStore = useWipeStore()
|
const wipeStore = useWipeStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
@@ -135,288 +141,489 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="profiles">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="page__head">
|
||||||
<div class="flex items-center gap-3">
|
<div>
|
||||||
<FileText class="w-5 h-5 text-oxide-500" />
|
<div class="t-eyebrow">Operations</div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Wipe Profiles</h1>
|
<h1 class="page__title">Wipe profiles</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button icon="plus" @click="openCreateModal">New profile</Button>
|
||||||
@click="openCreateModal"
|
</div>
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<Panel v-if="wipeStore.profiles.length === 0">
|
||||||
|
<EmptyState
|
||||||
|
icon="file-text"
|
||||||
|
title="No wipe profiles"
|
||||||
|
description="Create a profile to define pre-wipe and post-wipe behavior."
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4" />
|
<template #action>
|
||||||
New Profile
|
<Button icon="plus" size="sm" @click="openCreateModal">New profile</Button>
|
||||||
</button>
|
</template>
|
||||||
</div>
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Profiles -->
|
<!-- Profile list -->
|
||||||
<div v-if="wipeStore.profiles.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
<div v-else class="profile-list">
|
||||||
<FileText class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
<Panel
|
||||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">No Wipe Profiles</h3>
|
|
||||||
<p class="text-sm text-neutral-500">Create a profile to define pre-wipe and post-wipe behavior.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="profile in wipeStore.profiles"
|
v-for="profile in wipeStore.profiles"
|
||||||
:key="profile.id"
|
:key="profile.id"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"
|
:flush-body="true"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<template #title-append>
|
||||||
|
<!-- Nothing — title is injected via Panel slot at row level instead -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Accordion row header -->
|
||||||
|
<div class="profile-row">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
class="profile-toggle"
|
||||||
@click="toggle(profile.id)"
|
@click="toggle(profile.id)"
|
||||||
class="flex-1 flex items-center justify-between p-4 text-left hover:bg-neutral-800/50 transition-colors"
|
|
||||||
>
|
>
|
||||||
<div>
|
<div class="profile-meta">
|
||||||
<h3 class="text-sm font-medium text-neutral-100">{{ profile.profile_name }}</h3>
|
<span class="profile-name">{{ profile.profile_name }}</span>
|
||||||
<p class="text-xs text-neutral-500 mt-0.5">{{ profile.description || 'No description' }}</p>
|
<span class="profile-desc">{{ profile.description || 'No description' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<component :is="expandedId === profile.id ? ChevronDown : ChevronRight" class="w-4 h-4 text-neutral-500" />
|
<span class="profile-chev" :class="expandedId === profile.id && 'profile-chev--open'">
|
||||||
</button>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<div class="flex items-center gap-1 pr-4">
|
<polyline points="6 9 12 15 18 9" />
|
||||||
<button
|
</svg>
|
||||||
@click="openEditModal(profile)"
|
</span>
|
||||||
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="deleteProfile(profile)"
|
|
||||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
</button>
|
||||||
|
<div class="profile-actions">
|
||||||
|
<IconButton icon="pencil" size="sm" label="Edit" @click="openEditModal(profile)" />
|
||||||
|
<IconButton icon="trash-2" variant="danger" size="sm" label="Delete" @click="deleteProfile(profile)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="expandedId === profile.id" class="border-t border-neutral-800 p-4">
|
<!-- Expanded detail -->
|
||||||
<div class="grid grid-cols-2 gap-6">
|
<div v-if="expandedId === profile.id" class="profile-detail">
|
||||||
|
<div class="detail-grid">
|
||||||
<!-- Pre-wipe -->
|
<!-- Pre-wipe -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-3">Pre-Wipe</h4>
|
<div class="t-eyebrow detail-eyebrow">Pre-wipe</div>
|
||||||
<div class="space-y-2 text-sm">
|
<div class="detail-rows">
|
||||||
<div class="flex justify-between">
|
<div class="detail-kv">
|
||||||
<span class="text-neutral-500">Backup before wipe</span>
|
<span class="detail-k">Backup before wipe</span>
|
||||||
<span :class="profile.pre_wipe_config.backup_before_wipe ? 'text-green-400' : 'text-neutral-600'">
|
<Badge :tone="profile.pre_wipe_config.backup_before_wipe ? 'online' : 'neutral'">
|
||||||
{{ profile.pre_wipe_config.backup_before_wipe ? 'Yes' : 'No' }}
|
{{ profile.pre_wipe_config.backup_before_wipe ? 'Yes' : 'No' }}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="detail-kv">
|
||||||
<span class="text-neutral-500">Kick players</span>
|
<span class="detail-k">Kick players</span>
|
||||||
<span :class="profile.pre_wipe_config.kick_players_before_wipe ? 'text-green-400' : 'text-neutral-600'">
|
<Badge :tone="profile.pre_wipe_config.kick_players_before_wipe ? 'online' : 'neutral'">
|
||||||
{{ profile.pre_wipe_config.kick_players_before_wipe ? 'Yes' : 'No' }}
|
{{ profile.pre_wipe_config.kick_players_before_wipe ? 'Yes' : 'No' }}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="detail-kv">
|
||||||
<span class="text-neutral-500">Final save</span>
|
<span class="detail-k">Final save</span>
|
||||||
<span :class="profile.pre_wipe_config.run_final_save ? 'text-green-400' : 'text-neutral-600'">
|
<Badge :tone="profile.pre_wipe_config.run_final_save ? 'online' : 'neutral'">
|
||||||
{{ profile.pre_wipe_config.run_final_save ? 'Yes' : 'No' }}
|
{{ profile.pre_wipe_config.run_final_save ? 'Yes' : 'No' }}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="detail-kv">
|
||||||
<span class="text-neutral-500">Discord announce</span>
|
<span class="detail-k">Discord announce</span>
|
||||||
<span :class="profile.pre_wipe_config.discord_pre_announce ? 'text-green-400' : 'text-neutral-600'">
|
<Badge :tone="profile.pre_wipe_config.discord_pre_announce ? 'online' : 'neutral'">
|
||||||
{{ profile.pre_wipe_config.discord_pre_announce ? 'Yes' : 'No' }}
|
{{ profile.pre_wipe_config.discord_pre_announce ? 'Yes' : 'No' }}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Post-wipe -->
|
<!-- Post-wipe -->
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-3">Post-Wipe</h4>
|
<div class="t-eyebrow detail-eyebrow">Post-wipe</div>
|
||||||
<div class="space-y-2 text-sm">
|
<div class="detail-rows">
|
||||||
<div class="flex justify-between">
|
<div class="detail-kv">
|
||||||
<span class="text-neutral-500">Verify server started</span>
|
<span class="detail-k">Verify server started</span>
|
||||||
<span :class="profile.post_wipe_config.verify_server_started ? 'text-green-400' : 'text-neutral-600'">
|
<Badge :tone="profile.post_wipe_config.verify_server_started ? 'online' : 'neutral'">
|
||||||
{{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }}
|
{{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="detail-kv">
|
||||||
<span class="text-neutral-500">Verify plugins loaded</span>
|
<span class="detail-k">Verify plugins loaded</span>
|
||||||
<span :class="profile.post_wipe_config.verify_plugins_loaded ? 'text-green-400' : 'text-neutral-600'">
|
<Badge :tone="profile.post_wipe_config.verify_plugins_loaded ? 'online' : 'neutral'">
|
||||||
{{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }}
|
{{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="detail-kv">
|
||||||
<span class="text-neutral-500">Rollback on failure</span>
|
<span class="detail-k">Rollback on failure</span>
|
||||||
<span :class="profile.post_wipe_config.rollback_on_failure ? 'text-oxide-400' : 'text-neutral-600'">
|
<Badge :tone="profile.post_wipe_config.rollback_on_failure ? 'accent' : 'neutral'">
|
||||||
{{ profile.post_wipe_config.rollback_on_failure ? 'Yes' : 'No' }}
|
{{ profile.post_wipe_config.rollback_on_failure ? 'Yes' : 'No' }}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-neutral-500">Max restart attempts</span>
|
|
||||||
<span class="text-neutral-300">{{ profile.post_wipe_config.max_restart_attempts }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-kv">
|
||||||
|
<span class="detail-k">Max restart attempts</span>
|
||||||
|
<span class="detail-v-mono">{{ profile.post_wipe_config.max_restart_attempts }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create / Edit Modal -->
|
<!-- Create / Edit Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
v-if="showModal"
|
v-if="showModal"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
class="modal-backdrop"
|
||||||
@click.self="closeModal"
|
@click.self="closeModal"
|
||||||
>
|
>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
<div class="modal">
|
||||||
<!-- Modal header -->
|
<!-- Modal header -->
|
||||||
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
|
<div class="modal__head">
|
||||||
<h2 class="text-xl font-bold text-neutral-100">{{ editingProfile ? 'Edit Profile' : 'New Profile' }}</h2>
|
<h2 class="modal__title">{{ editingProfile ? 'Edit profile' : 'New profile' }}</h2>
|
||||||
<button @click="closeModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
|
<IconButton icon="x" label="Close" @click="closeModal" />
|
||||||
<X class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6 space-y-6">
|
<div class="modal__body">
|
||||||
<!-- Basic Info -->
|
<!-- Basic info -->
|
||||||
<div class="space-y-4">
|
<div class="form-section">
|
||||||
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3>
|
<div class="t-eyebrow form-section__eyebrow">Basic information</div>
|
||||||
<div>
|
<Input
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Profile Name</label>
|
|
||||||
<input
|
|
||||||
v-model="form.profile_name"
|
v-model="form.profile_name"
|
||||||
type="text"
|
label="Profile name"
|
||||||
placeholder="Default Wipe Profile"
|
placeholder="Default wipe profile"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
<div class="cc-field">
|
||||||
<div>
|
<span class="cc-field__label">Description (optional)</span>
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="Standard wipe configuration for monthly force wipes"
|
placeholder="Standard wipe configuration for monthly force wipes"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
class="cc-textarea"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pre-Wipe Config -->
|
<!-- Pre-wipe config -->
|
||||||
<div class="space-y-4">
|
<div class="form-section">
|
||||||
<div class="flex items-center justify-between">
|
<div class="form-section__row">
|
||||||
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Pre-Wipe</h3>
|
<div class="t-eyebrow">Pre-wipe</div>
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.pre_wipe_config.enabled" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.pre_wipe_config.enabled"
|
||||||
Enabled
|
label="Enabled"
|
||||||
</label>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="check-grid">
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.pre_wipe_config.backup_before_wipe" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.pre_wipe_config.backup_before_wipe"
|
||||||
Backup before wipe
|
label="Backup before wipe"
|
||||||
</label>
|
/>
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.pre_wipe_config.run_final_save" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.pre_wipe_config.run_final_save"
|
||||||
Run final save
|
label="Run final save"
|
||||||
</label>
|
/>
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.pre_wipe_config.kick_players_before_wipe" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.pre_wipe_config.kick_players_before_wipe"
|
||||||
Kick players before wipe
|
label="Kick players before wipe"
|
||||||
</label>
|
/>
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.pre_wipe_config.discord_pre_announce" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.pre_wipe_config.discord_pre_announce"
|
||||||
Discord pre-announce
|
label="Discord pre-announce"
|
||||||
</label>
|
/>
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.pre_wipe_config.pushbullet_notify" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.pre_wipe_config.pushbullet_notify"
|
||||||
Pushbullet notify
|
label="Pushbullet notify"
|
||||||
</label>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="form.pre_wipe_config.kick_players_before_wipe">
|
<Input
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Kick Message</label>
|
v-if="form.pre_wipe_config.kick_players_before_wipe"
|
||||||
<input
|
|
||||||
v-model="form.pre_wipe_config.kick_message"
|
v-model="form.pre_wipe_config.kick_message"
|
||||||
type="text"
|
label="Kick message"
|
||||||
placeholder="Server is wiping, back soon!"
|
placeholder="Server is wiping, back soon!"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Post-Wipe Config -->
|
<!-- Post-wipe config -->
|
||||||
<div class="space-y-4">
|
<div class="form-section">
|
||||||
<div class="flex items-center justify-between">
|
<div class="form-section__row">
|
||||||
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Post-Wipe</h3>
|
<div class="t-eyebrow">Post-wipe</div>
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.post_wipe_config.enabled" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.post_wipe_config.enabled"
|
||||||
Enabled
|
label="Enabled"
|
||||||
</label>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="check-grid">
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.post_wipe_config.verify_server_started" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.post_wipe_config.verify_server_started"
|
||||||
Verify server started
|
label="Verify server started"
|
||||||
</label>
|
/>
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.post_wipe_config.verify_correct_map" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.post_wipe_config.verify_correct_map"
|
||||||
Verify correct map
|
label="Verify correct map"
|
||||||
</label>
|
/>
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.post_wipe_config.verify_plugins_loaded" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.post_wipe_config.verify_plugins_loaded"
|
||||||
Verify plugins loaded
|
label="Verify plugins loaded"
|
||||||
</label>
|
/>
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.post_wipe_config.verify_player_slots_open" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.post_wipe_config.verify_player_slots_open"
|
||||||
Verify player slots open
|
label="Verify player slots open"
|
||||||
</label>
|
/>
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.post_wipe_config.rollback_on_failure" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.post_wipe_config.rollback_on_failure"
|
||||||
Rollback on failure
|
label="Rollback on failure"
|
||||||
</label>
|
/>
|
||||||
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
|
<Checkbox
|
||||||
<input v-model="form.post_wipe_config.discord_post_announce" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
|
v-model="form.post_wipe_config.discord_post_announce"
|
||||||
Discord post-announce
|
label="Discord post-announce"
|
||||||
</label>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="number-grid">
|
||||||
<div>
|
<div class="cc-field">
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Max Restart Attempts</label>
|
<span class="cc-field__label">Max restart attempts</span>
|
||||||
|
<span class="cc-input cc-input--mono">
|
||||||
<input
|
<input
|
||||||
v-model.number="form.post_wipe_config.max_restart_attempts"
|
v-model.number="form.post_wipe_config.max_restart_attempts"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="10"
|
max="10"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="cc-field">
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Health Check Timeout (seconds)</label>
|
<span class="cc-field__label">Health check timeout (s)</span>
|
||||||
|
<span class="cc-input cc-input--mono">
|
||||||
<input
|
<input
|
||||||
v-model.number="form.post_wipe_config.health_check_timeout_seconds"
|
v-model.number="form.post_wipe_config.health_check_timeout_seconds"
|
||||||
type="number"
|
type="number"
|
||||||
min="30"
|
min="30"
|
||||||
max="600"
|
max="600"
|
||||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal footer -->
|
<!-- Modal footer -->
|
||||||
<div class="sticky bottom-0 bg-neutral-900 border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
|
<div class="modal__foot">
|
||||||
<button
|
<Button variant="secondary" @click="closeModal">Cancel</Button>
|
||||||
@click="closeModal"
|
<Button
|
||||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
:loading="isSaving"
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="saveProfile"
|
@click="saveProfile"
|
||||||
:disabled="isSaving"
|
|
||||||
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
{{ isSaving ? 'Saving...' : (editingProfile ? 'Save Changes' : 'Create Profile') }}
|
{{ editingProfile ? 'Save changes' : 'Create profile' }}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.profiles {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.page__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile list */
|
||||||
|
.profile-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile row inside panel */
|
||||||
|
.profile-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.profile-toggle {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
gap: 12px;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.profile-toggle:hover { background: var(--surface-hover); }
|
||||||
|
.profile-meta { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.profile-name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.profile-desc {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.profile-chev {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
transition: transform var(--dur-base) var(--ease-standard);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.profile-chev--open { transform: rotate(180deg); }
|
||||||
|
.profile-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded detail */
|
||||||
|
.profile-detail {
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.detail-eyebrow { margin-bottom: 10px; }
|
||||||
|
.detail-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.detail-kv {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.detail-k {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.detail-v-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-xl, 0 24px 60px rgba(0,0,0,.45));
|
||||||
|
width: 100%;
|
||||||
|
max-width: 660px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.modal__title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.modal__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.modal__foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form sections */
|
||||||
|
.form-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.form-section__eyebrow { margin-bottom: 4px; }
|
||||||
|
.form-section__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.check-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.number-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea token-based */
|
||||||
|
.cc-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 11px;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
outline: 0;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||||
|
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
|
.check-grid { grid-template-columns: 1fr; }
|
||||||
|
.number-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -4,9 +4,15 @@ import { useWipeStore } from '@/stores/wipe'
|
|||||||
import { useServerStore } from '@/stores/server'
|
import { useServerStore } from '@/stores/server'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2, Check, X } from 'lucide-vue-next'
|
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { safeDate } from '@/utils/formatters'
|
import { safeDate } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Switch from '@/components/ds/forms/Switch.vue'
|
||||||
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const wipeStore = useWipeStore()
|
const wipeStore = useWipeStore()
|
||||||
const server = useServerStore()
|
const server = useServerStore()
|
||||||
@@ -65,10 +71,32 @@ async function toggleSchedule(scheduleId: string, currentlyActive: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WIPE_TYPE_OPTIONS = [
|
||||||
|
{ value: 'map', label: 'Map' },
|
||||||
|
{ value: 'blueprint', label: 'Blueprint' },
|
||||||
|
{ value: 'full', label: 'Full' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function profileOptions() {
|
||||||
|
const opts: { value: string; label: string }[] = [{ value: '', label: 'No profile' }]
|
||||||
|
for (const p of wipeStore.profiles) {
|
||||||
|
opts.push({ value: p.id, label: p.profile_name })
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
function wipeTone(status: string): 'online' | 'offline' | 'warn' | 'neutral' {
|
||||||
|
if (status === 'success') return 'online'
|
||||||
|
if (status === 'failed' || status === 'rolled_back') return 'offline'
|
||||||
|
if (status === 'wiping' || status === 'pre_wipe' || status === 'post_wipe') return 'warn'
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await wipeStore.fetchProfiles()
|
await wipeStore.fetchProfiles()
|
||||||
if (wipeStore.profiles.length > 0 && wipeStore.profiles[0]) {
|
const first = wipeStore.profiles[0]
|
||||||
selectedProfileId.value = wipeStore.profiles[0].id
|
if (first) {
|
||||||
|
selectedProfileId.value = first.id
|
||||||
}
|
}
|
||||||
wipeStore.fetchSchedules()
|
wipeStore.fetchSchedules()
|
||||||
wipeStore.fetchHistory()
|
wipeStore.fetchHistory()
|
||||||
@@ -76,220 +104,413 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6">
|
<div class="wipes">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="page__head">
|
||||||
<div class="flex items-center gap-3">
|
<div>
|
||||||
<RefreshCw class="w-5 h-5 text-oxide-500" />
|
<div class="t-eyebrow">Operations</div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Auto-Wiper</h1>
|
<h1 class="page__title">Auto-wiper</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="page__actions">
|
||||||
<RouterLink
|
<RouterLink to="/wipes/profiles">
|
||||||
to="/wipes/profiles"
|
<Button variant="secondary" size="sm" icon="file-text">Profiles</Button>
|
||||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Profiles
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink to="/wipes/calendar">
|
||||||
to="/wipes/calendar"
|
<Button variant="secondary" size="sm" icon="calendar">Calendar</Button>
|
||||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Calendar
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink to="/wipes/history">
|
||||||
to="/wipes/history"
|
<Button variant="secondary" size="sm" icon="clock">History</Button>
|
||||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
History
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manual Trigger -->
|
<!-- Manual trigger -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Manual wipe" subtitle="Trigger an immediate wipe outside the schedule">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Manual Wipe</h2>
|
<div class="trigger-body">
|
||||||
<div v-if="wipeStore.profiles.length === 0" class="mb-4 flex items-center gap-2 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-lg px-4 py-2">
|
<Alert
|
||||||
<AlertTriangle class="w-4 h-4 shrink-0" />
|
v-if="wipeStore.profiles.length === 0"
|
||||||
No wipe profiles found. <RouterLink to="/wipes/profiles" class="underline hover:text-yellow-300 ml-1">Create a profile</RouterLink> before triggering a wipe.
|
tone="warn"
|
||||||
</div>
|
title="No wipe profiles"
|
||||||
<div class="flex items-end gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-neutral-500 mb-2">Wipe Type</label>
|
|
||||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
|
||||||
<button
|
|
||||||
v-for="opt in (['map', 'blueprint', 'full'] as const)"
|
|
||||||
:key="opt"
|
|
||||||
@click="triggerType = opt"
|
|
||||||
class="px-4 py-2 text-sm font-medium transition-colors capitalize"
|
|
||||||
:class="triggerType === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
|
||||||
>
|
>
|
||||||
{{ opt }}
|
<template #default>
|
||||||
|
Create a profile before triggering a wipe.
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<RouterLink to="/wipes/profiles">
|
||||||
|
<Button variant="outline" size="sm" icon="plus">New profile</Button>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div class="trigger-row">
|
||||||
|
<!-- Wipe type segment -->
|
||||||
|
<div class="trigger-field">
|
||||||
|
<div class="cc-field__label">Wipe type</div>
|
||||||
|
<div class="type-seg">
|
||||||
|
<button
|
||||||
|
v-for="opt in WIPE_TYPE_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
class="type-seg__btn"
|
||||||
|
:class="triggerType === opt.value && 'type-seg__btn--active'"
|
||||||
|
@click="triggerType = opt.value as 'map' | 'blueprint' | 'full'"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-neutral-500 mb-2">Profile</label>
|
<!-- Profile select -->
|
||||||
<select
|
<Select
|
||||||
v-model="selectedProfileId"
|
label="Profile"
|
||||||
|
:options="profileOptions()"
|
||||||
:disabled="wipeStore.profiles.length === 0"
|
:disabled="wipeStore.profiles.length === 0"
|
||||||
class="px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors disabled:opacity-50"
|
:model-value="selectedProfileId"
|
||||||
>
|
@update:model-value="selectedProfileId = $event ?? ''"
|
||||||
<option value="">No profile</option>
|
/>
|
||||||
<option v-for="profile in wipeStore.profiles" :key="profile.id" :value="profile.id">
|
|
||||||
{{ profile.profile_name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="triggerDryRun"
|
|
||||||
:disabled="dryRunLoading || wipeStore.profiles.length === 0"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 border border-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="dryRunLoading" class="w-4 h-4 animate-spin" />
|
|
||||||
<AlertTriangle v-else class="w-4 h-4" />
|
|
||||||
Dry Run
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="triggerWipe"
|
|
||||||
:disabled="triggerLoading || wipeStore.profiles.length === 0 || server.connection?.connection_status !== 'connected'"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-red-600 hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="triggerLoading" class="w-4 h-4 animate-spin" />
|
|
||||||
<Zap v-else class="w-4 h-4" />
|
|
||||||
Trigger Wipe
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dry-Run Results -->
|
<div class="trigger-actions">
|
||||||
<div v-if="dryRunResult" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Button
|
||||||
<div class="flex items-center justify-between mb-4">
|
variant="secondary"
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Dry-Run Results</h2>
|
size="md"
|
||||||
<div class="flex items-center gap-3">
|
icon="flask-conical"
|
||||||
<span class="text-xs text-neutral-500">
|
:loading="dryRunLoading"
|
||||||
Estimated: {{ Math.round(dryRunResult.estimated_duration_seconds) }}s
|
:disabled="wipeStore.profiles.length === 0"
|
||||||
</span>
|
@click="triggerDryRun"
|
||||||
<button
|
|
||||||
@click="dryRunResult = null"
|
|
||||||
class="p-1 text-neutral-500 hover:text-neutral-300 rounded transition-colors"
|
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4" />
|
Dry run
|
||||||
</button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="md"
|
||||||
|
icon="zap"
|
||||||
|
:loading="triggerLoading"
|
||||||
|
:disabled="triggerLoading || wipeStore.profiles.length === 0 || server.connection?.connection_status !== 'connected'"
|
||||||
|
@click="triggerWipe"
|
||||||
|
>
|
||||||
|
Trigger wipe
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Dry-run results -->
|
||||||
|
<Panel
|
||||||
|
v-if="dryRunResult"
|
||||||
|
title="Dry-run results"
|
||||||
|
:subtitle="`Estimated duration: ${Math.round(dryRunResult.estimated_duration_seconds)}s`"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button variant="ghost" size="sm" icon="x" @click="dryRunResult = null">Dismiss</Button>
|
||||||
|
</template>
|
||||||
|
<div class="dry-run-grid">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-medium text-red-400 mb-2 flex items-center gap-1.5">
|
<div class="dry-run__head dry-run__head--delete">
|
||||||
<X class="w-3.5 h-3.5" />
|
Would delete ({{ dryRunResult.would_delete.length }})
|
||||||
Would Delete ({{ dryRunResult.would_delete.length }})
|
</div>
|
||||||
</p>
|
<div v-if="dryRunResult.would_delete.length === 0" class="dry-run__empty">
|
||||||
<div v-if="dryRunResult.would_delete.length === 0" class="text-xs text-neutral-600 italic">Nothing to delete</div>
|
Nothing to delete
|
||||||
<ul v-else class="space-y-1">
|
</div>
|
||||||
|
<ul v-else class="dry-run__list">
|
||||||
<li
|
<li
|
||||||
v-for="item in dryRunResult.would_delete"
|
v-for="item in dryRunResult.would_delete"
|
||||||
:key="item"
|
:key="item"
|
||||||
class="text-xs font-mono text-neutral-400 bg-red-500/5 border border-red-500/10 rounded px-2 py-1"
|
class="dry-run__item dry-run__item--delete"
|
||||||
>{{ item }}</li>
|
>{{ item }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-medium text-green-400 mb-2 flex items-center gap-1.5">
|
<div class="dry-run__head dry-run__head--keep">
|
||||||
<Check class="w-3.5 h-3.5" />
|
Would preserve ({{ dryRunResult.would_preserve.length }})
|
||||||
Would Preserve ({{ dryRunResult.would_preserve.length }})
|
</div>
|
||||||
</p>
|
<div v-if="dryRunResult.would_preserve.length === 0" class="dry-run__empty">
|
||||||
<div v-if="dryRunResult.would_preserve.length === 0" class="text-xs text-neutral-600 italic">Nothing preserved</div>
|
Nothing preserved
|
||||||
<ul v-else class="space-y-1">
|
</div>
|
||||||
|
<ul v-else class="dry-run__list">
|
||||||
<li
|
<li
|
||||||
v-for="item in dryRunResult.would_preserve"
|
v-for="item in dryRunResult.would_preserve"
|
||||||
:key="item"
|
:key="item"
|
||||||
class="text-xs font-mono text-neutral-400 bg-green-500/5 border border-green-500/10 rounded px-2 py-1"
|
class="dry-run__item dry-run__item--keep"
|
||||||
>{{ item }}</li>
|
>{{ item }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Upcoming Schedules -->
|
<!-- Scheduled wipes -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel title="Scheduled wipes" subtitle="Active cron schedules">
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Scheduled Wipes</h2>
|
<EmptyState
|
||||||
<div v-if="wipeStore.schedules.length === 0" class="text-sm text-neutral-500 py-4 text-center">
|
v-if="wipeStore.schedules.length === 0"
|
||||||
No wipe schedules configured. Create a profile and schedule to automate wipes.
|
icon="calendar-clock"
|
||||||
</div>
|
title="No schedules"
|
||||||
<div v-else class="space-y-3">
|
description="Create a profile and schedule to automate wipes."
|
||||||
|
/>
|
||||||
|
<div v-else class="sched-list">
|
||||||
<div
|
<div
|
||||||
v-for="schedule in wipeStore.schedules"
|
v-for="schedule in wipeStore.schedules"
|
||||||
:key="schedule.id"
|
:key="schedule.id"
|
||||||
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
|
class="sched-row"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="sched-info">
|
||||||
<Clock class="w-4 h-4 text-neutral-500" />
|
<div class="sched-name">{{ schedule.schedule_name }}</div>
|
||||||
<div>
|
<div class="sched-meta">
|
||||||
<p class="text-sm font-medium text-neutral-200">{{ schedule.schedule_name }}</p>
|
{{ schedule.wipe_type }} wipe
|
||||||
<p class="text-xs text-neutral-500">
|
·
|
||||||
{{ schedule.wipe_type }} wipe · {{ schedule.cron_expression }} ({{ schedule.timezone }})
|
<span class="mono">{{ schedule.cron_expression }}</span>
|
||||||
</p>
|
· {{ schedule.timezone }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="sched-controls">
|
||||||
<span
|
<Badge :tone="schedule.is_active ? 'online' : 'neutral'">
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
|
||||||
:class="schedule.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
|
||||||
>
|
|
||||||
{{ schedule.is_active ? 'Active' : 'Paused' }}
|
{{ schedule.is_active ? 'Active' : 'Paused' }}
|
||||||
</span>
|
</Badge>
|
||||||
<button
|
<Switch
|
||||||
@click="toggleSchedule(schedule.id, schedule.is_active)"
|
:model-value="schedule.is_active"
|
||||||
:disabled="scheduleToggling === schedule.id"
|
:disabled="scheduleToggling === schedule.id"
|
||||||
class="w-9 h-5 rounded-full transition-colors disabled:opacity-40 cursor-pointer"
|
@update:model-value="toggleSchedule(schedule.id, schedule.is_active)"
|
||||||
:class="schedule.is_active ? 'bg-oxide-500' : 'bg-neutral-700'"
|
|
||||||
:title="schedule.is_active ? 'Pause schedule' : 'Activate schedule'"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="scheduleToggling === schedule.id" class="w-3.5 h-3.5 text-white animate-spin mx-auto" />
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
|
||||||
:class="schedule.is_active ? 'translate-x-4.5' : 'translate-x-0.5'"
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Recent History -->
|
<!-- Recent history -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel :flush-body="true" title="Recent wipes">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<template #actions>
|
||||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Recent Wipes</h2>
|
<RouterLink to="/wipes/history">
|
||||||
<RouterLink to="/wipes/history" class="text-sm text-oxide-400 hover:text-oxide-300 transition-colors">
|
<Button variant="ghost" size="sm">View all</Button>
|
||||||
View All
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</template>
|
||||||
<div v-if="wipeStore.history.length === 0" class="text-sm text-neutral-500 py-4 text-center">
|
|
||||||
No wipe history yet.
|
<EmptyState
|
||||||
</div>
|
v-if="wipeStore.history.length === 0"
|
||||||
<div v-else class="space-y-2">
|
icon="trash-2"
|
||||||
<div
|
title="No wipe history"
|
||||||
|
description="Wipes will appear here once they run."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<table v-else class="cc-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Trigger</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
v-for="wipe in wipeStore.history.slice(0, 5)"
|
v-for="wipe in wipeStore.history.slice(0, 5)"
|
||||||
:key="wipe.id"
|
:key="wipe.id"
|
||||||
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
|
|
||||||
>
|
>
|
||||||
<div>
|
<td class="td-primary">{{ wipe.wipe_type }} wipe</td>
|
||||||
<p class="text-sm text-neutral-200">{{ wipe.wipe_type }} wipe</p>
|
<td>{{ wipe.trigger_type }}</td>
|
||||||
<p class="text-xs text-neutral-500">{{ wipe.trigger_type }} · {{ safeDate(wipe.started_at, 'Pending') }}</p>
|
<td class="td-mono">{{ safeDate(wipe.started_at, 'Pending') }}</td>
|
||||||
</div>
|
<td>
|
||||||
<span
|
<Badge :tone="wipeTone(wipe.status)">{{ wipe.status }}</Badge>
|
||||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
</td>
|
||||||
:class="{
|
</tr>
|
||||||
'bg-green-500/10 text-green-400': wipe.status === 'success',
|
</tbody>
|
||||||
'bg-red-500/10 text-red-400': wipe.status === 'failed' || wipe.status === 'rolled_back',
|
</table>
|
||||||
'bg-yellow-500/10 text-yellow-400': wipe.status === 'wiping' || wipe.status === 'pre_wipe' || wipe.status === 'post_wipe',
|
</Panel>
|
||||||
'bg-neutral-700/50 text-neutral-400': wipe.status === 'pending',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ wipe.status }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wipes {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.page__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page__title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.page__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page__actions a { text-decoration: none; }
|
||||||
|
|
||||||
|
/* Trigger body */
|
||||||
|
.trigger-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.trigger-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.trigger-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.trigger-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wipe type segment */
|
||||||
|
.type-seg {
|
||||||
|
display: flex;
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.type-seg__btn {
|
||||||
|
height: var(--control-h-md);
|
||||||
|
padding: 0 14px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.type-seg__btn:hover { color: var(--text-primary); background: var(--surface-hover); }
|
||||||
|
.type-seg__btn--active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dry-run results */
|
||||||
|
.dry-run-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.dry-run__head {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.dry-run__head--delete { color: var(--status-offline); }
|
||||||
|
.dry-run__head--keep { color: var(--status-online); }
|
||||||
|
.dry-run__empty {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.dry-run__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.dry-run__item {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.dry-run__item--delete {
|
||||||
|
background: var(--status-offline-soft);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--status-offline-border);
|
||||||
|
}
|
||||||
|
.dry-run__item--keep {
|
||||||
|
background: var(--status-online-soft);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--status-online-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schedule list */
|
||||||
|
.sched-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.sched-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 2px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.sched-row:last-child { border-bottom: 0; }
|
||||||
|
.sched-info { flex: 1; min-width: 0; }
|
||||||
|
.sched-name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.sched-meta {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.sched-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.cc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.cc-table thead tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
}
|
||||||
|
.cc-table th {
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cc-table tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.cc-table tbody tr:last-child { border-bottom: 0; }
|
||||||
|
.cc-table tbody tr:hover { background: var(--surface-hover); }
|
||||||
|
.cc-table td {
|
||||||
|
padding: 11px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.td-primary { color: var(--text-primary); font-weight: 500; }
|
||||||
|
.td-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dry-run-grid { grid-template-columns: 1fr; }
|
||||||
|
.trigger-row { flex-direction: column; align-items: stretch; }
|
||||||
|
.trigger-actions { margin-left: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
159
frontend/src/views/admin/_dashboardMock.ts
Normal file
159
frontend/src/views/admin/_dashboardMock.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard mock data — representative placeholder pending multi-instance backend.
|
||||||
|
* Current backend is single-server-per-license; the fleet view is a forward-looking
|
||||||
|
* surface that will bind to a multi-instance API. All data here is static and clearly
|
||||||
|
* labeled so it is never confused for real tenant data.
|
||||||
|
*
|
||||||
|
* Per-game fields are isolated by game key — a Dune row NEVER receives a Rust field
|
||||||
|
* like `umod`, and vice-versa. See GAME_FIELDS for the row-field contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ServerStatus = 'online' | 'offline' | 'starting' | 'wiping' | 'updating'
|
||||||
|
export type GameKey = 'rust' | 'dune' | 'conan' | 'soulmask'
|
||||||
|
|
||||||
|
export interface MockServer {
|
||||||
|
game: GameKey
|
||||||
|
gameIcon: string
|
||||||
|
name: string
|
||||||
|
region: string
|
||||||
|
map: string
|
||||||
|
version: string
|
||||||
|
status: ServerStatus
|
||||||
|
players: { cur: number; max: number }
|
||||||
|
cpu?: number
|
||||||
|
ram?: number
|
||||||
|
ramSub?: string
|
||||||
|
ip: string
|
||||||
|
// Rust-only
|
||||||
|
umod?: string
|
||||||
|
wipe?: string
|
||||||
|
// Dune-only
|
||||||
|
sietches?: string
|
||||||
|
control?: string
|
||||||
|
// Conan-only
|
||||||
|
clans?: string
|
||||||
|
purge?: string
|
||||||
|
// Soulmask-only
|
||||||
|
tribe?: string
|
||||||
|
mask?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockFeedLine {
|
||||||
|
time: string
|
||||||
|
level: 'cmd' | 'chat' | 'info' | 'warn' | 'error' | 'connect' | 'kill'
|
||||||
|
who?: string
|
||||||
|
msg: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockWipe {
|
||||||
|
game: GameKey
|
||||||
|
name: string
|
||||||
|
when: string
|
||||||
|
tone: 'wiping' | 'starting' | 'warn' | 'online'
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatItem {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fleet server roster
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const MOCK_SERVERS: MockServer[] = [
|
||||||
|
{
|
||||||
|
game: 'rust', gameIcon: 'box', name: 'Main · 2x Vanilla', region: 'US-East',
|
||||||
|
map: 'Procedural 4500', version: 'v2024.12', status: 'online',
|
||||||
|
players: { cur: 142, max: 200 }, cpu: 41, ram: 68, ramSub: '5.4 / 8 GB',
|
||||||
|
ip: '89.142.0.7:28015', umod: '14', wipe: '2d',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: 'rust', gameIcon: 'box', name: '5x Modded · Build', region: 'US-East',
|
||||||
|
map: 'Barren 3000', version: 'v2024.12', status: 'online',
|
||||||
|
players: { cur: 38, max: 100 }, ip: '89.142.0.7:28017', umod: '27', wipe: '2d',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: 'rust', gameIcon: 'box', name: 'Hardcore · Solo/Duo', region: 'US-West',
|
||||||
|
map: 'Procedural 3500', version: 'v2024.12', status: 'wiping',
|
||||||
|
players: { cur: 0, max: 80 }, cpu: 8, ram: 30, ramSub: '2.4 / 8 GB',
|
||||||
|
ip: '74.91.3.2:28015', umod: '9', wipe: 'now',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: 'dune', gameIcon: 'sun', name: 'Arrakis · Hardcore', region: 'EU-Frankfurt',
|
||||||
|
map: 'Hagga Basin', version: 'v0.9.4', status: 'online',
|
||||||
|
players: { cur: 54, max: 60 }, cpu: 63, ram: 74, ramSub: '11.8 / 16 GB',
|
||||||
|
ip: '51.83.12.4:7777', sietches: '3', control: '62%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: 'dune', gameIcon: 'sun', name: 'Deep Desert · PvP', region: 'EU-Frankfurt',
|
||||||
|
map: 'Deep Desert', version: 'v0.9.4', status: 'starting',
|
||||||
|
players: { cur: 0, max: 40 }, ip: '51.83.12.4:7779', sietches: '0', control: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: 'dune', gameIcon: 'sun', name: 'Sietch · Roleplay', region: 'SG-Singapore',
|
||||||
|
map: 'Hagga Basin', version: 'v0.9.4', status: 'offline',
|
||||||
|
players: { cur: 0, max: 50 }, ip: '139.99.4.8:7777', sietches: '5', control: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: 'conan', gameIcon: 'swords', name: 'Exiled Lands · PvP-C', region: 'US-East',
|
||||||
|
map: 'Exiled Lands', version: 'v3.0.5', status: 'online',
|
||||||
|
players: { cur: 32, max: 40 }, cpu: 48, ram: 60, ramSub: '9.6 / 16 GB',
|
||||||
|
ip: '89.142.0.7:7777', clans: '7', purge: 'Tier 4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
game: 'soulmask', gameIcon: 'drama', name: 'Sienna Plateau · PvE', region: 'EU-Frankfurt',
|
||||||
|
map: 'Sienna Plateau', version: 'v1.4', status: 'online',
|
||||||
|
players: { cur: 18, max: 30 }, cpu: 35, ram: 52, ramSub: '8.3 / 16 GB',
|
||||||
|
ip: '51.83.12.4:8777', tribe: '4', mask: 'Jaguar',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-game stat field sets — never share slots across games
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function pl(s: MockServer): string {
|
||||||
|
return `${s.players.cur} / ${s.players.max}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GAME_FIELDS: Record<GameKey, (s: MockServer) => StatItem[]> = {
|
||||||
|
rust: (s) => [{ label: 'Players', value: pl(s) }, { label: 'uMod', value: s.umod ?? '—' }, { label: 'Wipe', value: s.wipe ?? '—' }],
|
||||||
|
dune: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Sietches', value: s.sietches ?? '—' }, { label: 'Control', value: s.control ?? '—' }],
|
||||||
|
conan: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Clans', value: s.clans ?? '—' }, { label: 'Purge', value: s.purge ?? '—' }],
|
||||||
|
soulmask: (s) => [{ label: 'Players', value: pl(s) }, { label: 'Tribe', value: s.tribe ?? '—' }, { label: 'Mask', value: s.mask ?? '—' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStats(s: MockServer): StatItem[] {
|
||||||
|
const fn = GAME_FIELDS[s.game] ?? GAME_FIELDS.rust
|
||||||
|
return fn(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Live activity feed
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const MOCK_FEED: MockFeedLine[] = [
|
||||||
|
{ time: '18:42:07', level: 'connect', who: 'ShadowFox', msg: 'connected — 89.142.0.7' },
|
||||||
|
{ time: '18:41:55', level: 'cmd', who: 'admin', msg: 'oxide.grant group default kits.use' },
|
||||||
|
{ time: '18:41:30', level: 'kill', who: 'ironMaiden', msg: 'was killed by Scorpion (AK-47, 84m)' },
|
||||||
|
{ time: '18:40:12', level: 'warn', msg: '5x Modded agent reconnected — telemetry resuming' },
|
||||||
|
{ time: '18:39:48', level: 'chat', who: 'BlightWalker:', msg: 'anyone selling sulfur?' },
|
||||||
|
{ time: '18:38:02', level: 'info', msg: 'RaidableBases spawned Tier-3 at G14' },
|
||||||
|
{ time: '18:36:51', level: 'connect', who: 'Vex', msg: 'connected — 51.83.12.4' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Upcoming wipes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const MOCK_WIPES: MockWipe[] = [
|
||||||
|
{ game: 'rust', name: 'Main · 2x Vanilla', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map + BP' },
|
||||||
|
{ game: 'rust', name: '5x Modded · Build', when: 'Thu · 18:00 UTC', tone: 'wiping', label: 'Map only' },
|
||||||
|
{ game: 'dune', name: 'Deep Desert · PvP', when: 'Sun · 12:00 UTC', tone: 'starting', label: 'Deep Desert' },
|
||||||
|
]
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { Mail, ArrowLeft, CheckCircle2, Loader2 } from 'lucide-vue-next'
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
@@ -27,76 +30,149 @@ async function handleSubmit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-neutral-950 flex items-center justify-center p-6">
|
<div class="auth-shell">
|
||||||
<div class="w-full max-w-md">
|
<div class="auth-card">
|
||||||
<!-- Logo -->
|
<!-- Branding -->
|
||||||
<div class="text-center mb-8">
|
<div class="auth-brand">
|
||||||
<div class="flex items-center justify-center gap-2 mb-2">
|
<div class="auth-brand__mark">
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-10 w-10" />
|
<Logo :size="36" tagline="Game Server Operations" />
|
||||||
<h1 class="text-2xl font-bold text-oxide-500 tracking-wider">CORROSION</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-neutral-500 text-sm">Server Management Platform</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card -->
|
<!-- Success state -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-8">
|
<div v-if="success" class="auth-success">
|
||||||
<div v-if="success" class="text-center space-y-4">
|
<Alert tone="online" title="Check your email">
|
||||||
<CheckCircle2 class="w-12 h-12 text-green-500 mx-auto" />
|
Reset instructions sent to <strong class="auth-success__addr">{{ email }}</strong>
|
||||||
<h2 class="text-xl font-bold text-neutral-100">Check your email</h2>
|
</Alert>
|
||||||
<p class="text-sm text-neutral-400">
|
<RouterLink to="/login" class="auth-back-link">
|
||||||
We've sent password reset instructions to <strong class="text-neutral-200">{{ email }}</strong>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
</p>
|
<path d="M19 12H5M12 5l-7 7 7 7" />
|
||||||
<RouterLink
|
</svg>
|
||||||
to="/login"
|
Back to sign in
|
||||||
class="inline-flex items-center gap-2 text-sm text-oxide-400 hover:text-oxide-300 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft class="w-4 h-4" />
|
|
||||||
Back to login
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form v-else @submit.prevent="handleSubmit" class="space-y-6">
|
<!-- Form -->
|
||||||
<div class="text-center mb-6">
|
<template v-else>
|
||||||
<h2 class="text-xl font-bold text-neutral-100 mb-2">Forgot password?</h2>
|
<div class="auth-form-header">
|
||||||
<p class="text-sm text-neutral-400">Enter your email to receive reset instructions</p>
|
<h1 class="auth-form-header__title">Forgot password?</h1>
|
||||||
|
<p class="auth-form-header__sub">Enter your email to receive reset instructions.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<Alert v-if="errorMessage" tone="danger">{{ errorMessage }}</Alert>
|
||||||
<label class="block text-sm text-neutral-400 mb-2">Email</label>
|
|
||||||
<div class="relative">
|
<form class="auth-form" @submit.prevent="handleSubmit">
|
||||||
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-500" />
|
<Input
|
||||||
<input
|
|
||||||
v-model="email"
|
v-model="email"
|
||||||
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
|
||||||
class="w-full pl-10 pr-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
|
||||||
placeholder="admin@example.com"
|
placeholder="admin@example.com"
|
||||||
|
icon="mail"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="errorMessage" class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
<Button
|
||||||
<p class="text-sm text-red-400">{{ errorMessage }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading"
|
:loading="isLoading"
|
||||||
class="w-full flex items-center justify-center gap-2 py-2 bg-oxide-500 hover:bg-oxide-600 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
:block="true"
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isLoading" class="w-5 h-5 animate-spin" />
|
Send reset link
|
||||||
<span>{{ isLoading ? 'Sending...' : 'Send Reset Link' }}</span>
|
</Button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<RouterLink
|
|
||||||
to="/login"
|
|
||||||
class="flex items-center justify-center gap-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft class="w-4 h-4" />
|
|
||||||
Back to login
|
|
||||||
</RouterLink>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
|
<RouterLink to="/login" class="auth-back-link">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M19 12H5M12 5l-7 7 7 7" />
|
||||||
|
</svg>
|
||||||
|
Back to sign in
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default), var(--shadow-lg);
|
||||||
|
padding: var(--space-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand__mark {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success state */
|
||||||
|
.auth-success {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.auth-success__addr {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form header */
|
||||||
|
.auth-form-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.auth-form-header__title {
|
||||||
|
margin: 0 0 var(--space-1-5);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
.auth-form-header__sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back link */
|
||||||
|
.auth-back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1-5);
|
||||||
|
align-self: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 120ms ease;
|
||||||
|
}
|
||||||
|
.auth-back-link:hover { color: var(--text-secondary); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { AuthResponse } from '@/types'
|
import type { AuthResponse } from '@/types'
|
||||||
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -96,96 +100,58 @@ function handleBackToLogin() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-neutral-950 flex items-center justify-center px-4">
|
<div class="auth-shell">
|
||||||
<div class="w-full max-w-md">
|
<div class="auth-card">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-8">
|
|
||||||
<!-- Branding -->
|
<!-- Branding -->
|
||||||
<div class="text-center mb-8">
|
<div class="auth-brand">
|
||||||
<img src="/logo-hero.png" alt="Corrosion Management" class="h-32 mx-auto mb-2" />
|
<div class="auth-brand__mark">
|
||||||
|
<Logo :size="40" :glow="true" tagline="Game Server Operations" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error message -->
|
<!-- Error message -->
|
||||||
<div
|
<Alert v-if="error" tone="danger" class="auth-alert">{{ error }}</Alert>
|
||||||
v-if="error"
|
|
||||||
class="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login form -->
|
<!-- Login form -->
|
||||||
<form v-if="!showTotpInput" @submit.prevent="handleLogin" class="space-y-5">
|
<form v-if="!showTotpInput" class="auth-form" @submit.prevent="handleLogin">
|
||||||
<div>
|
<Input
|
||||||
<label for="email" class="block text-sm font-medium text-neutral-400 mb-1.5">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
v-model="email"
|
v-model="email"
|
||||||
|
id="email"
|
||||||
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
|
||||||
autocomplete="email"
|
|
||||||
placeholder="admin@example.com"
|
placeholder="admin@example.com"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
autocomplete="email"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Input
|
||||||
<label for="password" class="block text-sm font-medium text-neutral-400 mb-1.5">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
v-model="password"
|
v-model="password"
|
||||||
|
id="password"
|
||||||
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
|
||||||
autocomplete="current-password"
|
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
autocomplete="current-password"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="loading"
|
:loading="loading"
|
||||||
class="w-full py-2.5 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
:block="true"
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
<svg
|
Sign in
|
||||||
v-if="loading"
|
</Button>
|
||||||
class="animate-spin h-5 w-5 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{{ loading ? 'Signing in...' : 'Sign In' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- TOTP verification form -->
|
<!-- TOTP verification -->
|
||||||
<div v-else class="space-y-5">
|
<div v-else class="auth-form">
|
||||||
<div class="text-center">
|
<div class="auth-totp-hint">
|
||||||
<p class="text-sm text-neutral-400">
|
<p class="auth-totp-hint__text">Enter the 6-digit code from your authenticator app.</p>
|
||||||
Enter the 6-digit code from your authenticator app.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="auth-totp-field">
|
||||||
<label for="totp" class="block text-sm font-medium text-neutral-400 mb-1.5">
|
<label class="auth-totp-field__label" for="totp">Authentication code</label>
|
||||||
Authentication Code
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="totp"
|
id="totp"
|
||||||
ref="totpInputEl"
|
ref="totpInputEl"
|
||||||
@@ -195,59 +161,160 @@ function handleBackToLogin() {
|
|||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
placeholder="000000"
|
placeholder="000000"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
|
class="auth-totp-input"
|
||||||
@keydown.enter="handleTotpVerify"
|
@keydown.enter="handleTotpVerify"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors text-center tracking-widest text-lg font-mono"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="totpCode.length !== 6 || loading"
|
:loading="loading"
|
||||||
|
:disabled="totpCode.length !== 6"
|
||||||
|
:block="true"
|
||||||
|
size="md"
|
||||||
@click="handleTotpVerify"
|
@click="handleTotpVerify"
|
||||||
class="w-full py-2.5 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
>
|
||||||
<svg
|
Verify code
|
||||||
v-if="loading"
|
</Button>
|
||||||
class="animate-spin h-5 w-5 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{{ loading ? 'Verifying...' : 'Verify Code' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
|
class="auth-back-link"
|
||||||
@click="handleBackToLogin"
|
@click="handleBackToLogin"
|
||||||
class="w-full py-2 text-sm text-neutral-500 hover:text-neutral-300 transition-colors"
|
|
||||||
>
|
>
|
||||||
Back to sign in
|
Back to sign in
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Register link -->
|
<!-- Register link -->
|
||||||
<p v-if="!showTotpInput" class="mt-6 text-center text-sm text-neutral-500">
|
<p v-if="!showTotpInput" class="auth-footer">
|
||||||
Don't have an account?
|
No account?
|
||||||
<router-link to="/register" class="text-oxide-400 hover:text-oxide-300 transition-colors">
|
<router-link to="/register" class="auth-footer__link">Create one</router-link>
|
||||||
Create one
|
|
||||||
</router-link>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default), var(--shadow-lg);
|
||||||
|
padding: var(--space-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand__mark {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-alert {
|
||||||
|
/* Alert component handles its own layout */
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TOTP hint text */
|
||||||
|
.auth-totp-hint {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.auth-totp-hint__text {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TOTP bare input — monospace, centered, large tracking */
|
||||||
|
.auth-totp-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1-5);
|
||||||
|
}
|
||||||
|
.auth-totp-field__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.auth-totp-input {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--control-h-md);
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.35em;
|
||||||
|
text-align: center;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
.auth-totp-input:focus {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||||
|
}
|
||||||
|
.auth-totp-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost back link */
|
||||||
|
.auth-back-link {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 0;
|
||||||
|
transition: color 120ms ease;
|
||||||
|
}
|
||||||
|
.auth-back-link:hover { color: var(--text-secondary); }
|
||||||
|
.auth-back-link:disabled { opacity: 0.45; pointer-events: none; }
|
||||||
|
|
||||||
|
/* Footer link row */
|
||||||
|
.auth-footer {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.auth-footer__link {
|
||||||
|
color: var(--accent-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.auth-footer__link:hover { color: var(--accent-hover); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { AuthResponse } from '@/types'
|
import type { AuthResponse } from '@/types'
|
||||||
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -74,138 +78,149 @@ async function handleRegister() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-neutral-950 flex items-center justify-center px-4">
|
<div class="auth-shell">
|
||||||
<div class="w-full max-w-md">
|
<div class="auth-card">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-8">
|
|
||||||
<!-- Branding -->
|
<!-- Branding -->
|
||||||
<div class="text-center mb-8">
|
<div class="auth-brand">
|
||||||
<img src="/logo-hero.png" alt="Corrosion Management" class="h-28 mx-auto mb-2" />
|
<div class="auth-brand__mark">
|
||||||
<p class="text-sm text-neutral-500 mt-2">Create your account</p>
|
<Logo :size="36" tagline="Game Server Operations" />
|
||||||
|
</div>
|
||||||
|
<p class="auth-brand__sub">Create your account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error message -->
|
<!-- Error message -->
|
||||||
<div
|
<Alert v-if="error" tone="danger">{{ error }}</Alert>
|
||||||
v-if="error"
|
|
||||||
class="mb-6 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Register form -->
|
<!-- Register form -->
|
||||||
<form @submit.prevent="handleRegister" class="space-y-5">
|
<form class="auth-form" @submit.prevent="handleRegister">
|
||||||
<div>
|
<Input
|
||||||
<label for="email" class="block text-sm font-medium text-neutral-400 mb-1.5">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
v-model="email"
|
v-model="email"
|
||||||
|
id="email"
|
||||||
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
|
||||||
autocomplete="email"
|
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
autocomplete="email"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Input
|
||||||
<label for="username" class="block text-sm font-medium text-neutral-400 mb-1.5">
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
v-model="username"
|
v-model="username"
|
||||||
|
id="username"
|
||||||
|
label="Username"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
|
||||||
autocomplete="username"
|
|
||||||
placeholder="At least 3 characters"
|
placeholder="At least 3 characters"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
autocomplete="username"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Input
|
||||||
<label for="password" class="block text-sm font-medium text-neutral-400 mb-1.5">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
v-model="password"
|
v-model="password"
|
||||||
|
id="password"
|
||||||
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
required
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="At least 8 characters"
|
placeholder="At least 8 characters"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="confirm-password" class="block text-sm font-medium text-neutral-400 mb-1.5">
|
|
||||||
Confirm Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="confirm-password"
|
|
||||||
v-model="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
id="confirm-password"
|
||||||
|
label="Confirm password"
|
||||||
|
type="password"
|
||||||
placeholder="Re-enter your password"
|
placeholder="Re-enter your password"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
autocomplete="new-password"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Input
|
||||||
<label for="license-key" class="block text-sm font-medium text-neutral-400 mb-1.5">
|
|
||||||
License Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="license-key"
|
|
||||||
v-model="licenseKey"
|
v-model="licenseKey"
|
||||||
|
id="license-key"
|
||||||
|
label="License key"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="XXXX-XXXX-XXXX-XXXX"
|
placeholder="XXXX-XXXX-XXXX-XXXX"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors font-mono tracking-wider"
|
autocomplete="off"
|
||||||
|
:mono="true"
|
||||||
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="loading || !formValid"
|
:loading="loading"
|
||||||
class="w-full py-2.5 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
:disabled="!formValid"
|
||||||
|
:block="true"
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
<svg
|
Create account
|
||||||
v-if="loading"
|
</Button>
|
||||||
class="animate-spin h-5 w-5 text-white"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{{ loading ? 'Creating account...' : 'Create Account' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Login link -->
|
<!-- Login link -->
|
||||||
<p class="mt-6 text-center text-sm text-neutral-500">
|
<p class="auth-footer">
|
||||||
Already have an account?
|
Already have an account?
|
||||||
<router-link to="/login" class="text-oxide-400 hover:text-oxide-300 transition-colors">
|
<router-link to="/login" class="auth-footer__link">Sign in</router-link>
|
||||||
Sign in
|
|
||||||
</router-link>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default), var(--shadow-lg);
|
||||||
|
padding: var(--space-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand__mark {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand__sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.auth-footer__link {
|
||||||
|
color: var(--accent-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.auth-footer__link:hover { color: var(--accent-hover); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { Server, Wifi, CheckCircle, ArrowRight, Loader2 } from 'lucide-vue-next'
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -21,13 +24,25 @@ const serverForm = ref({
|
|||||||
game_port: 28015,
|
game_port: 28015,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// String mirrors for the DS Input component (which binds defineModel<string>).
|
||||||
|
// Changes sync back to serverForm as numbers before submission.
|
||||||
|
const serverPortStr = ref(String(serverForm.value.server_port))
|
||||||
|
const gamePortStr = ref(String(serverForm.value.game_port))
|
||||||
|
|
||||||
|
function syncPorts() {
|
||||||
|
serverForm.value.server_port = parseInt(serverPortStr.value, 10) || serverForm.value.server_port
|
||||||
|
serverForm.value.game_port = parseInt(gamePortStr.value, 10) || serverForm.value.game_port
|
||||||
|
}
|
||||||
|
|
||||||
const connectionTypes = [
|
const connectionTypes = [
|
||||||
{ value: 'bare_metal', label: 'Bare Metal / VPS', desc: 'Direct connection via Companion Agent' },
|
{ value: 'bare_metal', label: 'Bare metal / VPS', desc: 'Direct connection via Companion Agent' },
|
||||||
{ value: 'amp', label: 'AMP (CubeCoders)', desc: 'Connect through AMP panel API' },
|
{ value: 'amp', label: 'AMP (CubeCoders)', desc: 'Connect through AMP panel API' },
|
||||||
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
|
{ value: 'pterodactyl', label: 'Pterodactyl', desc: 'Connect through Pterodactyl panel API' },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function submitServerConfig() {
|
async function submitServerConfig() {
|
||||||
|
syncPorts()
|
||||||
|
|
||||||
if (!serverForm.value.server_name.trim()) {
|
if (!serverForm.value.server_name.trim()) {
|
||||||
error.value = 'Server name is required.'
|
error.value = 'Server name is required.'
|
||||||
return
|
return
|
||||||
@@ -58,180 +73,464 @@ async function completeSetup() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-neutral-950 flex items-center justify-center px-4">
|
<div class="setup-shell">
|
||||||
<div class="w-full max-w-lg">
|
<div class="setup-wrap">
|
||||||
<!-- Progress -->
|
<!-- Brand header -->
|
||||||
<div class="flex items-center justify-center gap-3 mb-8">
|
<div class="setup-brand">
|
||||||
<div
|
<div class="setup-brand__mark">
|
||||||
class="flex items-center gap-2 text-sm"
|
<Logo :size="30" tagline="Setup" />
|
||||||
:class="step >= 1 ? 'text-oxide-400' : 'text-neutral-600'"
|
|
||||||
>
|
|
||||||
<Server class="w-4 h-4" />
|
|
||||||
Server
|
|
||||||
</div>
|
|
||||||
<div class="w-8 h-px" :class="step >= 2 ? 'bg-oxide-500' : 'bg-neutral-700'" />
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 text-sm"
|
|
||||||
:class="step >= 2 ? 'text-oxide-400' : 'text-neutral-600'"
|
|
||||||
>
|
|
||||||
<Wifi class="w-4 h-4" />
|
|
||||||
Connect
|
|
||||||
</div>
|
|
||||||
<div class="w-8 h-px" :class="step >= 3 ? 'bg-oxide-500' : 'bg-neutral-700'" />
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 text-sm"
|
|
||||||
:class="step >= 3 ? 'text-oxide-400' : 'text-neutral-600'"
|
|
||||||
>
|
|
||||||
<CheckCircle class="w-4 h-4" />
|
|
||||||
Done
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 1: Server Config -->
|
<!-- Progress stepper -->
|
||||||
<div v-if="step === 1" class="bg-neutral-900 border border-neutral-800 rounded-lg p-8">
|
<div class="setup-steps">
|
||||||
<div class="text-center mb-6">
|
<div
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-12 w-12 mx-auto mb-3" />
|
v-for="(s, i) in [
|
||||||
<h1 class="text-xl font-bold text-neutral-100">Configure Your Server</h1>
|
{ label: 'Server', icon: 'server' },
|
||||||
<p class="text-sm text-neutral-500 mt-1">Let's get your Rust server connected to Corrosion.</p>
|
{ label: 'Connect', icon: 'wifi' },
|
||||||
|
{ label: 'Done', icon: 'check' },
|
||||||
|
]"
|
||||||
|
:key="i"
|
||||||
|
class="setup-step"
|
||||||
|
:class="{
|
||||||
|
'setup-step--active': step === i + 1,
|
||||||
|
'setup-step--done': step > i + 1,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="setup-step__num">{{ step > i + 1 ? '✓' : i + 1 }}</span>
|
||||||
|
<span class="setup-step__label">{{ s.label }}</span>
|
||||||
|
<div v-if="i < 2" class="setup-step__connector" :class="step > i + 1 ? 'setup-step__connector--done' : ''" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm">
|
<!-- Step 1: Server config -->
|
||||||
{{ error }}
|
<div v-if="step === 1" class="setup-card">
|
||||||
|
<div class="setup-card__head">
|
||||||
|
<h1 class="setup-card__title">Configure your server</h1>
|
||||||
|
<p class="setup-card__sub">Connect your Rust server to Corrosion.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="submitServerConfig" class="space-y-4">
|
<Alert v-if="error" tone="danger">{{ error }}</Alert>
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Server Name</label>
|
<form class="setup-form" @submit.prevent="submitServerConfig">
|
||||||
<input
|
<Input
|
||||||
v-model="serverForm.server_name"
|
v-model="serverForm.server_name"
|
||||||
|
label="Server name"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
|
||||||
placeholder="My Rust Server"
|
placeholder="My Rust Server"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 text-sm focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<!-- Connection type selector -->
|
||||||
<label class="block text-xs text-neutral-500 mb-2">Connection Type</label>
|
<div class="conn-group">
|
||||||
<div class="space-y-2">
|
<span class="conn-group__label">Connection type</span>
|
||||||
|
<div class="conn-options">
|
||||||
<label
|
<label
|
||||||
v-for="ct in connectionTypes"
|
v-for="ct in connectionTypes"
|
||||||
:key="ct.value"
|
:key="ct.value"
|
||||||
class="flex items-start gap-3 p-3 bg-neutral-800 border rounded-lg cursor-pointer transition-colors"
|
class="conn-option"
|
||||||
:class="serverForm.connection_type === ct.value
|
:class="serverForm.connection_type === ct.value ? 'conn-option--active' : ''"
|
||||||
? 'border-oxide-500/50 bg-oxide-500/5'
|
|
||||||
: 'border-neutral-700 hover:border-neutral-600'"
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="serverForm.connection_type"
|
v-model="serverForm.connection_type"
|
||||||
:value="ct.value"
|
:value="ct.value"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="connection_type"
|
name="connection_type"
|
||||||
class="mt-0.5 accent-oxide-500"
|
class="conn-option__radio"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div class="conn-option__body">
|
||||||
<p class="text-sm font-medium text-neutral-200">{{ ct.label }}</p>
|
<span class="conn-option__name">{{ ct.label }}</span>
|
||||||
<p class="text-xs text-neutral-500">{{ ct.desc }}</p>
|
<span class="conn-option__desc">{{ ct.desc }}</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<!-- Port / IP row -->
|
||||||
<div class="col-span-1">
|
<div class="setup-ports">
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Server IP</label>
|
<Input
|
||||||
<input
|
|
||||||
v-model="serverForm.server_ip"
|
v-model="serverForm.server_ip"
|
||||||
|
label="Server IP"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="0.0.0.0"
|
placeholder="0.0.0.0"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
:mono="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div>
|
v-model="serverPortStr"
|
||||||
<label class="block text-xs text-neutral-500 mb-1">RCON Port</label>
|
label="RCON port"
|
||||||
<input
|
type="text"
|
||||||
v-model.number="serverForm.server_port"
|
placeholder="28016"
|
||||||
type="number"
|
:mono="true"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div>
|
v-model="gamePortStr"
|
||||||
<label class="block text-xs text-neutral-500 mb-1">Game Port</label>
|
label="Game port"
|
||||||
<input
|
type="text"
|
||||||
v-model.number="serverForm.game_port"
|
placeholder="28015"
|
||||||
type="number"
|
:mono="true"
|
||||||
class="w-full px-3 py-2.5 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading"
|
:loading="isLoading"
|
||||||
class="w-full flex items-center justify-center gap-2 py-2.5 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
:block="true"
|
||||||
|
size="md"
|
||||||
|
icon-right="arrow-right"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isLoading" class="w-4 h-4 animate-spin" />
|
|
||||||
<template v-else>
|
|
||||||
Continue
|
Continue
|
||||||
<ArrowRight class="w-4 h-4" />
|
</Button>
|
||||||
</template>
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Connection Instructions -->
|
<!-- Step 2: Companion agent install -->
|
||||||
<div v-if="step === 2" class="bg-neutral-900 border border-neutral-800 rounded-lg p-8">
|
<div v-if="step === 2" class="setup-card">
|
||||||
<div class="text-center mb-6">
|
<div class="setup-card__head setup-card__head--center">
|
||||||
<Wifi class="w-10 h-10 text-oxide-500 mx-auto mb-3" />
|
<div class="setup-icon">
|
||||||
<h1 class="text-xl font-bold text-neutral-100">Install the Companion Agent</h1>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<p class="text-sm text-neutral-500 mt-1">
|
<path d="M5 12.55a11 11 0 0 1 14.08 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01" />
|
||||||
The Companion Agent runs on your server and connects to Corrosion securely — no inbound ports required.
|
</svg>
|
||||||
</p>
|
</div>
|
||||||
|
<h1 class="setup-card__title">Install the Companion Agent</h1>
|
||||||
|
<p class="setup-card__sub">The agent runs on your server and connects to Corrosion — no inbound ports required.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-black/50 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 mb-6">
|
<div class="setup-code">
|
||||||
<p class="text-neutral-500 mb-2"># Download and install the Companion Agent</p>
|
<p class="setup-code__comment"># Download and install the Companion Agent</p>
|
||||||
<p class="text-oxide-400">curl -sSL https://get.corrosionmgmt.com | sh</p>
|
<p class="setup-code__cmd">curl -sSL https://get.corrosionmgmt.com | sh</p>
|
||||||
<p class="text-neutral-500 mt-3 mb-2"># Start the agent with your license key</p>
|
<p class="setup-code__comment setup-code__comment--mt"># Start the agent with your license key</p>
|
||||||
<p class="text-oxide-400">corrosion-agent start --key {{ auth.license?.license_key || 'YOUR-LICENSE-KEY' }}</p>
|
<p class="setup-code__cmd">corrosion-agent start --key {{ auth.license?.license_key ?? 'YOUR-LICENSE-KEY' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-neutral-500 text-center mb-6">
|
<p class="setup-hint">
|
||||||
The agent will automatically register with your panel. You can also use the uMod plugin for lightweight integration.
|
The agent auto-registers with your panel. You can also use the uMod plugin for lightweight integration.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="setup-actions">
|
||||||
<button
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="md"
|
||||||
@click="step = 3"
|
@click="step = 3"
|
||||||
class="flex-1 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-medium rounded-lg text-sm transition-colors text-center"
|
|
||||||
>
|
>
|
||||||
Skip for Now
|
Skip for now
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="md"
|
||||||
|
icon-right="arrow-right"
|
||||||
@click="step = 3"
|
@click="step = 3"
|
||||||
class="flex-1 flex items-center justify-center gap-2 py-2.5 bg-oxide-600 hover:bg-oxide-700 text-white font-medium rounded-lg text-sm transition-colors"
|
|
||||||
>
|
>
|
||||||
I've Installed It
|
I've installed it
|
||||||
<ArrowRight class="w-4 h-4" />
|
</Button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 3: Complete -->
|
<!-- Step 3: Complete -->
|
||||||
<div v-if="step === 3" class="bg-neutral-900 border border-neutral-800 rounded-lg p-8 text-center">
|
<div v-if="step === 3" class="setup-card setup-card--center">
|
||||||
<CheckCircle class="w-12 h-12 text-green-400 mx-auto mb-4" />
|
<div class="setup-complete-icon">
|
||||||
<h1 class="text-xl font-bold text-neutral-100 mb-2">You're All Set</h1>
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<p class="text-sm text-neutral-500 mb-6">
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||||
Your server is configured. Head to the dashboard to start managing your Rust server.
|
<polyline points="22 4 12 14.01 9 11.01" />
|
||||||
</p>
|
</svg>
|
||||||
<button
|
</div>
|
||||||
|
<h1 class="setup-card__title">You're all set</h1>
|
||||||
|
<p class="setup-card__sub">Your server is configured. Head to the dashboard to start managing your Rust server.</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
:loading="isLoading"
|
||||||
|
:block="true"
|
||||||
|
size="md"
|
||||||
@click="completeSetup"
|
@click="completeSetup"
|
||||||
:disabled="isLoading"
|
|
||||||
class="w-full py-2.5 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white font-medium rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
Go to Dashboard
|
Go to dashboard
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.setup-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand */
|
||||||
|
.setup-brand {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.setup-brand__mark {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress stepper */
|
||||||
|
.setup-steps {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step__num {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex: none;
|
||||||
|
transition: background 200ms ease, color 200ms ease, box-shadow 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step--active .setup-step__num {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step--done .setup-step__num {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step--active .setup-step__label,
|
||||||
|
.setup-step--done .setup-step__label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step__connector {
|
||||||
|
width: 40px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-default);
|
||||||
|
margin: 0 var(--space-2);
|
||||||
|
flex: none;
|
||||||
|
transition: background 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-step__connector--done {
|
||||||
|
background: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.setup-card {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--ring-default), var(--shadow-lg);
|
||||||
|
padding: var(--space-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-card--center {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-card__head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-card__head--center {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: var(--tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-card__sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon in step 2 header */
|
||||||
|
.setup-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
color: var(--accent-text);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Step 3 check icon */
|
||||||
|
.setup-complete-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--status-online-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--status-online-border);
|
||||||
|
color: var(--status-online);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.setup-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection type group */
|
||||||
|
.conn-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-group__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--surface-inset);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-option--active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-option__radio {
|
||||||
|
margin-top: 2px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-option__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-option__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conn-option__desc {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Port grid */
|
||||||
|
.setup-ports {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code block */
|
||||||
|
.setup-code {
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--ring-subtle);
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
line-height: 1.8;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-code__comment {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-code__comment--mt {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-code__cmd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent-text);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-actions > * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Key, KeyRound, Users, DollarSign, Server, UserPlus, ArrowRight, ScrollText, CreditCard, MonitorCog } from 'lucide-vue-next'
|
|
||||||
import { safeCurrency, safeLocaleString } from '@/utils/formatters'
|
import { safeCurrency, safeLocaleString } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -21,26 +23,26 @@ const stats = ref<PlatformStats | null>(null)
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
const kpiCards = [
|
const kpiCards = [
|
||||||
{ key: 'total_licenses' as const, label: 'Total Licenses', icon: Key, format: 'number' },
|
{ key: 'total_licenses' as const, label: 'Total licenses', icon: 'key', format: 'number' },
|
||||||
{ key: 'active_licenses' as const, label: 'Active Licenses', icon: KeyRound, format: 'number' },
|
{ key: 'active_licenses' as const, label: 'Active licenses', icon: 'key', format: 'number' },
|
||||||
{ key: 'total_users' as const, label: 'Total Users', icon: Users, format: 'number' },
|
{ key: 'total_users' as const, label: 'Total users', icon: 'users', format: 'number' },
|
||||||
{ key: 'module_mrr' as const, label: 'Module MRR', icon: DollarSign, format: 'currency' },
|
{ key: 'module_mrr' as const, label: 'Module MRR', icon: 'dollar-sign', format: 'currency' },
|
||||||
{ key: 'servers_online' as const, label: 'Servers Online', icon: Server, format: 'number' },
|
{ key: 'servers_online' as const, label: 'Servers online', icon: 'server', format: 'number' },
|
||||||
{ key: 'new_signups_this_week' as const, label: 'New Signups This Week', icon: UserPlus, format: 'number' },
|
{ key: 'new_signups_this_week' as const, label: 'New signups this week', icon: 'users', format: 'number' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const quickLinks = [
|
const quickLinks = [
|
||||||
{ label: 'Licenses', description: 'Manage license keys and activations', icon: Key, route: '/admin/licenses' },
|
{ label: 'Licenses', description: 'Manage license keys and activations', icon: 'key', route: '/admin/licenses' },
|
||||||
{ label: 'Subscriptions', description: 'View module subscriptions and MRR', icon: CreditCard, route: '/admin/subscriptions' },
|
{ label: 'Subscriptions', description: 'View module subscriptions and MRR', icon: 'credit-card', route: '/admin/subscriptions' },
|
||||||
{ label: 'Users', description: 'Manage platform users and permissions', icon: Users, route: '/admin/users' },
|
{ label: 'Users', description: 'Manage platform users and permissions', icon: 'users', route: '/admin/users' },
|
||||||
{ label: 'Servers', description: 'Monitor connected game servers', icon: MonitorCog, route: '/admin/servers' },
|
{ label: 'Servers', description: 'Monitor connected game servers', icon: 'server-cog', route: '/admin/servers' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function formatValue(value: number | undefined, format: string): string {
|
function formatValue(value: number | undefined, format: string): string {
|
||||||
if (format === 'currency') {
|
if (format === 'currency') {
|
||||||
return safeCurrency(value, '$', '\u2014')
|
return safeCurrency(value, '$', '—')
|
||||||
}
|
}
|
||||||
return safeLocaleString(value, '\u2014')
|
return safeLocaleString(value, '—')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStats() {
|
async function fetchStats() {
|
||||||
@@ -60,58 +62,91 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-8 bg-neutral-950 min-h-screen">
|
<div class="pa-dash">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div>
|
<div class="pa-dash__head">
|
||||||
<div class="flex items-center gap-3 mb-1">
|
<div class="pa-dash__head-id">
|
||||||
<ScrollText class="w-6 h-6 text-oxide-500" />
|
<div class="pa-dash__chip">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Platform Admin</h1>
|
<Icon name="layout-dashboard" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="t-eyebrow">Platform admin</div>
|
||||||
|
<h1 class="pa-dash__title">Dashboard</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-neutral-400 ml-9">Overview of all platform activity and key metrics.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KPI Cards -->
|
<!-- KPI row -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="pa-dash__kpis">
|
||||||
<div
|
<StatCard
|
||||||
v-for="card in kpiCards"
|
v-for="card in kpiCards"
|
||||||
:key="card.key"
|
:key="card.key"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"
|
:icon="card.icon"
|
||||||
>
|
:label="card.label"
|
||||||
<div class="flex items-center gap-3 mb-3">
|
:value="isLoading ? '—' : (stats ? formatValue(stats[card.key], card.format) : '—')"
|
||||||
<div class="p-2 rounded-lg bg-oxide-500/10">
|
/>
|
||||||
<component :is="card.icon" class="w-4 h-4 text-oxide-400" />
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-neutral-400">{{ card.label }}</p>
|
|
||||||
</div>
|
|
||||||
<!-- Loading state -->
|
|
||||||
<div v-if="isLoading" class="h-8 w-24 bg-neutral-800 rounded animate-pulse" />
|
|
||||||
<!-- Value -->
|
|
||||||
<p v-else class="text-3xl font-bold text-neutral-100">
|
|
||||||
{{ stats ? formatValue(stats[card.key], card.format) : '\u2014' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Links -->
|
<!-- Quick links -->
|
||||||
<div>
|
<Panel title="Quick links">
|
||||||
<h2 class="text-lg font-semibold text-neutral-200 mb-4">Quick Links</h2>
|
<div class="pa-dash__links">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
<button
|
<button
|
||||||
v-for="link in quickLinks"
|
v-for="link in quickLinks"
|
||||||
:key="link.route"
|
:key="link.route"
|
||||||
|
class="pa-dash__link"
|
||||||
@click="router.push(link.route)"
|
@click="router.push(link.route)"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 text-left hover:border-oxide-500/40 hover:bg-neutral-800/50 transition-all group"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="pa-dash__link-icon">
|
||||||
<div class="p-2 rounded-lg bg-oxide-500/10">
|
<Icon :name="link.icon" :size="16" :stroke-width="2" />
|
||||||
<component :is="link.icon" class="w-4 h-4 text-oxide-400" />
|
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight class="w-4 h-4 text-neutral-600 group-hover:text-oxide-400 transition-colors" />
|
<div class="pa-dash__link-body">
|
||||||
|
<div class="pa-dash__link-label">{{ link.label }}</div>
|
||||||
|
<div class="pa-dash__link-desc">{{ link.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm font-semibold text-neutral-100 mt-3">{{ link.label }}</p>
|
<Icon name="chevron-right" :size="15" class="pa-dash__link-arrow" />
|
||||||
<p class="text-xs text-neutral-500 mt-1">{{ link.description }}</p>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pa-dash { max-width: 900px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
.pa-dash__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.pa-dash__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.pa-dash__chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.pa-dash__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pa-dash__kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 13px; }
|
||||||
|
|
||||||
|
.pa-dash__links { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.pa-dash__link {
|
||||||
|
display: flex; align-items: center; gap: 12px; padding: 12px 8px;
|
||||||
|
border-radius: var(--radius-md); background: transparent; border: 0;
|
||||||
|
cursor: pointer; text-align: left; transition: var(--transition-colors);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.pa-dash__link:hover { background: var(--surface-hover); }
|
||||||
|
.pa-dash__link-icon {
|
||||||
|
width: 32px; height: 32px; flex: none; border-radius: var(--radius-sm);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent-text); background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.pa-dash__link-body { flex: 1; min-width: 0; }
|
||||||
|
.pa-dash__link-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
||||||
|
.pa-dash__link-desc { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; }
|
||||||
|
.pa-dash__link-arrow { color: var(--text-muted); flex: none; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pa-dash__kpis { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { Key, Search, Download, Plus, X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -60,11 +67,11 @@ const statusOptions = [
|
|||||||
{ value: 'revoked', label: 'Revoked' },
|
{ value: 'revoked', label: 'Revoked' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const statusBadgeClass: Record<string, string> = {
|
const statusTone: Record<string, 'online' | 'warn' | 'neutral' | 'offline'> = {
|
||||||
active: 'bg-green-500/10 text-green-400',
|
active: 'online',
|
||||||
suspended: 'bg-yellow-500/10 text-yellow-400',
|
suspended: 'warn',
|
||||||
expired: 'bg-neutral-700/50 text-neutral-400',
|
expired: 'neutral',
|
||||||
revoked: 'bg-red-500/10 text-red-400',
|
revoked: 'offline',
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string | null | undefined): string {
|
function formatDate(iso: string | null | undefined): string {
|
||||||
@@ -175,214 +182,234 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6 bg-neutral-950 min-h-screen">
|
<div class="pal">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="pal__head">
|
||||||
<div class="flex items-center gap-3">
|
<div class="pal__head-id">
|
||||||
<Key class="w-5 h-5 text-oxide-500" />
|
<div class="pal__chip">
|
||||||
|
<Icon name="key" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">License Management</h1>
|
<div class="t-eyebrow">Platform admin</div>
|
||||||
<p class="text-sm text-neutral-400 mt-0.5">{{ total }} licenses total</p>
|
<h1 class="pal__title">License management</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="pal__head-actions">
|
||||||
<button
|
<Button variant="secondary" icon="download" @click="exportCsv">Export CSV</Button>
|
||||||
@click="exportCsv"
|
<Button icon="plus" @click="showGenerateForm = !showGenerateForm">Generate license</Button>
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Download class="w-4 h-4" />
|
|
||||||
Export CSV
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="showGenerateForm = !showGenerateForm"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
Generate License
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Generate License Form -->
|
<!-- Generate license form -->
|
||||||
<div
|
<Panel v-if="showGenerateForm" title="Generate new license">
|
||||||
v-if="showGenerateForm"
|
<template #actions>
|
||||||
class="bg-neutral-900 border border-oxide-500/30 rounded-lg p-4"
|
<IconButton icon="x" label="Close" @click="showGenerateForm = false" />
|
||||||
>
|
</template>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="pal__gen-form">
|
||||||
<h3 class="text-sm font-semibold text-neutral-200">Generate New License</h3>
|
<Input
|
||||||
<button @click="showGenerateForm = false" class="text-neutral-500 hover:text-neutral-300">
|
|
||||||
<X class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
v-model="generateEmail"
|
v-model="generateEmail"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Owner email address..."
|
placeholder="Owner email address"
|
||||||
class="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
icon="mail"
|
||||||
|
style="flex: 1"
|
||||||
@keydown.enter="generateLicense"
|
@keydown.enter="generateLicense"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
@click="generateLicense"
|
icon="key"
|
||||||
|
:loading="isGenerating"
|
||||||
:disabled="isGenerating || !generateEmail.trim()"
|
:disabled="isGenerating || !generateEmail.trim()"
|
||||||
class="px-4 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
@click="generateLicense"
|
||||||
>
|
>{{ isGenerating ? 'Generating...' : 'Generate' }}</Button>
|
||||||
{{ isGenerating ? 'Generating...' : 'Generate' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="pal__filters">
|
||||||
<div class="relative flex-1 max-w-sm">
|
<Input
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
placeholder="Search by key, email, or server name"
|
||||||
placeholder="Search by key, email, or server name..."
|
icon="search"
|
||||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
style="flex: 1; max-width: 360px"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Select
|
||||||
<select
|
|
||||||
v-model="statusFilter"
|
v-model="statusFilter"
|
||||||
class="px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
:options="statusOptions"
|
||||||
>
|
/>
|
||||||
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
|
|
||||||
{{ opt.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel :flush-body="true" :subtitle="total + ' licenses total'">
|
||||||
<table class="w-full">
|
<table class="pal__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr class="pal__thead-row">
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">License Key</th>
|
<th class="pal__th">License key</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Owner Email</th>
|
<th class="pal__th">Owner email</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Server Name</th>
|
<th class="pal__th">Server name</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
<th class="pal__th">Status</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Created</th>
|
<th class="pal__th">Created</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Expires</th>
|
<th class="pal__th">Expires</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-if="licenses.length === 0 && !isLoading">
|
|
||||||
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
|
||||||
<template v-if="searchQuery || statusFilter !== 'all'">No licenses matching your filters.</template>
|
|
||||||
<template v-else>No licenses found.</template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="isLoading">
|
<tr v-if="isLoading">
|
||||||
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">Loading licenses...</td>
|
<td colspan="6" class="pal__td-empty">Loading licenses...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="licenses.length === 0">
|
||||||
|
<td colspan="6" class="pal__td-es">
|
||||||
|
<EmptyState
|
||||||
|
icon="key"
|
||||||
|
:title="(searchQuery || statusFilter !== 'all') ? 'No matching licenses' : 'No licenses found'"
|
||||||
|
:description="(searchQuery || statusFilter !== 'all') ? 'Try adjusting your search or filter.' : 'Generate a license to get started.'"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr
|
||||||
v-for="license in licenses"
|
v-for="license in licenses"
|
||||||
:key="license.id"
|
:key="license.id"
|
||||||
|
class="pal__tr"
|
||||||
|
:class="{ 'pal__tr--selected': selectedLicenseId === license.id }"
|
||||||
@click="selectLicense(license.id)"
|
@click="selectLicense(license.id)"
|
||||||
class="hover:bg-neutral-800/50 transition-colors cursor-pointer"
|
|
||||||
:class="{ 'bg-neutral-800/30': selectedLicenseId === license.id }"
|
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-100 font-mono">{{ license.license_key }}</td>
|
<td class="pal__td pal__td--mono">{{ license.license_key }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ license.owner_email }}</td>
|
<td class="pal__td pal__td--secondary">{{ license.owner_email }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ license.server_name }}</td>
|
<td class="pal__td pal__td--secondary">{{ license.server_name ?? '—' }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="pal__td">
|
||||||
<span
|
<Badge :tone="statusTone[license.status] ?? 'neutral'">{{ license.status }}</Badge>
|
||||||
class="inline-flex text-xs font-medium px-2 py-0.5 rounded-full capitalize"
|
|
||||||
:class="statusBadgeClass[license.status] || 'bg-neutral-700/50 text-neutral-400'"
|
|
||||||
>
|
|
||||||
{{ license.status }}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatDate(license.created_at) }}</td>
|
<td class="pal__td pal__td--secondary">{{ formatDate(license.created_at) }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatDate(license.expires_at) }}</td>
|
<td class="pal__td pal__td--secondary">{{ formatDate(license.expires_at) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Detail Panel -->
|
<!-- Detail panel -->
|
||||||
<div
|
<Panel v-if="selectedLicenseId" title="License details">
|
||||||
v-if="selectedLicenseId"
|
<template #actions>
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"
|
<IconButton icon="x" label="Close" @click="selectedLicenseId = null; selectedDetail = null" />
|
||||||
>
|
</template>
|
||||||
<div v-if="isDetailLoading" class="space-y-3">
|
<div v-if="isDetailLoading" class="pal__detail-skeleton">
|
||||||
<div class="h-5 w-40 bg-neutral-800 rounded animate-pulse" />
|
<div class="pal__skel" />
|
||||||
<div class="h-4 w-64 bg-neutral-800 rounded animate-pulse" />
|
<div class="pal__skel pal__skel--wide" />
|
||||||
<div class="h-4 w-48 bg-neutral-800 rounded animate-pulse" />
|
<div class="pal__skel" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="selectedDetail">
|
<div v-else-if="selectedDetail">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="pal__detail-grid">
|
||||||
<h3 class="text-lg font-semibold text-neutral-100">License Details</h3>
|
<div class="pal__field">
|
||||||
<button @click="selectedLicenseId = null; selectedDetail = null" class="text-neutral-500 hover:text-neutral-300">
|
<div class="pal__field-label">License key</div>
|
||||||
<X class="w-4 h-4" />
|
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.license_key }}</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
<div class="pal__field">
|
||||||
<div>
|
<div class="pal__field-label">Owner</div>
|
||||||
<span class="text-neutral-500">License Key</span>
|
<div class="pal__field-val">{{ selectedDetail.owner_email }}</div>
|
||||||
<p class="text-neutral-200 mt-0.5 font-mono text-xs">{{ selectedDetail.license_key }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="pal__field">
|
||||||
<span class="text-neutral-500">Owner</span>
|
<div class="pal__field-label">Team members</div>
|
||||||
<p class="text-neutral-200 mt-0.5">{{ selectedDetail.owner_email }}</p>
|
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.team_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="pal__field">
|
||||||
<span class="text-neutral-500">Team Members</span>
|
<div class="pal__field-label">Wipe count</div>
|
||||||
<p class="text-neutral-200 mt-0.5">{{ selectedDetail.team_count }}</p>
|
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.wipe_count }}</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-neutral-500">Wipe Count</span>
|
|
||||||
<p class="text-neutral-200 mt-0.5">{{ selectedDetail.wipe_count }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedDetail.server_connection" class="mt-4 pt-4 border-t border-neutral-800">
|
<div v-if="selectedDetail.server_connection" class="pal__conn">
|
||||||
<h4 class="text-sm font-medium text-neutral-300 mb-3">Server Connection</h4>
|
<div class="pal__conn-head">Server connection</div>
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
|
<div class="pal__detail-grid">
|
||||||
<div>
|
<div class="pal__field">
|
||||||
<span class="text-neutral-500">Connection Type</span>
|
<div class="pal__field-label">Connection type</div>
|
||||||
<p class="text-neutral-200 mt-0.5 capitalize">{{ selectedDetail.server_connection.connection_type }}</p>
|
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.server_connection.connection_type }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="pal__field">
|
||||||
<span class="text-neutral-500">Server IP</span>
|
<div class="pal__field-label">Server IP</div>
|
||||||
<p class="text-neutral-200 mt-0.5 font-mono">{{ selectedDetail.server_connection.server_ip }}</p>
|
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.server_connection.server_ip }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="pal__field">
|
||||||
<span class="text-neutral-500">Game Port</span>
|
<div class="pal__field-label">Game port</div>
|
||||||
<p class="text-neutral-200 mt-0.5 font-mono">{{ selectedDetail.server_connection.game_port }}</p>
|
<div class="pal__field-val pal__field-val--mono">{{ selectedDetail.server_connection.game_port }}</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-neutral-500">Status</span>
|
|
||||||
<p class="text-neutral-200 mt-0.5 capitalize">{{ selectedDetail.server_connection.status }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pal__field">
|
||||||
|
<div class="pal__field-label">Status</div>
|
||||||
|
<div class="pal__field-val">{{ selectedDetail.server_connection.status }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="pal__pager">
|
||||||
<p class="text-sm text-neutral-500">
|
<span class="pal__pager-info">Page {{ page }} of {{ totalPages }}</span>
|
||||||
Page {{ page }} of {{ totalPages }}
|
<div class="pal__pager-btns">
|
||||||
</p>
|
<Button variant="secondary" size="sm" icon="chevron-left" :disabled="page <= 1" @click="prevPage">Prev</Button>
|
||||||
<div class="flex items-center gap-2">
|
<Button variant="secondary" size="sm" icon-right="chevron-right" :disabled="page >= totalPages" @click="nextPage">Next</Button>
|
||||||
<button
|
|
||||||
@click="prevPage"
|
|
||||||
:disabled="page <= 1"
|
|
||||||
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft class="w-4 h-4" />
|
|
||||||
Prev
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="nextPage"
|
|
||||||
:disabled="page >= totalPages"
|
|
||||||
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pal { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.pal__head { display: flex; align-items: flex-end; justify-content: space-between; flex-wrap: wrap; gap: 12px; row-gap: 10px; }
|
||||||
|
.pal__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.pal__chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.pal__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
.pal__head-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* Generate form */
|
||||||
|
.pal__gen-form { display: flex; align-items: flex-end; gap: 10px; }
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.pal__filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.pal__table { width: 100%; border-collapse: collapse; }
|
||||||
|
.pal__thead-row { border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.pal__th {
|
||||||
|
padding: 10px 16px; text-align: left;
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pal__tr { border-bottom: 1px solid var(--border-subtle); cursor: pointer; transition: var(--transition-colors); }
|
||||||
|
.pal__tr:last-child { border-bottom: 0; }
|
||||||
|
.pal__tr:hover { background: var(--surface-hover); }
|
||||||
|
.pal__tr--selected { background: var(--accent-soft); }
|
||||||
|
.pal__td { padding: 11px 16px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
|
.pal__td--secondary { color: var(--text-secondary); }
|
||||||
|
.pal__td--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs); }
|
||||||
|
.pal__td-empty { padding: 12px 16px; text-align: center; font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||||
|
.pal__td-es { padding: 0; }
|
||||||
|
|
||||||
|
/* Detail panel */
|
||||||
|
.pal__detail-skeleton { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.pal__skel { height: 16px; width: 160px; background: var(--surface-raised-2); border-radius: var(--radius-sm); animation: pal-pulse 1.4s ease-in-out infinite; }
|
||||||
|
.pal__skel--wide { width: 260px; }
|
||||||
|
@keyframes pal-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
|
||||||
|
.pal__detail-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
|
||||||
|
.pal__field-label { font-size: var(--text-xs); color: var(--text-tertiary); margin-bottom: 4px; }
|
||||||
|
.pal__field-val { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||||
|
.pal__field-val--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs); }
|
||||||
|
|
||||||
|
.pal__conn { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border-subtle); }
|
||||||
|
.pal__conn-head { font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: var(--tracking-wider); margin-bottom: 14px; }
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pal__pager { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.pal__pager-info { font-size: var(--text-sm); color: var(--text-tertiary); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.pal__pager-btns { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.pal__detail-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { Server, Search } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -29,24 +35,18 @@ const statusOptions = [
|
|||||||
{ value: 'offline', label: 'Offline' },
|
{ value: 'offline', label: 'Offline' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const connectionBadgeClass: Record<string, string> = {
|
const connTypeTone: Record<string, 'info' | 'accent' | 'neutral' | 'online' | 'warn'> = {
|
||||||
plugin: 'bg-blue-500/10 text-blue-400',
|
plugin: 'info',
|
||||||
companion: 'bg-purple-500/10 text-purple-400',
|
companion: 'accent',
|
||||||
amp: 'bg-cyan-500/10 text-cyan-400',
|
amp: 'neutral',
|
||||||
pterodactyl: 'bg-green-500/10 text-green-400',
|
pterodactyl: 'online',
|
||||||
bare_metal: 'bg-orange-500/10 text-orange-400',
|
bare_metal: 'warn',
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusDotClass: Record<string, string> = {
|
const statusTone: Record<string, 'online' | 'warn' | 'offline'> = {
|
||||||
connected: 'bg-green-500',
|
connected: 'online',
|
||||||
degraded: 'bg-yellow-500',
|
degraded: 'warn',
|
||||||
offline: 'bg-red-500',
|
offline: 'offline',
|
||||||
}
|
|
||||||
|
|
||||||
const statusTextClass: Record<string, string> = {
|
|
||||||
connected: 'text-green-400',
|
|
||||||
degraded: 'text-yellow-400',
|
|
||||||
offline: 'text-red-400',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredServers = computed(() => {
|
const filteredServers = computed(() => {
|
||||||
@@ -108,94 +108,138 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6 bg-neutral-950 min-h-screen">
|
<div class="pas">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="pas__head">
|
||||||
<Server class="w-5 h-5 text-oxide-500" />
|
<div class="pas__head-id">
|
||||||
|
<div class="pas__chip">
|
||||||
|
<Icon name="server" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Server Overview</h1>
|
<div class="t-eyebrow">Platform admin</div>
|
||||||
<p class="text-sm text-neutral-400 mt-0.5">
|
<h1 class="pas__title">Server overview</h1>
|
||||||
{{ filteredServers.length }} server{{ filteredServers.length !== 1 ? 's' : '' }}
|
</div>
|
||||||
<template v-if="statusFilter !== 'all'"> ({{ statusFilter }})</template>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="pas__filters">
|
||||||
<div class="relative flex-1 max-w-sm">
|
<Input
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
placeholder="Search by server name, email, or IP"
|
||||||
placeholder="Search by server name, email, or IP..."
|
icon="search"
|
||||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
style="flex: 1; max-width: 360px"
|
||||||
/>
|
/>
|
||||||
</div>
|
<Select
|
||||||
<select
|
|
||||||
v-model="statusFilter"
|
v-model="statusFilter"
|
||||||
class="px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
:options="statusOptions"
|
||||||
>
|
/>
|
||||||
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">
|
|
||||||
{{ opt.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel :flush-body="true" :subtitle="filteredServers.length + ' server' + (filteredServers.length !== 1 ? 's' : '') + (statusFilter !== 'all' ? ' · ' + statusFilter : '')">
|
||||||
<table class="w-full">
|
<table class="pas__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr class="pas__thead-row">
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Server Name</th>
|
<th class="pas__th">Server name</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Owner Email</th>
|
<th class="pas__th">Owner email</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Connection Type</th>
|
<th class="pas__th">Connection type</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
<th class="pas__th">Status</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Server IP</th>
|
<th class="pas__th">Server IP</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Game Port</th>
|
<th class="pas__th">Game port</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Last Heartbeat</th>
|
<th class="pas__th">Last heartbeat</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-if="filteredServers.length === 0 && !isLoading">
|
|
||||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
|
||||||
<template v-if="searchQuery">No servers matching "{{ searchQuery }}"</template>
|
|
||||||
<template v-else-if="statusFilter !== 'all'">No {{ statusFilter }} servers found.</template>
|
|
||||||
<template v-else>No servers found.</template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="isLoading">
|
<tr v-if="isLoading">
|
||||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">Loading servers...</td>
|
<td colspan="7" class="pas__td-empty">Loading servers...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="filteredServers.length === 0">
|
||||||
|
<td colspan="7" class="pas__td-es">
|
||||||
|
<EmptyState
|
||||||
|
icon="server"
|
||||||
|
:title="searchQuery ? 'No matching servers' : (statusFilter !== 'all' ? 'No ' + statusFilter + ' servers' : 'No servers found')"
|
||||||
|
:description="searchQuery ? 'Try a different search term.' : 'Servers will appear here once connected.'"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr
|
||||||
v-for="srv in filteredServers"
|
v-for="srv in filteredServers"
|
||||||
:key="srv.license_id"
|
:key="srv.license_id"
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
class="pas__tr"
|
||||||
>
|
|
||||||
<td class="px-4 py-3 text-sm font-medium text-neutral-100">{{ srv.server_name || 'Unnamed' }}</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ srv.owner_email }}</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<span
|
|
||||||
class="inline-flex text-xs font-medium px-2 py-0.5 rounded-full capitalize"
|
|
||||||
:class="connectionBadgeClass[srv.connection_type] || 'bg-neutral-700/50 text-neutral-400'"
|
|
||||||
>
|
>
|
||||||
|
<td class="pas__td pas__td--primary">{{ srv.server_name ?? 'Unnamed' }}</td>
|
||||||
|
<td class="pas__td pas__td--secondary">{{ srv.owner_email }}</td>
|
||||||
|
<td class="pas__td">
|
||||||
|
<Badge :tone="connTypeTone[srv.connection_type] ?? 'neutral'">
|
||||||
{{ srv.connection_type.replace('_', ' ') }}
|
{{ srv.connection_type.replace('_', ' ') }}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="pas__td">
|
||||||
<div class="flex items-center gap-2">
|
<div class="pas__status">
|
||||||
<span class="h-2 w-2 rounded-full" :class="statusDotClass[srv.connection_status] || 'bg-neutral-500'" />
|
<StatusDot :tone="statusTone[srv.connection_status] ?? 'neutral'" :pulse="srv.connection_status === 'connected'" />
|
||||||
<span class="text-sm capitalize" :class="statusTextClass[srv.connection_status] || 'text-neutral-400'">
|
<span class="pas__status-label" :data-tone="srv.connection_status">{{ srv.connection_status }}</span>
|
||||||
{{ srv.connection_status }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.server_ip || '—' }}</td>
|
<td class="pas__td pas__td--mono">{{ srv.server_ip ?? '—' }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ srv.game_port || '—' }}</td>
|
<td class="pas__td pas__td--mono">{{ srv.game_port ?? '—' }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ srv.plugin_last_seen ? relativeTime(srv.plugin_last_seen) : srv.companion_last_seen ? relativeTime(srv.companion_last_seen) : 'Never' }}</td>
|
<td class="pas__td pas__td--secondary">
|
||||||
|
{{ srv.plugin_last_seen
|
||||||
|
? relativeTime(srv.plugin_last_seen)
|
||||||
|
: srv.companion_last_seen
|
||||||
|
? relativeTime(srv.companion_last_seen)
|
||||||
|
: 'Never' }}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pas { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.pas__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.pas__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.pas__chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.pas__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.pas__filters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.pas__table { width: 100%; border-collapse: collapse; }
|
||||||
|
.pas__thead-row { border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.pas__th {
|
||||||
|
padding: 10px 16px; text-align: left;
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pas__tr { border-bottom: 1px solid var(--border-subtle); transition: var(--transition-colors); }
|
||||||
|
.pas__tr:last-child { border-bottom: 0; }
|
||||||
|
.pas__tr:hover { background: var(--surface-hover); }
|
||||||
|
.pas__td { padding: 11px 16px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
|
.pas__td--primary { font-weight: 500; }
|
||||||
|
.pas__td--secondary { color: var(--text-secondary); }
|
||||||
|
.pas__td--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
|
.pas__td-empty { padding: 12px 16px; text-align: center; font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||||
|
.pas__td-es { padding: 0; }
|
||||||
|
|
||||||
|
/* Status cell */
|
||||||
|
.pas__status { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.pas__status-label { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||||
|
.pas__status-label[data-tone="connected"] { color: var(--status-online); }
|
||||||
|
.pas__status-label[data-tone="degraded"] { color: var(--status-warn); }
|
||||||
|
.pas__status-label[data-tone="offline"] { color: var(--status-offline); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { CreditCard, Package, DollarSign, Users } from 'lucide-vue-next'
|
|
||||||
import { safeCurrency, safeLocaleString } from '@/utils/formatters'
|
import { safeCurrency, safeLocaleString } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -58,89 +62,147 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6 bg-neutral-950 min-h-screen">
|
<div class="pasub">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="pasub__head">
|
||||||
<CreditCard class="w-5 h-5 text-oxide-500" />
|
<div class="pasub__head-id">
|
||||||
|
<div class="pasub__chip">
|
||||||
|
<Icon name="credit-card" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">Subscriptions</h1>
|
<div class="t-eyebrow">Platform admin</div>
|
||||||
<p class="text-sm text-neutral-400 mt-0.5">Module subscription overview and subscriber details.</p>
|
<h1 class="pasub__title">Subscriptions</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Cards -->
|
<!-- KPI row -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="pasub__kpis">
|
||||||
<!-- Total Subscribers -->
|
<StatCard
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
icon="users"
|
||||||
<div class="flex items-center gap-3 mb-3">
|
label="Total subscribers"
|
||||||
<div class="p-2 rounded-lg bg-oxide-500/10">
|
:value="isLoading ? '—' : safeLocaleString(totalSubscribers)"
|
||||||
<Users class="w-4 h-4 text-oxide-400" />
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
<p class="text-sm text-neutral-400">Total Subscribers</p>
|
icon="dollar-sign"
|
||||||
</div>
|
label="Total MRR"
|
||||||
<div v-if="isLoading" class="h-8 w-16 bg-neutral-800 rounded animate-pulse" />
|
:value="isLoading ? '—' : safeCurrency(totalMrr, '$')"
|
||||||
<p v-else class="text-3xl font-bold text-neutral-100">{{ safeLocaleString(totalSubscribers) }}</p>
|
/>
|
||||||
|
<StatCard
|
||||||
|
v-for="mod in moduleBreakdown"
|
||||||
|
:key="mod.name"
|
||||||
|
icon="package"
|
||||||
|
:label="mod.name"
|
||||||
|
:value="String(mod.count)"
|
||||||
|
note="subscribers"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Total MRR -->
|
<!-- Module breakdown summary -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
<Panel v-if="moduleBreakdown.length > 0" title="Module breakdown" subtitle="Subscribers per module">
|
||||||
<div class="flex items-center gap-3 mb-3">
|
<div class="pasub__mods">
|
||||||
<div class="p-2 rounded-lg bg-green-500/10">
|
|
||||||
<DollarSign class="w-4 h-4 text-green-400" />
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-neutral-400">Total MRR</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="isLoading" class="h-8 w-24 bg-neutral-800 rounded animate-pulse" />
|
|
||||||
<p v-else class="text-3xl font-bold text-neutral-100">{{ safeCurrency(totalMrr, '$') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Per-Module Cards -->
|
|
||||||
<div
|
<div
|
||||||
v-for="mod in moduleBreakdown"
|
v-for="mod in moduleBreakdown"
|
||||||
:key="mod.name"
|
:key="mod.name"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5"
|
class="pasub__mod-row"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3 mb-3">
|
<div class="pasub__mod-icon">
|
||||||
<div class="p-2 rounded-lg bg-oxide-500/10">
|
<Icon name="package" :size="14" :stroke-width="2" />
|
||||||
<Package class="w-4 h-4 text-oxide-400" />
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-neutral-400 truncate">{{ mod.name }}</p>
|
<span class="pasub__mod-name">{{ mod.name }}</span>
|
||||||
</div>
|
<Badge tone="neutral" :mono="true">{{ mod.count }}</Badge>
|
||||||
<p class="text-3xl font-bold text-neutral-100">{{ mod.count }}</p>
|
|
||||||
<p class="text-xs text-neutral-500 mt-1">subscribers</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel :flush-body="true" title="All subscriptions" :subtitle="subscriptions.length + ' total'">
|
||||||
<table class="w-full">
|
<table class="pasub__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr class="pasub__thead-row">
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Owner Email</th>
|
<th class="pasub__th">Owner email</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Module Name</th>
|
<th class="pasub__th">Module name</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">License ID</th>
|
<th class="pasub__th">License ID</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-if="subscriptions.length === 0 && !isLoading">
|
|
||||||
<td colspan="3" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
|
||||||
No subscriptions found.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="isLoading">
|
<tr v-if="isLoading">
|
||||||
<td colspan="3" class="px-4 py-12 text-center text-neutral-500 text-sm">Loading subscriptions...</td>
|
<td colspan="3" class="pasub__td-empty">Loading subscriptions...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="subscriptions.length === 0">
|
||||||
|
<td colspan="3" class="pasub__td-es">
|
||||||
|
<EmptyState
|
||||||
|
icon="credit-card"
|
||||||
|
title="No subscriptions found"
|
||||||
|
description="Module subscriptions will appear here once customers subscribe."
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr
|
||||||
v-for="(sub, idx) in subscriptions"
|
v-for="(sub, idx) in subscriptions"
|
||||||
:key="`${sub.license_id}-${sub.module_name}-${idx}`"
|
:key="`${sub.license_id}-${sub.module_name}-${idx}`"
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
class="pasub__tr"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-100">{{ sub.owner_email }}</td>
|
<td class="pasub__td">{{ sub.owner_email }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ sub.module_name }}</td>
|
<td class="pasub__td pasub__td--secondary">{{ sub.module_name }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ sub.license_id }}</td>
|
<td class="pasub__td pasub__td--mono">{{ sub.license_id }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pasub { max-width: 1000px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.pasub__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.pasub__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.pasub__chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.pasub__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI row */
|
||||||
|
.pasub__kpis { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 13px; }
|
||||||
|
|
||||||
|
/* Module breakdown */
|
||||||
|
.pasub__mods { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.pasub__mod-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 9px 6px; border-radius: var(--radius-md);
|
||||||
|
transition: var(--transition-colors);
|
||||||
|
}
|
||||||
|
.pasub__mod-row:hover { background: var(--surface-hover); }
|
||||||
|
.pasub__mod-icon {
|
||||||
|
width: 26px; height: 26px; flex: none; border-radius: var(--radius-sm);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent-text); background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.pasub__mod-name { flex: 1; font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.pasub__table { width: 100%; border-collapse: collapse; }
|
||||||
|
.pasub__thead-row { border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.pasub__th {
|
||||||
|
padding: 10px 16px; text-align: left;
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pasub__tr { border-bottom: 1px solid var(--border-subtle); transition: var(--transition-colors); }
|
||||||
|
.pasub__tr:last-child { border-bottom: 0; }
|
||||||
|
.pasub__tr:hover { background: var(--surface-hover); }
|
||||||
|
.pasub__td { padding: 11px 16px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
|
.pasub__td--secondary { color: var(--text-secondary); }
|
||||||
|
.pasub__td--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
|
.pasub__td-empty { padding: 12px 16px; text-align: center; font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||||
|
.pasub__td-es { padding: 0; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { Users, Search, ShieldCheck, ShieldOff, UserX, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
@@ -114,126 +120,155 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6 space-y-6 bg-neutral-950 min-h-screen">
|
<div class="pau">
|
||||||
<!-- Header -->
|
<!-- Page head -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="pau__head">
|
||||||
<Users class="w-5 h-5 text-oxide-500" />
|
<div class="pau__head-id">
|
||||||
|
<div class="pau__chip">
|
||||||
|
<Icon name="users" :size="20" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-100">User Management</h1>
|
<div class="t-eyebrow">Platform admin</div>
|
||||||
<p class="text-sm text-neutral-400 mt-0.5">{{ total }} registered users</p>
|
<h1 class="pau__title">User management</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="pau__filters">
|
||||||
<div class="relative flex-1 max-w-sm">
|
<Input
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
placeholder="Search by email or username"
|
||||||
placeholder="Search by email or username..."
|
icon="search"
|
||||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
style="flex: 1; max-width: 360px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
<Panel :flush-body="true" :subtitle="total + ' registered users'">
|
||||||
<table class="w-full">
|
<table class="pau__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-neutral-800 text-left">
|
<tr class="pau__thead-row">
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Email</th>
|
<th class="pau__th">Email</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Username</th>
|
<th class="pau__th">Username</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Super Admin</th>
|
<th class="pau__th">Super admin</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Licenses</th>
|
<th class="pau__th">Licenses</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Created</th>
|
<th class="pau__th">Created</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Last Login</th>
|
<th class="pau__th">Last login</th>
|
||||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
<th class="pau__th pau__th--right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-neutral-800">
|
<tbody>
|
||||||
<tr v-if="users.length === 0 && !isLoading">
|
|
||||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
|
||||||
<template v-if="searchQuery">No users matching "{{ searchQuery }}"</template>
|
|
||||||
<template v-else>No users found.</template>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="isLoading">
|
<tr v-if="isLoading">
|
||||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">Loading users...</td>
|
<td colspan="7" class="pau__td-empty">Loading users...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-else-if="users.length === 0">
|
||||||
|
<td colspan="7" class="pau__td-es">
|
||||||
|
<EmptyState
|
||||||
|
icon="users"
|
||||||
|
:title="searchQuery ? 'No matching users' : 'No users found'"
|
||||||
|
:description="searchQuery ? 'Try a different search term.' : 'Registered users will appear here.'"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr
|
<tr
|
||||||
v-for="user in users"
|
v-for="user in users"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
class="hover:bg-neutral-800/50 transition-colors"
|
class="pau__tr"
|
||||||
:class="{ 'opacity-50': false }"
|
:class="{ 'pau__tr--disabled': false }"
|
||||||
>
|
>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-100">{{ user.email }}</td>
|
<td class="pau__td">{{ user.email }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ user.username }}</td>
|
<td class="pau__td pau__td--secondary">{{ user.username }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="pau__td">
|
||||||
<span
|
<Badge v-if="user.is_super_admin" tone="accent" icon="shield">Super admin</Badge>
|
||||||
v-if="user.is_super_admin"
|
<span v-else class="pau__empty-cell">—</span>
|
||||||
class="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full bg-oxide-500/10 text-oxide-400"
|
|
||||||
>
|
|
||||||
<ShieldCheck class="w-3 h-3" />
|
|
||||||
Super Admin
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-xs text-neutral-600">—</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ user.license_count }}</td>
|
<td class="pau__td pau__td--mono">{{ user.license_count }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatDate(user.created_at) }}</td>
|
<td class="pau__td pau__td--secondary">{{ formatDate(user.created_at) }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatLastLogin(user.last_login_at) }}</td>
|
<td class="pau__td pau__td--secondary">{{ formatLastLogin(user.last_login_at) }}</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="pau__td pau__td--actions">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="pau__actions">
|
||||||
<button
|
<IconButton
|
||||||
|
:icon="user.is_super_admin ? 'shield' : 'shield'"
|
||||||
|
:variant="user.is_super_admin ? 'accent' : 'ghost'"
|
||||||
|
size="sm"
|
||||||
|
:label="user.is_super_admin ? 'Remove super admin' : 'Grant super admin'"
|
||||||
|
:disabled="false"
|
||||||
@click="toggleSuperAdmin(user)"
|
@click="toggleSuperAdmin(user)"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="ban"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
label="Disable account"
|
||||||
:disabled="false"
|
:disabled="false"
|
||||||
class="p-1.5 rounded transition-colors"
|
|
||||||
:class="user.is_super_admin
|
|
||||||
? 'text-oxide-400 hover:text-oxide-300 hover:bg-oxide-500/10'
|
|
||||||
: 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-700'"
|
|
||||||
:title="user.is_super_admin ? 'Remove Super Admin' : 'Grant Super Admin'"
|
|
||||||
>
|
|
||||||
<ShieldCheck v-if="!user.is_super_admin" class="w-4 h-4" />
|
|
||||||
<ShieldOff v-else class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="disableAccount(user)"
|
@click="disableAccount(user)"
|
||||||
:disabled="false"
|
/>
|
||||||
class="p-1.5 text-neutral-500 hover:text-red-400 hover:bg-red-500/10 disabled:opacity-30 disabled:cursor-not-allowed rounded transition-colors"
|
|
||||||
title="Disable Account"
|
|
||||||
>
|
|
||||||
<UserX class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</Panel>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="pau__pager">
|
||||||
<p class="text-sm text-neutral-500">
|
<span class="pau__pager-info">Page {{ page }} of {{ totalPages }}</span>
|
||||||
Page {{ page }} of {{ totalPages }}
|
<div class="pau__pager-btns">
|
||||||
</p>
|
<Button variant="secondary" size="sm" icon="chevron-left" :disabled="page <= 1" @click="prevPage">Prev</Button>
|
||||||
<div class="flex items-center gap-2">
|
<Button variant="secondary" size="sm" icon-right="chevron-right" :disabled="page >= totalPages" @click="nextPage">Next</Button>
|
||||||
<button
|
|
||||||
@click="prevPage"
|
|
||||||
:disabled="page <= 1"
|
|
||||||
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft class="w-4 h-4" />
|
|
||||||
Prev
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="nextPage"
|
|
||||||
:disabled="page >= totalPages"
|
|
||||||
class="flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pau { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* Page head */
|
||||||
|
.pau__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.pau__head-id { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.pau__chip {
|
||||||
|
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--accent); background: var(--accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||||
|
}
|
||||||
|
.pau__title {
|
||||||
|
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary); margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters */
|
||||||
|
.pau__filters { display: flex; align-items: center; gap: 10px; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.pau__table { width: 100%; border-collapse: collapse; }
|
||||||
|
.pau__thead-row { border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.pau__th {
|
||||||
|
padding: 10px 16px; text-align: left;
|
||||||
|
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pau__th--right { text-align: right; }
|
||||||
|
.pau__tr { border-bottom: 1px solid var(--border-subtle); transition: var(--transition-colors); }
|
||||||
|
.pau__tr:last-child { border-bottom: 0; }
|
||||||
|
.pau__tr:hover { background: var(--surface-hover); }
|
||||||
|
.pau__td { padding: 11px 16px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||||
|
.pau__td--secondary { color: var(--text-secondary); }
|
||||||
|
.pau__td--mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
|
.pau__td--actions { text-align: right; }
|
||||||
|
.pau__td-empty { padding: 12px 16px; text-align: center; font-size: var(--text-sm); color: var(--text-tertiary); }
|
||||||
|
.pau__td-es { padding: 0; }
|
||||||
|
|
||||||
|
.pau__empty-cell { font-size: var(--text-sm); color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.pau__actions { display: inline-flex; align-items: center; gap: 2px; }
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.pau__pager { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.pau__pager-info { font-size: var(--text-sm); color: var(--text-tertiary); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
|
.pau__pager-btns { display: flex; align-items: center; gap: 8px; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { Server, Users, Calendar, MessageCircle, Loader2, ExternalLink } from 'lucide-vue-next'
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
|
|
||||||
interface ServerInfo {
|
interface ServerInfo {
|
||||||
server_name: string
|
server_name: string
|
||||||
@@ -51,99 +58,225 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-neutral-950">
|
<div class="si-page">
|
||||||
<!-- Loading State -->
|
<!-- Loading -->
|
||||||
<div v-if="isLoading" class="flex items-center justify-center min-h-screen">
|
<div v-if="isLoading" class="si-state">
|
||||||
<Loader2 class="w-8 h-8 text-oxide-500 animate-spin" />
|
<Icon name="loader" :size="28" class="si-spin" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error -->
|
||||||
<div v-else-if="error" class="flex items-center justify-center min-h-screen p-6">
|
<div v-else-if="error" class="si-state">
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-8 max-w-md text-center">
|
<EmptyState
|
||||||
<Server class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
icon="server"
|
||||||
<h1 class="text-xl font-bold text-neutral-100 mb-2">Server Not Found</h1>
|
title="Server not found"
|
||||||
<p class="text-sm text-neutral-400">{{ error }}</p>
|
:description="error"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Server Info -->
|
<!-- Content -->
|
||||||
<div v-else-if="serverInfo" class="max-w-4xl mx-auto p-6 space-y-6">
|
<template v-else-if="serverInfo">
|
||||||
<!-- Header Image -->
|
<!-- Sticky nav bar -->
|
||||||
<div v-if="serverInfo.header_image" class="rounded-lg overflow-hidden">
|
<header class="si-bar">
|
||||||
<img :src="serverInfo.header_image" :alt="serverInfo.server_name" class="w-full h-64 object-cover" />
|
<div class="si-bar__inner">
|
||||||
|
<Logo :size="22" :wordmark="true" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="si-main">
|
||||||
|
<!-- Hero image -->
|
||||||
|
<div v-if="serverInfo.header_image" class="si-hero">
|
||||||
|
<img
|
||||||
|
:src="serverInfo.header_image"
|
||||||
|
:alt="serverInfo.server_name"
|
||||||
|
class="si-hero__img"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Server Name & Stats -->
|
<!-- Identity + connect -->
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
<Panel>
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="si-identity">
|
||||||
<div>
|
<div class="si-identity__left">
|
||||||
<h1 class="text-3xl font-bold text-neutral-100 mb-2">{{ serverInfo.server_name }}</h1>
|
<h1 class="si-title">{{ serverInfo.server_name }}</h1>
|
||||||
<div class="flex items-center gap-2 text-neutral-400">
|
<p v-if="serverInfo.description" class="si-desc">
|
||||||
<Users class="w-4 h-4" />
|
|
||||||
<span class="text-sm">{{ serverInfo.player_count }}/{{ serverInfo.max_players }} players online</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="copyConnectUrl"
|
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 text-white font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<ExternalLink class="w-4 h-4" />
|
|
||||||
Connect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="serverInfo.description" class="text-neutral-300 leading-relaxed">
|
|
||||||
{{ serverInfo.description }}
|
{{ serverInfo.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button icon="external-link" size="md" @click="copyConnectUrl">
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- KPIs -->
|
||||||
|
<div class="si-kpis">
|
||||||
|
<StatCard
|
||||||
|
icon="users"
|
||||||
|
label="Players online"
|
||||||
|
:value="String(serverInfo.player_count)"
|
||||||
|
:unit="'/' + serverInfo.max_players"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
v-if="serverInfo.wipe_schedule"
|
||||||
|
icon="calendar"
|
||||||
|
label="Wipe schedule"
|
||||||
|
:value="serverInfo.wipe_schedule"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- MOTD -->
|
<!-- MOTD -->
|
||||||
<div v-if="serverInfo.motd" class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
<Panel v-if="serverInfo.motd" title="Message of the day">
|
||||||
<h2 class="text-lg font-bold text-neutral-100 mb-3">Message of the Day</h2>
|
<p class="si-motd">{{ serverInfo.motd }}</p>
|
||||||
<p class="text-neutral-300 whitespace-pre-line">{{ serverInfo.motd }}</p>
|
</Panel>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Wipe Schedule -->
|
<!-- Active mods -->
|
||||||
<div v-if="serverInfo.wipe_schedule" class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
<Panel v-if="serverInfo.mods.length > 0" title="Active mods">
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="si-mods">
|
||||||
<Calendar class="w-5 h-5 text-oxide-500" />
|
<Badge
|
||||||
<h2 class="text-lg font-bold text-neutral-100">Wipe Schedule</h2>
|
|
||||||
</div>
|
|
||||||
<p class="text-neutral-300">{{ serverInfo.wipe_schedule }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mods -->
|
|
||||||
<div v-if="serverInfo.mods.length > 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
|
||||||
<h2 class="text-lg font-bold text-neutral-100 mb-3">Active Mods</h2>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<span
|
|
||||||
v-for="mod in serverInfo.mods"
|
v-for="mod in serverInfo.mods"
|
||||||
:key="mod"
|
:key="mod"
|
||||||
class="px-3 py-1 bg-neutral-800 border border-neutral-700 rounded-full text-sm text-neutral-300"
|
tone="neutral"
|
||||||
>
|
size="lg"
|
||||||
{{ mod }}
|
>{{ mod }}</Badge>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Discord -->
|
<!-- Discord -->
|
||||||
<div v-if="serverInfo.discord_invite" class="bg-neutral-900 border border-neutral-800 rounded-lg p-6">
|
<Panel v-if="serverInfo.discord_invite" title="Community">
|
||||||
<div class="flex items-center justify-between">
|
<Alert tone="info" title="Join our Discord">
|
||||||
<div class="flex items-center gap-2">
|
<template #actions>
|
||||||
<MessageCircle class="w-5 h-5 text-oxide-500" />
|
|
||||||
<h2 class="text-lg font-bold text-neutral-100">Join our Discord</h2>
|
|
||||||
</div>
|
|
||||||
<a
|
<a
|
||||||
:href="serverInfo.discord_invite"
|
:href="serverInfo.discord_invite"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="flex items-center gap-2 px-4 py-2 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-medium rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
<ExternalLink class="w-4 h-4" />
|
<Button size="sm" variant="secondary" icon="external-link">Join Discord</Button>
|
||||||
Join
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</Alert>
|
||||||
</div>
|
</Panel>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.si-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading / error centering */
|
||||||
|
.si-state {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.si-spin {
|
||||||
|
color: var(--accent);
|
||||||
|
animation: si-rotate 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes si-rotate {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.si-spin { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky brand bar */
|
||||||
|
.si-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--surface-base);
|
||||||
|
box-shadow: 0 1px 0 var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.si-bar__inner {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-4) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
.si-main {
|
||||||
|
max-width: 860px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero image */
|
||||||
|
.si-hero {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.si-hero__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 240px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Identity block inside panel */
|
||||||
|
.si-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.si-identity__left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.si-title {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.si-desc {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI row */
|
||||||
|
.si-kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MOTD */
|
||||||
|
.si-motd {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.65;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mods */
|
||||||
|
.si-mods {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { Server, Users, Activity, TrendingUp, Search } from 'lucide-vue-next'
|
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { safeFixed } from '@/utils/formatters'
|
import { safeFixed } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
|
|
||||||
interface ServerStatus {
|
interface ServerStatus {
|
||||||
server_name: string
|
server_name: string
|
||||||
@@ -72,32 +80,16 @@ async function fetchStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColor(status: string) {
|
function getStatusTone(status: string): 'online' | 'offline' | 'warn' {
|
||||||
switch (status) {
|
if (status === 'online') return 'online'
|
||||||
case 'online':
|
if (status === 'degraded') return 'warn'
|
||||||
return 'bg-green-500'
|
return 'offline'
|
||||||
case 'degraded':
|
|
||||||
return 'bg-yellow-500'
|
|
||||||
default:
|
|
||||||
return 'bg-red-500'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusText(status: string) {
|
function getUptimeTone(uptime: number): 'online' | 'warn' | 'offline' {
|
||||||
switch (status) {
|
if (uptime >= 99) return 'online'
|
||||||
case 'online':
|
if (uptime >= 95) return 'warn'
|
||||||
return 'Online'
|
return 'offline'
|
||||||
case 'degraded':
|
|
||||||
return 'Degraded'
|
|
||||||
default:
|
|
||||||
return 'Offline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUptimeBadgeColor(uptime: number) {
|
|
||||||
if (uptime >= 99) return 'bg-green-500/10 text-green-400'
|
|
||||||
if (uptime >= 95) return 'bg-yellow-500/10 text-yellow-400'
|
|
||||||
return 'bg-red-500/10 text-red-400'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimeUntil(isoDate: string | null): string {
|
function formatTimeUntil(isoDate: string | null): string {
|
||||||
@@ -135,207 +127,376 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-neutral-950">
|
<div class="sp-page">
|
||||||
<!-- Header -->
|
<!-- Brand bar -->
|
||||||
<header class="bg-neutral-900 border-b border-neutral-800">
|
<header class="sp-bar">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div class="sp-bar__inner">
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
<div class="sp-bar__brand">
|
||||||
<div>
|
<Logo :size="22" :wordmark="true" />
|
||||||
<h1 class="text-3xl font-bold text-neutral-100 flex items-center gap-3">
|
<div class="sp-bar__sub">Real-time status for all Corrosion-powered servers</div>
|
||||||
<Activity class="w-8 h-8 text-oxide-500" />
|
|
||||||
Corrosion Status
|
|
||||||
</h1>
|
|
||||||
<p class="text-neutral-400 mt-1">
|
|
||||||
Real-time status for all Corrosion-powered Rust servers
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sp-bar__search">
|
||||||
<!-- Search -->
|
<Input
|
||||||
<div class="relative w-full md:w-80">
|
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
icon="search"
|
||||||
placeholder="Search servers..."
|
placeholder="Search servers..."
|
||||||
class="w-full pl-10 pr-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Platform Health Stats -->
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6" v-if="!loading">
|
|
||||||
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
|
|
||||||
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
|
|
||||||
<Server class="w-3.5 h-3.5" />
|
|
||||||
Total Servers
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-neutral-100">
|
|
||||||
{{ platformHealth.total_servers }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
|
|
||||||
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
|
|
||||||
<Activity class="w-3.5 h-3.5" />
|
|
||||||
Online Now
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-green-400">
|
|
||||||
{{ platformHealth.online_servers }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
|
|
||||||
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
|
|
||||||
<Users class="w-3.5 h-3.5" />
|
|
||||||
Total Players
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-oxide-400">
|
|
||||||
{{ platformHealth.total_players }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-neutral-800 rounded-lg p-4 border border-neutral-700">
|
|
||||||
<div class="flex items-center gap-2 text-neutral-400 text-xs mb-1">
|
|
||||||
<TrendingUp class="w-3.5 h-3.5" />
|
|
||||||
Platform Uptime
|
|
||||||
</div>
|
|
||||||
<p class="text-2xl font-bold text-neutral-100">
|
|
||||||
{{ safeFixed(platformHealth.uptime_percent, 1) }}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Server Grid -->
|
<!-- Platform KPIs -->
|
||||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div v-if="!loading" class="sp-kpis">
|
||||||
<!-- Loading State -->
|
<StatCard icon="server" label="Total servers" :value="String(platformHealth.total_servers)" />
|
||||||
<div v-if="loading" class="text-center py-12">
|
<StatCard icon="activity" label="Online now" :value="String(platformHealth.online_servers)" />
|
||||||
<div class="inline-block w-8 h-8 border-4 border-oxide-500/20 border-t-oxide-500 rounded-full animate-spin"></div>
|
<StatCard icon="users" label="Total players" :value="String(platformHealth.total_players)" />
|
||||||
<p class="text-neutral-400 mt-4">Loading server status...</p>
|
<StatCard icon="trending-up" label="Platform uptime" :value="safeFixed(platformHealth.uptime_percent, 1)" unit="%" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Body -->
|
||||||
<div v-else-if="error" class="bg-red-500/10 border border-red-500/20 rounded-lg p-6 text-center">
|
<main class="sp-main">
|
||||||
<p class="text-red-400">{{ error }}</p>
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="sp-state">
|
||||||
|
<Icon name="loader" :size="28" class="sp-spin" />
|
||||||
|
<span class="sp-state__label">Loading server status...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Error -->
|
||||||
<div v-else-if="filteredServers.length === 0" class="text-center py-12">
|
<Alert v-else-if="error" tone="danger" :title="error ?? ''" />
|
||||||
<Server class="w-16 h-16 text-neutral-600 mx-auto mb-4" />
|
|
||||||
<p class="text-neutral-400 text-lg">
|
|
||||||
{{ searchQuery ? 'No servers match your search' : 'No servers available yet' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Server Cards -->
|
<!-- Empty -->
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<EmptyState
|
||||||
<div
|
v-else-if="filteredServers.length === 0"
|
||||||
|
icon="server"
|
||||||
|
:title="searchQuery ? 'No servers match your search' : 'No servers available yet'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Server grid -->
|
||||||
|
<div v-else class="sp-grid">
|
||||||
|
<Panel
|
||||||
v-for="server in filteredServers"
|
v-for="server in filteredServers"
|
||||||
:key="server.subdomain"
|
:key="server.subdomain"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-5 hover:border-oxide-500/50 transition-colors"
|
class="sp-card"
|
||||||
>
|
>
|
||||||
<!-- Server Header -->
|
<div class="sp-card__body">
|
||||||
<div class="flex items-start justify-between mb-4">
|
<!-- Server name + link -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="sp-card__head">
|
||||||
<h3 class="text-lg font-semibold text-neutral-100 truncate">
|
<div class="sp-card__name-block">
|
||||||
{{ server.server_name }}
|
<h3 class="sp-card__name">{{ server.server_name }}</h3>
|
||||||
</h3>
|
|
||||||
<a
|
<a
|
||||||
:href="`https://${server.subdomain}.corrosionmgmt.com`"
|
:href="`https://${server.subdomain}.corrosionmgmt.com`"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="text-xs text-oxide-400 hover:text-oxide-300 truncate block"
|
class="sp-card__link"
|
||||||
>
|
>
|
||||||
|
<Icon name="external-link" :size="11" />
|
||||||
{{ server.subdomain }}.corrosionmgmt.com
|
{{ server.subdomain }}.corrosionmgmt.com
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<StatusDot :tone="getStatusTone(server.status)" :size="10" :pulse="server.status === 'online'" />
|
||||||
<!-- Status Indicator -->
|
|
||||||
<div class="flex items-center gap-2 ml-3">
|
|
||||||
<div
|
|
||||||
:class="getStatusColor(server.status)"
|
|
||||||
class="w-2.5 h-2.5 rounded-full animate-pulse"
|
|
||||||
></div>
|
|
||||||
<span class="text-xs font-medium text-neutral-300">
|
|
||||||
{{ getStatusText(server.status) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<p v-if="server.description" class="text-sm text-neutral-400 mb-4 line-clamp-2">
|
<p v-if="server.description" class="sp-card__desc">{{ server.description }}</p>
|
||||||
{{ server.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Player Count -->
|
<!-- Player bar -->
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="sp-players">
|
||||||
<Users class="w-4 h-4 text-neutral-500" />
|
<div class="sp-players__label">
|
||||||
<span class="text-sm text-neutral-300">
|
<Icon name="users" :size="12" />
|
||||||
{{ server.player_count }} / {{ server.max_players }} players
|
<span>{{ server.player_count }} / {{ server.max_players }} players</span>
|
||||||
</span>
|
</div>
|
||||||
<div class="flex-1 bg-neutral-800 rounded-full h-1.5 overflow-hidden">
|
<div class="sp-players__track">
|
||||||
<div
|
<div
|
||||||
class="h-full bg-oxide-500 transition-all duration-300"
|
class="sp-players__fill"
|
||||||
:style="{ width: `${(server.player_count / server.max_players) * 100}%` }"
|
:style="{ width: `${(server.player_count / server.max_players) * 100}%` }"
|
||||||
></div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Map Info -->
|
<!-- Map -->
|
||||||
<div v-if="server.map_name" class="text-xs text-neutral-500 mb-3">
|
<div v-if="server.map_name" class="sp-card__meta">
|
||||||
Map: {{ server.map_name }}
|
<Icon name="map" :size="12" />
|
||||||
|
<span>{{ server.map_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Wipe Schedule -->
|
<!-- Wipe schedule -->
|
||||||
<div v-if="server.wipe_schedule" class="bg-neutral-800 rounded-lg p-3 mb-3">
|
<div v-if="server.wipe_schedule" class="sp-wipe">
|
||||||
<div class="text-xs text-neutral-500 mb-1">Wipe Schedule</div>
|
<div class="sp-wipe__label">Wipe schedule</div>
|
||||||
<div class="text-sm text-neutral-300">{{ server.wipe_schedule }}</div>
|
<div class="sp-wipe__val">{{ server.wipe_schedule }}</div>
|
||||||
<div v-if="server.next_wipe" class="text-xs text-oxide-400 mt-1">
|
<div v-if="server.next_wipe" class="sp-wipe__next">
|
||||||
Next wipe: {{ formatTimeUntil(server.next_wipe) }}
|
Next: {{ formatTimeUntil(server.next_wipe) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uptime Badges -->
|
<!-- Uptime badges -->
|
||||||
<div class="flex gap-2">
|
<div class="sp-uptime">
|
||||||
<div :class="getUptimeBadgeColor(server.uptime_24h_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
|
<Badge :tone="getUptimeTone(server.uptime_24h_percent)" :mono="true" size="lg">
|
||||||
<div class="text-xs font-medium">{{ safeFixed(server.uptime_24h_percent, 1) }}%</div>
|
{{ safeFixed(server.uptime_24h_percent, 1) }}% <span class="sp-uptime__period">24h</span>
|
||||||
<div class="text-[10px] opacity-75">24h</div>
|
</Badge>
|
||||||
</div>
|
<Badge :tone="getUptimeTone(server.uptime_7d_percent)" :mono="true" size="lg">
|
||||||
<div :class="getUptimeBadgeColor(server.uptime_7d_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
|
{{ safeFixed(server.uptime_7d_percent, 1) }}% <span class="sp-uptime__period">7d</span>
|
||||||
<div class="text-xs font-medium">{{ safeFixed(server.uptime_7d_percent, 1) }}%</div>
|
</Badge>
|
||||||
<div class="text-[10px] opacity-75">7d</div>
|
<Badge :tone="getUptimeTone(server.uptime_30d_percent)" :mono="true" size="lg">
|
||||||
</div>
|
{{ safeFixed(server.uptime_30d_percent, 1) }}% <span class="sp-uptime__period">30d</span>
|
||||||
<div :class="getUptimeBadgeColor(server.uptime_30d_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
|
</Badge>
|
||||||
<div class="text-xs font-medium">{{ safeFixed(server.uptime_30d_percent, 1) }}%</div>
|
|
||||||
<div class="text-[10px] opacity-75">30d</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto-refresh indicator -->
|
<!-- Auto-refresh note -->
|
||||||
<div class="text-center mt-8 text-xs text-neutral-600">
|
<div class="sp-refresh-note">Auto-refreshing every 10 seconds</div>
|
||||||
Auto-refreshing every 10 seconds
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="bg-neutral-900 border-t border-neutral-800 mt-16">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 text-center">
|
|
||||||
<p class="text-neutral-400 text-sm mb-2">
|
|
||||||
Powered by
|
|
||||||
<a
|
|
||||||
href="https://panel.corrosionmgmt.com"
|
|
||||||
class="text-oxide-400 hover:text-oxide-300 font-medium"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Corrosion
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p class="text-neutral-600 text-xs">
|
|
||||||
The complete server management platform for Rust game servers
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sp-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand bar */
|
||||||
|
.sp-bar {
|
||||||
|
background: var(--surface-base);
|
||||||
|
box-shadow: 0 1px 0 var(--border-subtle);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-bar__inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-5);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-bar__brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-bar__sub {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-bar__search {
|
||||||
|
width: 260px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Platform KPIs */
|
||||||
|
.sp-kpis {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-5) var(--space-6) 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content area */
|
||||||
|
.sp-main {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-5) var(--space-6) var(--space-8);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.sp-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-16) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-spin {
|
||||||
|
color: var(--accent);
|
||||||
|
animation: sp-rotate 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sp-rotate {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sp-spin { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-state__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server grid */
|
||||||
|
.sp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Server card body */
|
||||||
|
.sp-card__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-card__name-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-card__name {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-card__link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-text);
|
||||||
|
transition: color var(--dur-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-card__link:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-card__desc {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.55;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-card__meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1-5);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player progress bar */
|
||||||
|
.sp-players {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-players__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1-5);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-players__track {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-players__fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
transition: width 0.4s var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wipe schedule block */
|
||||||
|
.sp-wipe {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2-5) var(--space-3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-wipe__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-wipe__val {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-wipe__next {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-text);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uptime badges row */
|
||||||
|
.sp-uptime {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sp-uptime__period {
|
||||||
|
opacity: 0.65;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-refresh note */
|
||||||
|
.sp-refresh-note {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.sp-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.sp-kpis { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sp-grid { grid-template-columns: 1fr; }
|
||||||
|
.sp-kpis { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.sp-bar__search { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,16 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import type { PublicStoreInfo, PublicStoreItem, StorePurchaseRequest, StorePurchaseResponse } from '@/types'
|
import type { PublicStoreInfo, PublicStoreItem, StorePurchaseRequest, StorePurchaseResponse } from '@/types'
|
||||||
import { ShoppingCart, Package, Filter, X, AlertCircle, ExternalLink, Check } from 'lucide-vue-next'
|
|
||||||
import { safeCurrency } from '@/utils/formatters'
|
import { safeCurrency } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
import Alert from '@/components/ds/feedback/Alert.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Input from '@/components/ds/forms/Input.vue'
|
||||||
|
import Select from '@/components/ds/forms/Select.vue'
|
||||||
|
import Logo from '@/components/ds/brand/Logo.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const subdomain = computed(() => route.params.subdomain as string)
|
const subdomain = computed(() => route.params.subdomain as string)
|
||||||
@@ -29,7 +37,7 @@ const categories = computed(() => {
|
|||||||
})
|
})
|
||||||
return Array.from(cats).map(name => ({
|
return Array.from(cats).map(name => ({
|
||||||
value: name,
|
value: name,
|
||||||
label: name === 'all' ? 'All Items' : name
|
label: name === 'all' ? 'All items' : name
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -142,14 +150,14 @@ function formatPrice(price: number): string {
|
|||||||
return safeCurrency(price, '$')
|
return safeCurrency(price, '$')
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemTypeBadgeClass(itemType: string): string {
|
function itemTypeTone(itemType: string): 'info' | 'wiping' | 'online' | 'warn' | 'neutral' {
|
||||||
const colors: Record<string, string> = {
|
const tones: Record<string, 'info' | 'wiping' | 'online' | 'warn' | 'neutral'> = {
|
||||||
kit: 'bg-blue-500/15 text-blue-400',
|
kit: 'info',
|
||||||
rank: 'bg-purple-500/15 text-purple-400',
|
rank: 'wiping',
|
||||||
currency: 'bg-green-500/15 text-green-400',
|
currency: 'online',
|
||||||
custom_command: 'bg-orange-500/15 text-orange-400',
|
custom_command: 'warn',
|
||||||
}
|
}
|
||||||
return colors[itemType] || 'bg-neutral-700/50 text-neutral-400'
|
return tones[itemType] ?? 'neutral'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -158,264 +166,631 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gradient-to-b from-neutral-950 to-neutral-900">
|
<div class="sv-page">
|
||||||
<!-- Header -->
|
<!-- Sticky store header -->
|
||||||
<div class="bg-neutral-950/80 backdrop-blur-sm border-b border-neutral-800 sticky top-0 z-10">
|
<header class="sv-bar">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div class="sv-bar__inner">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="sv-bar__brand">
|
||||||
<ShoppingCart class="w-6 h-6 text-oxide-500" />
|
<Logo :size="20" :wordmark="true" />
|
||||||
<h1 class="text-3xl font-bold text-neutral-100">
|
<div class="sv-bar__divider" />
|
||||||
{{ storeInfo?.store_name || 'Server Store' }}
|
<div class="sv-bar__store">
|
||||||
</h1>
|
<Icon name="shopping-cart" :size="16" />
|
||||||
|
<span class="sv-bar__name">{{ storeInfo?.store_name ?? 'Server store' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="storeInfo?.description" class="text-neutral-400 max-w-3xl">
|
|
||||||
{{ storeInfo.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Store description -->
|
||||||
|
<div v-if="storeInfo?.description" class="sv-desc-wrap">
|
||||||
|
<p class="sv-desc">{{ storeInfo.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="sv-body">
|
||||||
<!-- Loading state -->
|
<!-- Loading -->
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-20">
|
<div v-if="isLoading" class="sv-state">
|
||||||
<div class="text-center">
|
<Icon name="loader" :size="28" class="sv-spin" />
|
||||||
<div class="inline-block w-8 h-8 border-4 border-oxide-500 border-t-transparent rounded-full animate-spin mb-4"></div>
|
<span class="sv-state__label">Loading store...</span>
|
||||||
<p class="text-neutral-400">Loading store...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error -->
|
||||||
<div v-else-if="storeError" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
<div v-else-if="storeError" class="sv-error">
|
||||||
<AlertCircle class="w-16 h-16 text-red-400 mx-auto mb-4" />
|
<EmptyState
|
||||||
<h2 class="text-2xl font-bold text-neutral-100 mb-2">Store Unavailable</h2>
|
icon="shopping-bag"
|
||||||
<p class="text-neutral-400 mb-6">{{ storeError }}</p>
|
title="Store unavailable"
|
||||||
<button
|
:description="storeError"
|
||||||
@click="loadStore"
|
|
||||||
class="px-6 py-3 bg-oxide-600 hover:bg-oxide-700 text-white font-medium rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
Retry
|
<template #action>
|
||||||
</button>
|
<Button icon="refresh-cw" variant="secondary" @click="loadStore">Retry</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Store content -->
|
<!-- Store content -->
|
||||||
<div v-else-if="storeInfo">
|
<template v-else-if="storeInfo">
|
||||||
<!-- Category filter -->
|
<!-- Category filter -->
|
||||||
<div v-if="categories.length > 1" class="mb-6 flex items-center gap-3">
|
<div v-if="categories.length > 1" class="sv-filter">
|
||||||
<Filter class="w-4 h-4 text-neutral-500" />
|
<Icon name="list" :size="14" class="sv-filter__icon" />
|
||||||
<select
|
<Select
|
||||||
v-model="selectedCategory"
|
v-model="selectedCategory"
|
||||||
class="px-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
size="sm"
|
||||||
>
|
:options="categories"
|
||||||
<option v-for="cat in categories" :key="cat.value" :value="cat.value">
|
/>
|
||||||
{{ cat.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Items grid -->
|
<!-- Items grid -->
|
||||||
<div v-if="filteredItems.length > 0" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div v-if="filteredItems.length > 0" class="sv-grid">
|
||||||
<div
|
<div
|
||||||
v-for="item in filteredItems"
|
v-for="item in filteredItems"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden hover:border-oxide-500/50 transition-all group shadow-lg hover:shadow-oxide-500/10"
|
class="sv-item"
|
||||||
>
|
>
|
||||||
<!-- Item image -->
|
<!-- Item image -->
|
||||||
<div class="relative h-48 bg-gradient-to-br from-neutral-800 to-neutral-900 overflow-hidden">
|
<div class="sv-item__img-wrap">
|
||||||
<img
|
<img
|
||||||
v-if="item.image_url"
|
v-if="item.image_url"
|
||||||
:src="item.image_url"
|
:src="item.image_url"
|
||||||
:alt="item.name"
|
:alt="item.name"
|
||||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
class="sv-item__img"
|
||||||
/>
|
/>
|
||||||
<div v-else class="w-full h-full flex items-center justify-center">
|
<div v-else class="sv-item__img-ph">
|
||||||
<Package class="w-16 h-16 text-neutral-700" />
|
<Icon name="package" :size="32" :stroke-width="1.5" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="item.category_name" class="absolute top-3 right-3">
|
<div v-if="item.category_name" class="sv-item__cat">
|
||||||
<span class="text-xs font-medium px-2.5 py-1 rounded-full bg-neutral-950/80 backdrop-blur-sm text-neutral-300 border border-neutral-700">
|
<Badge tone="neutral">{{ item.category_name }}</Badge>
|
||||||
{{ item.category_name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Item details -->
|
<!-- Item details -->
|
||||||
<div class="p-5 space-y-4">
|
<div class="sv-item__body">
|
||||||
<div>
|
<div class="sv-item__top">
|
||||||
<div class="flex items-start justify-between gap-2 mb-2">
|
<h3 class="sv-item__name">{{ item.name }}</h3>
|
||||||
<h3 class="text-lg font-bold text-neutral-100 group-hover:text-oxide-400 transition-colors">
|
<span class="sv-item__price">{{ formatPrice(item.price) }}</span>
|
||||||
{{ item.name }}
|
|
||||||
</h3>
|
|
||||||
<span class="text-xl font-bold text-oxide-400 shrink-0">
|
|
||||||
{{ formatPrice(item.price) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="item.description" class="text-sm text-neutral-400 line-clamp-2 leading-relaxed">
|
|
||||||
{{ item.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="item.description" class="sv-item__desc">{{ item.description }}</p>
|
||||||
|
|
||||||
<!-- Item type badge -->
|
<!-- Type badge + limit -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="sv-item__meta">
|
||||||
<span class="text-xs font-medium px-2 py-1 rounded" :class="itemTypeBadgeClass(item.item_type)">
|
<Badge :tone="itemTypeTone(item.item_type)">
|
||||||
{{ item.item_type.replace('_', ' ') }}
|
{{ item.item_type.replace('_', ' ') }}
|
||||||
|
</Badge>
|
||||||
|
<span v-if="item.limit_per_player" class="sv-item__limit">
|
||||||
|
Limit {{ item.limit_per_player }} per player
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Purchase limit indicator -->
|
<Button
|
||||||
<div v-if="item.limit_per_player" class="text-xs text-neutral-500">
|
:block="true"
|
||||||
Limited to {{ item.limit_per_player }} per player
|
icon="shopping-cart"
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Buy button -->
|
|
||||||
<button
|
|
||||||
@click="openPurchaseModal(item)"
|
@click="openPurchaseModal(item)"
|
||||||
class="w-full py-3 text-sm font-semibold text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors shadow-lg shadow-oxide-500/20"
|
>Buy now</Button>
|
||||||
>
|
|
||||||
Buy Now
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty category -->
|
||||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg p-16 text-center">
|
<EmptyState
|
||||||
<Package class="w-16 h-16 text-neutral-600 mx-auto mb-4" />
|
v-else
|
||||||
<h3 class="text-xl font-medium text-neutral-300 mb-2">No Items Available</h3>
|
icon="package"
|
||||||
<p class="text-sm text-neutral-500">
|
title="No items available"
|
||||||
{{ selectedCategory === 'all' ? 'This store has no items at the moment.' : 'No items in this category.' }}
|
:description="selectedCategory === 'all' ? 'This store has no items at the moment.' : 'No items in this category.'"
|
||||||
</p>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Purchase Modal -->
|
<!-- Purchase modal -->
|
||||||
<div
|
<div
|
||||||
v-if="showPurchaseModal && selectedItem"
|
v-if="showPurchaseModal && selectedItem"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
class="sv-modal-scrim"
|
||||||
@click.self="closePurchaseModal"
|
@click.self="closePurchaseModal"
|
||||||
>
|
>
|
||||||
<div class="bg-neutral-900 border border-neutral-800 rounded-xl max-w-lg w-full shadow-2xl">
|
<div class="sv-modal">
|
||||||
<!-- Modal Header -->
|
<!-- Modal header -->
|
||||||
<div class="border-b border-neutral-800 px-6 py-5 flex items-start justify-between">
|
<div class="sv-modal__head">
|
||||||
<div class="flex-1">
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-neutral-100 mb-1">Complete Purchase</h2>
|
<div class="sv-modal__title">Complete purchase</div>
|
||||||
<p class="text-sm text-neutral-400">You'll be redirected to PayPal to complete payment</p>
|
<div class="sv-modal__sub">You'll be redirected to PayPal to complete payment</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
class="sv-modal__close"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
@click="closePurchaseModal"
|
@click="closePurchaseModal"
|
||||||
class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
<X class="w-5 h-5" />
|
<Icon name="x" :size="16" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal Body -->
|
<!-- Modal body -->
|
||||||
<div class="p-6 space-y-5">
|
<div class="sv-modal__body">
|
||||||
<!-- Item preview -->
|
<!-- Item preview -->
|
||||||
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 flex items-start gap-4">
|
<div class="sv-modal__item">
|
||||||
<div class="w-20 h-20 bg-neutral-800 rounded-lg overflow-hidden shrink-0">
|
<div class="sv-modal__thumb">
|
||||||
<img
|
<img
|
||||||
v-if="selectedItem.image_url"
|
v-if="selectedItem.image_url"
|
||||||
:src="selectedItem.image_url"
|
:src="selectedItem.image_url"
|
||||||
:alt="selectedItem.name"
|
:alt="selectedItem.name"
|
||||||
class="w-full h-full object-cover"
|
class="sv-modal__thumb-img"
|
||||||
/>
|
/>
|
||||||
<div v-else class="w-full h-full flex items-center justify-center">
|
<div v-else class="sv-modal__thumb-ph">
|
||||||
<Package class="w-8 h-8 text-neutral-600" />
|
<Icon name="package" :size="20" :stroke-width="1.5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="sv-modal__item-info">
|
||||||
<h3 class="text-lg font-semibold text-neutral-100 mb-1">{{ selectedItem.name }}</h3>
|
<div class="sv-modal__item-name">{{ selectedItem.name }}</div>
|
||||||
<p v-if="selectedItem.description" class="text-sm text-neutral-400 line-clamp-2 mb-2">
|
<p v-if="selectedItem.description" class="sv-modal__item-desc">{{ selectedItem.description }}</p>
|
||||||
{{ selectedItem.description }}
|
<div class="sv-modal__item-price">{{ formatPrice(selectedItem.price) }}</div>
|
||||||
</p>
|
|
||||||
<p class="text-2xl font-bold text-oxide-400">{{ formatPrice(selectedItem.price) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Steam ID input -->
|
<!-- Steam ID -->
|
||||||
<div>
|
<Input
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">
|
|
||||||
Steam ID <span class="text-red-400">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="steamId"
|
v-model="steamId"
|
||||||
type="text"
|
label="Steam ID"
|
||||||
|
:required="true"
|
||||||
|
:mono="true"
|
||||||
placeholder="76561198012345678"
|
placeholder="76561198012345678"
|
||||||
maxlength="17"
|
:error="purchaseError && !validateSteamId() ? 'Must be your 17-digit Steam ID' : undefined"
|
||||||
class="w-full px-4 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
hint="Required for item delivery."
|
||||||
:class="{ 'border-red-500': purchaseError && !validateSteamId() }"
|
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-neutral-500 mt-1.5">
|
|
||||||
Required for item delivery. Must be your 17-digit Steam ID.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Player name input -->
|
<!-- Player name -->
|
||||||
<div>
|
<Input
|
||||||
<label class="block text-sm font-medium text-neutral-300 mb-2">
|
|
||||||
Player Name (Optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="playerName"
|
v-model="playerName"
|
||||||
type="text"
|
label="Player name (optional)"
|
||||||
placeholder="Your in-game name"
|
placeholder="Your in-game name"
|
||||||
class="w-full px-4 py-3 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error message -->
|
<!-- Purchase error -->
|
||||||
<div v-if="purchaseError" class="flex items-start gap-2 bg-red-500/10 border border-red-500/20 rounded-lg p-3">
|
<Alert v-if="purchaseError" tone="danger" :title="purchaseError" />
|
||||||
<AlertCircle class="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
|
||||||
<p class="text-sm text-red-400">{{ purchaseError }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Legal disclaimer -->
|
<!-- Legal disclaimer -->
|
||||||
<div class="bg-neutral-800/30 border border-neutral-700 rounded-lg p-4">
|
<Panel variant="raised">
|
||||||
<p class="text-xs text-neutral-500 leading-relaxed space-y-1.5">
|
<div class="sv-modal__terms">
|
||||||
<span class="flex items-start gap-1.5">
|
<div class="sv-modal__term">
|
||||||
<Check class="w-3 h-3 text-oxide-500 shrink-0 mt-0.5" />
|
<Icon name="check" :size="13" class="sv-modal__term-icon" />
|
||||||
<span>Items will be delivered automatically to your in-game character after payment</span>
|
<span>Items delivered automatically to your character after payment</span>
|
||||||
</span>
|
</div>
|
||||||
<span class="flex items-start gap-1.5">
|
<div class="sv-modal__term">
|
||||||
<Check class="w-3 h-3 text-oxide-500 shrink-0 mt-0.5" />
|
<Icon name="check" :size="13" class="sv-modal__term-icon" />
|
||||||
<span>All purchases are final and non-refundable</span>
|
<span>All purchases are final and non-refundable</span>
|
||||||
</span>
|
</div>
|
||||||
<span class="flex items-start gap-1.5">
|
<div class="sv-modal__term">
|
||||||
<Check class="w-3 h-3 text-oxide-500 shrink-0 mt-0.5" />
|
<Icon name="check" :size="13" class="sv-modal__term-icon" />
|
||||||
<span>You must be logged into the server to receive items</span>
|
<span>You must be logged into the server to receive items</span>
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal Footer -->
|
<!-- Modal footer -->
|
||||||
<div class="border-t border-neutral-800 px-6 py-5 flex items-center justify-between gap-4">
|
<div class="sv-modal__foot">
|
||||||
<button
|
<Button
|
||||||
@click="closePurchaseModal"
|
variant="secondary"
|
||||||
:disabled="isPurchasing"
|
:disabled="isPurchasing"
|
||||||
class="px-6 py-3 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
@click="closePurchaseModal"
|
||||||
>
|
>Cancel</Button>
|
||||||
Cancel
|
<Button
|
||||||
</button>
|
icon="external-link"
|
||||||
<button
|
|
||||||
@click="confirmPurchase"
|
|
||||||
:disabled="isPurchasing || !steamId.trim()"
|
:disabled="isPurchasing || !steamId.trim()"
|
||||||
class="flex items-center gap-2 px-8 py-3 text-sm font-semibold text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-oxide-500/20"
|
:loading="isPurchasing"
|
||||||
>
|
@click="confirmPurchase"
|
||||||
<ExternalLink v-if="!isPurchasing" class="w-4 h-4" />
|
>{{ isPurchasing ? 'Processing...' : 'Proceed to PayPal' }}</Button>
|
||||||
<span>{{ isPurchasing ? 'Processing...' : 'Proceed to PayPal' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="mt-16 py-8 border-t border-neutral-800">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center justify-center gap-2 text-neutral-600 text-sm">
|
|
||||||
<img src="/logo.png" alt="Corrosion" class="h-4 w-4 opacity-60" />
|
|
||||||
<span>Powered by <span class="text-oxide-500 font-semibold">Corrosion</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sv-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--surface-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Store bar */
|
||||||
|
.sv-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--surface-base);
|
||||||
|
box-shadow: 0 1px 0 var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-bar__inner {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-4) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-bar__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-bar__divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--border-default);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-bar__store {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-bar__name {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Description strip */
|
||||||
|
.sv-desc-wrap {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-4) var(--space-6) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-desc {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.sv-body {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-5) var(--space-6) var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.sv-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-16) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-spin {
|
||||||
|
color: var(--accent);
|
||||||
|
animation: sv-rotate 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sv-rotate {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sv-spin { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-state__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error */
|
||||||
|
.sv-error {
|
||||||
|
background: var(--surface-base);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category filter */
|
||||||
|
.sv-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-filter__icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Items grid */
|
||||||
|
.sv-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item card */
|
||||||
|
.sv-item {
|
||||||
|
background: var(--surface-base);
|
||||||
|
box-shadow: var(--ring-default), var(--shadow-sm);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: box-shadow var(--dur-fast) var(--ease-standard),
|
||||||
|
transform var(--dur-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item:hover {
|
||||||
|
box-shadow: var(--ring-strong), var(--shadow-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item__img-wrap {
|
||||||
|
position: relative;
|
||||||
|
height: 180px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
overflow: hidden;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
transition: transform 0.3s var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item:hover .sv-item__img {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item__img-ph {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item__cat {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-3);
|
||||||
|
right: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item__body {
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item__name {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item__price {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-text);
|
||||||
|
flex: none;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item__desc {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.55;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-item__limit {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal scrim */
|
||||||
|
.sv-modal-scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--scrim);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.sv-modal {
|
||||||
|
background: var(--surface-overlay);
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: calc(100vh - var(--space-8));
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__head {
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__sub {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__close {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background var(--dur-fast) var(--ease-standard),
|
||||||
|
color var(--dur-fast) var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__close:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__close:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__body {
|
||||||
|
padding: var(--space-5) var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item preview in modal */
|
||||||
|
.sv-modal__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--ring-default);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__thumb {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
overflow: hidden;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__thumb-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__thumb-ph {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__item-name {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__item-desc {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__item-price {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terms list */
|
||||||
|
.sv-modal__terms {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__term {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sv-modal__term-icon {
|
||||||
|
color: var(--accent-text);
|
||||||
|
flex: none;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal footer */
|
||||||
|
.sv-modal__foot {
|
||||||
|
padding: var(--space-4) var(--space-6);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user