This commit is contained in:
mofeng-git
2025-12-28 18:19:16 +08:00
commit d143d158e4
771 changed files with 220548 additions and 0 deletions

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
web/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

20
web/components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/style.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>One-KVM</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3093
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
web/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.556.0",
"opus-decoder": "^0.7.11",
"pinia": "^3.0.4",
"radix-vue": "^1.9.17",
"reka-ui": "^2.6.1",
"simple-keyboard": "^3.8.93",
"tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0",
"uplot": "^1.6.32",
"vue": "^3.5.24",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.3",
"vue-sonner": "^2.0.9"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.17",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

1
web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

60
web/src/App.vue Normal file
View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSystemStore } from '@/stores/system'
const router = useRouter()
const authStore = useAuthStore()
const systemStore = useSystemStore()
// Check for dark mode preference
function initTheme() {
const stored = localStorage.getItem('theme')
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark')
}
}
// Initialize on mount
onMounted(async () => {
initTheme()
// Check setup status
try {
await authStore.checkSetupStatus()
if (authStore.needsSetup) {
router.push('/setup')
return
}
} catch {
// Continue anyway
}
// Check auth status
try {
await authStore.checkAuth()
if (authStore.isAuthenticated) {
// Fetch system info
await systemStore.fetchSystemInfo()
}
} catch {
// Not authenticated
}
})
// Listen for dark mode changes
watch(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
(dark) => {
const stored = localStorage.getItem('theme')
if (!stored) {
document.documentElement.classList.toggle('dark', dark)
}
}
)
</script>
<template>
<RouterView />
</template>

255
web/src/api/config.ts Normal file
View File

