feat(admin-ui): enhance dark mode support across UI components

- Updated various components in the llm-router to include dark mode styles, ensuring better visibility and aesthetics in dark theme.
- Added tests for API URL helpers to validate sign-in URL construction and redirection logic.
- Improved the sign-in flow to handle trusted redirects for standalone admin origins.
- Enhanced CSS styles for dark mode, including adjustments to backgrounds, borders, and text colors for better contrast and readability.
- Refactored input components to maintain consistent styling in both light and dark modes.
This commit is contained in:
RainbowBird
2026-06-11 21:59:08 +08:00
parent 8e572ad93c
commit 2786393aa5
16 changed files with 352 additions and 54 deletions
+7 -7
View File
@@ -52,23 +52,23 @@ onMounted(async () => {
</script>
<template>
<div class="min-h-screen bg-[#f7f8fa] text-[#171717]">
<div class="min-h-screen bg-[#f7f8fa] text-[#171717] dark:bg-neutral-950 dark:text-neutral-100">
<div v-if="loading" class="grid min-h-screen place-items-center">
<div class="flex items-center gap-3 text-sm text-neutral-500">
<div class="flex items-center gap-3 text-sm text-neutral-500 dark:text-neutral-400">
<span class="i-lucide-loader-2 animate-spin text-lg" />
Loading admin session
</div>
</div>
<div v-else-if="accessError" class="grid min-h-screen place-items-center px-6">
<section class="max-w-md w-full border border-neutral-200 rounded-lg bg-white p-6 shadow-sm">
<section class="max-w-md w-full border border-neutral-200 rounded-lg bg-white p-6 shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
<div class="mb-4 h-10 w-10 flex items-center justify-center rounded-lg bg-red-50 text-red-600">
<span class="i-lucide-shield-alert text-xl" />
</div>
<h1 class="text-xl font-semibold">
Admin access required
</h1>
<p class="mt-2 text-sm text-neutral-500">
<p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
{{ accessError }}
</p>
<a class="mt-5 h-9 inline-flex items-center gap-2 rounded-md bg-emerald-600 px-3 text-sm text-white" :href="signInUrl()">
@@ -88,7 +88,7 @@ onMounted(async () => {
<div class="truncate text-sm font-semibold">
AIRI Admin
</div>
<div class="truncate text-xs text-neutral-500">
<div class="truncate text-xs text-neutral-500 dark:text-neutral-400">
Operations
</div>
</div>
@@ -132,8 +132,8 @@ onMounted(async () => {
<main class="admin-main">
<header class="admin-topbar">
<div class="flex items-center gap-3">
<span class="i-lucide-panel-left text-neutral-500" />
<div class="h-5 w-px bg-neutral-200" />
<span class="i-lucide-panel-left text-neutral-500 dark:text-neutral-400" />
<div class="h-5 w-px bg-neutral-200 dark:bg-neutral-800" />
<h1 class="text-sm font-semibold">
{{ currentTitle }}
</h1>
@@ -19,16 +19,16 @@ const json = defineModel<string>({ required: true })
<template>
<section :class="['panel', 'overflow-hidden']">
<div :class="['flex', 'items-start', 'justify-between', 'gap-3', 'border-b', 'border-neutral-200', 'px-4', 'py-3']">
<div :class="['flex', 'items-start', 'justify-between', 'gap-3', 'border-b', 'border-neutral-200', 'px-4', 'py-3', 'dark:border-neutral-800']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Advanced JSON
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
<p :class="['mt-1', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
Escape hatch for auditing or unsupported future fields.
</p>
</div>
<span :class="['i-lucide-code-2', 'text-neutral-500']" />
<span :class="['i-lucide-code-2', 'text-neutral-500', 'dark:text-neutral-400']" />
</div>
<div :class="['space-y-4', 'p-4']">
@@ -9,13 +9,13 @@ const defaults = defineModel<RouterDefaultsDraft>({ required: true })
</script>
<template>
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<div :class="['mb-4', 'flex', 'items-start', 'justify-between', 'gap-3']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Defaults
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
<p :class="['mt-1', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
Writes default model aliases alongside provider slices when filled.
</p>
</div>
@@ -44,7 +44,7 @@ const defaults = defineModel<RouterDefaultsDraft>({ required: true })
<div :class="['text-sm', 'font-medium']">
Recommended voices
</div>
<div :class="['text-xs', 'text-neutral-500']">
<div :class="['text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
JSON object written to DEFAULT_TTS_VOICES.
</div>
</div>
@@ -7,13 +7,13 @@ const mode = defineModel<RouterConfigMode>({ required: true })
</script>
<template>
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<div :class="['flex', 'items-start', 'justify-between', 'gap-3']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Write Mode
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
<p :class="['mt-1', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
Merge keeps untouched router models. Reset replaces the router model tree with this request.
</p>
</div>
@@ -18,22 +18,22 @@ function formatJson(value: unknown): string {
<template>
<section :class="['panel', 'overflow-hidden']">
<div :class="['border-b', 'border-neutral-200', 'px-4', 'py-3', 'text-sm', 'font-semibold']">
<div :class="['border-b', 'border-neutral-200', 'px-4', 'py-3', 'text-sm', 'font-semibold', 'dark:border-neutral-800']">
{{ title }}
</div>
<div v-if="result" :class="['space-y-4', 'p-4']">
<div :class="['grid', 'gap-3', 'text-xs', 'sm:grid-cols-2']">
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3']">
<div :class="['text-neutral-500']">
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3', 'dark:border-neutral-800', 'dark:bg-neutral-950']">
<div :class="['text-neutral-500', 'dark:text-neutral-400']">
Slices
</div>
<div :class="['mt-1', 'text-lg', 'font-semibold']">
{{ applied.length }}
</div>
</div>
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3']">
<div :class="['text-neutral-500']">
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3', 'dark:border-neutral-800', 'dark:bg-neutral-950']">
<div :class="['text-neutral-500', 'dark:text-neutral-400']">
Invalidated keys
</div>
<div :class="['mt-1', 'text-lg', 'font-semibold']">
@@ -43,7 +43,7 @@ function formatJson(value: unknown): string {
</div>
<div v-if="applied.length" :class="['space-y-2']">
<div :class="['text-xs', 'font-semibold', 'uppercase', 'text-neutral-500']">
<div :class="['text-xs', 'font-semibold', 'uppercase', 'text-neutral-500', 'dark:text-neutral-400']">
Applied
</div>
<div :class="['flex', 'flex-wrap', 'gap-2']">
@@ -59,7 +59,7 @@ function formatJson(value: unknown): string {
</div>
<div v-if="invalidatedKeys.length" :class="['space-y-2']">
<div :class="['text-xs', 'font-semibold', 'uppercase', 'text-neutral-500']">
<div :class="['text-xs', 'font-semibold', 'uppercase', 'text-neutral-500', 'dark:text-neutral-400']">
Keys
</div>
<div :class="['flex', 'flex-wrap', 'gap-2']">
@@ -69,13 +69,13 @@ const streamingKeyPlaceholder = computed(() => {
</script>
<template>
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<div :class="['mb-4', 'flex', 'flex-col', 'gap-3', 'md:flex-row', 'md:items-start', 'md:justify-between']">
<div :class="['min-w-0']">
<h3 :class="['truncate', 'text-sm', 'font-semibold']">
{{ index }}. {{ title }}
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
<p :class="['mt-1', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
{{ slice.kind }}
</p>
</div>
@@ -122,11 +122,11 @@ const streamingKeyPlaceholder = computed(() => {
<div v-else :class="['space-y-4']">
<FieldInput v-model="slice.restBaseURL" input-class="font-mono text-xs" label="REST base URL" placeholder="http://airi-unspeech.railway.internal:5933" required />
<label :class="['flex', 'items-start', 'gap-3', 'rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3']">
<label :class="['flex', 'items-start', 'gap-3', 'rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-3', 'dark:border-neutral-800', 'dark:bg-neutral-950']">
<input v-model="slice.streamingEnabled" :class="['mt-1']" type="checkbox">
<span>
<span :class="['block', 'text-sm', 'font-medium']">Enable streaming upstream</span>
<span :class="['block', 'text-xs', 'text-neutral-500']">Writes UNSPEECH_UPSTREAM.streaming for WebSocket TTS.</span>
<span :class="['block', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">Writes UNSPEECH_UPSTREAM.streaming for WebSocket TTS.</span>
</span>
</label>
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { buildAdminSignInUrl } from './api'
describe('ui-admin API URL helpers', () => {
it('builds sign-in URLs with the absolute admin return URL', () => {
expect(buildAdminSignInUrl(
'http://127.0.0.1:3000',
'http://127.0.0.1:5178/llm-router?api_server_url=http%3A%2F%2F127.0.0.1%3A3000',
)).toBe(
'http://127.0.0.1:3000/auth/sign-in?redirect=http%3A%2F%2F127.0.0.1%3A5178%2Fllm-router%3Fapi_server_url%3Dhttp%253A%252F%252F127.0.0.1%253A3000',
)
})
it('builds local dev sign-in URLs on the local API origin', () => {
expect(buildAdminSignInUrl(
'http://localhost:3000',
'http://localhost:5178/',
)).toBe(
'http://localhost:3000/auth/sign-in?redirect=http%3A%2F%2Flocalhost%3A5178%2F',
)
})
})
+20 -3
View File
@@ -209,12 +209,29 @@ export function apiServerUrl(): string {
return getServerAdminBootstrapContext()?.apiServerUrl ?? defaultApiServerUrl()
}
export function signInUrl(): string {
const url = new URL('/auth/sign-in', apiServerUrl())
url.searchParams.set('redirect', `${window.location.pathname}${window.location.search}`)
/**
* Builds an API-owned sign-in URL that returns to the exact admin page.
*
* Use when:
* - The standalone admin app needs to bounce through the API auth route.
* - The admin app may be hosted on a different origin than the auth UI.
*
* Expects:
* - `currentUrl` is the browser's absolute admin URL.
*
* Returns:
* - An API `/auth/sign-in` URL carrying an absolute trusted return target.
*/
export function buildAdminSignInUrl(apiServerUrl: string, currentUrl: string): string {
const url = new URL('/auth/sign-in', apiServerUrl)
url.searchParams.set('redirect', currentUrl)
return url.toString()
}
export function signInUrl(): string {
return buildAdminSignInUrl(apiServerUrl(), window.location.href)
}
async function adminFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const endpoint = new URL(`/api/admin${path}`, apiServerUrl())
return fetchJson<T>(endpoint, init)
@@ -2,7 +2,7 @@
import { describe, expect, it } from 'vitest'
import { getServerAdminBootstrapContext, resolveStandaloneServerAdminContext } from './server-admin-context'
import { defaultStandaloneApiServerUrl, getServerAdminBootstrapContext, resolveStandaloneServerAdminContext } from './server-admin-context'
describe('ui-admin bootstrap context', () => {
it('uses the trusted API server origin carried by standalone server redirects', () => {
@@ -26,6 +26,15 @@ describe('ui-admin bootstrap context', () => {
)?.apiServerUrl).toBe('http://127.0.0.1:3000')
})
it('defaults local standalone dev UI origins to the local API port', () => {
expect(defaultStandaloneApiServerUrl('http://localhost:5178')).toBe('http://localhost:3000')
expect(defaultStandaloneApiServerUrl('http://127.0.0.1:5178')).toBe('http://127.0.0.1:3000')
})
it('keeps non-local standalone origins unchanged without redirect context', () => {
expect(defaultStandaloneApiServerUrl('https://admin.airi.build')).toBe('https://admin.airi.build')
})
it('falls back to the standalone query context when the static placeholder script is still present', () => {
document.body.innerHTML = '<script id="airi-server-admin-context" type="application/json">__AIRI_SERVER_ADMIN_CONTEXT__</script>'
window.history.replaceState(
@@ -45,7 +45,28 @@ export function getServerAdminBootstrapContext(): ServerAdminBootstrapContext |
}
export function defaultApiServerUrl(): string {
return import.meta.env.VITE_SERVER_URL || window.location.origin
return import.meta.env.VITE_SERVER_URL || defaultStandaloneApiServerUrl(window.location.origin)
}
/**
* Resolves the API origin for a standalone admin UI without redirect context.
*
* Before:
* - "http://localhost:5178"
*
* After:
* - "http://localhost:3000"
*/
export function defaultStandaloneApiServerUrl(currentOrigin: string): string {
const origin = new URL(currentOrigin).origin
if (TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS.some(pattern => pattern.test(origin))) {
const url = new URL(origin)
url.port = '3000'
return url.origin
}
return origin
}
/**
+22 -22
View File
@@ -250,12 +250,12 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
<template>
<div :class="['grid', 'gap-5', 'xl:grid-cols-[minmax(0,1fr)_420px]']">
<section :class="['panel', 'overflow-hidden']">
<div :class="['flex', 'flex-col', 'gap-3', 'border-b', 'border-neutral-200', 'px-5', 'py-4', 'md:flex-row', 'md:items-center', 'md:justify-between']">
<div :class="['flex', 'flex-col', 'gap-3', 'border-b', 'border-neutral-200', 'px-5', 'py-4', 'md:flex-row', 'md:items-center', 'md:justify-between', 'dark:border-neutral-800']">
<div>
<h2 :class="['text-sm', 'font-semibold']">
Router Config Form
</h2>
<p :class="['mt-1', 'text-sm', 'text-neutral-500']">
<p :class="['mt-1', 'text-sm', 'text-neutral-500', 'dark:text-neutral-400']">
Builds LLM_ROUTER_CONFIG, UNSPEECH_UPSTREAM, and default model aliases without hand-written JSON.
</p>
</div>
@@ -266,13 +266,13 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
</div>
<form :class="['space-y-5', 'p-5']" @submit.prevent="previewConfig">
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4']">
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'p-4', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<div :class="['flex', 'flex-col', 'gap-3', 'md:flex-row', 'md:items-center', 'md:justify-between']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Current Config
</h3>
<p :class="['mt-1', 'text-xs', currentError ? 'text-red-600' : 'text-neutral-500']">
<p :class="['mt-1', 'text-xs', currentError ? 'text-red-600 dark:text-red-400' : 'text-neutral-500 dark:text-neutral-400']">
{{ currentError ?? currentStatusLabel }}
</p>
</div>
@@ -293,13 +293,13 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
<RouterModeControl v-model="form.mode" />
<RouterDefaultsEditor v-model="form.defaults" />
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white']">
<div :class="['border-b', 'border-neutral-200', 'p-4']">
<section :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-white', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
<div :class="['border-b', 'border-neutral-200', 'p-4', 'dark:border-neutral-800']">
<div>
<h3 :class="['text-sm', 'font-semibold']">
Provider configuration
</h3>
<p :class="['mt-1', 'text-xs', 'text-neutral-500']">
<p :class="['mt-1', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
LLM and TTS providers are edited separately, then applied as one router config request.
</p>
</div>
@@ -308,7 +308,7 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
<div :class="['flex', 'flex-col', 'gap-4', 'p-4']">
<div :class="['flex', 'flex-col', 'gap-3', 'lg:flex-row', 'lg:items-end', 'lg:justify-between']">
<div
:class="['inline-grid', 'w-full', 'grid-cols-2', 'rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-1', 'sm:w-auto']"
:class="['inline-grid', 'w-full', 'grid-cols-2', 'rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50', 'p-1', 'sm:w-auto', 'dark:border-neutral-800', 'dark:bg-neutral-950']"
role="tablist"
>
<button
@@ -318,15 +318,15 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
:class="[
'h-9 rounded-md px-4 text-sm font-medium transition-colors whitespace-nowrap',
activeProviderTab === tab.value
? 'bg-white text-primary-700 shadow-sm ring-1 ring-primary-200'
: 'text-neutral-500 hover:text-neutral-900',
? 'bg-white text-primary-700 shadow-sm ring-1 ring-primary-200 dark:bg-neutral-900 dark:text-primary-300 dark:ring-primary-800'
: 'text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-200',
]"
role="tab"
type="button"
@click="selectProviderTab(tab.value)"
>
{{ tab.label }}
<span :class="['ml-1', 'text-xs', activeProviderTab === tab.value ? 'text-neutral-500' : 'text-neutral-400']">
<span :class="['ml-1', 'text-xs', activeProviderTab === tab.value ? 'text-neutral-500 dark:text-neutral-400' : 'text-neutral-400 dark:text-neutral-500']">
{{ tab.count }}
</span>
</button>
@@ -344,8 +344,8 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
</div>
</div>
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50/60', 'px-3', 'py-2', 'text-xs', 'text-neutral-600']">
<span :class="['font-medium', 'text-neutral-900']">{{ activeProviderTab === 'llm' ? 'LLM' : 'TTS' }}</span>
<div :class="['rounded-lg', 'border', 'border-neutral-200', 'bg-neutral-50/60', 'px-3', 'py-2', 'text-xs', 'text-neutral-600', 'dark:border-neutral-800', 'dark:bg-neutral-950/60', 'dark:text-neutral-300']">
<span :class="['font-medium', 'text-neutral-900', 'dark:text-neutral-100']">{{ activeProviderTab === 'llm' ? 'LLM' : 'TTS' }}</span>
<span v-if="activeProviderTab === 'llm'">
config writes chat model aliases under LLM_ROUTER_CONFIG.
</span>
@@ -364,11 +364,11 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
@remove="removeSlice(item.index)"
/>
</div>
<div v-else :class="['empty-state', 'min-h-40', 'rounded-lg', 'border', 'border-dashed', 'border-neutral-200', 'bg-white']">
<div v-else :class="['empty-state', 'min-h-40', 'rounded-lg', 'border', 'border-dashed', 'border-neutral-200', 'bg-white', 'dark:border-neutral-800', 'dark:bg-neutral-900']">
No {{ activeProviderTab === 'llm' ? 'LLM' : 'TTS' }} provider slices
</div>
</div>
<div :class="['border-t', 'border-neutral-200', 'px-4', 'py-3', 'text-xs', 'text-neutral-500']">
<div :class="['border-t', 'border-neutral-200', 'px-4', 'py-3', 'text-xs', 'text-neutral-500', 'dark:border-neutral-800', 'dark:text-neutral-400']">
UnSpeech is limited to one TTS slice per request.
</div>
</section>
@@ -381,26 +381,26 @@ function isCurrentConfigResponse(value: AdminRouterConfigCurrent | null): value
</ul>
</Callout>
<div :class="['flex', 'flex-col', 'gap-3', 'border-t', 'border-neutral-200', 'pt-4', 'md:flex-row', 'md:items-center', 'md:justify-between']">
<div :class="['grid', 'gap-2', 'text-xs', 'text-neutral-600', 'sm:grid-cols-5']">
<div :class="['flex', 'flex-col', 'gap-3', 'border-t', 'border-neutral-200', 'pt-4', 'md:flex-row', 'md:items-center', 'md:justify-between', 'dark:border-neutral-800']">
<div :class="['grid', 'gap-2', 'text-xs', 'text-neutral-600', 'sm:grid-cols-5', 'dark:text-neutral-400']">
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.slices }}</span>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.slices }}</span>
slices
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.llmSlices }}</span>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.llmSlices }}</span>
LLM
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.ttsSlices }}</span>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.ttsSlices }}</span>
TTS
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.unspeech ? 'yes' : 'no' }}</span>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.unspeech ? 'yes' : 'no' }}</span>
unspeech
</div>
<div>
<span :class="['block', 'font-semibold', 'text-neutral-900']">{{ pendingSummary.defaults }}</span>
<span :class="['block', 'font-semibold', 'text-neutral-900', 'dark:text-neutral-100']">{{ pendingSummary.defaults }}</span>
defaults
</div>
</div>
+223
View File
@@ -2,6 +2,7 @@
:root {
--admin-accent: #059669;
color-scheme: light;
}
html,
@@ -13,9 +14,19 @@ body,
body {
background: #f7f8fa;
color: #18181b;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
html.dark {
color-scheme: dark;
}
html.dark body {
background: #09090b;
color: #f4f4f5;
}
button,
input,
textarea,
@@ -52,10 +63,19 @@ select {
padding: 18px 14px;
}
html.dark .admin-sidebar {
background: #111113;
border-color: #27272a;
}
.admin-main {
min-width: 0;
}
html.dark .admin-main {
background: #09090b;
}
.admin-topbar {
align-items: center;
background: rgba(255, 255, 255, 0.86);
@@ -69,6 +89,11 @@ select {
z-index: 10;
}
html.dark .admin-topbar {
background: rgba(9, 9, 11, 0.88);
border-color: #27272a;
}
.admin-content {
padding: 22px;
}
@@ -101,16 +126,30 @@ select {
text-decoration: none;
}
html.dark .nav-item {
color: #d4d4d8;
}
.nav-item:hover {
background: #e9ecef;
}
html.dark .nav-item:hover {
background: #27272a;
}
.nav-item-active {
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
font-weight: 600;
}
html.dark .nav-item-active {
background: #18181b;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
color: #f4f4f5;
}
.panel {
background: white;
border: 1px solid #e5e7eb;
@@ -118,6 +157,12 @@ select {
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
html.dark .panel {
background: #18181b;
border-color: #27272a;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.metric-card {
background: linear-gradient(180deg, #ffffff 0%, #f8fbfa 100%);
border: 1px solid #e5e7eb;
@@ -126,6 +171,11 @@ select {
padding: 20px;
}
html.dark .metric-card {
background: linear-gradient(180deg, #18181b 0%, #111113 100%);
border-color: #27272a;
}
.field {
background: #fff;
border: 1px solid #dfe3e8;
@@ -137,6 +187,12 @@ select {
width: 100%;
}
html.dark .field {
background: #09090b;
border-color: #27272a;
color: #f4f4f5;
}
.field:focus,
.textarea:focus {
border-color: #10b981;
@@ -155,6 +211,12 @@ select {
width: 100%;
}
html.dark .textarea {
background: #09090b;
border-color: #27272a;
color: #f4f4f5;
}
.btn {
align-items: center;
border: 1px solid #dfe3e8;
@@ -179,12 +241,24 @@ select {
color: #27272a;
}
html.dark .btn-secondary {
background: #18181b;
border-color: #3f3f46;
color: #f4f4f5;
}
.btn-danger {
background: #fff;
border-color: #fecaca;
color: #b91c1c;
}
html.dark .btn-danger {
background: #18181b;
border-color: #7f1d1d;
color: #fecaca;
}
.table {
border-collapse: collapse;
width: 100%;
@@ -198,6 +272,11 @@ select {
vertical-align: middle;
}
html.dark .table th,
html.dark .table td {
border-color: #27272a;
}
.table th {
background: #f8fafc;
color: #52525b;
@@ -205,11 +284,20 @@ select {
font-weight: 600;
}
html.dark .table th {
background: #111113;
color: #a1a1aa;
}
.table td {
color: #27272a;
font-size: 13px;
}
html.dark .table td {
color: #e4e4e7;
}
.badge {
align-items: center;
border: 1px solid #e5e7eb;
@@ -222,18 +310,35 @@ select {
padding: 0 8px;
}
html.dark .badge {
border-color: #3f3f46;
color: #e4e4e7;
}
.badge-green {
background: #ecfdf5;
border-color: #bbf7d0;
color: #047857;
}
html.dark .badge-green {
background: rgba(6, 78, 59, 0.5);
border-color: rgba(52, 211, 153, 0.28);
color: #6ee7b7;
}
.badge-amber {
background: #fffbeb;
border-color: #fde68a;
color: #b45309;
}
html.dark .badge-amber {
background: rgba(120, 53, 15, 0.45);
border-color: rgba(251, 191, 36, 0.3);
color: #fcd34d;
}
.empty-state {
align-items: center;
color: #71717a;
@@ -245,6 +350,114 @@ select {
min-height: 180px;
}
html.dark .empty-state {
color: #a1a1aa;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
}
body {
background: #09090b;
color: #f4f4f5;
}
.admin-sidebar {
background: #111113;
border-color: #27272a;
}
.admin-main {
background: #09090b;
}
.admin-topbar {
background: rgba(9, 9, 11, 0.88);
border-color: #27272a;
}
.nav-item {
color: #d4d4d8;
}
.nav-item:hover {
background: #27272a;
}
.nav-item-active {
background: #18181b;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
color: #f4f4f5;
}
.panel {
background: #18181b;
border-color: #27272a;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.metric-card {
background: linear-gradient(180deg, #18181b 0%, #111113 100%);
border-color: #27272a;
}
.field,
.textarea {
background: #09090b;
border-color: #27272a;
color: #f4f4f5;
}
.btn-secondary {
background: #18181b;
border-color: #3f3f46;
color: #f4f4f5;
}
.btn-danger {
background: #18181b;
border-color: #7f1d1d;
color: #fecaca;
}
.table th,
.table td {
border-color: #27272a;
}
.table th {
background: #111113;
color: #a1a1aa;
}
.table td {
color: #e4e4e7;
}
.badge {
border-color: #3f3f46;
color: #e4e4e7;
}
.badge-green {
background: rgba(6, 78, 59, 0.5);
border-color: rgba(52, 211, 153, 0.28);
color: #6ee7b7;
}
.badge-amber {
background: rgba(120, 53, 15, 0.45);
border-color: rgba(251, 191, 36, 0.3);
color: #fcd34d;
}
.empty-state {
color: #a1a1aa;
}
}
@media (max-width: 900px) {
.admin-shell {
grid-template-columns: 1fr;
@@ -256,7 +469,17 @@ select {
min-height: auto;
}
html.dark .admin-sidebar {
border-bottom-color: #27272a;
}
.admin-content {
padding: 16px;
}
}
@media (max-width: 900px) and (prefers-color-scheme: dark) {
.admin-sidebar {
border-bottom-color: #27272a;
}
}
@@ -85,6 +85,7 @@ const modelValue = defineModel<T>({ required: false })
'text-disabled:neutral-400 dark:text-disabled:neutral-600',
'cursor-disabled:not-allowed',
'w-full rounded-lg px-2 py-1 text-sm outline-none',
'text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500',
'shadow-sm',
'bg-neutral-50 dark:bg-neutral-950 focus:bg-neutral-50 dark:focus:bg-neutral-900',
]"
@@ -40,6 +40,7 @@ const variantClasses: Record<InputVariant, Record<InputTheme, {
default: [
'w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none',
'bg-neutral-50 dark:bg-neutral-950 focus:bg-neutral-50 dark:focus:bg-neutral-900',
'text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500',
'focus:border-primary-300 dark:focus:border-primary-400/50 border-2 border-solid border-neutral-100 dark:border-neutral-900',
'text-disabled:neutral-400 dark:text-disabled:neutral-600',
'shadow-sm',
@@ -51,6 +52,7 @@ const variantClasses: Record<InputVariant, Record<InputTheme, {
default: [
'w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none',
'bg-neutral-50 dark:bg-neutral-950 focus:bg-neutral-50 dark:focus:bg-neutral-900',
'text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500',
'focus:border-primary-300 dark:focus:border-primary-400/50 border-2 border-solid border-neutral-100 dark:border-neutral-900',
'text-disabled:neutral-400 dark:text-disabled:neutral-600',
'shadow-sm',
@@ -62,6 +64,7 @@ const variantClasses: Record<InputVariant, Record<InputTheme, {
default: [
'w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none',
'bg-neutral-100 dark:bg-neutral-800 focus:bg-neutral-50 dark:focus:bg-neutral-950',
'text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500',
'focus:border-primary-500/30 dark:focus:border-primary-400/50 border-2 border-solid border-neutral-500/5 dark:border-neutral-700/40',
'text-disabled:neutral-400 dark:text-disabled:neutral-600',
],
@@ -13,6 +13,7 @@ const modelValue = defineModel<string>({ default: '' })
'text-disabled:neutral-400 dark:text-disabled:neutral-600',
'cursor-disabled:not-allowed',
'w-full rounded-lg px-2 py-1 text-nowrap text-sm outline-none',
'text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500',
'shadow-sm',
'bg-neutral-50 dark:bg-neutral-950 focus:bg-neutral-50 dark:focus:bg-neutral-900',
]"
+1 -1
View File
@@ -154,7 +154,7 @@ const baseClasses = computed(() => {
const theme = variant[props.theme] || variant.default
return [
'font-medium outline-none',
'font-medium outline-none whitespace-nowrap',
'transition-all duration-200 ease-in-out',
'disabled:cursor-not-allowed disabled:opacity-50',
'backdrop-blur-md',