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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
]"
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user