mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-10-28 08:20:49 +08:00
feat(web): 改版设置页与控制台界面
统一登录页、控制台和设置页的视觉样式,固定控制台设置入口并补充控件展开状态。 设置页改为按卡片保存,分别处理采集、编码、连接、HID 功能和设备标识,保留相邻卡片草稿。 增加共享帮助提示和移动端导航,固定前端构建依赖。 验证:独立暂存快照前端构建通过;控制台浏览器测试 5 组通过,覆盖登录、三种布局、窄屏、主题和语言切换。
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -42,4 +42,3 @@ CLAUDE.md
|
|||||||
secrets.toml
|
secrets.toml
|
||||||
.env
|
.env
|
||||||
/docs/
|
/docs/
|
||||||
web/package-lock.json
|
|
||||||
|
|||||||
2747
web/package-lock.json
generated
Normal file
2747
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@
|
|||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.6",
|
"vite": "^7.3.6",
|
||||||
"vue-tsc": "^3.3.7"
|
"vue-tsc": "3.3.6"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"defu": "^6.1.5",
|
"defu": "^6.1.5",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useSystemStore } from '@/stores/system'
|
import { useSystemStore } from '@/stores/system'
|
||||||
import type { VideoRotation, VideoScaleMode } from '@/composables/useVideoScaling'
|
import type { VideoRotation, VideoScaleMode } from '@/composables/useVideoScaling'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -33,7 +32,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Settings,
|
|
||||||
Power,
|
Power,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -53,7 +51,6 @@ import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
|
|||||||
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const router = useRouter()
|
|
||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
|
|
||||||
const overflowMenuOpen = ref(false)
|
const overflowMenuOpen = ref(false)
|
||||||
@@ -72,6 +69,10 @@ const props = defineProps<{
|
|||||||
showMic?: boolean
|
showMic?: boolean
|
||||||
scaleMode?: VideoScaleMode
|
scaleMode?: VideoScaleMode
|
||||||
sourceSizeAvailable?: boolean
|
sourceSizeAvailable?: boolean
|
||||||
|
keyboardVisible?: boolean
|
||||||
|
fullscreen?: boolean
|
||||||
|
statsOpen?: boolean
|
||||||
|
terminalOpen?: boolean
|
||||||
}>()
|
}>()
|
||||||
const isSidebarLayout = computed(() => props.layout === 'sidebar')
|
const isSidebarLayout = computed(() => props.layout === 'sidebar')
|
||||||
const isFloatingLayout = computed(() => props.layout === 'floating')
|
const isFloatingLayout = computed(() => props.layout === 'floating')
|
||||||
@@ -159,7 +160,7 @@ let layoutResizeObserver: ResizeObserver | null = null
|
|||||||
|
|
||||||
type CollapsibleItem =
|
type CollapsibleItem =
|
||||||
| 'msd' | 'atx' | 'paste'
|
| 'msd' | 'atx' | 'paste'
|
||||||
| 'stats' | 'terminal' | 'settings'
|
| 'stats' | 'terminal'
|
||||||
|
|
||||||
interface ItemSpec {
|
interface ItemSpec {
|
||||||
id: CollapsibleItem
|
id: CollapsibleItem
|
||||||
@@ -172,7 +173,6 @@ const ITEM_SPECS: ItemSpec[] = [
|
|||||||
{ id: 'paste', side: 'left' },
|
{ id: 'paste', side: 'left' },
|
||||||
{ id: 'stats', side: 'right' },
|
{ id: 'stats', side: 'right' },
|
||||||
{ id: 'terminal', side: 'right' },
|
{ id: 'terminal', side: 'right' },
|
||||||
{ id: 'settings', side: 'right' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
||||||
@@ -300,7 +300,7 @@ const visibleSet = computed(() => {
|
|||||||
if (isSidebarLayout.value) {
|
if (isSidebarLayout.value) {
|
||||||
// Reserve More and a gap between the two groups before assigning vertical slots.
|
// Reserve More and a gap between the two groups before assigning vertical slots.
|
||||||
let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16
|
let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16
|
||||||
const priority: CollapsibleItem[] = ['paste', 'settings', 'msd', 'atx', 'stats', 'terminal']
|
const priority: CollapsibleItem[] = ['paste', 'msd', 'atx', 'stats', 'terminal']
|
||||||
for (const id of priority) {
|
for (const id of priority) {
|
||||||
if (!collapsibleItems.value.some(item => item.id === id)) continue
|
if (!collapsibleItems.value.some(item => item.id === id)) continue
|
||||||
if (available < actionHeight.value) break
|
if (available < actionHeight.value) break
|
||||||
@@ -365,7 +365,7 @@ const hasRightOverflow = computed(() => {
|
|||||||
:aria-expanded="false"
|
:aria-expanded="false"
|
||||||
@click="setFloatingCollapsed(false)"
|
@click="setFloatingCollapsed(false)"
|
||||||
>
|
>
|
||||||
<ChevronDown class="size-4" />{{ t('actionbar.expandToolbar') }}
|
<ChevronDown class="size-4" /><span class="hidden sm:inline">{{ t('actionbar.expandToolbar') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div
|
<div
|
||||||
v-show="!isFloatingLayout || !floatingCollapsed"
|
v-show="!isFloatingLayout || !floatingCollapsed"
|
||||||
@@ -424,6 +424,8 @@ const hasRightOverflow = computed(() => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 gap-1.5 text-xs"
|
class="h-8 gap-1.5 text-xs"
|
||||||
:aria-label="t('actionbar.virtualMedia')"
|
:aria-label="t('actionbar.virtualMedia')"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
:aria-expanded="msdDialogOpen"
|
||||||
:title="t('actionbar.virtualMedia')"
|
:title="t('actionbar.virtualMedia')"
|
||||||
@click="msdDialogOpen = true"
|
@click="msdDialogOpen = true"
|
||||||
>
|
>
|
||||||
@@ -512,6 +514,8 @@ const hasRightOverflow = computed(() => {
|
|||||||
:text-only-scale="isSidebarLayout"
|
:text-only-scale="isSidebarLayout"
|
||||||
:scale-mode="props.scaleMode"
|
:scale-mode="props.scaleMode"
|
||||||
:source-size-available="props.sourceSizeAvailable"
|
:source-size-available="props.sourceSizeAvailable"
|
||||||
|
:keyboard-visible="props.keyboardVisible"
|
||||||
|
:fullscreen="props.fullscreen"
|
||||||
@toggle-fullscreen="emit('toggleFullscreen')"
|
@toggle-fullscreen="emit('toggleFullscreen')"
|
||||||
@update:scale-mode="emit('update:scaleMode', $event)"
|
@update:scale-mode="emit('update:scaleMode', $event)"
|
||||||
@toggle-virtual-keyboard="emit('toggleVirtualKeyboard')"
|
@toggle-virtual-keyboard="emit('toggleVirtualKeyboard')"
|
||||||
@@ -522,7 +526,7 @@ const hasRightOverflow = computed(() => {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.stats')" @click="emit('toggleStats')">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.stats')" :aria-pressed="!!props.statsOpen" @click="emit('toggleStats')">
|
||||||
<BarChart3 class="size-4" />
|
<BarChart3 class="size-4" />
|
||||||
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
|
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -545,6 +549,7 @@ const hasRightOverflow = computed(() => {
|
|||||||
class="h-8 gap-1.5 text-xs"
|
class="h-8 gap-1.5 text-xs"
|
||||||
:disabled="!props.ttydRunning"
|
:disabled="!props.ttydRunning"
|
||||||
:aria-label="t('actionbar.webTerminal')"
|
:aria-label="t('actionbar.webTerminal')"
|
||||||
|
:aria-pressed="!!props.terminalOpen"
|
||||||
@click="emit('openTerminal')"
|
@click="emit('openTerminal')"
|
||||||
>
|
>
|
||||||
<Terminal class="size-4" />
|
<Terminal class="size-4" />
|
||||||
@@ -558,25 +563,6 @@ const hasRightOverflow = computed(() => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Settings - Adaptive -->
|
|
||||||
<div v-if="isVisible('settings')">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger as-child>
|
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.settings')" @click="router.push('/settings')">
|
|
||||||
<Settings class="size-4" />
|
|
||||||
<span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{{ t('actionbar.settingsTip') }}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Overflow Menu - Only show if there are overflowed items -->
|
<!-- Overflow Menu - Only show if there are overflowed items -->
|
||||||
<DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen">
|
<DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen">
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
@@ -632,12 +618,6 @@ const hasRightOverflow = computed(() => {
|
|||||||
<Terminal class="size-4 mr-2" />
|
<Terminal class="size-4 mr-2" />
|
||||||
{{ t('actionbar.webTerminal') }}
|
{{ t('actionbar.webTerminal') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<!-- Settings -->
|
|
||||||
<DropdownMenuItem v-if="!isFloatingLayout && !isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
|
|
||||||
<Settings class="size-4 mr-2" />
|
|
||||||
{{ t('actionbar.settings') }}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button
|
<Button
|
||||||
@@ -700,7 +680,7 @@ const hasRightOverflow = computed(() => {
|
|||||||
|
|
||||||
<!-- Hidden measurement container: renders each collapsible button in both
|
<!-- Hidden measurement container: renders each collapsible button in both
|
||||||
icon-only and with-label forms so we can read their real offsetWidth. -->
|
icon-only and with-label forms so we can read their real offsetWidth. -->
|
||||||
<div ref="measureRef" aria-hidden="true" class="fixed pointer-events-none" style="visibility: hidden; top: -9999px; left: -9999px; white-space: nowrap;">
|
<div ref="measureRef" aria-hidden="true" class="console-action-measure fixed pointer-events-none" style="visibility: hidden; top: -9999px; left: -9999px; white-space: nowrap;">
|
||||||
<div class="flex items-center gap-0.5 sm:gap-1.5 px-2 sm:px-4 py-1 sm:py-1.5">
|
<div class="flex items-center gap-0.5 sm:gap-1.5 px-2 sm:px-4 py-1 sm:py-1.5">
|
||||||
<!-- MSD -->
|
<!-- MSD -->
|
||||||
<Button data-measure="msd-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
<Button data-measure="msd-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||||
@@ -717,9 +697,6 @@ const hasRightOverflow = computed(() => {
|
|||||||
<!-- Web Terminal -->
|
<!-- Web Terminal -->
|
||||||
<Button data-measure="terminal-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" /></Button>
|
<Button data-measure="terminal-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" /></Button>
|
||||||
<Button data-measure="terminal-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</Button>
|
<Button data-measure="terminal-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</Button>
|
||||||
<!-- Settings -->
|
|
||||||
<Button data-measure="settings-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" /></Button>
|
|
||||||
<Button data-measure="settings-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" />{{ t('actionbar.settings') }}</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -765,19 +742,13 @@ const hasRightOverflow = computed(() => {
|
|||||||
gap: 0;
|
gap: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-inline: 0.5rem;
|
padding-inline: 0.5rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-action-bar--sidebar :deep(button > span) {
|
.console-action-bar--sidebar :deep(button > span) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-action-bar--sidebar :deep([data-fixed-action][aria-hidden='true']) {
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1px;
|
|
||||||
margin: 0.35rem auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.console-action-bar--sidebar .console-action-bar__inner {
|
.console-action-bar--sidebar .console-action-bar__inner {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -801,10 +772,6 @@ const hasRightOverflow = computed(() => {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-action-bar--floating .console-action-bar__inner :deep(button[aria-expanded='true']) {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
.console-action-bar--floating .console-action-bar__inner {
|
.console-action-bar--floating .console-action-bar__inner {
|
||||||
padding-inline: 4px;
|
padding-inline: 4px;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { BarChart3, Settings, Terminal, MoreHorizontal } from 'lucide-vue-next'
|
import { BarChart3, Terminal, MoreHorizontal } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||||
|
|
||||||
@@ -10,12 +9,13 @@ defineProps<{
|
|||||||
showStats: boolean
|
showStats: boolean
|
||||||
showTerminal: boolean
|
showTerminal: boolean
|
||||||
terminalRunning: boolean
|
terminalRunning: boolean
|
||||||
|
statsOpen?: boolean
|
||||||
|
terminalOpen?: boolean
|
||||||
}>()
|
}>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'openStats'): void
|
(e: 'openStats'): void
|
||||||
(e: 'openTerminal'): void
|
(e: 'openTerminal'): void
|
||||||
}>()
|
}>()
|
||||||
const router = useRouter()
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const menuOpen = ref(false)
|
const menuOpen = ref(false)
|
||||||
function openFromMenu(action: () => void) {
|
function openFromMenu(action: () => void) {
|
||||||
@@ -27,10 +27,10 @@ function openFromMenu(action: () => void) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="console-header-actions flex shrink-0 items-center gap-1">
|
<div class="console-header-actions flex shrink-0 items-center gap-1">
|
||||||
<div class="hidden items-center gap-1 md:flex">
|
<div class="hidden items-center gap-1 md:flex">
|
||||||
<Button v-if="showStats" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.stats')" :title="t('actionbar.stats')" @click="emit('openStats')">
|
<Button v-if="showStats" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.stats')" :title="t('actionbar.stats')" :aria-pressed="!!statsOpen" @click="emit('openStats')">
|
||||||
<BarChart3 class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.stats') }}</span>
|
<BarChart3 class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.stats') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-if="showTerminal" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.webTerminal')" :title="t('actionbar.webTerminal')" :disabled="!terminalRunning" @click="emit('openTerminal')">
|
<Button v-if="showTerminal" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.webTerminal')" :title="t('actionbar.webTerminal')" :disabled="!terminalRunning" :aria-pressed="!!terminalOpen" @click="emit('openTerminal')">
|
||||||
<Terminal class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.webTerminal') }}</span>
|
<Terminal class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.webTerminal') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,8 +43,5 @@ function openFromMenu(action: () => void) {
|
|||||||
<DropdownMenuItem v-if="showTerminal" :disabled="!terminalRunning" @select="openFromMenu(() => emit('openTerminal'))"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</DropdownMenuItem>
|
<DropdownMenuItem v-if="showTerminal" :disabled="!terminalRunning" @select="openFromMenu(() => emit('openTerminal'))"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button variant="ghost" size="sm" class="gap-1.5 rounded-lg px-2 text-xs" :aria-label="t('actionbar.settings')" :title="t('actionbar.settings')" @click="router.push('/settings')">
|
|
||||||
<Settings class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.settings') }}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function dotClass(status: ConsoleStatusItem['status']) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="min-w-0 shrink gap-1.5 px-2 text-xs"
|
class="console-status-summary min-w-0 shrink gap-1.5 px-2 text-xs"
|
||||||
:class="hasError ? 'text-destructive' : issues.length ? 'text-warning' : ''"
|
:class="hasError ? 'text-destructive' : issues.length ? 'text-warning' : ''"
|
||||||
:aria-label="`${t('statusCard.connectionDetails')}: ${summary}`"
|
:aria-label="`${t('statusCard.connectionDetails')}: ${summary}`"
|
||||||
:title="summary"
|
:title="summary"
|
||||||
@@ -56,7 +56,7 @@ function dotClass(status: ConsoleStatusItem['status']) {
|
|||||||
<AlertCircle v-if="hasError" class="size-3.5 shrink-0" />
|
<AlertCircle v-if="hasError" class="size-3.5 shrink-0" />
|
||||||
<Info v-else-if="issues.length" class="size-3.5 shrink-0" />
|
<Info v-else-if="issues.length" class="size-3.5 shrink-0" />
|
||||||
<span v-else class="size-2 shrink-0 rounded-full bg-status-active" />
|
<span v-else class="size-2 shrink-0 rounded-full bg-status-active" />
|
||||||
<span class="truncate" role="status">{{ summary }}</span>
|
<span class="console-status-summary__label truncate" role="status">{{ summary }}</span>
|
||||||
<span v-if="showVideoInfo && !issues.length" class="hidden text-muted-foreground md:inline">{{ videoInfo }}</span>
|
<span v-if="showVideoInfo && !issues.length" class="hidden text-muted-foreground md:inline">{{ videoInfo }}</span>
|
||||||
<ChevronDown class="size-3 shrink-0" />
|
<ChevronDown class="size-3 shrink-0" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,28 +1,165 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { Loader2, Save, RefreshCw } from 'lucide-vue-next'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
import { useSystemStore } from '@/stores/system'
|
||||||
import { useHidConnection } from '@/composables/useHidConnection'
|
import { useHidConnection } from '@/composables/useHidConnection'
|
||||||
|
import { deviceRequest, matchesSelection, selectionFrom, supportsOtgFunctions, type HidSelection } from '@/lib/hidGuide'
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import HidDeviceOverview from './HidDeviceOverview.vue'
|
import CardTitle from './SettingsCardTitle.vue'
|
||||||
|
import HidDriverForm from './HidDriverForm.vue'
|
||||||
import HidDriverDialog from './HidDriverDialog.vue'
|
import HidDriverDialog from './HidDriverDialog.vue'
|
||||||
import BluetoothHidSettings from './BluetoothHidSettings.vue'
|
import BluetoothHidSettings from './BluetoothHidSettings.vue'
|
||||||
|
|
||||||
const props = defineProps<{ active: boolean; dirty?: boolean }>()
|
const props = defineProps<{ active: boolean; dirty?: boolean }>()
|
||||||
const emit = defineEmits<{ applied: []; discard: [] }>()
|
const emit = defineEmits<{ applied: []; discard: [] }>()
|
||||||
const { t } = useI18n(), store = useConfigStore(), open = ref(false)
|
const { t } = useI18n()
|
||||||
const active = computed(() => props.active && !open.value)
|
const store = useConfigStore(),
|
||||||
|
system = useSystemStore()
|
||||||
|
const draft = ref<HidSelection>(selectionFrom(store.hid))
|
||||||
|
let baseline = JSON.stringify(draft.value)
|
||||||
|
const valid = ref(false),
|
||||||
|
busy = ref(false),
|
||||||
|
error = ref(''),
|
||||||
|
uncertain = ref(false),
|
||||||
|
repairOpen = ref(false)
|
||||||
|
const polling = computed(
|
||||||
|
() => props.active && store.hid?.backend === 'bluetooth' && !repairOpen.value,
|
||||||
|
)
|
||||||
const backend = computed(() => store.hid?.backend)
|
const backend = computed(() => store.hid?.backend)
|
||||||
const { status, bluetooth, error, restart } = useHidConnection(active, backend)
|
const { bluetooth, restart } = useHidConnection(polling, backend)
|
||||||
|
watch(
|
||||||
|
() => store.hid,
|
||||||
|
(hid) => {
|
||||||
|
if (JSON.stringify(draft.value) === baseline) draft.value = selectionFrom(hid)
|
||||||
|
baseline = JSON.stringify(selectionFrom(hid))
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
draft,
|
||||||
|
() => {
|
||||||
|
uncertain.value = false
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
async function apply() {
|
||||||
|
if (busy.value || props.dirty || !valid.value || uncertain.value) return
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 30000)
|
||||||
|
try {
|
||||||
|
await store.updateHid(deviceRequest(draft.value), controller.signal)
|
||||||
|
baseline = JSON.stringify(selectionFrom(store.hid))
|
||||||
|
draft.value = selectionFrom(store.hid)
|
||||||
|
emit('applied')
|
||||||
|
restart()
|
||||||
|
toast.success(t('settingsUi.driverApplied'))
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
|
// A timed-out reset may already have applied. Re-read, never repeat it.
|
||||||
|
try {
|
||||||
|
const current = await store.refreshHid()
|
||||||
|
if (matchesSelection(current, draft.value)) {
|
||||||
|
uncertain.value = true
|
||||||
|
error.value += ` ${t('hidGuide.uncertain')}`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
uncertain.value = true
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function reload() {
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await store.refreshHid()
|
||||||
|
draft.value = selectionFrom(store.hid)
|
||||||
|
baseline = JSON.stringify(draft.value)
|
||||||
|
uncertain.value = false
|
||||||
|
emit('applied')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<section class="rounded-lg border p-5 space-y-4">
|
<Card>
|
||||||
<h3 class="font-semibold">{{ t('hidGuide.deviceTitle') }}</h3>
|
<CardHeader class="settings-card-heading"
|
||||||
<HidDeviceOverview :hid="store.hid" :status="status" :bluetooth="bluetooth" :error="error" />
|
><CardTitle :help="t('settingsUi.driverHelp')">{{ t('hidGuide.deviceTitle') }}</CardTitle>
|
||||||
<Button variant="outline" @click="open = true">{{ t(store.hid ? 'hidGuide.reconfigure' : 'hidGuide.configure') }}</Button>
|
<div class="settings-card-actions">
|
||||||
</section>
|
<Button :disabled="busy || !valid || dirty || uncertain" @click="apply"
|
||||||
<section v-if="store.hid?.backend === 'bluetooth'" class="rounded-lg border p-5 space-y-4">
|
><Loader2 v-if="busy" class="size-4 animate-spin" /><Save v-else class="size-4" />{{
|
||||||
<h3 class="font-semibold">{{ t('hidGuide.features') }}</h3>
|
t('settingsUi.applyDriver')
|
||||||
<BluetoothHidSettings :bluetooth="bluetooth" @refresh="restart" @reconfigure="open = true" />
|
}}</Button
|
||||||
</section>
|
>
|
||||||
<HidDriverDialog v-if="open" :dirty="dirty" @close="open = false" @applied="emit('applied')" @discard="emit('discard')" />
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<HidDriverForm
|
||||||
|
v-model="draft"
|
||||||
|
class="hid-settings-form"
|
||||||
|
:platform="system.platform"
|
||||||
|
:locked="busy"
|
||||||
|
@valid="valid = $event"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="supportsOtgFunctions(store.hid?.backend) && !supportsOtgFunctions(draft.backend)"
|
||||||
|
class="text-sm text-warning"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('hidGuide.disableUsb', {
|
||||||
|
functions: [t('hidGuide.msd'), t('hidGuide.audio'), t('hidGuide.network')].join(' / '),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p v-if="draft.backend === 'bluetooth'" class="text-sm text-warning">
|
||||||
|
{{ t('hidGuide.resetWarning') }}
|
||||||
|
</p>
|
||||||
|
<div v-if="dirty" class="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<span>{{ t('settingsUi.saveFeaturesFirst') }}</span
|
||||||
|
><Button variant="outline" :disabled="busy" @click="emit('discard')">{{
|
||||||
|
t('settingsUi.discardFeatures')
|
||||||
|
}}</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="space-y-2">
|
||||||
|
<p role="alert" class="text-sm text-destructive">{{ error }}</p>
|
||||||
|
<Button v-if="uncertain" variant="outline" :disabled="busy" @click="reload"
|
||||||
|
><RefreshCw class="size-4" />{{ t('common.refresh') }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card v-if="store.hid?.backend === 'bluetooth'">
|
||||||
|
<CardHeader
|
||||||
|
><CardTitle :help="t('hidGuide.pairInstructions', { name: store.hid.bluetooth.name })">{{
|
||||||
|
t('hidGuide.features')
|
||||||
|
}}</CardTitle></CardHeader
|
||||||
|
>
|
||||||
|
<CardContent class="space-y-4"
|
||||||
|
><p class="text-sm text-muted-foreground">
|
||||||
|
{{ t('hidGuide.pairInstructions', { name: store.hid.bluetooth.name }) }}
|
||||||
|
</p>
|
||||||
|
<BluetoothHidSettings
|
||||||
|
:bluetooth="bluetooth"
|
||||||
|
@refresh="restart"
|
||||||
|
@reconfigure="repairOpen = true"
|
||||||
|
/></CardContent>
|
||||||
|
</Card>
|
||||||
|
<HidDriverDialog
|
||||||
|
v-if="repairOpen"
|
||||||
|
:dirty="dirty"
|
||||||
|
@close="repairOpen = false"
|
||||||
|
@applied="emit('applied')"
|
||||||
|
@discard="emit('discard')"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { Copy } from 'lucide-vue-next'
|
import { Copy, Save, Loader2 } from 'lucide-vue-next'
|
||||||
import { mcpApi, type McpConfig } from '@/api'
|
import { mcpApi, type McpConfig } from '@/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
|
import CardTitle from './SettingsCardTitle.vue'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
|
|
||||||
const clipboard = useClipboard()
|
const clipboard = useClipboard()
|
||||||
@@ -18,7 +19,9 @@ const enabled = ref(false)
|
|||||||
const rate = ref(5)
|
const rate = ref(5)
|
||||||
const apiKey = ref('')
|
const apiKey = ref('')
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const endpoint = computed(() => new URL(config.value?.endpoint ?? '/mcp', window.location.origin).href)
|
const endpoint = computed(
|
||||||
|
() => new URL(config.value?.endpoint ?? '/mcp', window.location.origin).href,
|
||||||
|
)
|
||||||
|
|
||||||
function apply(value: McpConfig) {
|
function apply(value: McpConfig) {
|
||||||
config.value = value
|
config.value = value
|
||||||
@@ -29,9 +32,13 @@ function apply(value: McpConfig) {
|
|||||||
async function load() {
|
async function load() {
|
||||||
busy.value = true
|
busy.value = true
|
||||||
config.value = null
|
config.value = null
|
||||||
try { apply(await mcpApi.config()) }
|
try {
|
||||||
catch (error) { toast.error(error instanceof Error ? error.message : t('mcp.loadFailed')) }
|
apply(await mcpApi.config())
|
||||||
finally { busy.value = false }
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t('mcp.loadFailed'))
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function save(regenerate = false) {
|
async function save(regenerate = false) {
|
||||||
if (!Number.isInteger(rate.value) || rate.value < 1 || rate.value > 100) {
|
if (!Number.isInteger(rate.value) || rate.value < 1 || rate.value > 100) {
|
||||||
@@ -40,10 +47,19 @@ async function save(regenerate = false) {
|
|||||||
}
|
}
|
||||||
busy.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
apply(await mcpApi.updateConfig({ enabled: enabled.value, requests_per_second: rate.value, regenerate_api_key: regenerate }))
|
apply(
|
||||||
|
await mcpApi.updateConfig({
|
||||||
|
enabled: enabled.value,
|
||||||
|
requests_per_second: rate.value,
|
||||||
|
regenerate_api_key: regenerate,
|
||||||
|
}),
|
||||||
|
)
|
||||||
toast.success(t('mcp.saved'))
|
toast.success(t('mcp.saved'))
|
||||||
} catch (error) { toast.error(error instanceof Error ? error.message : t('mcp.saveFailed')) }
|
} catch (error) {
|
||||||
finally { busy.value = false }
|
toast.error(error instanceof Error ? error.message : t('mcp.saveFailed'))
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function copy(value: string) {
|
async function copy(value: string) {
|
||||||
if (await clipboard.copy(value)) toast.success(t('mcp.copied'))
|
if (await clipboard.copy(value)) toast.success(t('mcp.copied'))
|
||||||
@@ -54,40 +70,79 @@ onMounted(load)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader class="settings-card-heading">
|
||||||
<CardTitle>{{ t('mcp.title') }}</CardTitle>
|
<CardTitle :help="t('mcp.description') + ' ' + t('mcp.connectHint')">{{
|
||||||
<CardDescription>{{ t('mcp.description') }}</CardDescription>
|
t('mcp.title')
|
||||||
</CardHeader>
|
}}</CardTitle>
|
||||||
<CardContent class="space-y-5">
|
<div class="settings-card-actions">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<Button :disabled="busy || !config" @click="save()"
|
||||||
<Label for="mcp-enabled">{{ t('mcp.enabled') }}</Label>
|
><Loader2 v-if="busy" class="size-4 animate-spin" /><Save v-else class="size-4" />{{
|
||||||
<Switch id="mcp-enabled" v-model="enabled" :disabled="busy || !config" />
|
t('mcp.save')
|
||||||
|
}}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-5">
|
||||||
|
<div class="settings-toggle">
|
||||||
|
<Label for="mcp-enabled">{{ t('mcp.enabled') }}</Label>
|
||||||
|
<Switch id="mcp-enabled" v-model="enabled" :disabled="busy || !config" />
|
||||||
|
</div>
|
||||||
|
<div class="settings-field space-y-2">
|
||||||
|
<Label for="mcp-rate">{{ t('settingsUi.mcpRate') }}</Label>
|
||||||
|
<Input
|
||||||
|
id="mcp-rate"
|
||||||
|
:aria-description="t('mcp.rate')"
|
||||||
|
placeholder="1–100"
|
||||||
|
v-model.number="rate"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
:disabled="busy || !config"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field space-y-2">
|
||||||
|
<Label>{{ t('mcp.endpoint') }}</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code
|
||||||
|
id="mcp-endpoint"
|
||||||
|
class="min-w-0 flex-1 break-all rounded bg-muted px-3 py-2 font-mono text-sm"
|
||||||
|
>{{ endpoint }}</code
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:aria-label="t('mcp.copyEndpoint')"
|
||||||
|
@click="copy(endpoint)"
|
||||||
|
><Copy class="size-4"
|
||||||
|
/></Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
</div>
|
||||||
<Label for="mcp-rate">{{ t('mcp.rate') }}</Label>
|
<div class="settings-field space-y-2">
|
||||||
<Input id="mcp-rate" v-model.number="rate" type="number" min="1" max="100" step="1" :disabled="busy || !config" />
|
<Label>API Key</Label>
|
||||||
|
<div v-if="apiKey" class="flex items-center gap-2">
|
||||||
|
<code
|
||||||
|
id="mcp-key"
|
||||||
|
class="min-w-0 flex-1 break-all rounded bg-muted px-3 py-2 font-mono text-sm"
|
||||||
|
>{{ apiKey }}</code
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon" :aria-label="t('mcp.copyKey')" @click="copy(apiKey)"
|
||||||
|
><Copy class="size-4"
|
||||||
|
/></Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<p v-if="!apiKey" class="text-sm text-muted-foreground">
|
||||||
<Label>{{ t('mcp.endpoint') }}</Label>
|
{{ config?.api_key_configured ? t('mcp.keyConfigured') : t('mcp.keyMissing') }}
|
||||||
<div class="flex items-center gap-2">
|
</p>
|
||||||
<code id="mcp-endpoint" class="min-w-0 flex-1 break-all rounded bg-muted px-3 py-2 font-mono text-sm">{{ endpoint }}</code>
|
<Button variant="outline" :disabled="busy || !config" @click="save(true)">{{
|
||||||
<Button variant="ghost" size="icon" :aria-label="t('mcp.copyEndpoint')" @click="copy(endpoint)"><Copy class="size-4" /></Button>
|
t('mcp.regenerate')
|
||||||
</div>
|
}}</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
|
||||||
<Label>API Key</Label>
|
<div class="flex justify-end gap-2">
|
||||||
<div v-if="apiKey" class="flex items-center gap-2">
|
<Button v-if="!config && !busy" variant="outline" @click="load">{{
|
||||||
<code id="mcp-key" class="min-w-0 flex-1 break-all rounded bg-muted px-3 py-2 font-mono text-sm">{{ apiKey }}</code>
|
t('mcp.retry')
|
||||||
<Button variant="ghost" size="icon" :aria-label="t('mcp.copyKey')" @click="copy(apiKey)"><Copy class="size-4" /></Button>
|
}}</Button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!apiKey" class="text-sm text-muted-foreground">{{ config?.api_key_configured ? t('mcp.keyConfigured') : t('mcp.keyMissing') }}</p>
|
</CardContent>
|
||||||
<Button variant="outline" :disabled="busy || !config" @click="save(true)">{{ t('mcp.regenerate') }}</Button>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-muted-foreground">{{ t('mcp.connectHint') }}</p>
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Button v-if="!config && !busy" variant="outline" @click="load">{{ t('mcp.retry') }}</Button>
|
|
||||||
<Button :disabled="busy || !config" @click="save()">{{ t('mcp.save') }}</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-4 space-y-4">
|
<div class="p-4 space-y-4">
|
||||||
<h3 class="font-semibold text-sm">{{ t('paste.title') }}</h3>
|
<h3 class="text-sm font-medium">{{ t('paste.title') }}</h3>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
id="paste-text"
|
id="paste-text"
|
||||||
|
|||||||
10
web/src/components/SettingsCardTitle.vue
Normal file
10
web/src/components/SettingsCardTitle.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SettingsHelp from './SettingsHelp.vue'
|
||||||
|
defineProps<{ help?: string }>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<h2 data-slot="card-title" class="settings-card-title">
|
||||||
|
<slot />
|
||||||
|
<SettingsHelp v-if="help" :content="help" />
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
66
web/src/components/SettingsHelp.vue
Normal file
66
web/src/components/SettingsHelp.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onUnmounted } from 'vue'
|
||||||
|
import { onClickOutside, onKeyStroke } from '@vueuse/core'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { activeSettingsHelp } from '@/lib/settings-help'
|
||||||
|
import { HelpCircle } from 'lucide-vue-next'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
defineProps<{ content: string }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const trigger = ref<HTMLElement>()
|
||||||
|
const open = ref(false)
|
||||||
|
const pinned = ref(false)
|
||||||
|
const identity = Symbol('settings-help')
|
||||||
|
function close() {
|
||||||
|
pinned.value = false
|
||||||
|
open.value = false
|
||||||
|
if (activeSettingsHelp.value === identity) activeSettingsHelp.value = null
|
||||||
|
}
|
||||||
|
function show(value: boolean) {
|
||||||
|
if (value) {
|
||||||
|
activeSettingsHelp.value = identity
|
||||||
|
open.value = true
|
||||||
|
} else if (!pinned.value) close()
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
if (pinned.value) close()
|
||||||
|
else {
|
||||||
|
pinned.value = true
|
||||||
|
show(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(activeSettingsHelp, (value) => {
|
||||||
|
if (value !== identity) close()
|
||||||
|
})
|
||||||
|
onUnmounted(close)
|
||||||
|
onClickOutside(trigger, close)
|
||||||
|
onKeyStroke('Escape', close)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span ref="trigger" class="settings-help">
|
||||||
|
<TooltipProvider :delay-duration="150">
|
||||||
|
<Tooltip :open="open" @update:open="show">
|
||||||
|
<TooltipTrigger as-child>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('settingsUi.help')"
|
||||||
|
:aria-expanded="open"
|
||||||
|
@click.stop="toggle"
|
||||||
|
>
|
||||||
|
<HelpCircle aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
:show-arrow="false"
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
class="settings-help-content"
|
||||||
|
>
|
||||||
|
{{ content }}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
18
web/src/components/SettingsSidebarButton.vue
Normal file
18
web/src/components/SettingsSidebarButton.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SidebarMenuButton, useSidebar } from '@/components/ui/sidebar'
|
||||||
|
defineOptions({ inheritAttrs: false })
|
||||||
|
defineProps<{ isActive?: boolean }>()
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar()
|
||||||
|
function closeMobile() {
|
||||||
|
if (isMobile.value) setOpenMobile(false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<SidebarMenuButton
|
||||||
|
v-bind="$attrs"
|
||||||
|
:is-active="isActive"
|
||||||
|
:aria-current="isActive ? 'page' : undefined"
|
||||||
|
@click="closeMobile"
|
||||||
|
><slot
|
||||||
|
/></SidebarMenuButton>
|
||||||
|
</template>
|
||||||
@@ -150,7 +150,8 @@ const statusBadgeText = computed(() => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'h-auto flex-col items-start gap-0.5 text-left',
|
'console-status-card h-auto flex-col items-start gap-0.5 text-left',
|
||||||
|
compact && 'console-status-card--compact',
|
||||||
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
||||||
status === 'error' && 'border-destructive/50'
|
status === 'error' && 'border-destructive/50'
|
||||||
)"
|
)"
|
||||||
@@ -239,7 +240,8 @@ const statusBadgeText = computed(() => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'h-auto flex-col items-start gap-0.5 text-left',
|
'console-status-card h-auto flex-col items-start gap-0.5 text-left',
|
||||||
|
compact && 'console-status-card--compact',
|
||||||
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
||||||
status === 'error' && 'border-destructive/50'
|
status === 'error' && 'border-destructive/50'
|
||||||
)"
|
)"
|
||||||
|
|||||||
@@ -4,12 +4,18 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import QrcodeVue from 'qrcode.vue'
|
import QrcodeVue from 'qrcode.vue'
|
||||||
import { authApi } from '@/api'
|
import { authApi } from '@/api'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import CardTitle from './SettingsCardTitle.vue'
|
||||||
import { AlertTriangle, KeyRound, Loader2, ShieldCheck, ShieldOff } from 'lucide-vue-next'
|
import { AlertTriangle, KeyRound, Loader2, ShieldCheck, ShieldOff } from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
@@ -84,7 +90,8 @@ function normalizeCode(value: string) {
|
|||||||
|
|
||||||
function localizedError(error: unknown) {
|
function localizedError(error: unknown) {
|
||||||
const raw = error instanceof Error ? error.message : ''
|
const raw = error instanceof Error ? error.message : ''
|
||||||
if (raw.includes('Current password is incorrect')) return t('settings.totp.currentPasswordIncorrect')
|
if (raw.includes('Current password is incorrect'))
|
||||||
|
return t('settings.totp.currentPasswordIncorrect')
|
||||||
if (raw.includes('Invalid TOTP code')) return t('settings.totp.invalidCode')
|
if (raw.includes('Invalid TOTP code')) return t('settings.totp.invalidCode')
|
||||||
if (raw.includes('enrollment expired')) return t('settings.totp.enrollmentExpired')
|
if (raw.includes('enrollment expired')) return t('settings.totp.enrollmentExpired')
|
||||||
if (raw.includes('Too many attempts')) return t('settings.totp.rateLimited')
|
if (raw.includes('Too many attempts')) return t('settings.totp.rateLimited')
|
||||||
@@ -215,29 +222,25 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-row items-start justify-between gap-4 space-y-0">
|
<CardHeader class="settings-card-heading"
|
||||||
<div class="space-y-1.5">
|
><CardTitle :help="t('settings.totp.description')">{{ t('settings.totp.title') }}</CardTitle>
|
||||||
<CardTitle>{{ t('settings.totp.title') }}</CardTitle>
|
<div class="settings-card-actions">
|
||||||
<CardDescription>{{ t('settings.totp.description') }}</CardDescription>
|
<Button v-if="!enabled" :disabled="statusLoading" @click="enrollmentOpen = true">
|
||||||
</div>
|
<ShieldCheck class="size-4" />
|
||||||
<Badge :variant="enabled ? 'default' : 'secondary'">
|
{{ t('settings.totp.enable') }}
|
||||||
{{ enabled ? t('common.enabled') : t('common.disabled') }}
|
</Button>
|
||||||
</Badge>
|
<Button v-else variant="destructive" :disabled="statusLoading" @click="disableOpen = true">
|
||||||
</CardHeader>
|
<ShieldOff class="size-4" />
|
||||||
|
{{ t('settings.totp.disable') }}
|
||||||
|
</Button>
|
||||||
|
</div></CardHeader
|
||||||
|
>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="grid gap-3 text-sm sm:grid-cols-2">
|
<p class="text-sm text-muted-foreground">{{ t('settings.totp.description') }}</p>
|
||||||
<div class="space-y-1">
|
<div v-if="clockDriftWarning" class="space-y-1 text-sm text-muted-foreground">
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.totp.serverTime') }}</p>
|
<p>{{ serverTime }} UTC · {{ localTime }}</p>
|
||||||
<p class="font-mono">{{ serverTime }} UTC</p>
|
<p>{{ t('settings.totp.clockOffset', { seconds: clockOffsetSeconds }) }}</p>
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.totp.localTime') }}</p>
|
|
||||||
<p class="font-mono">{{ localTime }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
{{ t('settings.totp.clockOffset', { seconds: clockOffsetSeconds }) }}
|
|
||||||
</p>
|
|
||||||
<Alert v-if="clockDriftWarning" variant="destructive">
|
<Alert v-if="clockDriftWarning" variant="destructive">
|
||||||
<AlertTriangle />
|
<AlertTriangle />
|
||||||
<AlertDescription>{{ t('settings.totp.clockDriftWarning') }}</AlertDescription>
|
<AlertDescription>{{ t('settings.totp.clockDriftWarning') }}</AlertDescription>
|
||||||
@@ -247,16 +250,6 @@ onUnmounted(() => {
|
|||||||
<AlertDescription>{{ statusError }}</AlertDescription>
|
<AlertDescription>{{ statusError }}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter class="border-t pt-4 justify-end">
|
|
||||||
<Button v-if="!enabled" :disabled="statusLoading" @click="enrollmentOpen = true">
|
|
||||||
<ShieldCheck class="size-4" />
|
|
||||||
{{ t('settings.totp.enable') }}
|
|
||||||
</Button>
|
|
||||||
<Button v-else variant="destructive" :disabled="statusLoading" @click="disableOpen = true">
|
|
||||||
<ShieldOff class="size-4" />
|
|
||||||
{{ t('settings.totp.disable') }}
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog v-model:open="enrollmentOpen">
|
<Dialog v-model:open="enrollmentOpen">
|
||||||
@@ -268,7 +261,12 @@ onUnmounted(() => {
|
|||||||
<div v-if="enrollmentStep === 'password'" class="space-y-4">
|
<div v-if="enrollmentStep === 'password'" class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="totp-enrollment-password">{{ t('settings.currentPassword') }}</Label>
|
<Label for="totp-enrollment-password">{{ t('settings.currentPassword') }}</Label>
|
||||||
<Input id="totp-enrollment-password" v-model="enrollmentPassword" type="password" autocomplete="current-password" />
|
<Input
|
||||||
|
id="totp-enrollment-password"
|
||||||
|
v-model="enrollmentPassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
@@ -283,8 +281,18 @@ onUnmounted(() => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="totp-enrollment-code">{{ t('auth.totpCode') }}</Label>
|
<Label for="totp-enrollment-code">{{ t('auth.totpCode') }}</Label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<KeyRound class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<KeyRound
|
||||||
<Input id="totp-enrollment-code" v-model="enrollmentCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="pl-10 font-mono" @input="enrollmentCode = normalizeCode(enrollmentCode)" />
|
class="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="totp-enrollment-code"
|
||||||
|
v-model="enrollmentCode"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
maxlength="6"
|
||||||
|
class="pl-10 font-mono"
|
||||||
|
@input="enrollmentCode = normalizeCode(enrollmentCode)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,8 +302,14 @@ onUnmounted(() => {
|
|||||||
<AlertDescription>{{ enrollmentError }}</AlertDescription>
|
<AlertDescription>{{ enrollmentError }}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" :disabled="enrollmentLoading" @click="enrollmentOpen = false">{{ t('common.cancel') }}</Button>
|
<Button variant="outline" :disabled="enrollmentLoading" @click="enrollmentOpen = false">{{
|
||||||
<Button v-if="enrollmentStep === 'password'" :disabled="enrollmentLoading" @click="beginEnrollment">
|
t('common.cancel')
|
||||||
|
}}</Button>
|
||||||
|
<Button
|
||||||
|
v-if="enrollmentStep === 'password'"
|
||||||
|
:disabled="enrollmentLoading"
|
||||||
|
@click="beginEnrollment"
|
||||||
|
>
|
||||||
<Loader2 v-if="enrollmentLoading" class="size-4 animate-spin" />
|
<Loader2 v-if="enrollmentLoading" class="size-4 animate-spin" />
|
||||||
{{ t('common.next') }}
|
{{ t('common.next') }}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -315,11 +329,24 @@ onUnmounted(() => {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="totp-disable-password">{{ t('settings.currentPassword') }}</Label>
|
<Label for="totp-disable-password">{{ t('settings.currentPassword') }}</Label>
|
||||||
<Input id="totp-disable-password" v-model="disablePassword" type="password" autocomplete="current-password" />
|
<Input
|
||||||
|
id="totp-disable-password"
|
||||||
|
v-model="disablePassword"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="totp-disable-code">{{ t('auth.totpCode') }}</Label>
|
<Label for="totp-disable-code">{{ t('auth.totpCode') }}</Label>
|
||||||
<Input id="totp-disable-code" v-model="disableCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" class="font-mono" @input="disableCode = normalizeCode(disableCode)" />
|
<Input
|
||||||
|
id="totp-disable-code"
|
||||||
|
v-model="disableCode"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
maxlength="6"
|
||||||
|
class="font-mono"
|
||||||
|
@input="disableCode = normalizeCode(disableCode)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Alert v-if="disableError" variant="destructive">
|
<Alert v-if="disableError" variant="destructive">
|
||||||
<AlertTriangle />
|
<AlertTriangle />
|
||||||
@@ -327,7 +354,9 @@ onUnmounted(() => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" :disabled="disableLoading" @click="disableOpen = false">{{ t('common.cancel') }}</Button>
|
<Button variant="outline" :disabled="disableLoading" @click="disableOpen = false">{{
|
||||||
|
t('common.cancel')
|
||||||
|
}}</Button>
|
||||||
<Button variant="destructive" :disabled="disableLoading" @click="disableTotp">
|
<Button variant="destructive" :disabled="disableLoading" @click="disableTotp">
|
||||||
<Loader2 v-if="disableLoading" class="size-4 animate-spin" />
|
<Loader2 v-if="disableLoading" class="size-4 animate-spin" />
|
||||||
{{ t('settings.totp.disable') }}
|
{{ t('settings.totp.disable') }}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const props = defineProps<{
|
|||||||
textOnlyScale?: boolean
|
textOnlyScale?: boolean
|
||||||
scaleMode?: VideoScaleMode
|
scaleMode?: VideoScaleMode
|
||||||
sourceSizeAvailable?: boolean
|
sourceSizeAvailable?: boolean
|
||||||
|
keyboardVisible?: boolean
|
||||||
|
fullscreen?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -41,6 +43,7 @@ function toggleScaleMode() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
||||||
:aria-label="t('actionbar.fullscreen')"
|
:aria-label="t('actionbar.fullscreen')"
|
||||||
|
:aria-pressed="!!props.fullscreen"
|
||||||
@click="emit('toggleFullscreen')"
|
@click="emit('toggleFullscreen')"
|
||||||
>
|
>
|
||||||
<Maximize class="size-3.5 sm:size-4" />
|
<Maximize class="size-3.5 sm:size-4" />
|
||||||
@@ -86,6 +89,7 @@ function toggleScaleMode() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
||||||
:aria-label="t('actionbar.keyboard')"
|
:aria-label="t('actionbar.keyboard')"
|
||||||
|
:aria-pressed="!!props.keyboardVisible"
|
||||||
@click="emit('toggleVirtualKeyboard')"
|
@click="emit('toggleVirtualKeyboard')"
|
||||||
>
|
>
|
||||||
<Keyboard class="size-3.5 sm:size-4" />
|
<Keyboard class="size-3.5 sm:size-4" />
|
||||||
@@ -98,10 +102,4 @@ function toggleScaleMode() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!minimal"
|
|
||||||
data-fixed-action
|
|
||||||
aria-hidden="true"
|
|
||||||
class="mx-2 h-5 w-px shrink-0 self-center bg-border"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import type { VideoDevice, VideoFormat, VideoResolution } from '@/api'
|
|||||||
import { formatFpsLabel } from '@/lib/fps'
|
import { formatFpsLabel } from '@/lib/fps'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
device?: VideoDevice
|
device?: VideoDevice
|
||||||
@@ -66,7 +72,10 @@ const { t } = useI18n()
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex items-center justify-between gap-3" role="status">
|
<div v-else class="flex items-center justify-between gap-3" role="status">
|
||||||
<div class="flex min-w-0 items-center gap-2 text-muted-foreground" :class="compact ? 'text-xs' : 'text-sm'">
|
<div
|
||||||
|
class="flex min-w-0 items-center gap-2 text-muted-foreground"
|
||||||
|
:class="compact ? 'text-xs' : 'text-sm'"
|
||||||
|
>
|
||||||
<AlertTriangle class="size-4 shrink-0" />
|
<AlertTriangle class="size-4 shrink-0" />
|
||||||
<span>{{ t('videoInput.unavailable') }}</span>
|
<span>{{ t('videoInput.unavailable') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,14 +94,28 @@ const { t } = useI18n()
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="device">
|
<template v-else-if="device">
|
||||||
<div class="space-y-2">
|
<div class="settings-field space-y-2">
|
||||||
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.format') }}</Label>
|
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{
|
||||||
<Select :model-value="format" @update:model-value="value => emit('update:format', String(value))">
|
t('videoInput.format')
|
||||||
<SelectTrigger :size="compact ? 'sm' : 'default'" class="w-full" :class="compact ? 'text-xs' : undefined">
|
}}</Label>
|
||||||
|
<Select
|
||||||
|
:model-value="format"
|
||||||
|
@update:model-value="(value) => emit('update:format', String(value))"
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
:size="compact ? 'sm' : 'default'"
|
||||||
|
class="w-full"
|
||||||
|
:class="compact ? 'text-xs' : undefined"
|
||||||
|
>
|
||||||
<SelectValue :placeholder="t('videoInput.selectFormat')" />
|
<SelectValue :placeholder="t('videoInput.selectFormat')" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem v-for="item in formats" :key="item.format" :value="item.format" :disabled="item.disabled">
|
<SelectItem
|
||||||
|
v-for="item in formats"
|
||||||
|
:key="item.format"
|
||||||
|
:value="item.format"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
>
|
||||||
{{ item.description || item.format }}
|
{{ item.description || item.format }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -100,24 +123,46 @@ const { t } = useI18n()
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="resolutionFpsInline ? 'grid grid-cols-2 gap-3' : 'space-y-4'">
|
<div :class="resolutionFpsInline ? 'grid grid-cols-2 gap-3' : 'space-y-4'">
|
||||||
<div class="space-y-2">
|
<div class="settings-field space-y-2">
|
||||||
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.resolution') }}</Label>
|
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{
|
||||||
<Select :model-value="resolution" @update:model-value="value => emit('update:resolution', String(value))">
|
t('videoInput.resolution')
|
||||||
<SelectTrigger :size="compact ? 'sm' : 'default'" class="w-full" :class="compact ? 'text-xs' : undefined">
|
}}</Label>
|
||||||
|
<Select
|
||||||
|
:model-value="resolution"
|
||||||
|
@update:model-value="(value) => emit('update:resolution', String(value))"
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
:size="compact ? 'sm' : 'default'"
|
||||||
|
class="w-full"
|
||||||
|
:class="compact ? 'text-xs' : undefined"
|
||||||
|
>
|
||||||
<SelectValue :placeholder="t('videoInput.selectResolution')" />
|
<SelectValue :placeholder="t('videoInput.selectResolution')" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem v-for="item in resolutions" :key="`${item.width}x${item.height}`" :value="`${item.width}x${item.height}`">
|
<SelectItem
|
||||||
|
v-for="item in resolutions"
|
||||||
|
:key="`${item.width}x${item.height}`"
|
||||||
|
:value="`${item.width}x${item.height}`"
|
||||||
|
>
|
||||||
{{ item.width }}x{{ item.height }}
|
{{ item.width }}x{{ item.height }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="settings-field space-y-2">
|
||||||
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{ t('videoInput.frameRate') }}</Label>
|
<Label :class="compact ? 'text-xs text-muted-foreground' : undefined">{{
|
||||||
<Select :model-value="fps === null ? '' : String(fps)" @update:model-value="value => emit('update:fps', Number(value))">
|
t('videoInput.frameRate')
|
||||||
<SelectTrigger :size="compact ? 'sm' : 'default'" class="w-full" :class="compact ? 'text-xs' : undefined">
|
}}</Label>
|
||||||
|
<Select
|
||||||
|
:model-value="fps === null ? '' : String(fps)"
|
||||||
|
@update:model-value="(value) => emit('update:fps', Number(value))"
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
:size="compact ? 'sm' : 'default'"
|
||||||
|
class="w-full"
|
||||||
|
:class="compact ? 'text-xs' : undefined"
|
||||||
|
>
|
||||||
<SelectValue :placeholder="t('videoInput.selectFps')" />
|
<SelectValue :placeholder="t('videoInput.selectFps')" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ defineOptions({
|
|||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes["class"] }>(), {
|
const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes["class"]; showArrow?: boolean }>(), {
|
||||||
sideOffset: 4,
|
sideOffset: 4,
|
||||||
|
showArrow: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emits = defineEmits<TooltipContentEmits>()
|
const emits = defineEmits<TooltipContentEmits>()
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
const delegatedProps = reactiveOmit(props, "class", "showArrow")
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
<TooltipArrow class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xs" />
|
<TooltipArrow v-if="showArrow" class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xs" />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { settingsUiEn } from './settings-ui'
|
||||||
export default {
|
export default {
|
||||||
|
settingsUi: settingsUiEn,
|
||||||
hidGuide: {
|
hidGuide: {
|
||||||
driver_ch9329_otg: 'CH9329 + OTG composite mode',
|
driver_ch9329_otg: 'CH9329 + OTG composite mode',
|
||||||
compositeHelp: 'CH9329 handles keyboard and mouse. OTG provides virtual media, USB networking and USB audio. Enable advanced functions in their settings.',
|
compositeHelp: 'CH9329 handles keyboard and mouse. OTG provides virtual media, USB networking and USB audio. Enable advanced functions in their settings.',
|
||||||
@@ -12,6 +14,7 @@ export default {
|
|||||||
otgInactive: 'Not enabled',
|
otgInactive: 'Not enabled',
|
||||||
otgConfigured: 'USB configured by host',
|
otgConfigured: 'USB configured by host',
|
||||||
otgWaiting: 'Bound, waiting for host',
|
otgWaiting: 'Bound, waiting for host',
|
||||||
|
|
||||||
missingDevice: 'The previous device {device} is unavailable. Check the current selection.',
|
missingDevice: 'The previous device {device} is unavailable. Check the current selection.',
|
||||||
connectionTimeout: 'Connection timed out. Check the data cable and the controlled computer’s USB port. You can connect later or reconfigure.',
|
connectionTimeout: 'Connection timed out. Check the data cable and the controlled computer’s USB port. You can connect later or reconfigure.',
|
||||||
"deviceTitle": "Driver and device",
|
"deviceTitle": "Driver and device",
|
||||||
@@ -634,7 +637,7 @@ export default {
|
|||||||
videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers',
|
videoSubtitle: 'Configure capture device, video encoder and WebRTC ICE servers',
|
||||||
hidSubtitle: 'Configure the HID backend, device, descriptors, and USB gadget functions',
|
hidSubtitle: 'Configure the HID backend, device, descriptors, and USB gadget functions',
|
||||||
atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN',
|
atxSubtitle: 'Configure remote power control hardware and Wake-on-LAN',
|
||||||
environmentSubtitle: 'View device information and system runtime diagnostics',
|
environmentSubtitle: 'Diagnose USB OTG and video encoding',
|
||||||
otherSubtitle: 'Additional hardware controls and USB device maintenance',
|
otherSubtitle: 'Additional hardware controls and USB device maintenance',
|
||||||
aboutSubtitle: 'Online upgrade and version information',
|
aboutSubtitle: 'Online upgrade and version information',
|
||||||
extTtydSubtitle: 'Open a host Shell terminal in the browser',
|
extTtydSubtitle: 'Open a host Shell terminal in the browser',
|
||||||
@@ -642,7 +645,7 @@ export default {
|
|||||||
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
|
extRemoteAccessSubtitle: 'Remote access through NAT-traversal services',
|
||||||
extFrpcSubtitle: 'NAT traversal through the FRP client',
|
extFrpcSubtitle: 'NAT traversal through the FRP client',
|
||||||
watchdog: {
|
watchdog: {
|
||||||
title: 'Hardware Watchdog',
|
title: 'Hardware watchdog',
|
||||||
description: 'The hardware watchdog can trigger a hardware reset if the system hangs or the process exits',
|
description: 'The hardware watchdog can trigger a hardware reset if the system hangs or the process exits',
|
||||||
status: {
|
status: {
|
||||||
running: 'Running',
|
running: 'Running',
|
||||||
|
|||||||
46
web/src/i18n/settings-ui.ts
Normal file
46
web/src/i18n/settings-ui.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export const settingsUiZh = {
|
||||||
|
watchdogAutoReset: '启用自动复位',
|
||||||
|
mcpRate: '请求频率(次/秒)',
|
||||||
|
help: '查看提示',
|
||||||
|
back: '返回控制台',
|
||||||
|
saveCapture: '保存采集设置',
|
||||||
|
saveEncoder: '保存编码设置',
|
||||||
|
saveConnection: '保存连接设置',
|
||||||
|
project: '项目主页',
|
||||||
|
featuresHelp:
|
||||||
|
'USB 功能会按当前驱动与设备能力提供。修改后只保存本卡片配置,设备可能重新识别 USB 连接。',
|
||||||
|
identifiers: '高级设备标识',
|
||||||
|
identifiersHelp: 'VID、PID、厂商和产品名称决定被控机识别到的设备信息。修改后可能需要重新连接。',
|
||||||
|
applyDriver: '应用驱动',
|
||||||
|
saveFeaturesFirst: '请先保存或放弃功能配置,再切换驱动。',
|
||||||
|
discardFeatures: '放弃功能更改',
|
||||||
|
driverHelp: '选择驱动及对应设备,然后应用。应用驱动会重新建立键鼠连接。',
|
||||||
|
driverApplied: '驱动配置已应用',
|
||||||
|
saveFailed: '保存失败,输入已保留,请检查后重试。',
|
||||||
|
saveDone: '设置已保存',
|
||||||
|
checkUpdates: '检查更新',
|
||||||
|
}
|
||||||
|
export const settingsUiEn = {
|
||||||
|
watchdogAutoReset: 'Enable automatic recovery',
|
||||||
|
mcpRate: 'Requests per second',
|
||||||
|
help: 'Show help',
|
||||||
|
back: 'Back to console',
|
||||||
|
saveCapture: 'Save capture',
|
||||||
|
saveEncoder: 'Save encoder',
|
||||||
|
saveConnection: 'Save connection',
|
||||||
|
project: 'Project',
|
||||||
|
featuresHelp:
|
||||||
|
'Available USB functions depend on the driver and hardware. Saving applies only this card and may reconnect the USB device.',
|
||||||
|
identifiers: 'Advanced device identity',
|
||||||
|
identifiersHelp:
|
||||||
|
'VID, PID, manufacturer and product names identify the device to the target computer. Changes may require reconnection.',
|
||||||
|
applyDriver: 'Apply driver',
|
||||||
|
saveFeaturesFirst: 'Save or discard function changes before switching drivers.',
|
||||||
|
discardFeatures: 'Discard function changes',
|
||||||
|
driverHelp:
|
||||||
|
'Select a driver and its device, then apply. Applying re-establishes the keyboard and mouse connection.',
|
||||||
|
driverApplied: 'Driver configuration applied',
|
||||||
|
saveFailed: 'Could not save. Your input is preserved; check it and try again.',
|
||||||
|
saveDone: 'Settings saved',
|
||||||
|
checkUpdates: 'Check for updates',
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { settingsUiZh } from './settings-ui'
|
||||||
export default {
|
export default {
|
||||||
|
settingsUi: settingsUiZh,
|
||||||
hidGuide: {
|
hidGuide: {
|
||||||
driver_ch9329_otg: 'CH9329 + OTG 复合模式',
|
driver_ch9329_otg: 'CH9329 + OTG 复合模式',
|
||||||
compositeHelp: 'CH9329 负责键盘和鼠标,OTG 负责虚拟磁盘、USB 网络和 USB 音频。高级功能需在各自设置中启用。',
|
compositeHelp: 'CH9329 负责键盘和鼠标,OTG 负责虚拟磁盘、USB 网络和 USB 音频。高级功能需在各自设置中启用。',
|
||||||
@@ -12,6 +14,7 @@ export default {
|
|||||||
otgInactive: '未启用',
|
otgInactive: '未启用',
|
||||||
otgConfigured: '主机已配置 USB',
|
otgConfigured: '主机已配置 USB',
|
||||||
otgWaiting: '已绑定,等待主机连接',
|
otgWaiting: '已绑定,等待主机连接',
|
||||||
|
|
||||||
missingDevice: '原设备 {device} 已不可用,请检查当前选择。',
|
missingDevice: '原设备 {device} 已不可用,请检查当前选择。',
|
||||||
connectionTimeout: '等待连接超时,请检查数据线及被控机 USB 端口。可稍后连接或重新配置。',
|
connectionTimeout: '等待连接超时,请检查数据线及被控机 USB 端口。可稍后连接或重新配置。',
|
||||||
"deviceTitle": "驱动与设备",
|
"deviceTitle": "驱动与设备",
|
||||||
@@ -633,7 +636,7 @@ export default {
|
|||||||
videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器',
|
videoSubtitle: '配置采集设备、视频编码器与 WebRTC 信令服务器',
|
||||||
hidSubtitle: '配置 HID 后端、设备、描述符与 USB Gadget 功能',
|
hidSubtitle: '配置 HID 后端、设备、描述符与 USB Gadget 功能',
|
||||||
atxSubtitle: '配置远程电源控制硬件与网络唤醒',
|
atxSubtitle: '配置远程电源控制硬件与网络唤醒',
|
||||||
environmentSubtitle: '查看设备信息与系统级运行环境',
|
environmentSubtitle: '诊断 USB OTG 与视频编码环境',
|
||||||
otherSubtitle: '其他系统硬件控制与 USB 设备维护',
|
otherSubtitle: '其他系统硬件控制与 USB 设备维护',
|
||||||
aboutSubtitle: '在线升级与版本信息',
|
aboutSubtitle: '在线升级与版本信息',
|
||||||
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
|
extTtydSubtitle: '在浏览器中打开本机 Shell 终端',
|
||||||
@@ -641,7 +644,7 @@ export default {
|
|||||||
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
|
extRemoteAccessSubtitle: '通过内网穿透服务实现远程访问',
|
||||||
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
|
extFrpcSubtitle: '通过 FRP 客户端实现内网穿透',
|
||||||
watchdog: {
|
watchdog: {
|
||||||
title: '硬件 Watchdog',
|
title: '硬件看门狗',
|
||||||
description: '硬件 Watchdog 可在系统死机或程序退出时自动触发硬件复位信号',
|
description: '硬件 Watchdog 可在系统死机或程序退出时自动触发硬件复位信号',
|
||||||
status: {
|
status: {
|
||||||
running: '运行中',
|
running: '运行中',
|
||||||
|
|||||||
3
web/src/lib/settings-help.ts
Normal file
3
web/src/lib/settings-help.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
// A pinned explanation yields when another card's help is opened.
|
||||||
|
export const activeSettingsHelp = ref<symbol | null>(null)
|
||||||
@@ -4,6 +4,7 @@ import App from './App.vue'
|
|||||||
import router from './router'
|
import router from './router'
|
||||||
import i18n from './i18n'
|
import i18n from './i18n'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
import './styles/console.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
|||||||
139
web/src/styles/console.css
Normal file
139
web/src/styles/console.css
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/* Login and console share scheme B surfaces; settings keep their own styles. */
|
||||||
|
.login-page,
|
||||||
|
.console-page {
|
||||||
|
--console-control-active: color-mix(in srgb, var(--background) 93%, var(--foreground) 7%);
|
||||||
|
--console-workspace: color-mix(in srgb, var(--background) 97%, var(--foreground) 3%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page.dot-grid-bg,
|
||||||
|
.console-page .dot-grid-bg {
|
||||||
|
background-color: var(--console-workspace);
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 18px rgb(0 0 0 / 4%), 0 1px 3px rgb(0 0 0 / 3%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card [data-slot="input"] {
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--background);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card button {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card button:not([type="submit"]) {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card button:not([type="submit"]):is(:active, [aria-haspopup][aria-expanded="true"]) {
|
||||||
|
background: var(--console-control-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card [data-slot="card-title"] {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card [data-slot="field-label"] {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .text-xs,
|
||||||
|
.console-page :is(.console-header, .console-action-bar, .console-action-measure, .console-info-bar) .text-xs,
|
||||||
|
.console-page :is(.console-header, .console-action-bar, .console-action-measure) button {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-page .console-status-card {
|
||||||
|
gap: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-page .console-status-card:not(.console-status-card--compact) :is(span, div) {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-page :is(.console-header, .console-action-bar) button {
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover tooltips do not select a tool. Only pressed or genuinely open controls do. */
|
||||||
|
.console-page :is(.console-header, .console-action-bar) button:is(
|
||||||
|
:active, [aria-pressed="true"], [aria-haspopup][aria-expanded="true"]
|
||||||
|
) {
|
||||||
|
background: var(--console-control-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-page :is(.console-header, .console-action-bar) button svg {
|
||||||
|
stroke-width: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-page .console-action-bar--floating {
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--background);
|
||||||
|
box-shadow: 0 2px 7px rgb(0 0 0 / 5%);
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-page .console-action-bar--sidebar {
|
||||||
|
background: var(--background);
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.console-page .console-header--floating .console-status-summary__label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-page .console-info-bar [data-slot="badge"] {
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-page .console-info-bar [data-slot="badge"][aria-pressed="true"] {
|
||||||
|
background: var(--console-control-active);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal surfaces inherit the same visual grammar through their console owner. */
|
||||||
|
[data-console-layout] {
|
||||||
|
--console-surface-radius: 14px;
|
||||||
|
--console-control-radius: 10px;
|
||||||
|
--console-surface-shadow: 0 6px 22px rgb(0 0 0 / 9%);
|
||||||
|
--console-control-active: color-mix(in srgb, var(--background) 93%, var(--foreground) 7%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-console-layout] .text-xs {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-console-layout] button[data-variant="ghost"],
|
||||||
|
[data-console-layout] button[data-variant="outline"] {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-console-layout] button:is([data-variant="ghost"], [data-variant="outline"]):is(
|
||||||
|
:active, [aria-pressed="true"], [aria-haspopup][aria-expanded="true"]
|
||||||
|
) {
|
||||||
|
background: var(--console-control-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card :is(button, [data-slot="input"]):focus-visible,
|
||||||
|
.console-page :is(.console-header, .console-action-bar) button:focus-visible,
|
||||||
|
[data-console-layout] button:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
500
web/src/styles/settings.css
Normal file
500
web/src/styles/settings.css
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
/* Settings-specific surfaces share the product theme, including dark mode. */
|
||||||
|
.settings-page {
|
||||||
|
width: min(1088px, calc(100% - 112px));
|
||||||
|
margin: 0 48px 0 64px;
|
||||||
|
padding: 26px 0 44px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.settings-section-heading {
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
.settings-section-heading h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 32px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.settings-section-heading p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.settings-page > div > [data-slot='card'],
|
||||||
|
.settings-page > [data-slot='card'] {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.settings-page [data-slot='card'] {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 22px;
|
||||||
|
padding: 23px 24px 22px;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.settings-page [data-slot='card-header'],
|
||||||
|
.settings-page [data-slot='card-content'],
|
||||||
|
.settings-page [data-slot='card-footer'] {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
.settings-page [data-slot='card-header'] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-rows: none;
|
||||||
|
}
|
||||||
|
.settings-page .settings-card-heading:has(.settings-card-actions) {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 15;
|
||||||
|
background: var(--card);
|
||||||
|
margin-top: -12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.settings-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 650;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.settings-help {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.settings-help button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.settings-help button:hover,
|
||||||
|
.settings-help button:focus-visible {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.settings-help button:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.settings-help svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
}
|
||||||
|
[data-slot='tooltip-content'].settings-help-content {
|
||||||
|
z-index: 80;
|
||||||
|
max-width: min(290px, calc(100vw - 24px));
|
||||||
|
padding: 11px 13px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--popover-foreground);
|
||||||
|
background: var(--popover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 4px 16px #00000013;
|
||||||
|
border-radius: 7px;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.settings-help-content [data-slot='tooltip-arrow'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.settings-card-actions,
|
||||||
|
.settings-service-actions,
|
||||||
|
.settings-save-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.settings-service-actions + .settings-save-action {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding-left: 16px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.settings-page [data-slot='button'] {
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
min-height: 36px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.settings-page [data-slot='card-footer'] {
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
.settings-page [data-slot='card-content'] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.settings-page [data-slot='input'],
|
||||||
|
.settings-page [data-slot='select-trigger'],
|
||||||
|
.settings-page textarea {
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
min-height: 36px;
|
||||||
|
box-shadow: 0 1px 2px #00000004;
|
||||||
|
}
|
||||||
|
.settings-page [data-slot='label'] {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 21px;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 8px 24px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field > [data-slot='label'] {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
padding-top: 7px;
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field > :not([data-slot='label']) {
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field > [data-slot='label'] ~ [data-slot='label'] {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: auto;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field > [data-slot='select-trigger'],
|
||||||
|
.settings-page .settings-field [data-slot='select-trigger'] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.settings-page .settings-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.settings-page .settings-toggle > :first-child:not([data-slot='switch']) {
|
||||||
|
flex: 0 1 180px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field .settings-toggle > :first-child:not([data-slot='switch']) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.settings-page .settings-compare-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.settings-page .settings-gpio-group {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.settings-page .settings-gpio-group .settings-field {
|
||||||
|
grid-template-columns: 120px minmax(0, 1fr);
|
||||||
|
column-gap: 16px;
|
||||||
|
}
|
||||||
|
.settings-page .text-xs {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field-list > [data-slot='separator'] {
|
||||||
|
margin-block: 2px;
|
||||||
|
}
|
||||||
|
.settings-page .settings-section-services {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 36px;
|
||||||
|
}
|
||||||
|
.settings-page .settings-section-services > [data-slot='card'] {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.settings-page .settings-section-services > div[style*='display: none'] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.settings-back {
|
||||||
|
margin-top: auto;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.load-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--metric-count, 4), minmax(0, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
padding: 3px 0 5px;
|
||||||
|
}
|
||||||
|
.load-metric {
|
||||||
|
text-align: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.usage-ring {
|
||||||
|
position: relative;
|
||||||
|
width: 114px;
|
||||||
|
height: 114px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.usage-ring > svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
.usage-ring circle {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 8;
|
||||||
|
}
|
||||||
|
.ring-track {
|
||||||
|
stroke: var(--muted);
|
||||||
|
}
|
||||||
|
.ring-value {
|
||||||
|
stroke: var(--foreground);
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.usage-value {
|
||||||
|
font-size: 27px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.7px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.usage-value small {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
letter-spacing: 0;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.metric-label {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.metric-detail {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 21px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.system-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
column-gap: 44px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
.system-info-grid > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px minmax(0, 1fr);
|
||||||
|
column-gap: 20px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 11px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.system-info-grid > div:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.system-info-grid dt {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.system-info-grid dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.project-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px 28px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 13px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
.project-links a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
.project-links a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.project-links svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1199px) {
|
||||||
|
.settings-page {
|
||||||
|
width: calc(100% - 48px);
|
||||||
|
margin-inline: 24px;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field {
|
||||||
|
grid-template-columns: 150px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.settings-page .settings-compare-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.settings-card-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.usage-ring {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
}
|
||||||
|
.load-metrics {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.system-info-grid {
|
||||||
|
column-gap: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.settings-page {
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
margin-inline: 16px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.settings-page [data-slot='card'] {
|
||||||
|
padding: 18px 16px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.settings-page .settings-card-heading {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.settings-card-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.settings-card-actions [data-slot='button'] {
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
.settings-service-actions + .settings-save-action {
|
||||||
|
padding-left: 10px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field > [data-slot='label'] {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.settings-page .settings-toggle > :first-child:not([data-slot='switch']) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.settings-page .settings-field-list {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.load-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 24px 12px;
|
||||||
|
}
|
||||||
|
.system-info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.system-info-grid > div:nth-last-child(2) {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.usage-ring {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
.settings-section-heading h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
.settings-page .settings-compare-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.settings-page * {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.settings-page > .settings-section-services + .settings-section-services {
|
||||||
|
margin-top: 36px;
|
||||||
|
}
|
||||||
|
.update-release-notes {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.settings-page .hid-feature-card [data-slot='card-content'] .rounded-md.border:not([data-slot]) {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
.settings-page .hid-feature-card [data-slot='card-content'] .md\:grid-cols-2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.settings-page .hid-feature-card [data-slot='card-content'] > .space-y-4 > div:first-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.settings-page .hid-settings-form > label {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr);
|
||||||
|
gap: 8px 24px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.settings-page .hid-settings-form > label > :not(span) {
|
||||||
|
grid-column: 2;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.settings-page .hid-settings-form > label > span {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.settings-page .hid-settings-form > label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-page [data-slot='switch'] {
|
||||||
|
width: 34px;
|
||||||
|
height: 20px;
|
||||||
|
flex: none;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
.settings-page .settings-environment{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px;align-items:start}
|
||||||
|
.settings-page .settings-environment>[data-slot=card]{margin-top:0;min-width:0}
|
||||||
|
.settings-page .settings-environment>.settings-wide{grid-column:1/-1}
|
||||||
|
@media(max-width:1199px){.settings-page .settings-environment{grid-template-columns:1fr}}
|
||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Settings,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t, te } = useI18n()
|
const { t, te } = useI18n()
|
||||||
@@ -3103,8 +3104,8 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative h-screen h-dvh flex flex-col bg-background">
|
<div class="console-page relative h-screen h-dvh flex flex-col bg-background">
|
||||||
<header class="shrink-0 border-b bg-background" :class="consoleLayout === 'floating' && 'console-header--floating'">
|
<header class="console-header shrink-0 border-b bg-background" :class="consoleLayout === 'floating' && 'console-header--floating'">
|
||||||
<div class="px-2 sm:px-4">
|
<div class="px-2 sm:px-4">
|
||||||
<div class="console-header__row h-10 sm:h-14 flex items-center justify-between">
|
<div class="console-header__row h-10 sm:h-14 flex items-center justify-between">
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-2 sm:gap-6">
|
<div class="flex min-w-0 flex-1 items-center gap-2 sm:gap-6">
|
||||||
@@ -3146,6 +3147,8 @@ onUnmounted(() => {
|
|||||||
:show-stats="showConnectionStats"
|
:show-stats="showConnectionStats"
|
||||||
:show-terminal="showTerminal"
|
:show-terminal="showTerminal"
|
||||||
:terminal-running="!!ttydStatus?.running"
|
:terminal-running="!!ttydStatus?.running"
|
||||||
|
:stats-open="statsSheetOpen"
|
||||||
|
:terminal-open="showTerminalDialog"
|
||||||
|
|
||||||
@open-stats="openStatsSheet"
|
@open-stats="openStatsSheet"
|
||||||
@open-terminal="openTerminal"
|
@open-terminal="openTerminal"
|
||||||
@@ -3194,6 +3197,16 @@ onUnmounted(() => {
|
|||||||
<Moon v-else class="size-4" />
|
<Moon v-else class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<LanguageToggleButton class="size-8 hidden md:flex" />
|
<LanguageToggleButton class="size-8 hidden md:flex" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="console-settings-button"
|
||||||
|
:aria-label="t('actionbar.settings')"
|
||||||
|
:title="t('actionbar.settings')"
|
||||||
|
@click="router.push('/settings')"
|
||||||
|
>
|
||||||
|
<Settings class="size-4" />
|
||||||
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="outline" size="sm" class="gap-1 sm:gap-1.5 px-2 sm:px-3">
|
<Button variant="outline" size="sm" class="gap-1 sm:gap-1.5 px-2 sm:px-3">
|
||||||
@@ -3249,6 +3262,10 @@ onUnmounted(() => {
|
|||||||
:show-mic="microphoneTransferEnabled"
|
:show-mic="microphoneTransferEnabled"
|
||||||
:scale-mode="videoScaleMode"
|
:scale-mode="videoScaleMode"
|
||||||
:source-size-available="sourceSizeAvailable"
|
:source-size-available="sourceSizeAvailable"
|
||||||
|
:keyboard-visible="virtualKeyboardVisible"
|
||||||
|
:fullscreen="isFullscreen"
|
||||||
|
:stats-open="statsSheetOpen"
|
||||||
|
:terminal-open="showTerminalDialog"
|
||||||
@toggle-fullscreen="toggleFullscreen"
|
@toggle-fullscreen="toggleFullscreen"
|
||||||
@update:scale-mode="setVideoScaleMode"
|
@update:scale-mode="setVideoScaleMode"
|
||||||
@toggle-stats="openStatsSheet"
|
@toggle-stats="openStatsSheet"
|
||||||
@@ -3498,6 +3515,7 @@ onUnmounted(() => {
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
<div id="keyboard-anchor"></div>
|
<div id="keyboard-anchor"></div>
|
||||||
<InfoBar
|
<InfoBar
|
||||||
|
class="console-info-bar"
|
||||||
v-if="consoleLayout !== 'floating' || pressedKeys.length > 0 || isPointerLocked"
|
v-if="consoleLayout !== 'floating' || pressedKeys.length > 0 || isPointerLocked"
|
||||||
:compact="consoleLayout !== 'current'"
|
:compact="consoleLayout !== 'current'"
|
||||||
:captured="isPointerLocked"
|
:captured="isPointerLocked"
|
||||||
|
|||||||
@@ -120,8 +120,8 @@ function returnToPassword(keepError = false) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen min-h-dvh flex items-center justify-center dot-grid-bg p-4">
|
<div class="login-page min-h-screen min-h-dvh flex items-center justify-center dot-grid-bg p-4">
|
||||||
<Card class="relative w-full max-w-sm">
|
<Card class="login-card relative w-full max-w-sm">
|
||||||
<div class="absolute top-4 right-4">
|
<div class="absolute top-4 right-4">
|
||||||
<LanguageToggleButton />
|
<LanguageToggleButton />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user