@@ -0,0 +1,255 @@
/**
* 配置管理 API - 域分离架构
*
* 每个配置域video, stream, hid, msd, atx, audio有独立的 GET/PATCH 端点,
* 避免配置项之间的相互干扰。
*/
import type {
AppConfig,
VideoConfig,
VideoConfigUpdate,
StreamConfigResponse,
StreamConfigUpdate,
HidConfig,
HidConfigUpdate,
MsdConfig,
MsdConfigUpdate,
AtxConfig,
AtxConfigUpdate,
AudioConfig,
AudioConfigUpdate,
ExtensionsStatus,
ExtensionInfo,
ExtensionLogs,
TtydConfig,
TtydConfigUpdate,
GostcConfig,
GostcConfigUpdate,
EasytierConfig,
EasytierConfigUpdate,
TtydStatus,
} from '@/types/generated'
const API_BASE = '/api'
/**
* 通用请求函数
*/
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include',
})
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }))
throw new Error(error.message || `HTTP ${response.status}`)
}
return response.json()
}
// ===== 全局配置 API =====
export const configApi = {
/**
* 获取完整配置
*/
getAll: () => request<AppConfig>('/config'),
}
// ===== Video 配置 API =====
export const videoConfigApi = {
/**
* 获取视频配置
*/
get: () => request<VideoConfig>('/config/video'),
/**
* 更新视频配置
* @param config 要更新的字段(仅发送需要修改的字段)
*/
update: (config: VideoConfigUpdate) =>
request<VideoConfig>('/config/video', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
// ===== Stream 配置 API =====
export const streamConfigApi = {
/**
* 获取流配置
*/
get: () => request<StreamConfigResponse>('/config/stream'),
/**
* 更新流配置
* @param config 要更新的字段
*/
update: (config: StreamConfigUpdate) =>
request<StreamConfigResponse>('/config/stream', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
// ===== HID 配置 API =====
export const hidConfigApi = {
/**
* 获取 HID 配置
*/
get: () => request<HidConfig>('/config/hid'),
/**
* 更新 HID 配置
* @param config 要更新的字段
*/
update: (config: HidConfigUpdate) =>
request<HidConfig>('/config/hid', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
// ===== MSD 配置 API =====
export const msdConfigApi = {
/**
* 获取 MSD 配置
*/
get: () => request<MsdConfig>('/config/msd'),
/**
* 更新 MSD 配置
* @param config 要更新的字段
*/
update: (config: MsdConfigUpdate) =>
request<MsdConfig>('/config/msd', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
// ===== ATX 配置 API =====
import type { AtxDevices } from '@/types/generated'
export const atxConfigApi = {
/**
* 获取 ATX 配置
*/
get: () => request<AtxConfig>('/config/atx'),
/**
* 更新 ATX 配置
* @param config 要更新的字段
*/
update: (config: AtxConfigUpdate) =>
request<AtxConfig>('/config/atx', {
method: 'PATCH',
body: JSON.stringify(config),
}),
/**
* 获取可用的 ATX 设备GPIO chips, USB relays
*/
listDevices: () => request<AtxDevices>('/devices/atx'),
/**
* 发送 Wake-on-LAN 魔术包
* @param macAddress 目标 MAC 地址
*/
sendWol: (macAddress: string) =>
request<{ success: boolean; message?: string }>('/atx/wol', {
method: 'POST',
body: JSON.stringify({ mac_address: macAddress }),
}),
}
// ===== Audio 配置 API =====
export const audioConfigApi = {
/**
* 获取音频配置
*/
get: () => request<AudioConfig>('/config/audio'),
/**
* 更新音频配置
* @param config 要更新的字段
*/
update: (config: AudioConfigUpdate) =>
request<AudioConfig>('/config/audio', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
// ===== Extensions API =====
export const extensionsApi = {
/**
* 获取所有扩展状态
*/
getAll: () => request<ExtensionsStatus>('/extensions'),
/**
* 获取单个扩展状态
*/
get: (id: string) => request<ExtensionInfo>(`/extensions/${id}`),
/**
* 启动扩展
*/
start: (id: string) =>
request<ExtensionInfo>(`/extensions/${id}/start`, {
method: 'POST',
}),
/**
* 停止扩展
*/
stop: (id: string) =>
request<ExtensionInfo>(`/extensions/${id}/stop`, {
method: 'POST',
}),
/**
* 获取扩展日志
*/
logs: (id: string, lines = 100) =>
request<ExtensionLogs>(`/extensions/${id}/logs?lines=${lines}`),
/**
* 获取 ttyd 状态(简化版,用于控制台)
*/
getTtydStatus: () => request<TtydStatus>('/extensions/ttyd/status'),
/**
* 更新 ttyd 配置
*/
updateTtyd: (config: TtydConfigUpdate) =>
request<TtydConfig>('/extensions/ttyd/config', {
method: 'PATCH',
body: JSON.stringify(config),
}),
/**
* 更新 gostc 配置
*/
updateGostc: (config: GostcConfigUpdate) =>
request<GostcConfig>('/extensions/gostc/config', {
method: 'PATCH',
body: JSON.stringify(config),
}),
/**
* 更新 easytier 配置
*/
updateEasytier: (config: EasytierConfigUpdate) =>
request<EasytierConfig>('/extensions/easytier/config', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}

696
web/src/api/index.ts Normal file
View File

@@ -0,0 +1,696 @@
// API client for One-KVM backend
import { toast } from 'vue-sonner'
const API_BASE = '/api'
// Toast debounce mechanism - prevent toast spam (5 seconds)
const toastDebounceMap = new Map<string, number>()
const TOAST_DEBOUNCE_TIME = 5000
function shouldShowToast(key: string): boolean {
const now = Date.now()
const lastToastTime = toastDebounceMap.get(key)
if (!lastToastTime || now - lastToastTime >= TOAST_DEBOUNCE_TIME) {
toastDebounceMap.set(key, now)
return true
}
return false
}
class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_BASE}${endpoint}`
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
credentials: 'include',
})
// Parse response body (all responses are 200 OK with success field)
const data = await response.json().catch(() => ({
success: false,
message: 'Failed to parse response'
}))
// Check success field - all errors are indicated by success=false
if (data && typeof data.success === 'boolean' && !data.success) {
const errorMessage = data.message || 'Operation failed'
const apiError = new ApiError(response.status, errorMessage)
console.info(`[API] ${endpoint} failed:`, errorMessage)
// Show toast notification to user (with debounce)
if (shouldShowToast(`error_${endpoint}`)) {
toast.error('Operation Failed', {
description: errorMessage,
duration: 4000,
})
}
throw apiError
}
return data
} catch (error) {
// Network errors or JSON parsing errors
if (error instanceof ApiError) {
throw error // Already handled above
}
// Network connectivity issues
console.info(`[API] Network error for ${endpoint}:`, error)
// Show toast for network errors (with debounce)
if (shouldShowToast('network_error')) {
toast.error('Network Error', {
description: 'Unable to connect to server. Please check your connection.',
duration: 4000,
})
}
throw new ApiError(0, 'Network error')
}
}
// Auth API
export const authApi = {
login: (username: string, password: string) =>
request<{ success: boolean; message?: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
logout: () =>
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
check: () =>
request<{ authenticated: boolean; user?: string; is_admin?: boolean }>('/auth/check'),
}
// System API
export interface NetworkAddress {
interface: string
ip: string
}
export interface DeviceInfo {
hostname: string
cpu_model: string
cpu_usage: number
memory_total: number
memory_used: number
network_addresses: NetworkAddress[]
}
export const systemApi = {
info: () =>
request<{
version: string
build_date: string
initialized: boolean
capabilities: {
video: { available: boolean; backend?: string }
hid: { available: boolean; backend?: string }
msd: { available: boolean }
atx: { available: boolean; backend?: string }
audio: { available: boolean; backend?: string }
}
disk_space?: {
total: number
available: number
used: number
}
device_info?: DeviceInfo
}>('/info'),
health: () => request<{ status: string; version: string }>('/health'),
setupStatus: () =>
request<{ initialized: boolean; needs_setup: boolean }>('/setup'),
setup: (data: {
username: string
password: string
video_device?: string
video_format?: string
video_width?: number
video_height?: number
video_fps?: number
hid_backend?: string
hid_ch9329_port?: string
hid_ch9329_baudrate?: number
hid_otg_udc?: string
encoder_backend?: string
}) =>
request<{ success: boolean; message?: string }>('/setup/init', {
method: 'POST',
body: JSON.stringify(data),
}),
}
// Stream API
export interface VideoCodecInfo {
id: string
name: string
protocol: 'http' | 'webrtc'
hardware: boolean
backend: string | null
available: boolean
}
export interface EncoderBackendInfo {
id: string
name: string
is_hardware: boolean
supported_formats: string[]
}
export interface AvailableCodecsResponse {
success: boolean
backends: EncoderBackendInfo[]
codecs: VideoCodecInfo[]
}
export const streamApi = {
status: () =>
request<{
state: 'uninitialized' | 'ready' | 'streaming' | 'no_signal' | 'error'
device: string | null
format: string | null
resolution: [number, number] | null
clients: number
target_fps: number
fps: number
frames_captured: number
frames_dropped: number
}>('/stream/status'),
start: () =>
request<{ success: boolean }>('/stream/start', { method: 'POST' }),
stop: () =>
request<{ success: boolean }>('/stream/stop', { method: 'POST' }),
getMjpegUrl: (clientId?: string) => {
const base = `${API_BASE}/stream/mjpeg`
return clientId ? `${base}?client_id=${clientId}` : base
},
getSnapshotUrl: () => `${API_BASE}/snapshot`,
getMode: () =>
request<{ success: boolean; mode: string; message?: string }>('/stream/mode'),
setMode: (mode: string) =>
request<{ success: boolean; mode: string; message?: string }>('/stream/mode', {
method: 'POST',
body: JSON.stringify({ mode }),
}),
getCodecs: () =>
request<AvailableCodecsResponse>('/stream/codecs'),
setBitrate: (bitrate_kbps: number) =>
request<{ success: boolean; message?: string }>('/stream/bitrate', {
method: 'POST',
body: JSON.stringify({ bitrate_kbps }),
}),
}
// WebRTC API
export interface IceCandidate {
candidate: string
sdpMid?: string
sdpMLineIndex?: number
usernameFragment?: string
}
export interface IceServerConfig {
urls: string[]
username?: string
credential?: string
}
export const webrtcApi = {
createSession: () =>
request<{ session_id: string }>('/webrtc/session', { method: 'POST' }),
offer: (sdp: string, clientId?: string) =>
request<{ sdp: string; session_id: string; ice_candidates: IceCandidate[] }>('/webrtc/offer', {
method: 'POST',
body: JSON.stringify({ sdp, client_id: clientId }),
}),
addIceCandidate: (sessionId: string, candidate: IceCandidate) =>
request<{ success: boolean }>('/webrtc/ice', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, candidate }),
}),
status: () =>
request<{
session_count: number
sessions: Array<{ session_id: string; state: string }>
}>('/webrtc/status'),
close: (sessionId: string) =>
request<{ success: boolean }>('/webrtc/close', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
}),
getIceServers: () =>
request<{ ice_servers: IceServerConfig[] }>('/webrtc/ice-servers'),
}
// HID API
// Import HID WebSocket composable
import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket'
// Create shared HID WebSocket instance
const hidWs = useHidWebSocket()
let hidWsInitialized = false
// Initialize HID WebSocket connection
async function ensureHidConnection() {
if (!hidWsInitialized) {
hidWsInitialized = true
await hidWs.connect()
}
}
// Map button string to number
function mapButton(button?: 'left' | 'right' | 'middle'): number | undefined {
if (!button) return undefined
const buttonMap = { left: 0, middle: 1, right: 2 }
return buttonMap[button]
}
export const hidApi = {
status: () =>
request<{
available: boolean
backend: string
initialized: boolean
supports_absolute_mouse: boolean
screen_resolution: [number, number] | null
}>('/hid/status'),
keyboard: async (type: 'down' | 'up', key: number, modifiers?: {
ctrl?: boolean
shift?: boolean
alt?: boolean
meta?: boolean
}) => {
await ensureHidConnection()
const event: HidKeyboardEvent = {
type: type === 'down' ? 'keydown' : 'keyup',
key,
modifiers,
}
await hidWs.sendKeyboard(event)
return { success: true }
},
mouse: async (data: {
type: 'move' | 'move_abs' | 'down' | 'up' | 'scroll'
x?: number | null
y?: number | null
button?: 'left' | 'right' | 'middle' | null
scroll?: number | null
}) => {
await ensureHidConnection()
// Ensure all values are properly typed (convert null to undefined)
const event: HidMouseEvent = {
type: data.type === 'move_abs' ? 'moveabs' : data.type,
x: data.x ?? undefined,
y: data.y ?? undefined,
button: mapButton(data.button ?? undefined),
scroll: data.scroll ?? undefined,
}
await hidWs.sendMouse(event)
return { success: true }
},
reset: () =>
request<{ success: boolean }>('/hid/reset', { method: 'POST' }),
// WebSocket connection management
connectWebSocket: () => hidWs.connect(),
disconnectWebSocket: () => hidWs.disconnect(),
isWebSocketConnected: () => hidWs.connected.value,
}
// ATX API
export const atxApi = {
status: () =>
request<{
available: boolean
backend: string
initialized: boolean
power_status: 'on' | 'off' | 'unknown'
led_supported: boolean
}>('/atx/status'),
power: (action: 'short' | 'long' | 'reset') =>
request<{ success: boolean; message?: string }>('/atx/power', {
method: 'POST',
body: JSON.stringify({ action }),
}),
}
// MSD API
export interface MsdImage {
id: string
name: string
size: number
created_at: string
}
export interface DriveFile {
name: string
path: string
is_dir: boolean
size: number
}
export const msdApi = {
status: () =>
request<{
available: boolean
state: {
connected: boolean
mode: 'none' | 'image' | 'drive'
current_image: {
id: string
name: string
size: number
created_at: string
} | null
drive_info: {
size: number
used: number
free: number
initialized: boolean
} | null
}
}>('/msd/status'),
// Image management
listImages: () => request<MsdImage[]>('/msd/images'),
uploadImage: async (file: File, onProgress?: (progress: number) => void) => {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `${API_BASE}/msd/images`)
xhr.withCredentials = true
return new Promise<MsdImage>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress((e.loaded / e.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new ApiError(xhr.status, 'Upload failed'))
}
}
xhr.onerror = () => reject(new ApiError(0, 'Network error'))
xhr.send(formData)
})
},
deleteImage: (id: string) =>
request<{ success: boolean }>(`/msd/images/${id}`, { method: 'DELETE' }),
connect: (mode: 'image' | 'drive', imageId?: string, cdrom?: boolean, readOnly?: boolean) =>
request<{ success: boolean }>('/msd/connect', {
method: 'POST',
body: JSON.stringify({ mode, image_id: imageId, cdrom, read_only: readOnly }),
}),
disconnect: () =>
request<{ success: boolean }>('/msd/disconnect', { method: 'POST' }),
// Virtual drive
driveInfo: () =>
request<{
size: number
used: number
free: number
initialized: boolean
}>('/msd/drive'),
initDrive: (sizeMb?: number) =>
request<{ path: string; size_mb: number }>('/msd/drive/init', {
method: 'POST',
body: JSON.stringify({ size_mb: sizeMb }),
}),
deleteDrive: () =>
request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }),
listDriveFiles: (path = '/') =>
request<DriveFile[]>(`/msd/drive/files?path=${encodeURIComponent(path)}`),
uploadDriveFile: async (file: File, targetPath = '/', onProgress?: (progress: number) => void) => {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `${API_BASE}/msd/drive/files?path=${encodeURIComponent(targetPath)}`)
xhr.withCredentials = true
return new Promise<{ success: boolean; message?: string }>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress((e.loaded / e.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new ApiError(xhr.status, 'Upload failed'))
}
}
xhr.onerror = () => reject(new ApiError(0, 'Network error'))
xhr.send(formData)
})
},
downloadDriveFile: (path: string) =>
`${API_BASE}/msd/drive/files${path.startsWith('/') ? path : '/' + path}`,
deleteDriveFile: (path: string) =>
request<{ success: boolean }>(`/msd/drive/files${path.startsWith('/') ? path : '/' + path}`, {
method: 'DELETE',
}),
createDirectory: (path: string) =>
request<{ success: boolean }>(`/msd/drive/mkdir${path.startsWith('/') ? path : '/' + path}`, {
method: 'POST',
}),
// Download from URL
downloadFromUrl: (url: string, filename?: string) =>
request<{
download_id: string
url: string
filename: string
bytes_downloaded: number
total_bytes: number | null
progress_pct: number | null
status: string
error: string | null
}>('/msd/images/download', {
method: 'POST',
body: JSON.stringify({ url, filename }),
}),
cancelDownload: (downloadId: string) =>
request<{ success: boolean }>('/msd/images/download/cancel', {
method: 'POST',
body: JSON.stringify({ download_id: downloadId }),
}),
}
// Config API
/** @deprecated 使用域特定 APIvideoConfigApi, hidConfigApi 等)替代 */
export const configApi = {
get: () => request<Record<string, unknown>>('/config'),
/** @deprecated 使用域特定 API 的 update 方法替代 */
update: (updates: Record<string, unknown>) =>
request<{ success: boolean }>('/config', {
method: 'POST',
body: JSON.stringify(updates),
}),
listDevices: () =>
request<{
video: Array<{
path: string
name: string
driver: string
formats: Array<{
format: string
description: string
resolutions: Array<{
width: number
height: number
fps: number[]
}>
}>
}>
serial: Array<{ path: string; name: string }>
audio: Array<{ name: string; description: string }>
udc: Array<{ name: string }>
}>('/devices'),
}
// 导出新的域分离配置 API
export {
videoConfigApi,
streamConfigApi,
hidConfigApi,
msdConfigApi,
atxConfigApi,
audioConfigApi,
extensionsApi,
} from './config'
// 导出生成的类型
export type {
AppConfig,
VideoConfig,
VideoConfigUpdate,
StreamConfig,
StreamConfigUpdate,
HidConfig,
HidConfigUpdate,
MsdConfig,
MsdConfigUpdate,
AtxConfig,
AtxConfigUpdate,
AudioConfig,
AudioConfigUpdate,
HidBackend,
StreamMode,
EncoderType,
} from '@/types/generated'
// Audio API
export const audioApi = {
status: () =>
request<{
enabled: boolean
streaming: boolean
device: string | null
sample_rate: number
channels: number
quality: string
subscriber_count: number
frames_encoded: number
bytes_output: number
error: string | null
}>('/audio/status'),
start: () =>
request<{ success: boolean }>('/audio/start', { method: 'POST' }),
stop: () =>
request<{ success: boolean }>('/audio/stop', { method: 'POST' }),
setQuality: (quality: 'voice' | 'balanced' | 'high') =>
request<{ success: boolean }>('/audio/quality', {
method: 'POST',
body: JSON.stringify({ quality }),
}),
selectDevice: (device: string) =>
request<{ success: boolean }>('/audio/device', {
method: 'POST',
body: JSON.stringify({ device }),
}),
}
// User Management API
export interface User {
id: string
username: string
role: 'admin' | 'user'
created_at: string
}
interface UserApiResponse {
id: string
username: string
is_admin: boolean
created_at: string
}
export const userApi = {
list: async () => {
const rawUsers = await request<UserApiResponse[]>('/users')
const users: User[] = rawUsers.map(u => ({
id: u.id,
username: u.username,
role: u.is_admin ? 'admin' : 'user',
created_at: u.created_at,
}))
return { success: true, users }
},
create: (username: string, password: string, role: 'admin' | 'user' = 'user') =>
request<UserApiResponse>('/users', {
method: 'POST',
body: JSON.stringify({ username, password, is_admin: role === 'admin' }),
}),
update: (id: string, data: { username?: string; role?: 'admin' | 'user' }) =>
request<{ success: boolean }>(`/users/${id}`, {
method: 'PUT',
body: JSON.stringify({ username: data.username, is_admin: data.role === 'admin' }),
}),
delete: (id: string) =>
request<{ success: boolean }>(`/users/${id}`, { method: 'DELETE' }),
changePassword: (id: string, newPassword: string, currentPassword?: string) =>
request<{ success: boolean }>(`/users/${id}/password`, {
method: 'POST',
body: JSON.stringify({ new_password: newPassword, current_password: currentPassword }),
}),
}
export { ApiError }

1
web/src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,306 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
ClipboardPaste,
HardDrive,
Keyboard,
Cable,
Settings,
Maximize,
Power,
BarChart3,
Terminal,
MoreHorizontal,
} from 'lucide-vue-next'
import PasteModal from '@/components/PasteModal.vue'
import AtxPopover from '@/components/AtxPopover.vue'
import VideoConfigPopover, { type VideoMode } from '@/components/VideoConfigPopover.vue'
import HidConfigPopover from '@/components/HidConfigPopover.vue'
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
import MsdDialog from '@/components/MsdDialog.vue'
const { t } = useI18n()
const router = useRouter()
// Overflow menu state
const overflowMenuOpen = ref(false)
const props = defineProps<{
mouseMode?: 'absolute' | 'relative'
videoMode?: VideoMode
ttydRunning?: boolean
isAdmin?: boolean
}>()
const emit = defineEmits<{
(e: 'toggleFullscreen'): void
(e: 'toggleStats'): void
(e: 'toggleVirtualKeyboard'): void
(e: 'toggleMouseMode'): void
(e: 'update:videoMode', mode: VideoMode): void
(e: 'powerShort'): void
(e: 'powerLong'): void
(e: 'reset'): void
(e: 'wol', macAddress: string): void
(e: 'openTerminal'): void
}>()
const pasteOpen = ref(false)
const atxOpen = ref(false)
const videoPopoverOpen = ref(false)
const hidPopoverOpen = ref(false)
const audioPopoverOpen = ref(false)
const msdDialogOpen = ref(false)
const extensionOpen = ref(false)
</script>
<template>
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="flex items-center justify-between gap-2 px-4 py-1.5">
<!-- Left side buttons -->
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<!-- Video Config - Always visible -->
<VideoConfigPopover
v-model:open="videoPopoverOpen"
:video-mode="props.videoMode || 'mjpeg'"
:is-admin="props.isAdmin"
@update:video-mode="emit('update:videoMode', $event)"
/>
<!-- Audio Config - Always visible -->
<AudioConfigPopover v-model:open="audioPopoverOpen" :is-admin="props.isAdmin" />
<!-- HID Config - Always visible -->
<HidConfigPopover
v-model:open="hidPopoverOpen"
:mouse-mode="mouseMode"
:is-admin="props.isAdmin"
@update:mouse-mode="emit('toggleMouseMode')"
/>
<!-- Virtual Media (MSD) - Hidden on small screens, shown in overflow -->
<TooltipProvider v-if="props.isAdmin" class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
<HardDrive class="h-4 w-4" />
<span class="hidden md:inline">{{ t('actionbar.virtualMedia') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.virtualMediaTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- ATX Power Control - Hidden on small screens -->
<Popover v-if="props.isAdmin" v-model:open="atxOpen" class="hidden sm:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Power class="h-4 w-4" />
<span class="hidden md:inline">{{ t('actionbar.power') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[280px] p-0" align="start">
<AtxPopover
@close="atxOpen = false"
@power-short="emit('powerShort')"
@power-long="emit('powerLong')"
@reset="emit('reset')"
@wol="(mac) => emit('wol', mac)"
/>
</PopoverContent>
</Popover>
<!-- Paste Text - Hidden on small screens -->
<Popover v-model:open="pasteOpen" class="hidden md:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<ClipboardPaste class="h-4 w-4" />
<span class="hidden lg:inline">{{ t('actionbar.paste') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[400px] p-0" align="start">
<PasteModal @close="pasteOpen = false" />
</PopoverContent>
</Popover>
</div>
<!-- Right side buttons -->
<div class="flex items-center gap-1.5 shrink-0">
<!-- Extension Menu - Admin only, hidden on small screens -->
<Popover v-if="props.isAdmin" v-model:open="extensionOpen" class="hidden lg:block">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Cable class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.extension') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-48 p-1" align="start">
<div class="space-y-0.5">
<Button
variant="ghost"
size="sm"
class="w-full justify-start gap-2 h-8"
:disabled="!props.ttydRunning"
@click="extensionOpen = false; emit('openTerminal')"
>
<Terminal class="h-4 w-4" />
{{ t('extensions.ttyd.title') }}
</Button>
</div>
</PopoverContent>
</Popover>
<!-- Settings - Admin only, hidden on small screens -->
<TooltipProvider v-if="props.isAdmin" class="hidden lg:block">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
<Settings class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.settings') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.settingsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Connection Stats - Hidden on very small screens -->
<TooltipProvider class="hidden sm:block">
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
<BarChart3 class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.stats') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.statsTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden sm:block" />
<!-- Virtual Keyboard - Always visible (important for mobile) -->
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs"
@click="emit('toggleVirtualKeyboard')"
>
<Keyboard class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.keyboard') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.keyboardTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Fullscreen - Always visible -->
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-8 gap-1.5 text-xs"
@click="emit('toggleFullscreen')"
>
<Maximize class="h-4 w-4" />
<span class="hidden xl:inline">{{ t('actionbar.fullscreen') }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('actionbar.fullscreenTip') }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<!-- Overflow Menu - Shows hidden items on small screens -->
<DropdownMenu v-model:open="overflowMenuOpen">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 lg:hidden">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-48">
<!-- MSD - Mobile only -->
<DropdownMenuItem v-if="props.isAdmin" class="sm:hidden" @click="msdDialogOpen = true; overflowMenuOpen = false">
<HardDrive class="h-4 w-4 mr-2" />
{{ t('actionbar.virtualMedia') }}
</DropdownMenuItem>
<!-- ATX - Mobile only -->
<DropdownMenuItem v-if="props.isAdmin" class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
<Power class="h-4 w-4 mr-2" />
{{ t('actionbar.power') }}
</DropdownMenuItem>
<!-- Paste - Tablet and below -->
<DropdownMenuItem class="md:hidden" @click="pasteOpen = true; overflowMenuOpen = false">
<ClipboardPaste class="h-4 w-4 mr-2" />
{{ t('actionbar.paste') }}
</DropdownMenuItem>
<DropdownMenuSeparator class="lg:hidden" />
<!-- Stats - Mobile only -->
<DropdownMenuItem class="sm:hidden" @click="emit('toggleStats'); overflowMenuOpen = false">
<BarChart3 class="h-4 w-4 mr-2" />
{{ t('actionbar.stats') }}
</DropdownMenuItem>
<!-- Extension - Tablet and below -->
<DropdownMenuItem
v-if="props.isAdmin"
class="lg:hidden"
:disabled="!props.ttydRunning"
@click="emit('openTerminal'); overflowMenuOpen = false"
>
<Terminal class="h-4 w-4 mr-2" />
{{ t('extensions.ttyd.title') }}
</DropdownMenuItem>
<!-- Settings - Tablet and below -->
<DropdownMenuItem v-if="props.isAdmin" class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
<Settings class="h-4 w-4 mr-2" />
{{ t('actionbar.settings') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<!-- MSD Dialog -->
<MsdDialog v-model:open="msdDialogOpen" />
</template>

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useSystemStore } from '@/stores/system'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Monitor,
Settings,
LogOut,
Sun,
Moon,
Languages,
Menu,
} from 'lucide-vue-next'
import { setLanguage } from '@/i18n'
const { t, locale } = useI18n()
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const systemStore = useSystemStore()
const navItems = computed(() => [
{ path: '/', name: 'Console', icon: Monitor, label: t('nav.console') },
{ path: '/settings', name: 'Settings', icon: Settings, label: t('nav.settings') },
])
function toggleTheme() {
const isDark = document.documentElement.classList.contains('dark')
document.documentElement.classList.toggle('dark', !isDark)
localStorage.setItem('theme', isDark ? 'light' : 'dark')
}
function toggleLanguage() {
const newLang = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
setLanguage(newLang)
}
async function handleLogout() {
await authStore.logout()
router.push('/login')
}
</script>
<template>
<div class="min-h-screen bg-background">
<!-- Header -->
<header class="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="flex h-14 items-center px-4 max-w-full">
<!-- Logo -->
<RouterLink to="/" class="flex items-center gap-2 font-semibold">
<Monitor class="h-5 w-5" />
<span class="hidden sm:inline">One-KVM</span>
</RouterLink>
<!-- Navigation -->
<nav class="hidden md:flex items-center gap-1 ml-6">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors"
:class="route.path === item.path
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'"
>
<component :is="item.icon" class="h-4 w-4" />
{{ item.label }}
</RouterLink>
</nav>
<!-- Right Side -->
<div class="flex items-center gap-2 ml-auto">
<!-- Version Badge -->
<span v-if="systemStore.version" class="hidden sm:inline text-xs text-muted-foreground">
v{{ systemStore.version }}
</span>
<!-- Theme Toggle -->
<Button variant="ghost" size="icon" @click="toggleTheme">
<Sun class="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon class="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">{{ t('common.toggleTheme') }}</span>
</Button>
<!-- Language Toggle -->
<Button variant="ghost" size="icon" @click="toggleLanguage">
<Languages class="h-4 w-4" />
<span class="sr-only">{{ t('common.toggleLanguage') }}</span>
</Button>
<!-- Mobile Menu -->
<DropdownMenu>
<DropdownMenuTrigger as-child class="md:hidden">
<Button variant="ghost" size="icon">
<Menu class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem v-for="item in navItems" :key="item.path" @click="router.push(item.path)">
<component :is="item.icon" class="h-4 w-4 mr-2" />
{{ item.label }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleLogout">
<LogOut class="h-4 w-4 mr-2" />
{{ t('nav.logout') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- Logout Button (Desktop) -->
<Button variant="ghost" size="icon" class="hidden md:flex" @click="handleLogout">
<LogOut class="h-4 w-4" />
<span class="sr-only">{{ t('nav.logout') }}</span>
</Button>
</div>
</div>
</header>
<!-- Main Content -->
<main class="px-4 py-6 max-w-full">
<slot />
</main>
</div>
</template>

View File

@@ -0,0 +1,262 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Power, RotateCcw, CircleDot, Wifi, Send } from 'lucide-vue-next'
const emit = defineEmits<{
(e: 'close'): void
(e: 'powerShort'): void
(e: 'powerLong'): void
(e: 'reset'): void
(e: 'wol', macAddress: string): void
}>()
const { t } = useI18n()
const activeTab = ref('atx')
// ATX state
const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
const confirmAction = ref<'short' | 'long' | 'reset' | null>(null)
// WOL state
const wolMacAddress = ref('')
const wolHistory = ref<string[]>([])
const wolSending = ref(false)
const powerStateColor = computed(() => {
switch (powerState.value) {
case 'on': return 'bg-green-500'
case 'off': return 'bg-slate-400'
default: return 'bg-yellow-500'
}
})
const powerStateText = computed(() => {
switch (powerState.value) {
case 'on': return t('atx.stateOn')
case 'off': return t('atx.stateOff')
default: return t('atx.stateUnknown')
}
})
function handleAction() {
if (confirmAction.value === 'short') emit('powerShort')
else if (confirmAction.value === 'long') emit('powerLong')
else if (confirmAction.value === 'reset') emit('reset')
confirmAction.value = null
}
const confirmTitle = computed(() => {
switch (confirmAction.value) {
case 'short': return t('atx.confirmShortTitle')
case 'long': return t('atx.confirmLongTitle')
case 'reset': return t('atx.confirmResetTitle')
default: return ''
}
})
const confirmDescription = computed(() => {
switch (confirmAction.value) {
case 'short': return t('atx.confirmShortDesc')
case 'long': return t('atx.confirmLongDesc')
case 'reset': return t('atx.confirmResetDesc')
default: return ''
}
})
// MAC address validation
const isValidMac = computed(() => {
const mac = wolMacAddress.value.trim()
// Support formats: AA:BB:CC:DD:EE:FF or AA-BB-CC-DD-EE-FF or AABBCCDDEEFF
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$|^([0-9A-Fa-f]{12})$/
return macRegex.test(mac)
})
function sendWol() {
if (!isValidMac.value) return
wolSending.value = true
// Normalize MAC address
let mac = wolMacAddress.value.trim().toUpperCase()
if (mac.length === 12) {
mac = mac.match(/.{2}/g)!.join(':')
} else {
mac = mac.replace(/-/g, ':')
}
emit('wol', mac)
// Add to history if not exists
if (!wolHistory.value.includes(mac)) {
wolHistory.value.unshift(mac)
// Keep only last 5
if (wolHistory.value.length > 5) {
wolHistory.value.pop()
}
// Save to localStorage
localStorage.setItem('wol_history', JSON.stringify(wolHistory.value))
}
setTimeout(() => {
wolSending.value = false
}, 1000)
}
function selectFromHistory(mac: string) {
wolMacAddress.value = mac
}
// Load WOL history on mount
const savedHistory = localStorage.getItem('wol_history')
if (savedHistory) {
try {
wolHistory.value = JSON.parse(savedHistory)
} catch (e) {
wolHistory.value = []
}
}
</script>
<template>
<div class="p-3 space-y-3">
<Tabs v-model="activeTab">
<TabsList class="w-full grid grid-cols-2">
<TabsTrigger value="atx" class="text-xs">
<Power class="h-3.5 w-3.5 mr-1" />
{{ t('atx.title') }}
</TabsTrigger>
<TabsTrigger value="wol" class="text-xs">
<Wifi class="h-3.5 w-3.5 mr-1" />
WOL
</TabsTrigger>
</TabsList>
<!-- ATX Tab -->
<TabsContent value="atx" class="mt-3 space-y-3">
<p class="text-xs text-muted-foreground">{{ t('atx.description') }}</p>
<!-- Power State -->
<div class="flex items-center justify-between p-2 rounded-md bg-muted/50">
<span class="text-xs text-muted-foreground">{{ t('atx.powerState') }}</span>
<Badge variant="outline" class="gap-1.5 text-xs">
<span :class="['h-2 w-2 rounded-full', powerStateColor]" />
{{ powerStateText }}
</Badge>
</div>
<Separator />
<!-- Power Actions -->
<div class="space-y-1.5">
<Button
variant="outline"
size="sm"
class="w-full justify-start gap-2 h-8 text-xs"
@click="confirmAction = 'short'"
>
<Power class="h-3.5 w-3.5" />
{{ t('atx.shortPress') }}
</Button>
<Button
variant="outline"
size="sm"
class="w-full justify-start gap-2 h-8 text-xs text-orange-600 hover:text-orange-700 hover:bg-orange-50 dark:hover:bg-orange-950"
@click="confirmAction = 'long'"
>
<CircleDot class="h-3.5 w-3.5" />
{{ t('atx.longPress') }}
</Button>
<Button
variant="outline"
size="sm"
class="w-full justify-start gap-2 h-8 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
@click="confirmAction = 'reset'"
>
<RotateCcw class="h-3.5 w-3.5" />
{{ t('atx.reset') }}
</Button>
</div>
</TabsContent>
<!-- WOL Tab -->
<TabsContent value="wol" class="mt-3 space-y-3">
<p class="text-xs text-muted-foreground">
{{ t('atx.wolDescription') }}
</p>
<div class="space-y-2">
<Label for="mac-address" class="text-xs">{{ t('atx.macAddress') }}</Label>
<div class="flex gap-2">
<Input
id="mac-address"
v-model="wolMacAddress"
placeholder="AA:BB:CC:DD:EE:FF"
class="h-8 text-xs font-mono"
@keyup.enter="sendWol"
/>
<Button
size="sm"
class="h-8 px-3"
:disabled="!isValidMac || wolSending"
@click="sendWol"
>
<Send class="h-3.5 w-3.5" />
</Button>
</div>
<p v-if="wolMacAddress && !isValidMac" class="text-xs text-destructive">
{{ t('atx.invalidMac') }}
</p>
</div>
<!-- History -->
<div v-if="wolHistory.length > 0" class="space-y-2">
<Separator />
<Label class="text-xs text-muted-foreground">{{ t('atx.recentMac') }}</Label>
<div class="space-y-1">
<button
v-for="mac in wolHistory"
:key="mac"
class="w-full text-left px-2 py-1.5 rounded text-xs font-mono hover:bg-muted transition-colors"
@click="selectFromHistory(mac)"
>
{{ mac }}
</button>
</div>
</div>
</TabsContent>
</Tabs>
</div>
<!-- Confirm Dialog -->
<AlertDialog :open="!!confirmAction" @update:open="confirmAction = null">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ confirmTitle }}</AlertDialogTitle>
<AlertDialogDescription>{{ confirmDescription }}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('common.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleAction">{{ t('common.confirm') }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>

View File

@@ -0,0 +1,318 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Slider } from '@/components/ui/slider'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Volume2, RefreshCw, Loader2 } from 'lucide-vue-next'
import { audioApi, configApi } from '@/api'
import { useSystemStore } from '@/stores/system'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
interface AudioDevice {
name: string
description: string
}
const props = defineProps<{
open: boolean
isAdmin?: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const { t } = useI18n()
const systemStore = useSystemStore()
const unifiedAudio = getUnifiedAudio()
// === Playback Control (immediate effect) ===
const localVolume = ref([unifiedAudio.volume.value * 100])
// Volume change - immediate effect, also triggers connection if needed
async function handleVolumeChange(value: number[] | undefined) {
if (!value || value.length === 0 || value[0] === undefined) return
const newVolume = value[0] / 100
unifiedAudio.setVolume(newVolume)
localVolume.value = value
// If backend is streaming but audio not connected, connect now (user gesture)
if (newVolume > 0 && systemStore.audio?.streaming && !unifiedAudio.connected.value) {
console.log('[Audio] User adjusted volume, connecting unified audio')
try {
await unifiedAudio.connect()
} catch (e) {
console.info('[Audio] Connect failed:', e)
}
}
}
// === Device Settings (requires apply) ===
const devices = ref<AudioDevice[]>([])
const loadingDevices = ref(false)
const applying = ref(false)
// Config values
const audioEnabled = ref(false)
const selectedDevice = ref('')
const selectedQuality = ref<'voice' | 'balanced' | 'high'>('balanced')
// Load device list
async function loadDevices() {
loadingDevices.value = true
try {
const result = await configApi.listDevices()
devices.value = result.audio
} catch (e) {
console.info('[AudioConfig] Failed to load devices')
} finally {
loadingDevices.value = false
}
}
// Initialize from current config
function initializeFromCurrent() {
const audio = systemStore.audio
if (audio) {
audioEnabled.value = audio.available && audio.streaming
selectedDevice.value = audio.device || ''
selectedQuality.value = (audio.quality as 'voice' | 'balanced' | 'high') || 'balanced'
}
// Sync playback control state
localVolume.value = [unifiedAudio.volume.value * 100]
}
// Apply device configuration
async function applyConfig() {
applying.value = true
try {
// Update config
await configApi.update({
audio: {
enabled: audioEnabled.value,
device: selectedDevice.value,
quality: selectedQuality.value,
},
})
// If enabled and device is selected, try to start audio stream
if (audioEnabled.value && selectedDevice.value) {
try {
// Restore default volume BEFORE starting audio
// This ensures handleAudioStateChanged sees the correct volume
if (localVolume.value[0] === 0) {
localVolume.value = [100]
unifiedAudio.setVolume(1)
}
await audioApi.start()
// Note: handleAudioStateChanged in ConsoleView will handle the connection
// when it receives the audio.state_changed event with streaming=true
} catch (startError) {
// Audio start failed - config was saved but streaming not started
console.info('[AudioConfig] Audio start failed:', startError)
}
} else if (!audioEnabled.value) {
// Reset volume to 0 when disabling audio
localVolume.value = [0]
unifiedAudio.setVolume(0)
try {
await audioApi.stop()
} catch {
// Ignore stop errors
}
unifiedAudio.disconnect()
}
toast.success(t('config.applied'))
} catch (e) {
console.info('[AudioConfig] Failed to apply config:', e)
} finally {
applying.value = false
}
}
// Watch popover open state
watch(() => props.open, (isOpen) => {
if (isOpen) {
if (devices.value.length === 0) {
loadDevices()
}
initializeFromCurrent()
}
})
</script>
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Volume2 class="h-4 w-4" />
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[320px] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
<Separator />
<!-- Playback Control (immediate effect) -->
<div class="space-y-3">
<h5 class="text-xs font-medium text-muted-foreground">
{{ t('actionbar.playbackControl') }}
</h5>
<!-- Volume -->
<div class="space-y-2">
<div class="flex justify-between items-center">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.volume') }}</Label>
<span class="text-xs font-mono">{{ Math.round(localVolume[0] ?? 0) }}%</span>
</div>
<div class="flex items-center gap-2">
<Volume2 class="h-3.5 w-3.5 text-muted-foreground opacity-50" />
<Slider
:model-value="localVolume"
@update:model-value="handleVolumeChange"
:min="0"
:max="100"
:step="1"
:disabled="!systemStore.audio?.streaming"
class="flex-1"
/>
<Volume2 class="h-3.5 w-3.5 text-muted-foreground" />
</div>
</div>
</div>
<!-- Device Settings (requires apply) - Admin only -->
<template v-if="props.isAdmin">
<Separator />
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">
{{ t('actionbar.audioDeviceSettings') }}
</h5>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:disabled="loadingDevices"
@click="loadDevices"
>
<RefreshCw :class="['h-3.5 w-3.5', loadingDevices && 'animate-spin']" />
</Button>
</div>
<!-- Enable Audio -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioEnabled') }}</Label>
<div class="flex gap-2">
<Button
:variant="audioEnabled ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="audioEnabled = true"
>
{{ t('common.enabled') }}
</Button>
<Button
:variant="!audioEnabled ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="audioEnabled = false"
>
{{ t('common.disabled') }}
</Button>
</div>
</div>
<!-- Device Selection -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioDevice') }}</Label>
<Select
:model-value="selectedDevice"
@update:model-value="(v) => selectedDevice = v as string"
:disabled="loadingDevices || devices.length === 0"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('actionbar.selectAudioDevice')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="device in devices"
:key="device.name"
:value="device.name"
class="text-xs"
>
{{ device.description || device.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Audio Quality -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioQuality') }}</Label>
<div class="flex gap-1">
<Button
:variant="selectedQuality === 'voice' ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="selectedQuality = 'voice'"
>
{{ t('actionbar.qualityVoice') }} 32k
</Button>
<Button
:variant="selectedQuality === 'balanced' ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="selectedQuality = 'balanced'"
>
{{ t('actionbar.qualityBalanced') }} 64k
</Button>
<Button
:variant="selectedQuality === 'high' ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="selectedQuality = 'high'"
>
{{ t('actionbar.qualityHigh') }} 128k
</Button>
</div>
</div>
<!-- Apply Button -->
<Button
class="w-full h-8 text-xs"
:disabled="applying"
@click="applyConfig"
>
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
</template>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { HelpCircle } from 'lucide-vue-next'
defineProps<{
content: string
side?: 'top' | 'right' | 'bottom' | 'left'
align?: 'start' | 'center' | 'end'
iconSize?: 'sm' | 'md'
}>()
</script>
<template>
<TooltipProvider :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="inline-flex items-center justify-center text-muted-foreground/60 hover:text-muted-foreground transition-colors focus:outline-none"
:class="iconSize === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'"
>
<HelpCircle :class="iconSize === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'" />
</button>
</TooltipTrigger>
<TooltipContent
:side="side || 'top'"
:align="align || 'center'"
class="max-w-[280px] text-xs leading-relaxed"
>
<p>{{ content }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>

View File

@@ -0,0 +1,386 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi } from '@/api'
import { useSystemStore } from '@/stores/system'
import { setMouseThrottle } from '@/composables/useHidWebSocket'
const props = defineProps<{
open: boolean
mouseMode?: 'absolute' | 'relative'
isAdmin?: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'update:mouseMode', value: 'absolute' | 'relative'): void
}>()
const { t } = useI18n()
const systemStore = useSystemStore()
// Mouse Settings (real-time)
const mouseThrottle = ref<number>(
Number(localStorage.getItem('hidMouseThrottle')) || 0
)
const showCursor = ref<boolean>(
localStorage.getItem('hidShowCursor') !== 'false' // default true
)
// Watch showCursor changes and sync to localStorage + notify ConsoleView
watch(showCursor, (newValue, oldValue) => {
// Only sync if value actually changed (avoid triggering on initialization)
if (newValue !== oldValue) {
localStorage.setItem('hidShowCursor', newValue ? 'true' : 'false')
window.dispatchEvent(new CustomEvent('hidCursorVisibilityChanged', {
detail: { visible: newValue }
}))
}
})
// HID Device Settings (requires apply)
const hidBackend = ref<'otg' | 'ch9329' | 'none'>('none')
const devicePath = ref<string>('')
const baudrate = ref<number>(9600)
// UI state
const applying = ref(false)
const loadingDevices = ref(false)
// Device lists
const serialDevices = ref<Array<{ path: string; name: string }>>([])
const udcDevices = ref<Array<{ name: string }>>([])
// Button display text - simplified to just show label
const buttonText = computed(() => t('actionbar.hidConfig'))
// Available device paths based on backend type
const availableDevicePaths = computed(() => {
if (hidBackend.value === 'ch9329') {
return serialDevices.value
} else if (hidBackend.value === 'otg') {
// For OTG, we show UDC devices
return udcDevices.value.map(udc => ({
path: udc.name,
name: udc.name,
}))
}
return []
})
// Load devices
async function loadDevices() {
loadingDevices.value = true
try {
const result = await configApi.listDevices()
serialDevices.value = result.serial
udcDevices.value = result.udc
} catch (e) {
console.info('[HidConfig] Failed to load devices')
} finally {
loadingDevices.value = false
}
}
// Initialize from current config
function initializeFromCurrent() {
// Re-sync real-time settings from localStorage
const storedThrottle = Number(localStorage.getItem('hidMouseThrottle')) || 0
mouseThrottle.value = storedThrottle
setMouseThrottle(storedThrottle)
const storedCursor = localStorage.getItem('hidShowCursor') !== 'false'
showCursor.value = storedCursor
// Initialize HID device settings from system state
const hid = systemStore.hid
if (hid) {
hidBackend.value = (hid.backend as 'otg' | 'ch9329' | 'none') || 'none'
}
}
// Toggle mouse mode (real-time)
function toggleMouseMode() {
const newMode = props.mouseMode === 'absolute' ? 'relative' : 'absolute'
emit('update:mouseMode', newMode)
// Update backend config
configApi.update({
hid: {
mouse_absolute: newMode === 'absolute',
},
}).catch(_e => {
console.info('[HidConfig] Failed to update mouse mode')
toast.error(t('config.updateFailed'))
})
}
// Update mouse throttle (real-time)
function handleThrottleChange(value: number[] | undefined) {
if (!value || value.length === 0 || value[0] === undefined) return
const throttleValue = value[0]
mouseThrottle.value = throttleValue
setMouseThrottle(throttleValue)
// Save to localStorage
localStorage.setItem('hidMouseThrottle', String(throttleValue))
}
// Handle backend change
function handleBackendChange(backend: unknown) {
if (typeof backend !== 'string') return
hidBackend.value = backend as 'otg' | 'ch9329' | 'none'
// Clear device path when changing backend
devicePath.value = ''
// Auto-select first device if available
if (availableDevicePaths.value.length > 0 && availableDevicePaths.value[0]) {
devicePath.value = availableDevicePaths.value[0].path
}
}
// Handle device path change
function handleDevicePathChange(path: unknown) {
if (typeof path !== 'string') return
devicePath.value = path
}
// Handle baudrate change
function handleBaudrateChange(rate: unknown) {
if (typeof rate !== 'string') return
baudrate.value = Number(rate)
}
// Apply HID device configuration
async function applyHidConfig() {
applying.value = true
try {
const config: Record<string, unknown> = {
backend: hidBackend.value,
}
if (hidBackend.value === 'ch9329') {
config.ch9329_port = devicePath.value
config.ch9329_baudrate = baudrate.value
} else if (hidBackend.value === 'otg') {
config.otg_udc = devicePath.value
}
await configApi.update({ hid: config })
toast.success(t('config.applied'))
// HID state will be updated via WebSocket device_info event
} catch (e) {
console.info('[HidConfig] Failed to apply config:', e)
// Error toast already shown by API layer
} finally {
applying.value = false
}
}
// Watch open state
watch(() => props.open, (isOpen) => {
if (isOpen) {
// Load devices on first open
if (serialDevices.value.length === 0) {
loadDevices()
}
// Initialize from current config
initializeFromCurrent()
}
})
</script>
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<MousePointer v-if="mouseMode === 'absolute'" class="h-4 w-4" />
<Move v-else class="h-4 w-4" />
<span class="hidden sm:inline">{{ buttonText }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[320px] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>
<Separator />
<!-- Mouse Settings (Real-time) -->
<div class="space-y-3">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.mouseSettings') }}</h5>
<!-- Positioning Mode -->
<div class="space-y-2">
<div class="flex items-center gap-1">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.positioningMode') }}</Label>
<HelpTooltip :content="mouseMode === 'absolute' ? t('help.absoluteMode') : t('help.relativeMode')" icon-size="sm" />
</div>
<div class="flex gap-2">
<Button
:variant="mouseMode === 'absolute' ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="toggleMouseMode"
>
<MousePointer class="h-3.5 w-3.5 mr-1" />
{{ t('actionbar.absolute') }}
</Button>
<Button
:variant="mouseMode === 'relative' ? 'default' : 'outline'"
size="sm"
class="flex-1 h-8 text-xs"
@click="toggleMouseMode"
>
<Move class="h-3.5 w-3.5 mr-1" />
{{ t('actionbar.relative') }}
</Button>
</div>
</div>
<!-- Event Throttle -->
<div class="space-y-2">
<div class="flex justify-between items-center">
<div class="flex items-center gap-1">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.sendInterval') }}</Label>
<HelpTooltip :content="t('help.mouseThrottle')" icon-size="sm" />
</div>
<span class="text-xs font-mono">{{ mouseThrottle }}ms</span>
</div>
<Slider
:model-value="[mouseThrottle]"
@update:model-value="handleThrottleChange"
:min="0"
:max="1000"
:step="10"
class="py-2"
/>
<div class="flex justify-between text-xs text-muted-foreground">
<span>0ms</span>
<span>1000ms</span>
</div>
</div>
<!-- Show Cursor -->
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.showCursor') }}</Label>
<Switch v-model="showCursor" />
</div>
</div>
<!-- HID Device Settings (Requires Apply) - Admin only -->
<template v-if="props.isAdmin">
<Separator />
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.hidDeviceSettings') }}</h5>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:disabled="loadingDevices"
@click="loadDevices"
>
<RefreshCw :class="['h-3.5 w-3.5', loadingDevices && 'animate-spin']" />
</Button>
</div>
<!-- Backend Type -->
<div class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.backend') }}</Label>
<Select
:model-value="hidBackend"
@update:model-value="handleBackendChange"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="otg" class="text-xs">USB OTG</SelectItem>
<SelectItem value="ch9329" class="text-xs">CH9329 (Serial)</SelectItem>
<SelectItem value="none" class="text-xs">{{ t('common.disabled') }}</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Device Path (OTG or CH9329) -->
<div v-if="hidBackend !== 'none'" class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.devicePath') }}</Label>
<Select
:model-value="devicePath"
@update:model-value="handleDevicePathChange"
:disabled="availableDevicePaths.length === 0"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('actionbar.selectDevice')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="device in availableDevicePaths"
:key="device.path"
:value="device.path"
class="text-xs"
>
{{ device.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Baudrate (CH9329 only) -->
<div v-if="hidBackend === 'ch9329'" class="space-y-2">
<Label class="text-xs text-muted-foreground">{{ t('actionbar.baudrate') }}</Label>
<Select
:model-value="String(baudrate)"
@update:model-value="handleBaudrateChange"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="9600" class="text-xs">9600</SelectItem>
<SelectItem value="19200" class="text-xs">19200</SelectItem>
<SelectItem value="38400" class="text-xs">38400</SelectItem>
<SelectItem value="57600" class="text-xs">57600</SelectItem>
<SelectItem value="115200" class="text-xs">115200</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Apply Button -->
<Button
class="w-full h-8 text-xs"
:disabled="applying"
@click="applyHidConfig"
>
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
</template>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/lib/utils'
const props = defineProps<{
pressedKeys?: string[]
capsLock?: boolean
numLock?: boolean
scrollLock?: boolean
mousePosition?: { x: number; y: number }
debugMode?: boolean
compact?: boolean
}>()
const { t } = useI18n()
const keysDisplay = computed(() => {
if (!props.pressedKeys || props.pressedKeys.length === 0) return ''
return props.pressedKeys.join(', ')
})
// Has any LED active
const hasActiveLed = computed(() => props.capsLock || props.numLock || props.scrollLock)
</script>
<template>
<div class="w-full border-t border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<!-- Compact mode for small screens -->
<div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5">
<!-- LED indicators only in compact mode -->
<div class="flex items-center gap-1">
<span
v-if="capsLock"
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
>C</span>
<span
v-if="numLock"
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
>N</span>
<span
v-if="scrollLock"
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
>S</span>
<span v-if="!hasActiveLed" class="text-muted-foreground/40 text-[10px]">-</span>
</div>
<!-- Keys in compact mode -->
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]">
{{ keysDisplay }}
</div>
</div>
<!-- Normal mode -->
<div v-else class="flex flex-wrap items-center justify-between text-xs">
<!-- Left side: Debug info and pressed keys -->
<div class="flex items-center gap-4 px-3 py-1 min-w-0 flex-1">
<!-- Pressed Keys -->
<div class="flex items-center gap-1.5 min-w-0">
<span class="font-medium text-muted-foreground shrink-0 hidden sm:inline">{{ t('infobar.keys') }}:</span>
<span class="text-foreground truncate">{{ keysDisplay || '-' }}</span>
</div>
<!-- Debug: Mouse Position -->
<div v-if="debugMode && mousePosition" class="flex items-center gap-1.5 hidden md:flex">
<span class="font-medium text-muted-foreground">{{ t('infobar.pointer') }}:</span>
<span class="text-foreground">{{ mousePosition.x }}, {{ mousePosition.y }}</span>
</div>
</div>
<!-- Right side: Keyboard LED states -->
<div class="flex items-center divide-x divide-slate-200 dark:divide-slate-700 shrink-0">
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
<span class="sm:hidden">C</span>
</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.num') }}</span>
<span class="sm:hidden">N</span>
</div>
<div
:class="cn(
'px-2 py-1 select-none transition-colors',
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
)"
>
<span class="hidden sm:inline">{{ t('infobar.scroll') }}</span>
<span class="sm:hidden">S</span>
</div>
</div>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,590 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSystemStore } from '@/stores/system'
import { msdApi, type MsdImage, type DriveFile } from '@/api'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Progress } from '@/components/ui/progress'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
HardDrive,
Upload,
Trash2,
Link,
Unlink,
Disc,
File,
Folder,
FolderPlus,
Download,
RefreshCw,
ChevronRight,
ArrowLeft,
} from 'lucide-vue-next'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const { t } = useI18n()
const systemStore = useSystemStore()
// Tab state
const activeTab = ref('images')
// Image state
const images = ref<MsdImage[]>([])
const loadingImages = ref(false)
const uploadProgress = ref(0)
const uploading = ref(false)
const cdromMode = ref(true)
const readOnly = ref(true)
// Drive state
const driveFiles = ref<DriveFile[]>([])
const currentPath = ref('/')
const loadingDrive = ref(false)
const driveInfo = ref<{ size: number; used: number; free: number; initialized: boolean } | null>(null)
const driveInitialized = ref(false)
const uploadingFile = ref(false)
const fileUploadProgress = ref(0)
// Dialog state
const showDeleteDialog = ref(false)
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
const showNewFolderDialog = ref(false)
const newFolderName = ref('')
// Computed
const msdConnected = computed(() => systemStore.msd?.connected ?? false)
const msdMode = computed(() => systemStore.msd?.mode ?? 'none')
const breadcrumbs = computed(() => {
const parts = currentPath.value.split('/').filter(Boolean)
const crumbs = [{ name: '/', path: '/' }]
let path = ''
for (const part of parts) {
path += '/' + part
crumbs.push({ name: part, path })
}
return crumbs
})
// Load data when sheet opens
watch(() => props.open, async (isOpen) => {
if (isOpen) {
await loadData()
}
})
async function loadData() {
await systemStore.fetchMsdState()
await loadImages()
await loadDriveInfo()
if (driveInitialized.value) {
await loadDriveFiles()
}
}
// Image functions
async function loadImages() {
loadingImages.value = true
try {
images.value = await msdApi.listImages()
} catch (e) {
console.error('Failed to load images:', e)
} finally {
loadingImages.value = false
}
}
async function handleImageUpload(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploading.value = true
uploadProgress.value = 0
try {
const image = await msdApi.uploadImage(file, (progress) => {
uploadProgress.value = progress
})
images.value.push(image)
} catch (e) {
console.error('Failed to upload image:', e)
} finally {
uploading.value = false
uploadProgress.value = 0
input.value = ''
}
}
async function connectImage(image: MsdImage) {
try {
await msdApi.connect('image', image.id, cdromMode.value, readOnly.value)
await systemStore.fetchMsdState()
} catch (e) {
console.error('Failed to connect image:', e)
}
}
async function disconnect() {
try {
await msdApi.disconnect()
await systemStore.fetchMsdState()
} catch (e) {
console.error('Failed to disconnect:', e)
}
}
function confirmDelete(type: 'image' | 'file', id: string, name: string) {
deleteTarget.value = { type, id, name }
showDeleteDialog.value = true
}
async function executeDelete() {
if (!deleteTarget.value) return
try {
if (deleteTarget.value.type === 'image') {
await msdApi.deleteImage(deleteTarget.value.id)
images.value = images.value.filter(i => i.id !== deleteTarget.value!.id)
} else {
await msdApi.deleteDriveFile(deleteTarget.value.id)
await loadDriveFiles()
}
} catch (e) {
console.error('Failed to delete:', e)
} finally {
showDeleteDialog.value = false
deleteTarget.value = null
}
}
// Drive functions
async function loadDriveInfo() {
try {
driveInfo.value = await msdApi.driveInfo()
driveInitialized.value = true
} catch {
driveInitialized.value = false
}
}
async function initializeDrive() {
try {
await msdApi.initDrive(256)
await loadDriveInfo()
await loadDriveFiles()
} catch (e) {
console.error('Failed to initialize drive:', e)
}
}
async function loadDriveFiles() {
loadingDrive.value = true
try {
driveFiles.value = await msdApi.listDriveFiles(currentPath.value)
} catch (e) {
console.error('Failed to load drive files:', e)
} finally {
loadingDrive.value = false
}
}
function navigateTo(path: string) {
currentPath.value = path
loadDriveFiles()
}
function navigateUp() {
const parts = currentPath.value.split('/').filter(Boolean)
parts.pop()
currentPath.value = '/' + parts.join('/')
loadDriveFiles()
}
async function handleFileUpload(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploadingFile.value = true
fileUploadProgress.value = 0
try {
await msdApi.uploadDriveFile(file, currentPath.value, (progress) => {
fileUploadProgress.value = progress
})
await loadDriveFiles()
} catch (e) {
console.error('Failed to upload file:', e)
} finally {
uploadingFile.value = false
fileUploadProgress.value = 0
input.value = ''
}
}
async function createFolder() {
if (!newFolderName.value.trim()) return
try {
const path = currentPath.value === '/'
? '/' + newFolderName.value
: currentPath.value + '/' + newFolderName.value
await msdApi.createDirectory(path)
await loadDriveFiles()
} catch (e) {
console.error('Failed to create folder:', e)
} finally {
showNewFolderDialog.value = false
newFolderName.value = ''
}
}
async function connectDrive() {
try {
await msdApi.connect('drive')
await systemStore.fetchMsdState()
} catch (e) {
console.error('Failed to connect drive:', e)
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB'
}
onMounted(async () => {
if (props.open) {
await loadData()
}
})
</script>
<template>
<Sheet :open="open" @update:open="emit('update:open', $event)">
<SheetContent side="right" class="w-full sm:max-w-lg overflow-hidden flex flex-col">
<SheetHeader>
<div class="flex items-center justify-between pr-8">
<div>
<SheetTitle class="flex items-center gap-2">
<HardDrive class="h-5 w-5" />
{{ t('msd.title') }}
</SheetTitle>
<SheetDescription class="flex items-center gap-2 mt-1">
{{ msdConnected ? t('common.connected') : t('common.disconnected') }}
<Badge v-if="msdConnected" variant="secondary" class="text-xs">{{ msdMode }}</Badge>
</SheetDescription>
</div>
<Button v-if="msdConnected" variant="destructive" size="sm" @click="disconnect">
<Unlink class="h-4 w-4 mr-1" />
{{ t('msd.disconnect') }}
</Button>
</div>
</SheetHeader>
<Separator class="my-4" />
<Tabs v-model="activeTab" class="flex-1 flex flex-col overflow-hidden">
<TabsList class="w-full grid grid-cols-2">
<TabsTrigger value="images">
<Disc class="h-4 w-4 mr-1.5" />
{{ t('msd.images') }}
</TabsTrigger>
<TabsTrigger value="drive">
<HardDrive class="h-4 w-4 mr-1.5" />
{{ t('msd.drive') }}
</TabsTrigger>
</TabsList>
<ScrollArea class="flex-1 mt-4">
<!-- Images Tab -->
<TabsContent value="images" class="m-0 space-y-4">
<!-- Upload Area -->
<div class="space-y-3">
<label class="block">
<input
type="file"
accept=".iso,.img"
class="hidden"
:disabled="uploading"
@change="handleImageUpload"
/>
<div class="flex items-center justify-center h-16 border-2 border-dashed rounded-lg cursor-pointer hover:border-primary transition-colors">
<div class="text-center">
<Upload class="h-5 w-5 mx-auto text-muted-foreground" />
<p class="text-xs text-muted-foreground mt-1">{{ t('msd.uploadImage') }} (ISO/IMG)</p>
</div>
</div>
</label>
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1" />
</div>
<!-- Options -->
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
<div class="flex items-center gap-2">
<Switch id="cdrom" v-model:checked="cdromMode" />
<Label for="cdrom" class="text-xs">{{ t('msd.cdromMode') }}</Label>
</div>
<div class="flex items-center gap-2">
<Switch id="readonly" v-model:checked="readOnly" />
<Label for="readonly" class="text-xs">{{ t('msd.readOnly') }}</Label>
</div>
</div>
<!-- Image List -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadImages">
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
</Button>
</div>
<div v-if="images.length === 0" class="text-center py-6 text-muted-foreground text-sm">
{{ t('msd.noImages') }}
</div>
<div v-else class="space-y-1.5">
<div
v-for="image in images"
:key="image.id"
class="flex items-center justify-between p-2.5 rounded-lg border hover:bg-accent/50 transition-colors"
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<Disc class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ image.name }}</p>
<p class="text-xs text-muted-foreground">
{{ formatBytes(image.size) }}
</p>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
v-if="!msdConnected || systemStore.msd?.imageId !== image.id"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="connectImage(image)"
>
<Link class="h-3.5 w-3.5 mr-1" />
{{ t('msd.connect') }}
</Button>
<Badge v-else variant="default" class="text-xs">{{ t('common.connected') }}</Badge>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
@click="confirmDelete('image', image.id, image.name)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
</TabsContent>
<!-- Drive Tab -->
<TabsContent value="drive" class="m-0 space-y-4">
<template v-if="!driveInitialized">
<div class="text-center py-8 space-y-4">
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
<p class="text-sm text-muted-foreground">{{ t('msd.driveNotInitialized') }}</p>
<Button size="sm" @click="initializeDrive">
{{ t('msd.initializeDrive') }}
</Button>
</div>
</template>
<template v-else>
<!-- Drive Info -->
<div class="p-3 rounded-lg bg-muted/50 space-y-2">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<p class="text-xs text-muted-foreground">{{ t('msd.driveSize') }}: {{ (driveInfo?.size || 0) / 1024 / 1024 }}MB</p>
<p class="text-xs text-muted-foreground">
{{ formatBytes(driveInfo?.used || 0) }} / {{ formatBytes(driveInfo?.size || 0) }}
</p>
</div>
<Button
v-if="!msdConnected || msdMode !== 'drive'"
variant="outline"
size="sm"
class="h-7 text-xs"
@click="connectDrive"
>
<Link class="h-3.5 w-3.5 mr-1" />
{{ t('msd.connect') }}
</Button>
<Badge v-else class="text-xs">{{ t('common.connected') }}</Badge>
</div>
<Progress
v-if="driveInfo"
:model-value="driveInfo.size > 0 ? (driveInfo.used / driveInfo.size) * 100 : 0"
class="h-1"
/>
</div>
<!-- File Browser -->
<div class="space-y-2">
<!-- Toolbar -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<Button
v-if="currentPath !== '/'"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
@click="navigateUp"
>
<ArrowLeft class="h-3.5 w-3.5" />
</Button>
<nav class="flex items-center text-xs min-w-0 overflow-hidden">
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
<ChevronRight v-if="index > 0" class="h-3 w-3 text-muted-foreground mx-0.5 shrink-0" />
<button
class="hover:text-primary transition-colors truncate"
:class="index === breadcrumbs.length - 1 ? 'font-medium' : 'text-muted-foreground'"
@click="navigateTo(crumb.path)"
>
{{ crumb.name }}
</button>
</template>
</nav>
</div>
<div class="flex items-center gap-1 shrink-0">
<label>
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
<Upload class="h-3.5 w-3.5" />
</Button>
</label>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showNewFolderDialog = true">
<FolderPlus class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadDriveFiles">
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingDrive }" />
</Button>
</div>
</div>
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1" />
<!-- File List -->
<div v-if="driveFiles.length === 0" class="text-center py-6 text-muted-foreground text-sm">
{{ t('msd.emptyFolder') }}
</div>
<div v-else class="space-y-1">
<div
v-for="file in driveFiles"
:key="file.path"
class="flex items-center justify-between p-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<div
class="flex items-center gap-2 cursor-pointer flex-1 min-w-0"
@click="file.is_dir && navigateTo(file.path)"
>
<Folder v-if="file.is_dir" class="h-4 w-4 text-blue-500 shrink-0" />
<File v-else class="h-4 w-4 text-muted-foreground shrink-0" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">{{ file.name }}</p>
<p v-if="!file.is_dir" class="text-xs text-muted-foreground">
{{ formatBytes(file.size) }}
</p>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<Button
v-if="!file.is_dir"
variant="ghost"
size="icon"
class="h-7 w-7"
as="a"
:href="msdApi.downloadDriveFile(file.path)"
download
>
<Download class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-destructive"
@click="confirmDelete('file', file.path, file.name)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
</template>
</TabsContent>
</ScrollArea>
</Tabs>
</SheetContent>
</Sheet>
<!-- Delete Dialog -->
<Dialog v-model:open="showDeleteDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ t('common.confirm') }}</DialogTitle>
<DialogDescription>
{{ t('msd.confirmDelete', { name: deleteTarget?.name }) }}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" @click="showDeleteDialog = false">{{ t('common.cancel') }}</Button>
<Button variant="destructive" @click="executeDelete">{{ t('common.delete') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- New Folder Dialog -->
<Dialog v-model:open="showNewFolderDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ t('msd.createFolder') }}</DialogTitle>
</DialogHeader>
<Input v-model="newFolderName" :placeholder="t('msd.folderName')" @keyup.enter="createFolder" />
<DialogFooter>
<Button variant="outline" @click="showNewFolderDialog = false">{{ t('common.cancel') }}</Button>
<Button @click="createFolder">{{ t('common.confirm') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,272 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { CornerDownLeft, Square, AlertCircle } from 'lucide-vue-next'
import { charToKey, analyzeText } from '@/lib/charToHid'
import { hidApi } from '@/api'
const emit = defineEmits<{
(e: 'close'): void
}>()
const { t } = useI18n()
const text = ref('')
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const isPasting = ref(false)
const progress = ref(0)
const currentChar = ref(0)
const totalChars = ref(0)
const abortController = ref<AbortController | null>(null)
// Typing speed in milliseconds between characters
// Configurable delay to prevent target system from missing keystrokes
const typingDelay = ref(10)
// Text analysis for warning display
const textAnalysis = computed(() => {
if (!text.value) return null
return analyzeText(text.value)
})
const hasUntypableChars = computed(() => {
return textAnalysis.value && textAnalysis.value.untypable > 0
})
onMounted(() => {
// Auto focus the textarea
setTimeout(() => {
textareaRef.value?.focus()
}, 100)
})
onUnmounted(() => {
// Cancel any ongoing paste operation when component is unmounted
cancelPaste()
})
/**
* Sleep utility function
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* Type a single character via HID
* Sends keydown then keyup events with appropriate modifiers
*/
async function typeChar(char: string, signal: AbortSignal): Promise<boolean> {
if (signal.aborted) return false
const mapping = charToKey(char)
if (!mapping) {
// Skip untypable characters
return true
}
const { keyCode, shift } = mapping
const modifiers = shift ? { shift: true } : undefined
try {
// Send keydown
await hidApi.keyboard('down', keyCode, modifiers)
// Small delay between down and up to ensure key is registered
await sleep(5)
if (signal.aborted) {
// Even if aborted, still send keyup to release the key
await hidApi.keyboard('up', keyCode, modifiers)
return false
}
// Send keyup
await hidApi.keyboard('up', keyCode, modifiers)
// Additional small delay after keyup to ensure it's processed
await sleep(2)
return true
} catch (error) {
console.error('[Paste] Failed to type character:', char, error)
// Try to release the key even on error
try {
await hidApi.keyboard('up', keyCode, modifiers)
} catch {
// Ignore cleanup errors
}
return false
}
}
/**
* Main paste function - types all characters sequentially
*/
async function handlePaste() {
const textToType = text.value
if (!textToType.trim()) return
isPasting.value = true
progress.value = 0
currentChar.value = 0
totalChars.value = textToType.length
// Create abort controller for cancellation
abortController.value = new AbortController()
const signal = abortController.value.signal
try {
const chars = [...textToType] // Convert to array for proper iteration
const totalLength = chars.length
let charIndex = 0
for (const char of chars) {
if (signal.aborted) {
break
}
charIndex++
currentChar.value = charIndex
progress.value = Math.round((charIndex / totalLength) * 100)
// Handle CRLF: skip \r if followed by \n
if (char === '\r' && charIndex < totalLength && chars[charIndex] === '\n') {
continue
}
await typeChar(char, signal)
// Delay between characters (configurable)
if (typingDelay.value > 0 && charIndex < totalLength) {
await sleep(typingDelay.value)
}
}
// Success - close the modal after a brief delay
if (!signal.aborted) {
await sleep(200)
text.value = ''
emit('close')
}
} catch (error) {
console.error('[Paste] Error during paste operation:', error)
} finally {
// Reset HID to ensure no keys are stuck
try {
await hidApi.reset()
} catch {
// Ignore reset errors
}
isPasting.value = false
progress.value = 0
currentChar.value = 0
totalChars.value = 0
abortController.value = null
}
}
/**
* Cancel ongoing paste operation
*/
function cancelPaste() {
if (abortController.value) {
abortController.value.abort()
abortController.value = null
}
}
function handleKeydown(e: KeyboardEvent) {
// Ctrl/Cmd + Enter to paste
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
if (!isPasting.value) {
handlePaste()
}
}
// Escape to cancel or close
if (e.key === 'Escape') {
e.preventDefault()
if (isPasting.value) {
cancelPaste()
} else {
emit('close')
}
}
// Stop propagation to prevent HID interference
e.stopPropagation()
}
</script>
<template>
<div class="p-4 space-y-4">
<div class="space-y-1">
<h3 class="font-semibold text-sm">{{ t('paste.title') }}</h3>
<p class="text-xs text-muted-foreground">{{ t('paste.description') }}</p>
</div>
<div class="space-y-2">
<Label for="paste-text">{{ t('paste.label') }}</Label>
<Textarea
id="paste-text"
ref="textareaRef"
v-model="text"
:placeholder="t('paste.placeholder')"
class="min-h-[120px] resize-none font-mono text-sm"
:disabled="isPasting"
@keydown="handleKeydown"
@keyup.stop
/>
</div>
<!-- Warning for untypable characters -->
<div v-if="hasUntypableChars && !isPasting" class="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400">
<AlertCircle class="h-4 w-4 shrink-0 mt-0.5" />
<div class="text-xs">
<p class="font-medium">{{ t('paste.untypableWarning') }}</p>
<p class="text-muted-foreground mt-0.5">
{{ t('paste.untypableChars', { chars: textAnalysis?.untypableChars.slice(0, 5).map(c => c === '\n' ? '\\n' : c === '\r' ? '\\r' : c === '\t' ? '\\t' : c).join(', ') }) }}
<span v-if="textAnalysis && textAnalysis.untypableChars.length > 5">...</span>
</p>
</div>
</div>
<!-- Progress indicator during paste -->
<div v-if="isPasting" class="space-y-2">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t('paste.typing') }}</span>
<span>{{ currentChar }} / {{ totalChars }}</span>
</div>
<Progress :model-value="progress" class="h-2" />
</div>
<div class="flex items-center justify-between">
<p v-if="!isPasting" class="text-xs text-muted-foreground">
{{ t('paste.hint') }}
</p>
<p v-else class="text-xs text-muted-foreground">
{{ t('paste.escToCancel') }}
</p>
<div class="flex gap-2">
<Button v-if="!isPasting" variant="ghost" size="sm" @click="emit('close')">
{{ t('common.cancel') }}
</Button>
<Button v-else variant="ghost" size="sm" @click="cancelPaste">
<Square class="h-3 w-3 mr-1.5 fill-current" />
{{ t('paste.stop') }}
</Button>
<Button
size="sm"
:disabled="!text.trim() || isPasting"
@click="handlePaste"
>
<CornerDownLeft class="h-4 w-4 mr-1.5" />
{{ t('paste.confirm') }}
</Button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,658 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import uPlot from 'uplot'
import 'uplot/dist/uPlot.min.css'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { ScrollArea } from '@/components/ui/scroll-area'
import type { WebRTCStats } from '@/composables/useWebRTC'
const { t } = useI18n()
const props = defineProps<{
open: boolean
videoMode: 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
// MJPEG stats
mjpegFps?: number
wsLatency?: number
// WebRTC stats
webrtcStats?: WebRTCStats
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
// Chart containers
const stabilityChartRef = ref<HTMLDivElement | null>(null)
const delayChartRef = ref<HTMLDivElement | null>(null)
const packetLossChartRef = ref<HTMLDivElement | null>(null)
const fpsChartRef = ref<HTMLDivElement | null>(null)
// Chart instances
let stabilityChart: uPlot | null = null
let delayChart: uPlot | null = null
let packetLossChart: uPlot | null = null
let fpsChart: uPlot | null = null
// Data history (last 120 seconds)
const MAX_POINTS = 120
const timestamps = ref<number[]>([])
const jitterHistory = ref<number[]>([])
const delayHistory = ref<number[]>([])
const packetLossHistory = ref<number[]>([])
const fpsHistory = ref<number[]>([])
const bitrateHistory = ref<number[]>([])
// For delta calculations
let lastBytesReceived = 0
let lastPacketsLost = 0
let lastTimestamp = 0
// Is WebRTC mode
const isWebRTC = computed(() => props.videoMode !== 'mjpeg')
// Format time for axis
function formatTime(ts: number): string {
const date = new Date(ts * 1000)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
// Chart theme colors
const chartColors = {
line: '#3b82f6',
fill: 'rgba(59, 130, 246, 0.1)',
grid: 'rgba(148, 163, 184, 0.1)',
axis: '#64748b',
text: '#94a3b8',
}
// Chart options factory
function createChartOptions(
container: HTMLElement,
_yLabel: string,
yFormatter: (v: number) => string
): uPlot.Options {
const width = container.clientWidth || 300
return {
width,
height: 100,
cursor: {
show: true,
x: true,
y: false,
drag: { x: false, y: false },
},
legend: { show: false },
scales: {
x: { time: false },
y: { auto: true, range: (_u, min, max) => [Math.max(0, min - 1), max + 1] },
},
axes: [
{
show: true,
stroke: chartColors.axis,
grid: { show: false },
ticks: { show: false },
gap: 4,
size: 20,
values: (_, splits) => splits.map(v => formatTime(v)),
font: '10px system-ui',
},
{
show: true,
side: 1, // Right side
stroke: chartColors.axis,
size: 55,
gap: 8,
grid: { stroke: chartColors.grid, width: 1 },
values: (_, splits) => splits.map(v => yFormatter(v)),
font: '10px system-ui',
},
],
series: [
{},
{
stroke: chartColors.line,
width: 1.5,
fill: chartColors.fill,
paths: uPlot.paths.spline?.() || undefined,
},
],
}
}
// Tooltip state for each chart
const activeTooltip = ref<{
chartId: string
time: string
value: string
unit: string
left: number
top: number
visible: boolean
}>({
chartId: '',
time: '',
value: '',
unit: '',
left: 0,
top: 0,
visible: false,
})
function createTooltipPlugin(chartId: string, unit: string): uPlot.Plugin {
return {
hooks: {
setCursor: [
(u) => {
const idx = u.cursor.idx
if (idx !== null && idx !== undefined && u.cursor.left !== undefined && u.cursor.top !== undefined) {
const ts = u.data[0]?.[idx]
const val = u.data[1]?.[idx]
if (ts !== undefined && ts !== null && val !== undefined && val !== null) {
const date = new Date(ts * 1000)
activeTooltip.value = {
chartId,
time: date.toLocaleTimeString('zh-CN'),
value: val.toFixed(1),
unit,
left: u.cursor.left,
top: u.cursor.top,
visible: true,
}
}
}
},
],
ready: [
(u) => {
const over = u.over
over.addEventListener('mouseleave', () => {
if (activeTooltip.value.chartId === chartId) {
activeTooltip.value.visible = false
}
})
},
],
},
}
}
// Initialize charts
function initCharts() {
if (!props.open) return
nextTick(() => {
// Initialize timestamps if empty
if (timestamps.value.length === 0) {
const now = Date.now() / 1000
for (let i = MAX_POINTS - 1; i >= 0; i--) {
timestamps.value.push(now - i)
}
jitterHistory.value = new Array(MAX_POINTS).fill(0)
delayHistory.value = new Array(MAX_POINTS).fill(0)
packetLossHistory.value = new Array(MAX_POINTS).fill(0)
fpsHistory.value = new Array(MAX_POINTS).fill(0)
bitrateHistory.value = new Array(MAX_POINTS).fill(0)
}
// Network Stability (Jitter) Chart
if (stabilityChartRef.value && !stabilityChart) {
const opts = createChartOptions(stabilityChartRef.value, 'ms', (v) => `${v.toFixed(0)} ms`)
opts.plugins = [createTooltipPlugin('stability', 'ms')]
stabilityChart = new uPlot(
opts,
[timestamps.value, jitterHistory.value],
stabilityChartRef.value
)
}
// Playback Delay Chart
if (delayChartRef.value && !delayChart) {
const opts = createChartOptions(delayChartRef.value, 'ms', (v) => `${v.toFixed(0)} ms`)
opts.plugins = [createTooltipPlugin('delay', 'ms')]
delayChart = new uPlot(
opts,
[timestamps.value, delayHistory.value],
delayChartRef.value
)
}
// Packet Loss Chart
if (packetLossChartRef.value && !packetLossChart) {
const opts = createChartOptions(packetLossChartRef.value, '', (v) => `${v.toFixed(0)}`)
opts.plugins = [createTooltipPlugin('packetLoss', '个')]
packetLossChart = new uPlot(
opts,
[timestamps.value, packetLossHistory.value],
packetLossChartRef.value
)
}
// FPS Chart
if (fpsChartRef.value && !fpsChart) {
const opts = createChartOptions(fpsChartRef.value, 'fps', (v) => `${v.toFixed(0)} fps`)
opts.plugins = [createTooltipPlugin('fps', 'fps')]
fpsChart = new uPlot(
opts,
[timestamps.value, fpsHistory.value],
fpsChartRef.value
)
}
})
}
// Destroy charts
function destroyCharts() {
stabilityChart?.destroy()
stabilityChart = null
delayChart?.destroy()
delayChart = null
packetLossChart?.destroy()
packetLossChart = null
fpsChart?.destroy()
fpsChart = null
}
// Add data point
function addDataPoint() {
const now = Date.now() / 1000
// Shift timestamps
timestamps.value.push(now)
if (timestamps.value.length > MAX_POINTS) {
timestamps.value.shift()
}
if (isWebRTC.value && props.webrtcStats) {
// Jitter in ms
const jitter = (props.webrtcStats.jitter || 0) * 1000
jitterHistory.value.push(jitter)
// RTT (round trip time) as delay in ms
const rtt = (props.webrtcStats.roundTripTime || 0) * 1000
delayHistory.value.push(rtt)
// Packet loss delta
const currentLost = props.webrtcStats.packetsLost || 0
const lostDelta = lastPacketsLost > 0 ? Math.max(0, currentLost - lastPacketsLost) : 0
lastPacketsLost = currentLost
packetLossHistory.value.push(lostDelta)
// FPS
fpsHistory.value.push(props.webrtcStats.framesPerSecond || 0)
// Calculate bitrate
const currentBytes = props.webrtcStats.bytesReceived || 0
const currentTime = Date.now()
if (lastTimestamp > 0 && currentBytes > lastBytesReceived) {
const timeDiff = (currentTime - lastTimestamp) / 1000
const bytesDiff = currentBytes - lastBytesReceived
const bitrate = (bytesDiff * 8) / (timeDiff * 1000000)
bitrateHistory.value.push(Math.round(bitrate * 100) / 100)
} else {
bitrateHistory.value.push(bitrateHistory.value[bitrateHistory.value.length - 1] || 0)
}
lastBytesReceived = currentBytes
lastTimestamp = currentTime
} else {
// MJPEG mode
jitterHistory.value.push(0)
delayHistory.value.push(props.wsLatency || 0)
packetLossHistory.value.push(0)
fpsHistory.value.push(props.mjpegFps || 0)
bitrateHistory.value.push(0)
}
// Trim arrays
if (jitterHistory.value.length > MAX_POINTS) jitterHistory.value.shift()
if (delayHistory.value.length > MAX_POINTS) delayHistory.value.shift()
if (packetLossHistory.value.length > MAX_POINTS) packetLossHistory.value.shift()
if (fpsHistory.value.length > MAX_POINTS) fpsHistory.value.shift()
if (bitrateHistory.value.length > MAX_POINTS) bitrateHistory.value.shift()
// Update charts
updateCharts()
}
// Update charts with new data
function updateCharts() {
stabilityChart?.setData([timestamps.value, jitterHistory.value])
delayChart?.setData([timestamps.value, delayHistory.value])
packetLossChart?.setData([timestamps.value, packetLossHistory.value])
fpsChart?.setData([timestamps.value, fpsHistory.value])
}
// Data collection interval
let dataInterval: number | null = null
function startDataCollection() {
if (dataInterval) return
dataInterval = window.setInterval(addDataPoint, 1000)
}
function stopDataCollection() {
if (dataInterval) {
clearInterval(dataInterval)
dataInterval = null
}
}
// Format candidate type for display
function formatCandidateType(type: string): string {
const typeMap: Record<string, string> = {
host: 'Host (Local)',
srflx: 'STUN (NAT)',
prflx: 'Peer Reflexive',
relay: 'TURN Relay',
unknown: '-',
}
return typeMap[type] || type
}
// Current stats for header display
const currentStats = computed(() => {
if (isWebRTC.value && props.webrtcStats) {
const lastBitrate = bitrateHistory.value[bitrateHistory.value.length - 1]
const bitrate = lastBitrate !== undefined ? lastBitrate : 0
return {
jitter: Math.round((props.webrtcStats.jitter || 0) * 1000 * 10) / 10,
delay: Math.round((props.webrtcStats.roundTripTime || 0) * 1000),
fps: props.webrtcStats.framesPerSecond || 0,
resolution: props.webrtcStats.frameWidth && props.webrtcStats.frameHeight
? `${props.webrtcStats.frameWidth}x${props.webrtcStats.frameHeight}`
: '-',
bitrate: bitrate.toFixed(2),
packetsLost: props.webrtcStats.packetsLost || 0,
// ICE connection info
isRelay: props.webrtcStats.isRelay || false,
transport: (props.webrtcStats.transportProtocol || '-').toUpperCase(),
localType: formatCandidateType(props.webrtcStats.localCandidateType || 'unknown'),
remoteType: formatCandidateType(props.webrtcStats.remoteCandidateType || 'unknown'),
}
}
return {
jitter: 0,
delay: props.wsLatency || 0,
fps: props.mjpegFps || 0,
resolution: '-',
bitrate: '0',
packetsLost: 0,
isRelay: false,
transport: '-',
localType: '-',
remoteType: '-',
}
})
// Watch open state
watch(() => props.open, (isOpen) => {
if (isOpen) {
// Reset data
timestamps.value = []
jitterHistory.value = []
delayHistory.value = []
packetLossHistory.value = []
fpsHistory.value = []
bitrateHistory.value = []
lastBytesReceived = 0
lastPacketsLost = 0
lastTimestamp = 0
setTimeout(() => {
initCharts()
startDataCollection()
}, 150)
} else {
stopDataCollection()
destroyCharts()
}
})
// Resize handler
function handleResize() {
if (!props.open) return
destroyCharts()
setTimeout(initCharts, 50)
}
onMounted(() => {
window.addEventListener('resize', handleResize)
if (props.open) {
initCharts()
startDataCollection()
}
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
stopDataCollection()
destroyCharts()
})
</script>
<template>
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
<SheetContent
side="right"
class="w-[400px] sm:w-[440px] p-0 border-l border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950"
>
<!-- Header -->
<SheetHeader class="px-6 py-3 border-b border-slate-200 dark:border-slate-800">
<div class="flex items-center gap-2">
<SheetTitle class="text-base">{{ t('stats.title') }}</SheetTitle>
<span class="text-xs px-2 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-muted-foreground">
{{ isWebRTC ? 'WebRTC' : 'MJPEG' }}
</span>
</div>
</SheetHeader>
<ScrollArea class="h-[calc(100vh-60px)]">
<div class="px-6 py-4 space-y-6">
<!-- Video Section Header -->
<div>
<h3 class="text-sm font-medium">{{ t('stats.video') }}</h3>
<p class="text-xs text-muted-foreground mt-0.5">
{{ t('stats.videoDesc') }}
</p>
</div>
<!-- Network Stability (Jitter) -->
<div class="space-y-2" v-if="isWebRTC">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('stats.stability') }}</h4>
</div>
<p class="text-xs text-muted-foreground">
{{ t('stats.stabilityDesc') }}
</p>
<div class="relative">
<div
ref="stabilityChartRef"
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
/>
<div
v-if="activeTooltip.visible && activeTooltip.chartId === 'stability'"
class="chart-tooltip"
:style="{ left: `${activeTooltip.left + 60}px`, top: `${activeTooltip.top - 40}px` }"
>
<div class="text-xs font-medium">{{ activeTooltip.time }}</div>
<div class="text-xs text-blue-500">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
</div>
</div>
</div>
<!-- Playback Delay -->
<div class="space-y-2" v-if="isWebRTC">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('stats.delay') }}</h4>
<span class="text-xs text-muted-foreground">
{{ currentStats.delay }} ms
</span>
</div>
<p class="text-xs text-muted-foreground">
{{ t('stats.delayDesc') }}
</p>
<div class="relative">
<div
ref="delayChartRef"
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
/>
<div
v-if="activeTooltip.visible && activeTooltip.chartId === 'delay'"
class="chart-tooltip"
:style="{ left: `${activeTooltip.left + 60}px`, top: `${activeTooltip.top - 40}px` }"
>
<div class="text-xs font-medium">{{ activeTooltip.time }}</div>
<div class="text-xs text-blue-500">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
</div>
</div>
</div>
<!-- Packet Loss -->
<div class="space-y-2" v-if="isWebRTC">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('stats.packetLoss') }}</h4>
<span class="text-xs text-muted-foreground">
{{ currentStats.packetsLost }} {{ t('stats.total') }}
</span>
</div>
<p class="text-xs text-muted-foreground">
{{ t('stats.packetLossDesc') }}
</p>
<div class="relative">
<div
ref="packetLossChartRef"
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
/>
<div
v-if="activeTooltip.visible && activeTooltip.chartId === 'packetLoss'"
class="chart-tooltip"
:style="{ left: `${activeTooltip.left + 60}px`, top: `${activeTooltip.top - 40}px` }"
>
<div class="text-xs font-medium">{{ activeTooltip.time }}</div>
<div class="text-xs text-blue-500">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
</div>
</div>
</div>
<!-- FPS -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-sm font-medium">{{ t('stats.frameRate') }}</h4>
<span class="text-xs text-muted-foreground">
{{ currentStats.fps }} fps
</span>
</div>
<p class="text-xs text-muted-foreground">
{{ t('stats.frameRateDesc') }}
</p>
<div class="relative">
<div
ref="fpsChartRef"
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
/>
<div
v-if="activeTooltip.visible && activeTooltip.chartId === 'fps'"
class="chart-tooltip"
:style="{ left: `${activeTooltip.left + 60}px`, top: `${activeTooltip.top - 40}px` }"
>
<div class="text-xs font-medium">{{ activeTooltip.time }}</div>
<div class="text-xs text-blue-500">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
</div>
</div>
</div>
<!-- Additional Stats -->
<div class="space-y-3 pt-2 border-t border-slate-200 dark:border-slate-800" v-if="isWebRTC">
<h4 class="text-sm font-medium">{{ t('stats.additional') }}</h4>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
<p class="text-xs text-muted-foreground">{{ t('stats.resolution') }}</p>
<p class="text-sm font-medium mt-1">{{ currentStats.resolution }}</p>
</div>
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
<p class="text-xs text-muted-foreground">{{ t('stats.bitrate') }}</p>
<p class="text-sm font-medium mt-1">{{ currentStats.bitrate }} Mbps</p>
</div>
</div>
<!-- Connection Info -->
<h4 class="text-sm font-medium pt-2">{{ t('stats.connection') }}</h4>
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
<p class="text-xs text-muted-foreground">{{ t('stats.connectionType') }}</p>
<p class="text-sm font-medium mt-1 flex items-center gap-1.5">
<span
:class="[
'inline-block w-2 h-2 rounded-full',
currentStats.isRelay ? 'bg-amber-500' : 'bg-green-500'
]"
/>
{{ currentStats.isRelay ? t('stats.relay') : t('stats.p2p') }}
</p>
</div>
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
<p class="text-xs text-muted-foreground">{{ t('stats.transport') }}</p>
<p class="text-sm font-medium mt-1">{{ currentStats.transport }}</p>
</div>
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
<p class="text-xs text-muted-foreground">{{ t('stats.localCandidate') }}</p>
<p class="text-sm font-medium mt-1">{{ currentStats.localType }}</p>
</div>
<div class="rounded-lg bg-slate-50 dark:bg-slate-900/50 p-3">
<p class="text-xs text-muted-foreground">{{ t('stats.remoteCandidate') }}</p>
<p class="text-sm font-medium mt-1">{{ currentStats.remoteType }}</p>
</div>
</div>
</div>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</template>
<style>
/* Override uPlot styles for dark mode */
.dark .u-wrap {
background: transparent !important;
}
.dark .u-over {
background: transparent !important;
}
/* Chart cursor line */
.u-cursor-x {
border-right: 1px dashed #64748b !important;
}
.u-cursor-y {
display: none !important;
}
/* Chart tooltip */
.chart-tooltip {
position: absolute;
z-index: 50;
pointer-events: none;
padding: 6px 10px;
border-radius: 6px;
background: rgba(15, 23, 42, 0.9);
color: white;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
white-space: nowrap;
transform: translateX(-50%);
}
.dark .chart-tooltip {
background: rgba(30, 41, 59, 0.95);
border: 1px solid rgba(71, 85, 105, 0.5);
}
</style>

View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/lib/utils'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive } from 'lucide-vue-next'
const { t } = useI18n()
export interface StatusDetail {
label: string
value: string
status?: 'ok' | 'warning' | 'error'
}
const props = withDefaults(defineProps<{
title: string
type: 'device' | 'video' | 'hid' | 'audio' | 'msd'
status: 'connected' | 'connecting' | 'disconnected' | 'error'
quickInfo?: string // Quick info displayed on trigger (e.g., "1920x1080 30fps")
subtitle?: string
errorMessage?: string
details?: StatusDetail[]
hoverAlign?: 'start' | 'center' | 'end' // HoverCard alignment
}>(), {
hoverAlign: 'start',
})
const statusColor = computed(() => {
switch (props.status) {
case 'connected':
return 'bg-green-500'
case 'connecting':
return 'bg-yellow-500 animate-pulse'
case 'disconnected':
return 'bg-slate-400'
case 'error':
return 'bg-red-500'
default:
return 'bg-slate-400'
}
})
const StatusIcon = computed(() => {
switch (props.type) {
case 'device':
return Monitor
case 'video':
return Video
case 'hid':
return Usb
case 'audio':
return Volume2
case 'msd':
return HardDrive
default:
return Monitor
}
})
const statusIcon = computed(() => {
switch (props.status) {
case 'connected':
return CheckCircle
case 'connecting':
return Loader2
case 'error':
return AlertCircle
default:
return null
}
})
// Localized status text
const statusText = computed(() => {
switch (props.status) {
case 'connected':
return t('status.connected')
case 'connecting':
return t('status.connecting')
case 'disconnected':
return t('status.disconnected')
case 'error':
return t('status.error')
default:
return props.status
}
})
// Localized status badge text (for hover card)
const statusBadgeText = computed(() => {
switch (props.status) {
case 'connected':
return t('statusCard.online')
case 'connecting':
return t('statusCard.connecting')
case 'disconnected':
return t('statusCard.offline')
case 'error':
return t('status.error')
default:
return props.status
}
})
</script>
<template>
<HoverCard :open-delay="200" :close-delay="100">
<HoverCardTrigger as-child>
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
<div
:class="cn(
'flex flex-col gap-0.5 px-3 py-1.5 rounded-md border text-sm cursor-pointer transition-colors min-w-[100px]',
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
'border-slate-200 dark:border-slate-700',
status === 'error' && 'border-red-300 dark:border-red-800'
)"
>
<!-- Top: Title -->
<span class="font-medium text-foreground text-xs">{{ title }}</span>
<!-- Bottom: Status dot + Quick info -->
<div class="flex items-center gap-1.5">
<span :class="cn('h-2 w-2 rounded-full shrink-0', statusColor)" />
<span class="text-[11px] text-muted-foreground leading-tight truncate">
{{ quickInfo || subtitle || statusText }}
</span>
</div>
</div>
</HoverCardTrigger>
<HoverCardContent class="w-80" :align="hoverAlign">
<div class="space-y-3">
<!-- Header -->
<div class="flex items-center gap-3">
<div :class="cn(
'p-2 rounded-lg',
status === 'connected' ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' :
status === 'error' ? 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400' :
'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
)">
<component :is="StatusIcon" class="h-5 w-5" />
</div>
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-sm">{{ title }}</h4>
<div class="flex items-center gap-1.5 mt-0.5">
<component
v-if="statusIcon"
:is="statusIcon"
:class="cn(
'h-3.5 w-3.5',
status === 'connected' ? 'text-green-500' :
status === 'connecting' ? 'text-yellow-500 animate-spin' :
status === 'error' ? 'text-red-500' :
'text-slate-400'
)"
/>
<Badge
:variant="status === 'connected' ? 'default' : status === 'error' ? 'destructive' : 'secondary'"
class="text-[10px] px-1.5 py-0"
>
{{ statusBadgeText }}
</Badge>
</div>
</div>
</div>
<!-- Error Message -->
<div
v-if="status === 'error' && errorMessage"
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
>
<p class="text-xs text-red-600 dark:text-red-400">
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
{{ errorMessage }}
</p>
</div>
<!-- Details -->
<div v-if="details && details.length > 0" class="space-y-2">
<Separator />
<div class="space-y-1.5">
<div
v-for="(detail, index) in details"
:key="index"
class="flex items-center justify-between text-xs"
>
<span class="text-muted-foreground">{{ detail.label }}</span>
<span
:class="cn(
'font-medium',
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
'text-foreground'
)"
>
{{ detail.value }}
</span>
</div>
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
</template>

View File

@@ -0,0 +1,708 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Slider } from '@/components/ui/slider'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Monitor, RefreshCw, Loader2, Settings } from 'lucide-vue-next'
import HelpTooltip from '@/components/HelpTooltip.vue'
import { configApi, streamApi, type VideoCodecInfo, type EncoderBackendInfo } from '@/api'
import { useSystemStore } from '@/stores/system'
import { useDebounceFn } from '@vueuse/core'
import { useRouter } from 'vue-router'
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
interface VideoDevice {
path: string
name: string
driver: string
formats: {
format: string
description: string
resolutions: {
width: number
height: number
fps: number[]
}[]
}[]
}
const props = defineProps<{
open: boolean
videoMode: VideoMode
isAdmin?: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
(e: 'update:videoMode', value: VideoMode): void
}>()
const { t } = useI18n()
const systemStore = useSystemStore()
const router = useRouter()
// Device list
const devices = ref<VideoDevice[]>([])
const loadingDevices = ref(false)
// Codec list
const codecs = ref<VideoCodecInfo[]>([])
const loadingCodecs = ref(false)
// Backend list
const backends = ref<EncoderBackendInfo[]>([])
const currentEncoderBackend = ref<string>('auto')
// Browser supported codecs (WebRTC receive capabilities)
const browserSupportedCodecs = ref<Set<string>>(new Set())
// Check browser WebRTC codec support
function detectBrowserCodecSupport() {
const supported = new Set<string>()
// MJPEG is always supported (HTTP streaming, no WebRTC)
supported.add('mjpeg')
// Check WebRTC receive capabilities
if (typeof RTCRtpReceiver !== 'undefined' && RTCRtpReceiver.getCapabilities) {
const capabilities = RTCRtpReceiver.getCapabilities('video')
if (capabilities?.codecs) {
for (const codec of capabilities.codecs) {
const mimeType = codec.mimeType.toLowerCase()
// Map MIME types to our codec IDs
if (mimeType.includes('h264') || mimeType.includes('avc')) {
supported.add('h264')
}
if (mimeType.includes('h265') || mimeType.includes('hevc')) {
supported.add('h265')
}
if (mimeType.includes('vp8')) {
supported.add('vp8')
}
if (mimeType.includes('vp9')) {
supported.add('vp9')
}
if (mimeType.includes('av1')) {
supported.add('av1')
}
}
}
} else {
// Fallback: assume basic codecs are supported
supported.add('h264')
supported.add('vp8')
supported.add('vp9')
}
browserSupportedCodecs.value = supported
console.info('[VideoConfig] Browser supported codecs:', Array.from(supported))
}
// Check if a codec is supported by browser
const isBrowserSupported = (codecId: string): boolean => {
return browserSupportedCodecs.value.has(codecId)
}
// Translate backend name for display
const translateBackendName = (backend: string | undefined): string => {
if (!backend) return ''
// Translate known backend names
const lowerBackend = backend.toLowerCase()
if (lowerBackend === 'software') {
return t('actionbar.backendSoftware')
}
if (lowerBackend === 'auto') {
return t('actionbar.backendAuto')
}
// Hardware backends (VAAPI, V4L2 M2M, etc.) keep original names
return backend
}
// Check if a format has fps >= 30 in any resolution
const hasHighFps = (format: { resolutions: { fps: number[] }[] }): boolean => {
return format.resolutions.some(res => res.fps.some(fps => fps >= 30))
}
// Check if a format is recommended based on video mode
const isFormatRecommended = (formatName: string): boolean => {
const formats = availableFormats.value
const upperFormat = formatName.toUpperCase()
// MJPEG/HTTP mode: recommend MJPEG
if (props.videoMode === 'mjpeg') {
return upperFormat === 'MJPEG'
}
// WebRTC mode: check NV12 first, then YUYV
const currentFormat = formats.find(f => f.format.toUpperCase() === upperFormat)
if (!currentFormat) return false
// Check if NV12 exists with fps >= 30
const nv12Format = formats.find(f => f.format.toUpperCase() === 'NV12')
const nv12HasHighFps = nv12Format && hasHighFps(nv12Format)
// Check if YUYV exists with fps >= 30
const yuyvFormat = formats.find(f => f.format.toUpperCase() === 'YUYV')
const yuyvHasHighFps = yuyvFormat && hasHighFps(yuyvFormat)
// Priority 1: NV12 with high fps
if (nv12HasHighFps) {
return upperFormat === 'NV12'
}
// Priority 2: YUYV with high fps (only if NV12 doesn't qualify)
if (yuyvHasHighFps) {
return upperFormat === 'YUYV'
}
return false
}
// Selected values (mode comes from props)
const selectedDevice = ref<string>('')
const selectedFormat = ref<string>('')
const selectedResolution = ref<string>('')
const selectedFps = ref<number>(30)
const selectedBitrate = ref<number[]>([8000])
// UI state
const applying = ref(false)
const applyingBitrate = ref(false)
// Current config from store
const currentConfig = computed(() => ({
device: systemStore.stream?.device || '',
format: systemStore.stream?.format || '',
width: systemStore.stream?.resolution?.[0] || 1920,
height: systemStore.stream?.resolution?.[1] || 1080,
fps: systemStore.stream?.targetFps || 30,
}))
// Button display text - simplified to just show label
const buttonText = computed(() => t('actionbar.videoConfig'))
// Available codecs for selection (filtered by backend support and enriched with backend info)
const availableCodecs = computed(() => {
const allAvailable = codecs.value.filter(c => c.available)
// Auto mode: show all available with their best (hardware-preferred) backend
if (currentEncoderBackend.value === 'auto') {
return allAvailable
}
// Specific backend: filter by supported formats and override backend info
const backend = backends.value.find(b => b.id === currentEncoderBackend.value)
if (!backend) return allAvailable
return allAvailable
.filter(codec => {
// MJPEG is always available (doesn't require encoder)
if (codec.id === 'mjpeg') return true
// Check if codec format is supported by the configured backend
return backend.supported_formats.includes(codec.id)
})
.map(codec => {
// For MJPEG, keep original info
if (codec.id === 'mjpeg') return codec
// Override backend info for WebRTC codecs based on selected backend
return {
...codec,
hardware: backend.is_hardware,
backend: backend.name,
}
})
})
// Cascading filters
const availableFormats = computed(() => {
const device = devices.value.find(d => d.path === selectedDevice.value)
return device?.formats || []
})
const availableResolutions = computed(() => {
const format = availableFormats.value.find(f => f.format === selectedFormat.value)
return format?.resolutions || []
})
const availableFps = computed(() => {
const resolution = availableResolutions.value.find(
r => `${r.width}x${r.height}` === selectedResolution.value
)
return resolution?.fps || []
})
// Get selected format description for display in trigger
const selectedFormatInfo = computed(() => {
const format = availableFormats.value.find(f => f.format === selectedFormat.value)
return format ? { description: format.description, format: format.format } : null
})
// Get selected codec info for display in trigger
const selectedCodecInfo = computed(() => {
const codec = availableCodecs.value.find(c => c.id === props.videoMode)
return codec || null
})
// Load devices
async function loadDevices() {
loadingDevices.value = true
try {
const result = await configApi.listDevices()
devices.value = result.video
} catch (e) {
console.info('[VideoConfig] Failed to load devices')
toast.error(t('config.loadDevicesFailed'))
} finally {
loadingDevices.value = false
}
}
// Load available codecs and backends
async function loadCodecs() {
loadingCodecs.value = true
try {
const result = await streamApi.getCodecs()
codecs.value = result.codecs
backends.value = result.backends || []
} catch (e) {
console.info('[VideoConfig] Failed to load codecs')
// Fallback to default codecs
codecs.value = [
{ id: 'mjpeg', name: 'MJPEG / HTTP', protocol: 'http', hardware: false, backend: 'software', available: true },
{ id: 'h264', name: 'H.264 / WebRTC', protocol: 'webrtc', hardware: false, backend: 'software', available: true },
]
} finally {
loadingCodecs.value = false
}
}
// Load current encoder backend from config
async function loadEncoderBackend() {
try {
const config = await configApi.get()
// Access nested stream.encoder
const streamConfig = config.stream as { encoder?: string } | undefined
currentEncoderBackend.value = streamConfig?.encoder || 'auto'
} catch (e) {
console.info('[VideoConfig] Failed to load encoder backend config')
currentEncoderBackend.value = 'auto'
}
}
// Navigate to settings page (video tab)
function goToSettings() {
router.push('/settings?tab=video')
}
// Initialize selected values from current config
function initializeFromCurrent() {
const config = currentConfig.value
selectedDevice.value = config.device
selectedFormat.value = config.format
selectedResolution.value = `${config.width}x${config.height}`
selectedFps.value = config.fps
}
// Handle video mode change
function handleVideoModeChange(mode: unknown) {
if (typeof mode !== 'string') return
emit('update:videoMode', mode as VideoMode)
}
// Handle device change
function handleDeviceChange(devicePath: unknown) {
if (typeof devicePath !== 'string') return
selectedDevice.value = devicePath
// Auto-select first format
const device = devices.value.find(d => d.path === devicePath)
if (device?.formats[0]) {
selectedFormat.value = device.formats[0].format
// Auto-select first resolution
const resolution = device.formats[0].resolutions[0]
if (resolution) {
selectedResolution.value = `${resolution.width}x${resolution.height}`
selectedFps.value = resolution.fps[0] || 30
}
}
}
// Handle format change
function handleFormatChange(format: unknown) {
if (typeof format !== 'string') return
selectedFormat.value = format
// Auto-select first resolution for this format
const formatData = availableFormats.value.find(f => f.format === format)
if (formatData?.resolutions[0]) {
const resolution = formatData.resolutions[0]
selectedResolution.value = `${resolution.width}x${resolution.height}`
selectedFps.value = resolution.fps[0] || 30
}
}
// Handle resolution change
function handleResolutionChange(resolution: unknown) {
if (typeof resolution !== 'string') return
selectedResolution.value = resolution
// Auto-select first FPS for this resolution
const resolutionData = availableResolutions.value.find(
r => `${r.width}x${r.height}` === resolution
)
if (resolutionData?.fps[0]) {
selectedFps.value = resolutionData.fps[0]
}
}
// Handle FPS change
function handleFpsChange(fps: unknown) {
if (typeof fps !== 'string' && typeof fps !== 'number') return
selectedFps.value = typeof fps === 'string' ? Number(fps) : fps
}
// Apply bitrate change (real-time)
async function applyBitrate(bitrate: number) {
if (applyingBitrate.value) return
applyingBitrate.value = true
try {
await streamApi.setBitrate(bitrate)
} catch (e) {
console.info('[VideoConfig] Failed to apply bitrate:', e)
} finally {
applyingBitrate.value = false
}
}
// Debounced bitrate application
const debouncedApplyBitrate = useDebounceFn((bitrate: number) => {
applyBitrate(bitrate)
}, 300)
// Watch bitrate slider changes (only when in WebRTC mode)
watch(selectedBitrate, (newValue) => {
if (props.videoMode !== 'mjpeg' && newValue[0] !== undefined) {
debouncedApplyBitrate(newValue[0])
}
})
// Apply video configuration
async function applyVideoConfig() {
const [width, height] = selectedResolution.value.split('x').map(Number)
applying.value = true
try {
await configApi.update({
video: {
device: selectedDevice.value,
format: selectedFormat.value,
width,
height,
fps: selectedFps.value,
},
})
toast.success(t('config.applied'))
// Stream state will be updated via WebSocket system.device_info event
} catch (e) {
console.info('[VideoConfig] Failed to apply config:', e)
// Error toast already shown by API layer
} finally {
applying.value = false
}
}
// Watch open state
watch(() => props.open, (isOpen) => {
if (isOpen) {
// Detect browser codec support on first open
if (browserSupportedCodecs.value.size === 0) {
detectBrowserCodecSupport()
}
// Load devices on first open
if (devices.value.length === 0) {
loadDevices()
}
// Load codecs and backends on first open
if (codecs.value.length === 0) {
loadCodecs()
}
// Load encoder backend config
loadEncoderBackend()
// Initialize from current config
initializeFromCurrent()
}
})
</script>
<template>
<Popover :open="open" @update:open="emit('update:open', $event)">
<PopoverTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
<Monitor class="h-4 w-4" />
<span class="hidden sm:inline">{{ buttonText }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[320px] p-3" align="start">
<div class="space-y-3">
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>
<Separator />
<!-- Stream Settings Section -->
<div class="space-y-3">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.streamSettings') }}</h5>
<!-- Mode Selection -->
<div class="space-y-2">
<Label class="text-xs">{{ t('actionbar.videoMode') }}</Label>
<Select
:model-value="props.videoMode"
@update:model-value="handleVideoModeChange"
:disabled="loadingCodecs || availableCodecs.length === 0"
>
<SelectTrigger class="h-8 text-xs">
<div v-if="selectedCodecInfo" class="flex items-center gap-1.5 truncate">
<span class="truncate">{{ selectedCodecInfo.name }}</span>
<span
v-if="selectedCodecInfo.backend && selectedCodecInfo.id !== 'mjpeg'"
class="text-[10px] px-1 py-0.5 rounded shrink-0"
:class="selectedCodecInfo.hardware
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'"
>
{{ translateBackendName(selectedCodecInfo.backend) }}
</span>
</div>
<span v-else class="text-muted-foreground">{{ loadingCodecs ? t('common.loading') : t('actionbar.selectMode') }}</span>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="codec in availableCodecs"
:key="codec.id"
:value="codec.id"
:disabled="!isBrowserSupported(codec.id)"
:class="['text-xs', { 'opacity-50': !isBrowserSupported(codec.id) }]"
>
<div class="flex items-center gap-2">
<span>{{ codec.name }}</span>
<!-- Show backend badge for WebRTC codecs -->
<span
v-if="codec.backend && codec.id !== 'mjpeg'"
class="text-[10px] px-1.5 py-0.5 rounded"
:class="codec.hardware
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'"
>
{{ translateBackendName(codec.backend) }}
</span>
<span
v-if="!isBrowserSupported(codec.id)"
class="text-[10px] text-muted-foreground"
>
({{ t('actionbar.browserUnsupported') }})
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<p v-if="props.videoMode !== 'mjpeg'" class="text-xs text-muted-foreground">
{{ t('actionbar.webrtcHint') }}
</p>
</div>
<!-- Bitrate Slider - Only shown for WebRTC modes -->
<div v-if="props.videoMode !== 'mjpeg'" class="space-y-2">
<div class="flex items-center gap-1">
<Label class="text-xs">{{ t('actionbar.bitrate') }}</Label>
<HelpTooltip :content="t('help.videoBitrate')" icon-size="sm" />
</div>
<div class="flex items-center gap-3">
<Slider
v-model="selectedBitrate"
:min="1000"
:max="15000"
:step="500"
class="flex-1"
/>
<span class="text-xs text-muted-foreground w-20 text-right">{{ selectedBitrate[0] }} kbps</span>
</div>
</div>
<!-- Settings Link - Admin only -->
<Button
v-if="props.isAdmin"
variant="ghost"
size="sm"
class="w-full h-7 text-xs text-muted-foreground hover:text-foreground justify-start px-0"
@click="goToSettings"
>
<Settings class="h-3.5 w-3.5 mr-1.5" />
{{ t('actionbar.changeEncoderBackend') }}
</Button>
</div>
<!-- Device Settings Section - Admin only -->
<template v-if="props.isAdmin">
<Separator />
<div class="space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5>
<Button
variant="ghost"
size="icon"
class="h-6 w-6"
:disabled="loadingDevices"
@click="loadDevices"
>
<RefreshCw :class="['h-3.5 w-3.5', loadingDevices && 'animate-spin']" />
</Button>
</div>
<!-- Device Selection -->
<div class="space-y-2">
<Label class="text-xs">{{ t('actionbar.videoDevice') }}</Label>
<Select
:model-value="selectedDevice"
@update:model-value="handleDeviceChange"
:disabled="loadingDevices || devices.length === 0"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="loadingDevices ? t('common.loading') : t('actionbar.selectDevice')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="device in devices"
:key="device.path"
:value="device.path"
class="text-xs"
>
{{ device.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Format Selection -->
<div class="space-y-2">
<Label class="text-xs">{{ t('actionbar.videoFormat') }}</Label>
<Select
:model-value="selectedFormat"
@update:model-value="handleFormatChange"
:disabled="!selectedDevice || availableFormats.length === 0"
>
<SelectTrigger class="h-8 text-xs">
<div v-if="selectedFormatInfo" class="flex items-center gap-1.5 truncate">
<span class="truncate">{{ selectedFormatInfo.description }}</span>
<span
v-if="isFormatRecommended(selectedFormatInfo.format)"
class="text-[10px] px-1 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 shrink-0"
>
{{ t('actionbar.recommended') }}
</span>
</div>
<span v-else class="text-muted-foreground">{{ t('actionbar.selectFormat') }}</span>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="format in availableFormats"
:key="format.format"
:value="format.format"
class="text-xs"
>
<div class="flex items-center gap-2">
<span>{{ format.description }}</span>
<span
v-if="isFormatRecommended(format.format)"
class="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
>
{{ t('actionbar.recommended') }}
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Resolution Selection -->
<div class="space-y-2">
<Label class="text-xs">{{ t('actionbar.videoResolution') }}</Label>
<Select
:model-value="selectedResolution"
@update:model-value="handleResolutionChange"
:disabled="!selectedFormat || availableResolutions.length === 0"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('actionbar.selectResolution')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="res in availableResolutions"
:key="`${res.width}x${res.height}`"
:value="`${res.width}x${res.height}`"
class="text-xs"
>
{{ res.width }} x {{ res.height }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- FPS Selection -->
<div class="space-y-2">
<Label class="text-xs">{{ t('actionbar.videoFps') }}</Label>
<Select
:model-value="String(selectedFps)"
@update:model-value="handleFpsChange"
:disabled="!selectedResolution || availableFps.length === 0"
>
<SelectTrigger class="h-8 text-xs">
<SelectValue :placeholder="t('actionbar.selectFps')" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="fps in availableFps"
:key="fps"
:value="String(fps)"
class="text-xs"
>
{{ fps }} FPS
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Apply Button -->
<Button
class="w-full h-8 text-xs"
:disabled="applying || !selectedDevice || !selectedFormat"
@click="applyVideoConfig"
>
<Loader2 v-if="applying" class="h-3.5 w-3.5 mr-1.5 animate-spin" />
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
</Button>
</div>
</template>
</div>
</PopoverContent>
</Popover>
</template>

View File

@@ -0,0 +1,961 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import Keyboard from 'simple-keyboard'
import 'simple-keyboard/build/css/index.css'
import { hidApi } from '@/api'
import {
keys,
latchingKeys,
modifiers,
type KeyName,
} from '@/lib/keyboardMappings'
const props = defineProps<{
visible: boolean
attached?: boolean
}>()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'update:attached', value: boolean): void
(e: 'keyDown', key: string): void
(e: 'keyUp', key: string): void
}>()
const { t } = useI18n()
// State
const isAttached = ref(props.attached ?? true)
// Keyboard instances
const mainKeyboard = ref<Keyboard | null>(null)
const controlKeyboard = ref<Keyboard | null>(null)
const arrowsKeyboard = ref<Keyboard | null>(null)
// Pressed keys tracking
const pressedModifiers = ref<number>(0)
const keysDown = ref<string[]>([])
// Shift state for display
const isShiftActive = computed(() => {
return (pressedModifiers.value & 0x22) !== 0
})
const layoutName = computed(() => {
return isShiftActive.value ? 'shift' : 'default'
})
// Keys currently pressed (for highlighting)
const keyNamesForDownKeys = computed(() => {
const activeModifierMask = pressedModifiers.value || 0
const modifierNames = Object.entries(modifiers)
.filter(([_, mask]) => (activeModifierMask & mask) !== 0)
.map(([name]) => name)
return [...modifierNames, ...keysDown.value, ' ']
})
// Dragging state (for floating mode)
const keyboardRef = ref<HTMLDivElement | null>(null)
const isDragging = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
const position = ref({ x: 100, y: 100 })
// Unique ID for this keyboard instance
const keyboardId = ref(`kb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
// Keyboard layouts - matching JetKVM style
const keyboardLayout = {
main: {
default: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace',
'Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
'CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter',
'ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight',
'ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight',
],
shift: [
'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
'Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) Backspace',
'Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)',
'CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter',
'ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight',
'ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight',
],
},
control: {
default: [
'PrintScreen ScrollLock Pause',
'Insert Home PageUp',
'Delete End PageDown',
],
},
arrows: {
default: [
'ArrowUp',
'ArrowLeft ArrowDown ArrowRight',
],
},
}
// Key display mapping with Unicode symbols (JetKVM style)
const keyDisplayMap: Record<string, string> = {
// Macros - compact format
CtrlAltDelete: 'Ctrl+Alt+Del',
AltMetaEscape: 'Alt+Meta+Esc',
CtrlAltBackspace: 'Ctrl+Alt+Bksp',
// Modifiers with symbols
ControlLeft: '^Ctrl',
ControlRight: 'Ctrl^',
ShiftLeft: '⇧Shift',
ShiftRight: 'Shift⇧',
AltLeft: '⌥Alt',
AltGr: 'AltGr',
MetaLeft: '⌘Meta',
MetaRight: 'Meta⌘',
Menu: 'Menu',
// Special keys with symbols
Escape: 'Esc',
Backspace: '⌫',
Tab: '⇥Tab',
CapsLock: '⇪Caps',
Enter: '↵',
Space: ' ',
// Navigation with symbols
Insert: 'Ins',
Delete: '⌫Del',
Home: 'Home',
End: 'End',
PageUp: 'PgUp',
PageDown: 'PgDn',
// Arrows
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
// Control cluster
PrintScreen: 'PrtSc',
ScrollLock: 'ScrLk',
Pause: 'Pause',
// Function keys
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4',
F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8',
F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12',
// Letters
KeyA: 'a', KeyB: 'b', KeyC: 'c', KeyD: 'd', KeyE: 'e',
KeyF: 'f', KeyG: 'g', KeyH: 'h', KeyI: 'i', KeyJ: 'j',
KeyK: 'k', KeyL: 'l', KeyM: 'm', KeyN: 'n', KeyO: 'o',
KeyP: 'p', KeyQ: 'q', KeyR: 'r', KeyS: 's', KeyT: 't',
KeyU: 'u', KeyV: 'v', KeyW: 'w', KeyX: 'x', KeyY: 'y',
KeyZ: 'z',
// Capital letters
'(KeyA)': 'A', '(KeyB)': 'B', '(KeyC)': 'C', '(KeyD)': 'D', '(KeyE)': 'E',
'(KeyF)': 'F', '(KeyG)': 'G', '(KeyH)': 'H', '(KeyI)': 'I', '(KeyJ)': 'J',
'(KeyK)': 'K', '(KeyL)': 'L', '(KeyM)': 'M', '(KeyN)': 'N', '(KeyO)': 'O',
'(KeyP)': 'P', '(KeyQ)': 'Q', '(KeyR)': 'R', '(KeyS)': 'S', '(KeyT)': 'T',
'(KeyU)': 'U', '(KeyV)': 'V', '(KeyW)': 'W', '(KeyX)': 'X', '(KeyY)': 'Y',
'(KeyZ)': 'Z',
// Numbers
Digit1: '1', Digit2: '2', Digit3: '3', Digit4: '4', Digit5: '5',
Digit6: '6', Digit7: '7', Digit8: '8', Digit9: '9', Digit0: '0',
// Shifted Numbers
'(Digit1)': '!', '(Digit2)': '@', '(Digit3)': '#', '(Digit4)': '$', '(Digit5)': '%',
'(Digit6)': '^', '(Digit7)': '&', '(Digit8)': '*', '(Digit9)': '(', '(Digit0)': ')',
// Symbols
Minus: '-', '(Minus)': '_',
Equal: '=', '(Equal)': '+',
BracketLeft: '[', '(BracketLeft)': '{',
BracketRight: ']', '(BracketRight)': '}',
Backslash: '\\', '(Backslash)': '|',
Semicolon: ';', '(Semicolon)': ':',
Quote: "'", '(Quote)': '"',
Comma: ',', '(Comma)': '<',
Period: '.', '(Period)': '>',
Slash: '/', '(Slash)': '?',
Backquote: '`', '(Backquote)': '~',
}
// Key press handler
async function onKeyDown(key: string) {
// Handle macro keys
if (key === 'CtrlAltDelete') {
await executeMacro([
{ keys: ['Delete'], modifiers: ['ControlLeft', 'AltLeft'] },
])
return
}
if (key === 'AltMetaEscape') {
await executeMacro([
{ keys: ['Escape'], modifiers: ['AltLeft', 'MetaLeft'] },
])
return
}
if (key === 'CtrlAltBackspace') {
await executeMacro([
{ keys: ['Backspace'], modifiers: ['ControlLeft', 'AltLeft'] },
])
return
}
// Clean key name (remove parentheses for shifted keys)
const cleanKey = key.replace(/[()]/g, '')
// Check if key exists
if (!(cleanKey in keys)) {
console.warn(`[VirtualKeyboard] Unknown key: ${cleanKey}`)
return
}
const keyCode = keys[cleanKey as KeyName]
// Handle latching keys (Caps Lock, etc.)
if ((latchingKeys as readonly string[]).includes(cleanKey)) {
emit('keyDown', cleanKey)
await sendKeyPress(keyCode, true)
setTimeout(() => {
sendKeyPress(keyCode, false)
emit('keyUp', cleanKey)
}, 100)
return
}
// Handle modifier keys (toggle)
if (cleanKey in modifiers) {
const mask = modifiers[cleanKey as keyof typeof modifiers]
const isCurrentlyDown = (pressedModifiers.value & mask) !== 0
if (isCurrentlyDown) {
pressedModifiers.value &= ~mask
await sendKeyPress(keyCode, false)
emit('keyUp', cleanKey)
} else {
pressedModifiers.value |= mask
await sendKeyPress(keyCode, true)
emit('keyDown', cleanKey)
}
updateKeyboardButtonTheme()
return
}
// Regular key: press and release
keysDown.value.push(cleanKey)
emit('keyDown', cleanKey)
await sendKeyPress(keyCode, true)
updateKeyboardButtonTheme()
setTimeout(async () => {
keysDown.value = keysDown.value.filter(k => k !== cleanKey)
await sendKeyPress(keyCode, false)
emit('keyUp', cleanKey)
updateKeyboardButtonTheme()
}, 50)
}
async function onKeyUp() {
// Not used for now - we handle up in onKeyDown with setTimeout
}
async function sendKeyPress(keyCode: number, press: boolean) {
try {
const mods = {
ctrl: (pressedModifiers.value & 0x11) !== 0,
shift: (pressedModifiers.value & 0x22) !== 0,
alt: (pressedModifiers.value & 0x44) !== 0,
meta: (pressedModifiers.value & 0x88) !== 0,
}
await hidApi.keyboard(press ? 'down' : 'up', keyCode, mods)
} catch (err) {
console.error('[VirtualKeyboard] Key send failed:', err)
}
}
interface MacroStep {
keys: string[]
modifiers: string[]
}
async function executeMacro(steps: MacroStep[]) {
for (const step of steps) {
for (const mod of step.modifiers) {
if (mod in keys) {
await sendKeyPress(keys[mod as KeyName], true)
}
}
for (const key of step.keys) {
if (key in keys) {
await sendKeyPress(keys[key as KeyName], true)
}
}
await new Promise(resolve => setTimeout(resolve, 50))
for (const key of step.keys) {
if (key in keys) {
await sendKeyPress(keys[key as KeyName], false)
}
}
for (const mod of step.modifiers) {
if (mod in keys) {
await sendKeyPress(keys[mod as KeyName], false)
}
}
}
}
// Update keyboard button theme for pressed keys
function updateKeyboardButtonTheme() {
const downKeys = keyNamesForDownKeys.value.join(' ')
const buttonTheme = [
{
class: 'combination-key',
buttons: 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
},
{
class: 'down-key',
buttons: downKeys,
},
]
mainKeyboard.value?.setOptions({ buttonTheme })
controlKeyboard.value?.setOptions({ buttonTheme })
arrowsKeyboard.value?.setOptions({ buttonTheme })
}
// Update layout when shift state changes
watch(layoutName, (name) => {
mainKeyboard.value?.setOptions({ layoutName: name })
})
// Initialize keyboards with unique selectors
function initKeyboards() {
const id = keyboardId.value
// Check if elements exist - use full selector with #
const mainEl = document.querySelector(`#${id}-main`)
const controlEl = document.querySelector(`#${id}-control`)
const arrowsEl = document.querySelector(`#${id}-arrows`)
if (!mainEl || !controlEl || !arrowsEl) {
console.warn('[VirtualKeyboard] DOM elements not ready, retrying...', id)
setTimeout(initKeyboards, 50)
return
}
// Main keyboard - pass element directly instead of selector string
mainKeyboard.value = new Keyboard(mainEl, {
layout: keyboardLayout.main,
layoutName: layoutName.value,
display: keyDisplayMap,
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
onKeyPress: onKeyDown,
onKeyReleased: onKeyUp,
buttonTheme: [
{
class: 'combination-key',
buttons: 'CtrlAltDelete AltMetaEscape CtrlAltBackspace',
},
],
disableButtonHold: true,
preventMouseDownDefault: true,
preventMouseUpDefault: true,
stopMouseDownPropagation: true,
stopMouseUpPropagation: true,
})
// Control keyboard
controlKeyboard.value = new Keyboard(controlEl, {
layout: keyboardLayout.control,
layoutName: 'default',
display: keyDisplayMap,
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
onKeyPress: onKeyDown,
onKeyReleased: onKeyUp,
disableButtonHold: true,
preventMouseDownDefault: true,
preventMouseUpDefault: true,
stopMouseDownPropagation: true,
stopMouseUpPropagation: true,
})
// Arrows keyboard
arrowsKeyboard.value = new Keyboard(arrowsEl, {
layout: keyboardLayout.arrows,
layoutName: 'default',
display: keyDisplayMap,
theme: 'hg-theme-default hg-layout-default vkb-keyboard',
onKeyPress: onKeyDown,
onKeyReleased: onKeyUp,
disableButtonHold: true,
preventMouseDownDefault: true,
preventMouseUpDefault: true,
stopMouseDownPropagation: true,
stopMouseUpPropagation: true,
})
console.log('[VirtualKeyboard] Keyboards initialized:', id)
}
// Destroy keyboards
function destroyKeyboards() {
mainKeyboard.value?.destroy()
controlKeyboard.value?.destroy()
arrowsKeyboard.value?.destroy()
mainKeyboard.value = null
controlKeyboard.value = null
arrowsKeyboard.value = null
}
// Dragging handlers
function getClientCoords(e: MouseEvent | TouchEvent): { x: number; y: number } | null {
if ('touches' in e) {
const touch = e.touches[0]
return touch ? { x: touch.clientX, y: touch.clientY } : null
}
return { x: e.clientX, y: e.clientY }
}
function startDrag(e: MouseEvent | TouchEvent) {
if (isAttached.value || !keyboardRef.value) return
const coords = getClientCoords(e)
if (!coords) return
isDragging.value = true
const rect = keyboardRef.value.getBoundingClientRect()
dragOffset.value = {
x: coords.x - rect.left,
y: coords.y - rect.top,
}
}
function onDrag(e: MouseEvent | TouchEvent) {
if (!isDragging.value || !keyboardRef.value) return
const coords = getClientCoords(e)
if (!coords) return
const rect = keyboardRef.value.getBoundingClientRect()
const maxX = window.innerWidth - rect.width
const maxY = window.innerHeight - rect.height
position.value = {
x: Math.min(maxX, Math.max(0, coords.x - dragOffset.value.x)),
y: Math.min(maxY, Math.max(0, coords.y - dragOffset.value.y)),
}
}
function endDrag() {
isDragging.value = false
}
async function toggleAttached() {
destroyKeyboards()
isAttached.value = !isAttached.value
emit('update:attached', isAttached.value)
// Wait for Teleport to move the component
await nextTick()
await nextTick() // Extra tick for Teleport
// Reinitialize keyboards in new location
setTimeout(() => {
initKeyboards()
}, 100)
}
function close() {
emit('update:visible', false)
}
// Watch visibility to init/destroy keyboards
watch(() => props.visible, async (visible) => {
console.log('[VirtualKeyboard] Visibility changed:', visible, 'attached:', isAttached.value, 'id:', keyboardId.value)
if (visible) {
await nextTick()
initKeyboards()
} else {
destroyKeyboards()
}
}, { immediate: true })
watch(() => props.attached, (value) => {
if (value !== undefined) {
isAttached.value = value
}
})
onMounted(() => {
document.addEventListener('mousemove', onDrag)
document.addEventListener('touchmove', onDrag)
document.addEventListener('mouseup', endDrag)
document.addEventListener('touchend', endDrag)
})
onUnmounted(() => {
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('mouseup', endDrag)
document.removeEventListener('touchend', endDrag)
destroyKeyboards()
})
</script>
<template>
<Transition name="keyboard-fade">
<div
v-if="visible"
:id="keyboardId"
ref="keyboardRef"
class="vkb"
:class="{
'vkb--attached': isAttached,
'vkb--floating': !isAttached,
'vkb--dragging': isDragging,
}"
:style="!isAttached ? { transform: `translate(${position.x}px, ${position.y}px)` } : undefined"
>
<!-- Header -->
<div
class="vkb-header"
@mousedown="startDrag"
@touchstart="startDrag"
>
<button class="vkb-btn" @click="toggleAttached">
{{ isAttached ? t('virtualKeyboard.detach') : t('virtualKeyboard.attach') }}
</button>
<span class="vkb-title">{{ t('virtualKeyboard.title') }}</span>
<button class="vkb-btn" @click="close">
{{ t('virtualKeyboard.hide') }}
</button>
</div>
<!-- Keyboard body -->
<div class="vkb-body">
<div :id="`${keyboardId}-main`" class="kb-main-container"></div>
<div class="vkb-side">
<div :id="`${keyboardId}-control`" class="kb-control-container"></div>
<div :id="`${keyboardId}-arrows`" class="kb-arrows-container"></div>
</div>
</div>
</div>
</Transition>
</template>
<style>
/* Simple-keyboard global overrides */
.vkb .simple-keyboard.hg-theme-default {
font-family: inherit;
background: transparent;
padding: 0;
}
.vkb .simple-keyboard .hg-button {
height: 36px;
border-radius: 6px;
background: var(--keyboard-button-bg, white);
color: var(--keyboard-button-color, #1f2937);
border: 1px solid var(--keyboard-button-border, #e5e7eb);
border-bottom-width: 2px;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
font-size: 12px;
font-weight: 500;
padding: 0 6px;
margin: 0 2px 4px 0;
/* Key sizing for alignment */
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
min-width: 40px;
}
.vkb .simple-keyboard .hg-button:hover {
background: var(--keyboard-button-hover-bg, #f3f4f6);
}
.vkb .simple-keyboard .hg-button:active {
background: #3b82f6;
color: white;
border-bottom-width: 1px;
margin-top: 1px;
}
.vkb .simple-keyboard .hg-button span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Combination/macro keys */
.vkb .simple-keyboard .hg-button.combination-key {
font-size: 10px;
height: 28px;
min-width: auto !important;
max-width: fit-content !important;
flex-grow: 0 !important;
padding: 0 8px;
}
/* Pressed keys */
.vkb .simple-keyboard .hg-button.down-key {
background: #3b82f6;
color: white;
font-weight: 600;
border-color: #2563eb;
}
/* Space bar */
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
min-width: 200px;
flex-grow: 6;
}
/* Row spacing */
.vkb .simple-keyboard .hg-row {
margin-bottom: 0;
}
.vkb .simple-keyboard .hg-row:last-child {
margin-bottom: 0;
}
/* First row (macros) - left aligned */
.kb-main-container .hg-row:first-child {
justify-content: flex-start !important;
margin-bottom: 8px;
gap: 4px;
}
/* Second row (function keys) spacing */
.kb-main-container .hg-row:nth-child(2) {
margin-bottom: 8px;
}
/* Backspace - wider */
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"] {
flex-grow: 2;
min-width: 80px;
}
/* Tab key */
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"] {
flex-grow: 1.5;
min-width: 60px;
}
/* Backslash - adjust to match row width */
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"] {
flex-grow: 1.5;
min-width: 60px;
}
/* Caps Lock */
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
flex-grow: 1.75;
min-width: 70px;
}
/* Enter key */
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"] {
flex-grow: 2.25;
min-width: 90px;
}
/* Left Shift - wider */
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"] {
flex-grow: 2.25;
min-width: 90px;
}
/* Right Shift - wider */
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
flex-grow: 2.75;
min-width: 110px;
}
/* Bottom row modifiers */
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"] {
flex-grow: 1.25;
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"] {
flex-grow: 1.25;
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"] {
flex-grow: 1.25;
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="AltGr"] {
flex-grow: 1.25;
min-width: 55px;
}
.vkb .simple-keyboard .hg-button[data-skbtn="Menu"] {
flex-grow: 1.25;
min-width: 55px;
}
/* Control keyboard */
.kb-control-container .hg-button {
min-width: 54px !important;
justify-content: center;
}
/* Arrow buttons */
.kb-arrows-container .hg-button {
min-width: 44px !important;
width: 44px !important;
justify-content: center;
}
.kb-arrows-container .hg-row {
justify-content: center;
}
/* Dark mode - must be after simple-keyboard CSS import */
/* Use multiple selectors to ensure matching */
:root.dark .hg-theme-default .hg-button,
html.dark .hg-theme-default .hg-button,
.dark .hg-theme-default .hg-button {
background: #374151 !important;
color: #f9fafb !important;
border-color: #4b5563 !important;
border-bottom-color: #4b5563 !important;
box-shadow: none !important;
}
:root.dark .hg-theme-default .hg-button:hover,
html.dark .hg-theme-default .hg-button:hover,
.dark .hg-theme-default .hg-button:hover {
background: #4b5563 !important;
}
:root.dark .hg-theme-default .hg-button:active,
html.dark .hg-theme-default .hg-button:active,
.dark .hg-theme-default .hg-button:active,
:root.dark .hg-theme-default .hg-button.hg-activeButton,
html.dark .hg-theme-default .hg-button.hg-activeButton,
.dark .hg-theme-default .hg-button.hg-activeButton {
background: #3b82f6 !important;
color: white !important;
}
:root.dark .hg-theme-default .hg-button.down-key,
html.dark .hg-theme-default .hg-button.down-key,
.dark .hg-theme-default .hg-button.down-key {
background: #3b82f6 !important;
color: white !important;
border-color: #2563eb !important;
border-bottom-color: #2563eb !important;
}
</style>
<style scoped>
.vkb {
z-index: 100;
user-select: none;
background: white;
border: 1px solid #e5e7eb;
}
:global(.dark .vkb) {
background: #1f2937;
border-color: #374151;
}
.vkb--attached {
position: relative;
width: 100%;
border-left: 0;
border-right: 0;
border-bottom: 0;
}
.vkb--floating {
position: fixed;
top: 0;
left: 0;
min-width: 1200px;
max-width: 1600px;
width: auto;
border-radius: 8px;
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
}
.vkb--dragging {
cursor: move;
}
/* Header - compact */
.vkb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
min-height: 28px;
}
:global(.dark .vkb-header) {
background: #111827;
border-color: #374151;
}
.vkb--floating .vkb-header {
cursor: move;
border-radius: 8px 8px 0 0;
}
.vkb-title {
font-size: 12px;
font-weight: 500;
color: #374151;
}
:global(.dark .vkb-title) {
color: #d1d5db;
}
.vkb-btn {
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
color: #374151;
background: white;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
line-height: 1.4;
}
.vkb-btn:hover {
background: #f3f4f6;
}
:global(.dark .vkb-btn) {
color: #d1d5db;
background: #374151;
border-color: #4b5563;
}
:global(.dark .vkb-btn:hover) {
background: #4b5563;
}
/* Keyboard body */
.vkb-body {
display: flex;
flex-direction: row;
padding: 8px;
gap: 8px;
background: #f3f4f6;
}
:global(.dark .vkb-body) {
background: #111827;
}
.vkb--floating .vkb-body {
border-radius: 0 0 8px 8px;
}
.kb-main-container {
flex: 1;
}
.vkb-side {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
}
.kb-control-container,
.kb-arrows-container {
display: inline-block;
}
/* Responsive */
@media (max-width: 900px) {
.vkb-body {
flex-direction: column;
}
.vkb-side {
flex-direction: row;
justify-content: center;
gap: 16px;
}
}
/* Floating mode - slightly smaller keys but still readable */
.vkb--floating .vkb-body {
padding: 8px;
}
.vkb--floating :deep(.simple-keyboard .hg-button) {
height: 34px;
font-size: 12px;
}
.vkb--floating :deep(.simple-keyboard .hg-button.combination-key) {
height: 26px;
font-size: 10px;
}
.vkb--floating :deep(.kb-control-container .hg-button) {
min-width: 52px !important;
}
.vkb--floating :deep(.kb-arrows-container .hg-button) {
min-width: 42px !important;
width: 42px !important;
}
/* Animation */
.keyboard-fade-enter-active,
.keyboard-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.keyboard-fade-enter-from,
.keyboard-fade-leave-to {
opacity: 0;
}
.vkb--attached.keyboard-fade-enter-from,
.vkb--attached.keyboard-fade-leave-to {
transform: translateY(20px);
}
.vkb--floating.keyboard-fade-enter-from,
.vkb--floating.keyboard-fade-leave-to {
transform: scale(0.95);
}
</style>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<AlertDialogAction :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<AlertDialogCancel :class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
<slot />
</AlertDialogCancel>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
type AlertDialogContentEmits,
type AlertDialogContentProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AlertDialogContentEmits>()
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<AlertDialogContent
v-bind="props"
:class="cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class
)"
@escape-key-down="emits('escapeKeyDown', $event)"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AlertDialogDescription, type AlertDialogDescriptionProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<AlertDialogDescription :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</AlertDialogDescription>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex flex-col space-y-2 text-center sm:text-left', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { AlertDialogTitle, type AlertDialogTitleProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
</script>
<template>
<AlertDialogTitle :class="cn('text-lg font-semibold', props.class)">
<slot />
</AlertDialogTitle>
</template>

