mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-29 00:51:53 +08:00
init
This commit is contained in:
60
web/src/App.vue
Normal file
60
web/src/App.vue
Normal 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
255
web/src/api/config.ts
Normal 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
696
web/src/api/index.ts
Normal 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 使用域特定 API(videoConfigApi, 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
1
web/src/assets/vue.svg
Normal 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 |
306
web/src/components/ActionBar.vue
Normal file
306
web/src/components/ActionBar.vue
Normal 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>
|
||||
135
web/src/components/AppLayout.vue
Normal file
135
web/src/components/AppLayout.vue
Normal 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>
|
||||
262
web/src/components/AtxPopover.vue
Normal file
262
web/src/components/AtxPopover.vue
Normal 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>
|
||||
318
web/src/components/AudioConfigPopover.vue
Normal file
318
web/src/components/AudioConfigPopover.vue
Normal 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>
|
||||
41
web/src/components/HelloWorld.vue
Normal file
41
web/src/components/HelloWorld.vue
Normal 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>
|
||||
39
web/src/components/HelpTooltip.vue
Normal file
39
web/src/components/HelpTooltip.vue
Normal 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>
|
||||
386
web/src/components/HidConfigPopover.vue
Normal file
386
web/src/components/HidConfigPopover.vue
Normal 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>
|
||||
102
web/src/components/InfoBar.vue
Normal file
102
web/src/components/InfoBar.vue
Normal 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>
|
||||
1104
web/src/components/MsdDialog.vue
Normal file
1104
web/src/components/MsdDialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
590
web/src/components/MsdSheet.vue
Normal file
590
web/src/components/MsdSheet.vue
Normal 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>
|
||||
272
web/src/components/PasteModal.vue
Normal file
272
web/src/components/PasteModal.vue
Normal 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>
|
||||
658
web/src/components/StatsSheet.vue
Normal file
658
web/src/components/StatsSheet.vue
Normal 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>
|
||||
211
web/src/components/StatusCard.vue
Normal file
211
web/src/components/StatusCard.vue
Normal 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>
|
||||
708
web/src/components/VideoConfigPopover.vue
Normal file
708
web/src/components/VideoConfigPopover.vue
Normal 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>
|
||||
961
web/src/components/VirtualKeyboard.vue
Normal file
961
web/src/components/VirtualKeyboard.vue
Normal 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>
|
||||
14
web/src/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
14
web/src/components/ui/alert-dialog/AlertDialogAction.vue
Normal 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>
|
||||
14
web/src/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
14
web/src/components/ui/alert-dialog/AlertDialogCancel.vue
Normal 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>
|
||||
32
web/src/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
32
web/src/components/ui/alert-dialog/AlertDialogContent.vue
Normal 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>
|
||||
@@ -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>
|
||||
14
web/src/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
14
web/src/components/ui/alert-dialog/AlertDialogFooter.vue
Normal 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>
|
||||
14
web/src/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
14
web/src/components/ui/alert-dialog/AlertDialogHeader.vue
Normal 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>
|
||||
13
web/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
13
web/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal 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>
|
||||
11
web/src/components/ui/alert-dialog/index.ts
Normal file
11
web/src/components/ui/alert-dialog/index.ts
Normal 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'
|
||||
18
web/src/components/ui/avatar/Avatar.vue
Normal file
18
web/src/components/ui/avatar/Avatar.vue
Normal 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>
|
||||
21
web/src/components/ui/avatar/AvatarFallback.vue
Normal file
21
web/src/components/ui/avatar/AvatarFallback.vue
Normal 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>
|
||||
16
web/src/components/ui/avatar/AvatarImage.vue
Normal file
16
web/src/components/ui/avatar/AvatarImage.vue
Normal 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>
|
||||
3
web/src/components/ui/avatar/index.ts
Normal file
3
web/src/components/ui/avatar/index.ts
Normal 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"
|
||||
26
web/src/components/ui/badge/Badge.vue
Normal file
26
web/src/components/ui/badge/Badge.vue
Normal 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>
|
||||
26
web/src/components/ui/badge/index.ts
Normal file
26
web/src/components/ui/badge/index.ts
Normal 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>
|
||||
29
web/src/components/ui/button/Button.vue
Normal file
29
web/src/components/ui/button/Button.vue
Normal 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>
|
||||
38
web/src/components/ui/button/index.ts
Normal file
38
web/src/components/ui/button/index.ts
Normal 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>
|
||||
22
web/src/components/ui/card/Card.vue
Normal file
22
web/src/components/ui/card/Card.vue
Normal 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>
|
||||
17
web/src/components/ui/card/CardAction.vue
Normal file
17
web/src/components/ui/card/CardAction.vue
Normal 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>
|
||||
17
web/src/components/ui/card/CardContent.vue
Normal file
17
web/src/components/ui/card/CardContent.vue
Normal 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>
|
||||
17
web/src/components/ui/card/CardDescription.vue
Normal file
17
web/src/components/ui/card/CardDescription.vue
Normal 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>
|
||||
17
web/src/components/ui/card/CardFooter.vue
Normal file
17
web/src/components/ui/card/CardFooter.vue
Normal 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>
|
||||
17
web/src/components/ui/card/CardHeader.vue
Normal file
17
web/src/components/ui/card/CardHeader.vue
Normal 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>
|
||||
17
web/src/components/ui/card/CardTitle.vue
Normal file
17
web/src/components/ui/card/CardTitle.vue
Normal 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>
|
||||
7
web/src/components/ui/card/index.ts
Normal file
7
web/src/components/ui/card/index.ts
Normal 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"
|
||||
19
web/src/components/ui/dialog/Dialog.vue
Normal file
19
web/src/components/ui/dialog/Dialog.vue
Normal 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>
|
||||
15
web/src/components/ui/dialog/DialogClose.vue
Normal file
15
web/src/components/ui/dialog/DialogClose.vue
Normal 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>
|
||||
53
web/src/components/ui/dialog/DialogContent.vue
Normal file
53
web/src/components/ui/dialog/DialogContent.vue
Normal 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>
|
||||
23
web/src/components/ui/dialog/DialogDescription.vue
Normal file
23
web/src/components/ui/dialog/DialogDescription.vue
Normal 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>
|
||||
15
web/src/components/ui/dialog/DialogFooter.vue
Normal file
15
web/src/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||
17
web/src/components/ui/dialog/DialogHeader.vue
Normal file
17
web/src/components/ui/dialog/DialogHeader.vue
Normal 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>
|
||||
21
web/src/components/ui/dialog/DialogOverlay.vue
Normal file
21
web/src/components/ui/dialog/DialogOverlay.vue
Normal 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>
|
||||
59
web/src/components/ui/dialog/DialogScrollContent.vue
Normal file
59
web/src/components/ui/dialog/DialogScrollContent.vue
Normal 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>
|
||||
23
web/src/components/ui/dialog/DialogTitle.vue
Normal file
23
web/src/components/ui/dialog/DialogTitle.vue
Normal 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>
|
||||
15
web/src/components/ui/dialog/DialogTrigger.vue
Normal file
15
web/src/components/ui/dialog/DialogTrigger.vue
Normal 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>
|
||||
10
web/src/components/ui/dialog/index.ts
Normal file
10
web/src/components/ui/dialog/index.ts
Normal 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"
|
||||
19
web/src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
19
web/src/components/ui/dropdown-menu/DropdownMenu.vue
Normal 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>
|
||||
@@ -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>
|
||||
39
web/src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal file
39
web/src/components/ui/dropdown-menu/DropdownMenuContent.vue
Normal 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>
|
||||
15
web/src/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal file
15
web/src/components/ui/dropdown-menu/DropdownMenuGroup.vue
Normal 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>
|
||||
31
web/src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal file
31
web/src/components/ui/dropdown-menu/DropdownMenuItem.vue
Normal 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>
|
||||
23
web/src/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal file
23
web/src/components/ui/dropdown-menu/DropdownMenuLabel.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
17
web/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal file
17
web/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue
Normal 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>
|
||||
18
web/src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal file
18
web/src/components/ui/dropdown-menu/DropdownMenuSub.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
17
web/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal file
17
web/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue
Normal 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>
|
||||
16
web/src/components/ui/dropdown-menu/index.ts
Normal file
16
web/src/components/ui/dropdown-menu/index.ts
Normal 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"
|
||||
30
web/src/components/ui/hover-card/HoverCardContent.vue
Normal file
30
web/src/components/ui/hover-card/HoverCardContent.vue
Normal 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>
|
||||
5
web/src/components/ui/hover-card/index.ts
Normal file
5
web/src/components/ui/hover-card/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
HoverCardRoot as HoverCard,
|
||||
HoverCardTrigger,
|
||||
} from 'reka-ui'
|
||||
export { default as HoverCardContent } from './HoverCardContent.vue'
|
||||
33
web/src/components/ui/input/Input.vue
Normal file
33
web/src/components/ui/input/Input.vue
Normal 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>
|
||||
1
web/src/components/ui/input/index.ts
Normal file
1
web/src/components/ui/input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from "./Input.vue"
|
||||
26
web/src/components/ui/label/Label.vue
Normal file
26
web/src/components/ui/label/Label.vue
Normal 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>
|
||||
1
web/src/components/ui/label/index.ts
Normal file
1
web/src/components/ui/label/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Label } from "./Label.vue"
|
||||
30
web/src/components/ui/popover/PopoverContent.vue
Normal file
30
web/src/components/ui/popover/PopoverContent.vue
Normal 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>
|
||||
5
web/src/components/ui/popover/index.ts
Normal file
5
web/src/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
PopoverRoot as Popover,
|
||||
PopoverTrigger,
|
||||
} from 'reka-ui'
|
||||
export { default as PopoverContent } from './PopoverContent.vue'
|
||||
38
web/src/components/ui/progress/Progress.vue
Normal file
38
web/src/components/ui/progress/Progress.vue
Normal 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>
|
||||
1
web/src/components/ui/progress/index.ts
Normal file
1
web/src/components/ui/progress/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Progress } from "./Progress.vue"
|
||||
23
web/src/components/ui/radio-group/RadioGroup.vue
Normal file
23
web/src/components/ui/radio-group/RadioGroup.vue
Normal 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>
|
||||
34
web/src/components/ui/radio-group/RadioGroupItem.vue
Normal file
34
web/src/components/ui/radio-group/RadioGroupItem.vue
Normal 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>
|
||||
2
web/src/components/ui/radio-group/index.ts
Normal file
2
web/src/components/ui/radio-group/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as RadioGroup } from "./RadioGroup.vue"
|
||||
export { default as RadioGroupItem } from "./RadioGroupItem.vue"
|
||||
40
web/src/components/ui/scroll-area/ScrollArea.vue
Normal file
40
web/src/components/ui/scroll-area/ScrollArea.vue
Normal 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>
|
||||
1
web/src/components/ui/scroll-area/index.ts
Normal file
1
web/src/components/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ScrollArea } from './ScrollArea.vue'
|
||||
15
web/src/components/ui/select/Select.vue
Normal file
15
web/src/components/ui/select/Select.vue
Normal 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>
|
||||
49
web/src/components/ui/select/SelectContent.vue
Normal file
49
web/src/components/ui/select/SelectContent.vue
Normal 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>
|
||||
17
web/src/components/ui/select/SelectGroup.vue
Normal file
17
web/src/components/ui/select/SelectGroup.vue
Normal 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>
|
||||
41
web/src/components/ui/select/SelectItem.vue
Normal file
41
web/src/components/ui/select/SelectItem.vue
Normal 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>
|
||||
12
web/src/components/ui/select/SelectItemText.vue
Normal file
12
web/src/components/ui/select/SelectItemText.vue
Normal 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>
|
||||
14
web/src/components/ui/select/SelectLabel.vue
Normal file
14
web/src/components/ui/select/SelectLabel.vue
Normal 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>
|
||||
22
web/src/components/ui/select/SelectScrollDownButton.vue
Normal file
22
web/src/components/ui/select/SelectScrollDownButton.vue
Normal 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>
|
||||
22
web/src/components/ui/select/SelectScrollUpButton.vue
Normal file
22
web/src/components/ui/select/SelectScrollUpButton.vue
Normal 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>
|
||||
15
web/src/components/ui/select/SelectSeparator.vue
Normal file
15
web/src/components/ui/select/SelectSeparator.vue
Normal 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>
|
||||
29
web/src/components/ui/select/SelectTrigger.vue
Normal file
29
web/src/components/ui/select/SelectTrigger.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:truncate text-start',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="w-4 h-4 opacity-50 shrink-0" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
12
web/src/components/ui/select/SelectValue.vue
Normal file
12
web/src/components/ui/select/SelectValue.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectValueProps } from "reka-ui"
|
||||
import { SelectValue } from "reka-ui"
|
||||
|
||||
const props = defineProps<SelectValueProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectValue v-bind="props">
|
||||
<slot />
|
||||
</SelectValue>
|
||||
</template>
|
||||
11
web/src/components/ui/select/index.ts
Normal file
11
web/src/components/ui/select/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { default as Select } from "./Select.vue"
|
||||
export { default as SelectContent } from "./SelectContent.vue"
|
||||
export { default as SelectGroup } from "./SelectGroup.vue"
|
||||
export { default as SelectItem } from "./SelectItem.vue"
|
||||
export { default as SelectItemText } from "./SelectItemText.vue"
|
||||
export { default as SelectLabel } from "./SelectLabel.vue"
|
||||
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
|
||||
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
|
||||
export { default as SelectSeparator } from "./SelectSeparator.vue"
|
||||
export { default as SelectTrigger } from "./SelectTrigger.vue"
|
||||
export { default as SelectValue } from "./SelectValue.vue"
|
||||
29
web/src/components/ui/separator/Separator.vue
Normal file
29
web/src/components/ui/separator/Separator.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { SeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Separator } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<
|
||||
SeparatorProps & { class?: HTMLAttributes["class"] }
|
||||
>(), {
|
||||
orientation: "horizontal",
|
||||
decorative: true,
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
data-slot="separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
1
web/src/components/ui/separator/index.ts
Normal file
1
web/src/components/ui/separator/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Separator } from "./Separator.vue"
|
||||
71
web/src/components/ui/sheet/SheetContent.vue
Normal file
71
web/src/components/ui/sheet/SheetContent.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
type DialogContentProps,
|
||||
type DialogContentEmits,
|
||||
} from 'reka-ui'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'right',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
type SheetVariants = VariantProps<typeof sheetVariants>
|
||||
|
||||
const props = defineProps<DialogContentProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
side?: SheetVariants['side']
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, side: __, ...delegated } = props
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
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"
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(sheetVariants({ side: props.side }), props.class)"
|
||||
@escape-key-down="emits('escapeKeyDown', $event)"
|
||||
@pointer-down-outside="emits('pointerDownOutside', $event)"
|
||||
@focus-outside="emits('focusOutside', $event)"
|
||||
@interact-outside="emits('interactOutside', $event)"
|
||||
>
|
||||
<slot />
|
||||
<DialogClose
|
||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
13
web/src/components/ui/sheet/SheetDescription.vue
Normal file
13
web/src/components/ui/sheet/SheetDescription.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { DialogDescription, type DialogDescriptionProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user