View File

@@ -0,0 +1,11 @@
export {
AlertDialogRoot as AlertDialog,
AlertDialogTrigger,
} from 'reka-ui'
export { default as AlertDialogContent } from './AlertDialogContent.vue'
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
export { default as AlertDialogAction } from './AlertDialogAction.vue'
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { AvatarRoot } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<AvatarRoot
data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
>
<slot />
</AvatarRoot>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AvatarFallback } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
>
<slot />
</AvatarFallback>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AvatarImageProps } from "reka-ui"
import { AvatarImage } from "reka-ui"
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full"
>
<slot />
</AvatarImage>
</template>

View File

@@ -0,0 +1,3 @@
export { default as Avatar } from "./Avatar.vue"
export { default as AvatarFallback } from "./AvatarFallback.vue"
export { default as AvatarImage } from "./AvatarImage.vue"

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,38 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-6', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>

View File

@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import DialogOverlay from "./DialogOverlay.vue"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
>
<slot />
</DialogOverlay>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
>
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,10 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui"
import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot
v-slot="slotProps"
data-slot="dropdown-menu"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DropdownMenuRoot>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class=" cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
{
sideOffset: 4,
},
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuGroupProps } from "reka-ui"
import { DropdownMenuGroup } from "reka-ui"
const props = defineProps<DropdownMenuGroupProps>()
</script>
<template>
<DropdownMenuGroup
data-slot="dropdown-menu-group"
v-bind="props"
>
<slot />
</DropdownMenuGroup>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { DropdownMenuItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DropdownMenuItem, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<DropdownMenuItemProps & {
class?: HTMLAttributes["class"]
inset?: boolean
variant?: "default" | "destructive"
}>(), {
variant: "default",
})
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
>
<slot />
</DropdownMenuItem>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DropdownMenuLabel, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class", "inset")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuLabel
data-slot="dropdown-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui"
import {
DropdownMenuRadioGroup,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<DropdownMenuRadioGroupProps>()
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRadioGroup
data-slot="dropdown-menu-radio-group"
v-bind="forwarded"
>
<slot />
</DropdownMenuRadioGroup>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Circle } from "lucide-vue-next"
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Circle class="size-2 fill-current" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuSeparator,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSeparatorProps & {
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DropdownMenuSeparator
data-slot="dropdown-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
data-slot="dropdown-menu-shortcut"
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui"
import {
DropdownMenuSub,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<DropdownMenuSubProps>()
const emits = defineEmits<DropdownMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
<slot v-bind="slotProps" />
</DropdownMenuSub>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuSubContent,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
>
<slot />
</DropdownMenuSubContent>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import {
DropdownMenuSubTrigger,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class", "inset")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
props.class,
)"
>
<slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { DropdownMenuTriggerProps } from "reka-ui"
import { DropdownMenuTrigger, useForwardProps } from "reka-ui"
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger
data-slot="dropdown-menu-trigger"
v-bind="forwardedProps"
>
<slot />
</DropdownMenuTrigger>
</template>

View File

@@ -0,0 +1,16 @@
export { default as DropdownMenu } from "./DropdownMenu.vue"
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
export { DropdownMenuPortal } from "reka-ui"

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import {
HoverCardContent,
HoverCardPortal,
type HoverCardContentProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<HoverCardPortal>
<HoverCardContent
v-bind="delegatedProps"
:class="cn(
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)"
>
<slot />
</HoverCardContent>
</HoverCardPortal>
</template>

View File

@@ -0,0 +1,5 @@
export {
HoverCardRoot as HoverCard,
HoverCardTrigger,
} from 'reka-ui'
export { default as HoverCardContent } from './HoverCardContent.vue'

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)"
>
</template>

View File

@@ -0,0 +1 @@
export { default as Input } from "./Input.vue"

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { LabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Label } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1 @@
export { default as Label } from "./Label.vue"

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import {
PopoverContent,
PopoverPortal,
type PopoverContentProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<PopoverPortal>
<PopoverContent
v-bind="delegatedProps"
:class="cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>

View File

@@ -0,0 +1,5 @@
export {
PopoverRoot as Popover,
PopoverTrigger,
} from 'reka-ui'
export { default as PopoverContent } from './PopoverContent.vue'

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ProgressRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ProgressIndicator,
ProgressRoot,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<ProgressRootProps & { class?: HTMLAttributes["class"] }>(),
{
modelValue: 0,
},
)
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ProgressRoot
data-slot="progress"
v-bind="delegatedProps"
:class="
cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
props.class,
)
"
>
<ProgressIndicator
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
/>
</ProgressRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Progress } from "./Progress.vue"

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { RadioGroupRootEmits, RadioGroupRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RadioGroupRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RadioGroupRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<RadioGroupRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<RadioGroupRoot
:class="cn('grid gap-2', props.class)"
v-bind="forwarded"
>
<slot />
</RadioGroupRoot>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { RadioGroupItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
RadioGroupIndicator,
RadioGroupItem,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RadioGroupItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RadioGroupItem
v-bind="forwardedProps"
:class="
cn(
'peer aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<RadioGroupIndicator class="flex items-center justify-center">
<Check class="h-3.5 w-3.5 text-primary" />
</RadioGroupIndicator>
</RadioGroupItem>
</template>

View File

@@ -0,0 +1,2 @@
export { default as RadioGroup } from "./RadioGroup.vue"
export { default as RadioGroupItem } from "./RadioGroupItem.vue"

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { type HTMLAttributes, computed } from 'vue'
import {
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
type ScrollAreaRootProps,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
<slot />
</ScrollAreaViewport>
<ScrollAreaScrollbar
class="flex touch-none select-none transition-colors data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=vertical]:border-l data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:border-l-transparent data-[orientation=horizontal]:p-[1px] data-[orientation=vertical]:p-[1px]"
orientation="vertical"
>
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar
class="flex touch-none select-none transition-colors data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=vertical]:border-l data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:border-l-transparent data-[orientation=horizontal]:p-[1px] data-[orientation=vertical]:p-[1px]"
orientation="horizontal"
>
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbar>
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as ScrollArea } from './ScrollArea.vue'

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot v-bind="forwarded">
<slot />
</SelectRoot>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { SelectScrollDownButton, SelectScrollUpButton } from "."
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{
position: "popper",
},
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
v-bind="{ ...forwarded, ...$attrs }" :class="cn(
'relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[--reka-select-trigger-height] w-full min-w-[--reka-select-trigger-width]')">
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectGroupProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectGroup } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectGroupProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<SelectGroup :class="cn('p-1 w-full', props.class)" v-bind="delegatedProps">
<slot />
</SelectGroup>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectItem
v-bind="forwardedProps"
:class="
cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class,
)
"
>
<span class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectItemIndicator>
<Check class="h-4 w-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { SelectItemTextProps } from "reka-ui"
import { SelectItemText } from "reka-ui"
const props = defineProps<SelectItemTextProps>()
</script>
<template>
<SelectItemText v-bind="props">
<slot />
</SelectItemText>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { SelectLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { SelectLabel } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<SelectLabel :class="cn('px-2 py-1.5 text-sm font-semibold', props.class)">
<slot />
</SelectLabel>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollDownButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)">
<slot>
<ChevronDown />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronUp } from "lucide-vue-next"
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectScrollUpButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)">
<slot>
<ChevronUp />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { SelectSeparator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<SelectSeparator v-bind="delegatedProps" :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" />
</template>

Some files were not shown because too many files have changed in this diff Show More