mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-07-29 14:51:44 +08:00
update: 更新前端依赖;修改前端样式
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"style": "new-york-v4",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
|
||||
1525
web/package-lock.json
generated
1525
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,36 +9,35 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.556.0",
|
||||
"opus-decoder": "^0.7.11",
|
||||
"pinia": "^3.0.4",
|
||||
"radix-vue": "^1.9.17",
|
||||
"reka-ui": "^2.6.1",
|
||||
"simple-keyboard": "^3.8.93",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"reka-ui": "^2.10.1",
|
||||
"simple-keyboard": "^3.8.163",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"uplot": "^1.6.32",
|
||||
"vue": "^3.5.24",
|
||||
"vue": "^3.5.40",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.6.3",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-sonner": "^2.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"postcss": "^8.5.19",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.2",
|
||||
"vue-tsc": "^3.1.4"
|
||||
"vite": "^7.3.6",
|
||||
"vue-tsc": "^3.3.7"
|
||||
},
|
||||
"overrides": {
|
||||
"defu": "^6.1.5",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import 'vue-sonner/style.css'
|
||||
import '@/sonner-overrides.css'
|
||||
import { computed, KeepAlive, onMounted, watch } from 'vue'
|
||||
import { computed, KeepAlive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -22,17 +23,9 @@ const toasterToastOptions = computed(() => ({
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
function initTheme() {
|
||||
const stored = localStorage.getItem('theme')
|
||||
if (stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
}
|
||||
const { isDark } = useTheme()
|
||||
|
||||
onMounted(async () => {
|
||||
initTheme()
|
||||
|
||||
try {
|
||||
await authStore.checkSetupStatus()
|
||||
if (authStore.needsSetup) {
|
||||
@@ -50,16 +43,6 @@ onMounted(async () => {
|
||||
} catch {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
|
||||
(dark) => {
|
||||
const stored = localStorage.getItem('theme')
|
||||
if (!stored) {
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,7 +58,7 @@ watch(
|
||||
expand
|
||||
position="top-center"
|
||||
close-button-position="top-right"
|
||||
theme="system"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
:duration="4000"
|
||||
:gap="14"
|
||||
:offset="{ top: '1rem', right: '1rem', left: '1rem', bottom: '1rem' }"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ButtonGroup } from '@/components/ui/button-group'
|
||||
import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
@@ -276,10 +277,10 @@ const hasRightOverflow = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="w-full border-b bg-background">
|
||||
<div ref="barRef" class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
|
||||
<!-- Left side buttons -->
|
||||
<div class="left-buttons flex items-center gap-0.5 sm:gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||
<ButtonGroup class="left-buttons flex-1 min-w-0 overflow-hidden">
|
||||
<!-- Video Config - Always visible -->
|
||||
<VideoConfigPopover
|
||||
v-model:open="videoPopoverOpen"
|
||||
@@ -349,10 +350,10 @@ const hasRightOverflow = computed(() => {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
|
||||
<!-- Right side buttons -->
|
||||
<div class="flex items-center gap-0.5 sm:gap-1.5 shrink-0 ml-1 sm:ml-2">
|
||||
<ButtonGroup class="shrink-0 ml-1 sm:ml-2">
|
||||
<!-- Connection Stats - Adaptive -->
|
||||
<div v-if="isVisible('stats')">
|
||||
<TooltipProvider>
|
||||
@@ -393,25 +394,6 @@ const hasRightOverflow = computed(() => {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<!-- Settings - Adaptive -->
|
||||
<div v-if="isVisible('settings')">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
||||
<Settings class="h-4 w-4" />
|
||||
<span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ t('actionbar.settingsTip') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div v-if="isVisible('stats') || isVisible('terminal') || isVisible('settings')" class="h-5 w-px bg-slate-200 dark:bg-slate-700" />
|
||||
|
||||
<!-- Computer Use - Optional -->
|
||||
<TooltipProvider v-if="props.showComputerUse !== false">
|
||||
<Tooltip>
|
||||
@@ -432,6 +414,29 @@ const hasRightOverflow = computed(() => {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<!-- Settings - Adaptive -->
|
||||
<div v-if="isVisible('settings')">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
||||
<Settings class="h-4 w-4" />
|
||||
<span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ t('actionbar.settingsTip') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isVisible('settings')"
|
||||
aria-hidden="true"
|
||||
class="mr-4 h-5 w-px shrink-0 -translate-x-px self-center bg-border"
|
||||
/>
|
||||
|
||||
<!-- Virtual Keyboard - Always visible -->
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -523,7 +528,7 @@ const hasRightOverflow = computed(() => {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSystemStore } from '@/stores/system'
|
||||
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||
import BrandMark from '@/components/BrandMark.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -23,12 +24,7 @@ const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const systemStore = useSystemStore()
|
||||
|
||||
function toggleTheme() {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
document.documentElement.classList.toggle('dark', !isDark)
|
||||
localStorage.setItem('theme', isDark ? 'light' : 'dark')
|
||||
}
|
||||
const { toggleTheme } = useTheme()
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
@@ -40,11 +36,12 @@ async function handleLogout() {
|
||||
<div class="h-screen h-dvh flex flex-col bg-background overflow-hidden">
|
||||
<!-- Header -->
|
||||
<header class="shrink-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div class="flex h-11 sm:h-14 items-center px-3 sm:px-4 max-w-full">
|
||||
<div class="flex h-10 sm:h-14 items-center px-2 sm:px-4 max-w-full">
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/" class="flex items-center gap-1.5 sm:gap-2 font-semibold">
|
||||
<BrandMark size="sm" />
|
||||
<span class="hidden sm:inline">One-KVM</span>
|
||||
<RouterLink to="/" class="flex items-center gap-1.5 sm:gap-2">
|
||||
<BrandMark size="md" class="hidden sm:block" />
|
||||
<BrandMark size="sm" class="sm:hidden" />
|
||||
<span class="font-bold text-sm sm:text-lg">One-KVM</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Right Side -->
|
||||
|
||||
@@ -46,15 +46,15 @@ const actionBusy = computed(() => activeAction.value !== null)
|
||||
|
||||
const powerStateIconColor = computed(() => {
|
||||
switch (powerState.value) {
|
||||
case 'on': return 'text-green-600'
|
||||
case 'off': return 'text-slate-500'
|
||||
default: return 'text-yellow-600'
|
||||
case 'on': return 'text-success'
|
||||
case 'off': return 'text-muted-foreground'
|
||||
default: return 'text-warning'
|
||||
}
|
||||
})
|
||||
|
||||
const powerStateTextColor = computed(() => {
|
||||
switch (powerState.value) {
|
||||
case 'on': return 'text-green-600'
|
||||
case 'on': return 'text-success'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
@@ -69,15 +69,15 @@ const powerStateText = computed(() => {
|
||||
|
||||
const hddStateIconColor = computed(() => {
|
||||
switch (hddState.value) {
|
||||
case 'active': return 'text-green-600'
|
||||
case 'inactive': return 'text-slate-500'
|
||||
default: return 'text-yellow-600'
|
||||
case 'active': return 'text-success'
|
||||
case 'inactive': return 'text-muted-foreground'
|
||||
default: return 'text-warning'
|
||||
}
|
||||
})
|
||||
|
||||
const hddStateTextColor = computed(() => {
|
||||
switch (hddState.value) {
|
||||
case 'active': return 'text-green-600'
|
||||
case 'active': return 'text-success'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
@@ -254,7 +254,7 @@ watch(
|
||||
size="sm"
|
||||
:disabled="actionBusy"
|
||||
:class="[
|
||||
'w-full justify-start gap-2 h-7 text-xs text-orange-600 hover:text-orange-700 hover:bg-orange-50 dark:hover:bg-orange-950',
|
||||
'h-7 w-full justify-start gap-2 text-xs text-warning hover:bg-warning/10 hover:text-warning',
|
||||
activeAction === 'long' ? 'bg-muted text-muted-foreground hover:text-muted-foreground hover:bg-muted dark:hover:bg-muted' : '',
|
||||
]"
|
||||
@click="handleAction('long')"
|
||||
@@ -268,7 +268,7 @@ watch(
|
||||
size="sm"
|
||||
:disabled="actionBusy"
|
||||
:class="[
|
||||
'w-full justify-start gap-2 h-7 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950',
|
||||
'h-7 w-full justify-start gap-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive',
|
||||
activeAction === 'reset' ? 'bg-muted text-muted-foreground hover:text-muted-foreground hover:bg-muted dark:hover:bg-muted' : '',
|
||||
]"
|
||||
@click="handleAction('reset')"
|
||||
@@ -318,14 +318,16 @@ watch(
|
||||
<Separator />
|
||||
<Label class="text-xs text-muted-foreground">{{ t('atx.recentMac') }}</Label>
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
<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"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="w-full justify-start font-mono text-xs"
|
||||
@click="selectFromHistory(mac)"
|
||||
>
|
||||
{{ mac }}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -10,13 +10,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { Volume2, RefreshCw, Loader2 } from 'lucide-vue-next'
|
||||
import { audioApi, configApi } from '@/api'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
@@ -232,25 +226,22 @@ watch(() => props.open, (isOpen) => {
|
||||
<!-- Device Selection -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.audioDevice') }}</Label>
|
||||
<Select
|
||||
<NativeSelect
|
||||
:model-value="selectedDevice"
|
||||
@update:model-value="(v) => selectedDevice = v as string"
|
||||
:disabled="loadingDevices || devices.length === 0"
|
||||
class="h-8 w-full text-xs"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('actionbar.selectAudioDevice')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
<NativeSelectOption value="">{{ t('actionbar.selectAudioDevice') }}</NativeSelectOption>
|
||||
<NativeSelectOption
|
||||
v-for="device in devices"
|
||||
:key="device.name"
|
||||
:value="device.name"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ device.description || device.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<!-- Audio Quality -->
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent } from '@/components/ui/tabs'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -192,28 +192,10 @@ onMounted(loadConfig)
|
||||
|
||||
<Tabs v-model="activeTab" class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="px-3 py-2">
|
||||
<div class="grid grid-cols-2 rounded-md bg-muted p-1">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-sm px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
activeTab === 'chat' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||
]"
|
||||
@click="activeTab = 'chat'"
|
||||
>
|
||||
对话
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'rounded-sm px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
activeTab === 'settings' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||
]"
|
||||
@click="activeTab = 'settings'"
|
||||
>
|
||||
设置
|
||||
</button>
|
||||
</div>
|
||||
<TabsList class="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="chat">对话</TabsTrigger>
|
||||
<TabsTrigger value="settings">设置</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="chat" class="m-0 flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden">
|
||||
@@ -248,7 +230,7 @@ onMounted(loadConfig)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="item.type === 'actions_executed'" class="rounded-md border bg-emerald-50 p-2 text-emerald-950 dark:bg-emerald-950/20 dark:text-emerald-100">
|
||||
<div v-else-if="item.type === 'actions_executed'" class="rounded-md border border-success/35 bg-success/10 p-2 text-success">
|
||||
<div class="mb-2 text-xs font-medium">已执行</div>
|
||||
<div class="space-y-1">
|
||||
<div v-for="(action, index) in item.actions" :key="index" class="rounded-sm bg-background/60 px-2 py-1.5 text-xs">
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { HelpCircle } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
defineProps<{
|
||||
content: string
|
||||
@@ -19,13 +20,15 @@ defineProps<{
|
||||
<TooltipProvider :delay-duration="200">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center text-muted-foreground/60 hover:text-muted-foreground transition-colors focus:outline-none"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
class="text-muted-foreground/60 hover:text-muted-foreground"
|
||||
: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>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
:side="side || 'top'"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
|
||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||
import { configApi } from '@/api'
|
||||
@@ -329,7 +330,7 @@ watch(() => props.open, (isOpen) => {
|
||||
:model-value="hidBackend"
|
||||
@update:model-value="handleBackendChange"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectTrigger class="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -343,25 +344,22 @@ watch(() => props.open, (isOpen) => {
|
||||
<!-- Device Path (OTG or CH9329) -->
|
||||
<div v-if="hidBackend !== HidBackend.None" class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.devicePath') }}</Label>
|
||||
<Select
|
||||
<NativeSelect
|
||||
:model-value="devicePath"
|
||||
@update:model-value="handleDevicePathChange"
|
||||
:disabled="availableDevicePaths.length === 0"
|
||||
class="h-8 w-full text-xs"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('actionbar.selectDevice')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
<NativeSelectOption value="">{{ t('actionbar.selectDevice') }}</NativeSelectOption>
|
||||
<NativeSelectOption
|
||||
v-for="device in availableDevicePaths"
|
||||
:key="device.path"
|
||||
:value="device.path"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ device.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<!-- Baudrate (CH9329 only) -->
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { CanonicalKey } from '@/types/generated'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const props = defineProps<{
|
||||
pressedKeys?: CanonicalKey[]
|
||||
@@ -40,20 +42,20 @@ const keysDisplay = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full border-t border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||
<div class="w-full border-t bg-background">
|
||||
<!-- Compact mode (explicit prop or auto on small screens via sm:hidden) -->
|
||||
<div :class="compact ? '' : 'sm:hidden'">
|
||||
<div class="flex items-center justify-between text-xs px-2 py-0.5">
|
||||
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
|
||||
<span
|
||||
:class="capsLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
|
||||
>C</span>
|
||||
<span
|
||||
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
|
||||
>N</span>
|
||||
<span
|
||||
:class="scrollLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
|
||||
>S</span>
|
||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="capsLock ? 'bg-success' : 'bg-warning'" />C
|
||||
</Badge>
|
||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="numLock ? 'bg-success' : 'bg-warning'" />N
|
||||
</Badge>
|
||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="scrollLock ? 'bg-success' : 'bg-warning'" />S
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else class="text-[10px] text-muted-foreground/60">
|
||||
{{ t('infobar.keyboardLedUnavailable') }}
|
||||
@@ -71,7 +73,7 @@ const keysDisplay = computed(() => {
|
||||
<div class="flex items-center gap-4 px-3 py-1 min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<span class="font-medium text-muted-foreground shrink-0">{{ t('infobar.keys') }}:</span>
|
||||
<span class="text-foreground truncate">{{ keysDisplay || '-' }}</span>
|
||||
<Kbd class="max-w-full truncate">{{ keysDisplay || '-' }}</Kbd>
|
||||
</div>
|
||||
|
||||
<div v-if="debugMode && mousePosition" class="flex items-center gap-1.5 hidden md:flex">
|
||||
@@ -82,25 +84,20 @@ const keysDisplay = computed(() => {
|
||||
|
||||
<!-- Right side: Keyboard LED states -->
|
||||
<div class="flex items-center shrink-0">
|
||||
<Separator orientation="vertical" class="h-5" />
|
||||
<template v-if="keyboardLedEnabled">
|
||||
<div
|
||||
:class="cn(
|
||||
'px-2 py-1 select-none transition-colors',
|
||||
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||
)"
|
||||
>{{ t('infobar.caps') }}</div>
|
||||
<div
|
||||
:class="cn(
|
||||
'px-2 py-1 select-none transition-colors',
|
||||
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||
)"
|
||||
>{{ t('infobar.num') }}</div>
|
||||
<div
|
||||
:class="cn(
|
||||
'px-2 py-1 select-none transition-colors',
|
||||
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||
)"
|
||||
>{{ t('infobar.scroll') }}</div>
|
||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="capsLock ? 'bg-success' : 'bg-warning'" />
|
||||
{{ t('infobar.caps') }}
|
||||
</Badge>
|
||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="numLock ? 'bg-success' : 'bg-warning'" />
|
||||
{{ t('infobar.num') }}
|
||||
</Badge>
|
||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="scrollLock ? 'bg-success' : 'bg-warning'" />
|
||||
{{ t('infobar.scroll') }}
|
||||
</Badge>
|
||||
</template>
|
||||
<div v-else class="px-3 py-1 text-muted-foreground/60">
|
||||
{{ t('infobar.keyboardLedUnavailable') }}
|
||||
|
||||
@@ -29,6 +29,8 @@ import { Label } from '@/components/ui/label'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia } from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
HardDrive,
|
||||
Upload,
|
||||
@@ -151,9 +153,9 @@ const downloadProgress = ref<{
|
||||
} | null>(null)
|
||||
|
||||
const TWO_POINT_TWO_GB = 2.2 * 1024 * 1024 * 1024
|
||||
const tabTriggerClass = 'h-9 rounded-md border border-transparent bg-transparent text-center text-muted-foreground shadow-none hover:text-foreground data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm'
|
||||
const segmentedGroupClass = 'grid w-full grid-cols-2 items-center gap-1 rounded-md bg-muted p-1'
|
||||
const segmentedItemClass = 'h-8 w-full justify-center rounded-md border border-transparent bg-transparent px-3 text-center text-xs text-muted-foreground shadow-none hover:bg-background/60 hover:text-foreground data-[state=on]:border-border data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm'
|
||||
const tabTriggerClass = 'h-9 rounded-md border-0 bg-transparent text-center text-muted-foreground shadow-none hover:text-foreground data-[state=active]:border-0 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm'
|
||||
const segmentedGroupClass = 'grid w-full grid-cols-2 items-center gap-1 rounded-md border border-border bg-muted p-1'
|
||||
const segmentedItemClass = 'h-8 w-full justify-center rounded-md border-0 bg-transparent px-3 text-center text-xs text-muted-foreground shadow-none hover:bg-transparent hover:text-foreground data-[state=on]:border-0 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm data-[state=on]:hover:bg-background'
|
||||
|
||||
const diskMode = computed(() => systemStore.msd?.diskMode ?? 'single')
|
||||
const slotCapacity = computed(() => systemStore.msd?.slotCapacity ?? 1)
|
||||
@@ -684,10 +686,10 @@ onUnmounted(() => {
|
||||
</DialogTitle>
|
||||
<DialogDescription as="div" class="mt-1 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span :class="msdConnected ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'" class="flex items-center gap-1.5">
|
||||
<span :class="msdConnected ? 'text-success' : 'text-muted-foreground'" class="flex items-center gap-1.5">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span v-if="msdConnected" class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span :class="msdConnected ? 'bg-green-500' : 'bg-muted-foreground'" class="relative inline-flex rounded-full h-2 w-2"></span>
|
||||
<span v-if="msdConnected" class="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-75"></span>
|
||||
<span :class="msdConnected ? 'bg-success' : 'bg-muted-foreground'" class="relative inline-flex h-2 w-2 rounded-full"></span>
|
||||
</span>
|
||||
{{ msdConnected ? t('common.connected') : t('common.disconnected') }}
|
||||
</span>
|
||||
@@ -696,11 +698,11 @@ onUnmounted(() => {
|
||||
<Badge
|
||||
v-if="mediaSlotsFull"
|
||||
variant="outline"
|
||||
class="h-6 rounded-md border-amber-500/40 bg-amber-500/10 px-2 text-xs text-amber-700 dark:text-amber-400"
|
||||
class="h-6 border-warning/40 bg-warning/10 px-2 text-xs text-warning"
|
||||
>
|
||||
{{ t('msd.mediaSlotsFull') }}
|
||||
</Badge>
|
||||
<span v-if="usbReenumerating" class="text-xs text-amber-600 dark:text-amber-400">
|
||||
<span v-if="usbReenumerating" class="text-xs text-warning">
|
||||
{{ t('msd.reenumerating') }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -722,6 +724,7 @@ onUnmounted(() => {
|
||||
:model-value="diskMode"
|
||||
type="single"
|
||||
size="sm"
|
||||
:spacing="1"
|
||||
:class="[segmentedGroupClass, 'min-w-0 flex-1 sm:w-[200px] sm:flex-none']"
|
||||
:disabled="operationInProgress"
|
||||
@update:model-value="changeDiskMode"
|
||||
@@ -783,9 +786,13 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1 shrink-0" />
|
||||
|
||||
<div v-if="images.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
||||
{{ t('msd.noImages') }}
|
||||
</div>
|
||||
<Skeleton v-if="loadingImages" class="h-24 w-full" />
|
||||
<Empty v-else-if="images.length === 0" class="shrink-0 py-6">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><HardDrive /></EmptyMedia>
|
||||
<EmptyDescription>{{ t('msd.noImages') }}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
|
||||
<div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div class="space-y-2">
|
||||
@@ -818,7 +825,7 @@ onUnmounted(() => {
|
||||
<TooltipTrigger as-child>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] h-4 px-1.5 border-amber-500/50 text-amber-600 dark:text-amber-400 cursor-help"
|
||||
class="h-4 cursor-help border-warning/50 px-1.5 text-[10px] text-warning"
|
||||
>
|
||||
<AlertCircle class="h-2.5 w-2.5 mr-0.5" />
|
||||
{{ t('msd.largeFileWarning') }}
|
||||
@@ -1017,8 +1024,10 @@ onUnmounted(() => {
|
||||
<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"
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="h-auto min-w-0 truncate p-0 font-normal"
|
||||
:class="[
|
||||
index === breadcrumbs.length - 1 ? 'font-medium' : 'text-muted-foreground',
|
||||
driveConnectedToTarget ? 'cursor-not-allowed opacity-50' : ''
|
||||
@@ -1027,7 +1036,7 @@ onUnmounted(() => {
|
||||
@click="!driveConnectedToTarget && navigateTo(crumb.path)"
|
||||
>
|
||||
{{ crumb.name }}
|
||||
</button>
|
||||
</Button>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -1084,12 +1093,13 @@ onUnmounted(() => {
|
||||
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1 shrink-0" />
|
||||
|
||||
<!-- File List -->
|
||||
<div
|
||||
v-if="driveFiles.length === 0 && !driveConnectedToTarget && !driveError"
|
||||
class="shrink-0 text-center py-6 text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('msd.emptyFolder') }}
|
||||
</div>
|
||||
<Skeleton v-if="loadingDrive" class="h-24 w-full" />
|
||||
<Empty v-else-if="driveFiles.length === 0 && !driveConnectedToTarget && !driveError" class="shrink-0 py-6">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><Folder /></EmptyMedia>
|
||||
<EmptyDescription>{{ t('msd.emptyFolder') }}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
|
||||
<!-- Connected placeholder: file list hidden while drive mounted on target -->
|
||||
<div
|
||||
@@ -1110,7 +1120,7 @@ onUnmounted(() => {
|
||||
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" />
|
||||
<Folder v-if="file.is_dir" class="h-4 w-4 shrink-0 text-info" />
|
||||
<File v-else class="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<Tooltip>
|
||||
@@ -1284,6 +1294,7 @@ onUnmounted(() => {
|
||||
:model-value="mountMode"
|
||||
type="single"
|
||||
size="sm"
|
||||
:spacing="1"
|
||||
:class="segmentedGroupClass"
|
||||
@update:model-value="updateMountMode"
|
||||
>
|
||||
@@ -1298,6 +1309,7 @@ onUnmounted(() => {
|
||||
:model-value="accessMode"
|
||||
type="single"
|
||||
size="sm"
|
||||
:spacing="1"
|
||||
:class="segmentedGroupClass"
|
||||
@update:model-value="updateAccessMode"
|
||||
>
|
||||
@@ -1386,7 +1398,7 @@ onUnmounted(() => {
|
||||
/ {{ formatBytes(downloadProgress.total_bytes) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="downloadProgress.status === 'completed'" class="text-xs text-green-600">
|
||||
<div v-if="downloadProgress.status === 'completed'" class="text-xs text-success">
|
||||
{{ t('msd.downloadComplete') }}
|
||||
</div>
|
||||
<div v-else-if="downloadProgress.status.startsWith('failed')" class="text-xs text-destructive">
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { CornerDownLeft, Square, AlertCircle } from 'lucide-vue-next'
|
||||
import { charToKey, analyzeText } from '@/lib/charToHid'
|
||||
import { hidApi } from '@/api'
|
||||
@@ -203,16 +204,14 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
</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">
|
||||
<Alert v-if="hasUntypableChars && !isPasting" variant="warning">
|
||||
<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">
|
||||
<AlertTitle>{{ t('paste.untypableWarning') }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{{ 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>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Progress indicator during paste -->
|
||||
<div v-if="isPasting" class="space-y-2">
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Item, ItemContent, ItemDescription, ItemTitle } from '@/components/ui/item'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import type { WebRTCStats } from '@/composables/useWebRTC'
|
||||
import { formatFpsValue } from '@/lib/fps'
|
||||
|
||||
@@ -405,26 +408,25 @@ onUnmounted(() => {
|
||||
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
|
||||
<SheetContent
|
||||
side="right"
|
||||
class="w-[90vw] max-w-[440px] p-0 border-l border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950"
|
||||
class="w-[90vw] max-w-[440px] border-l bg-background p-0"
|
||||
>
|
||||
<!-- Header -->
|
||||
<SheetHeader class="px-6 py-3 border-b border-slate-200 dark:border-slate-800">
|
||||
<SheetHeader class="border-b px-6 py-3">
|
||||
<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">
|
||||
WebRTC
|
||||
</span>
|
||||
<Badge variant="secondary">WebRTC</Badge>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea class="h-[calc(100dvh-60px)]">
|
||||
<div class="px-6 py-4 space-y-6">
|
||||
<!-- Video Section Header -->
|
||||
<div>
|
||||
<!-- Video Information -->
|
||||
<div class="space-y-3">
|
||||
<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 class="grid grid-cols-2 gap-3">
|
||||
<Item variant="muted" size="sm"><ItemContent><ItemDescription>{{ t('stats.resolution') }}</ItemDescription><ItemTitle>{{ currentStats.resolution }}</ItemTitle></ItemContent></Item>
|
||||
<Item variant="muted" size="sm"><ItemContent><ItemDescription>{{ t('stats.bitrate') }}</ItemDescription><ItemTitle>{{ currentStats.bitrate }} Mbps</ItemTitle></ItemContent></Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Stability (Jitter) -->
|
||||
@@ -432,13 +434,10 @@ onUnmounted(() => {
|
||||
<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">
|
||||
<Card class="relative gap-0 p-2 shadow-none">
|
||||
<div
|
||||
ref="stabilityChartRef"
|
||||
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
|
||||
class="w-full"
|
||||
/>
|
||||
<div
|
||||
v-if="activeTooltip.visible && activeTooltip.chartId === 'stability'"
|
||||
@@ -446,9 +445,9 @@ onUnmounted(() => {
|
||||
: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 class="text-xs text-info">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Playback Delay -->
|
||||
@@ -459,13 +458,10 @@ onUnmounted(() => {
|
||||
{{ currentStats.delay }} ms
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('stats.delayDesc') }}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<Card class="relative gap-0 p-2 shadow-none">
|
||||
<div
|
||||
ref="delayChartRef"
|
||||
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
|
||||
class="w-full"
|
||||
/>
|
||||
<div
|
||||
v-if="activeTooltip.visible && activeTooltip.chartId === 'delay'"
|
||||
@@ -473,9 +469,9 @@ onUnmounted(() => {
|
||||
: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 class="text-xs text-info">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Packet Loss -->
|
||||
@@ -486,13 +482,10 @@ onUnmounted(() => {
|
||||
{{ currentStats.packetsLost }} {{ t('stats.total') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('stats.packetLossDesc') }}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<Card class="relative gap-0 p-2 shadow-none">
|
||||
<div
|
||||
ref="packetLossChartRef"
|
||||
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
|
||||
class="w-full"
|
||||
/>
|
||||
<div
|
||||
v-if="activeTooltip.visible && activeTooltip.chartId === 'packetLoss'"
|
||||
@@ -500,9 +493,9 @@ onUnmounted(() => {
|
||||
: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 class="text-xs text-info">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- FPS -->
|
||||
@@ -513,13 +506,10 @@ onUnmounted(() => {
|
||||
{{ formatFpsValue(currentStats.fps) }} fps
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('stats.frameRateDesc') }}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<Card class="relative gap-0 p-2 shadow-none">
|
||||
<div
|
||||
ref="fpsChartRef"
|
||||
class="w-full rounded-lg bg-slate-50 dark:bg-slate-900/50 p-2"
|
||||
class="w-full"
|
||||
/>
|
||||
<div
|
||||
v-if="activeTooltip.visible && activeTooltip.chartId === 'fps'"
|
||||
@@ -527,52 +517,27 @@ onUnmounted(() => {
|
||||
: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 class="text-xs text-info">{{ activeTooltip.value }} {{ activeTooltip.unit }}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="space-y-3 pt-2 border-t border-slate-200 dark:border-slate-800">
|
||||
<h4 class="text-sm font-medium">{{ t('stats.additional') }}</h4>
|
||||
<!-- Connection Information -->
|
||||
<div class="space-y-3 border-t pt-2">
|
||||
<h4 class="text-sm font-medium">{{ 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.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">
|
||||
<Item variant="muted" size="sm"><ItemContent><ItemDescription>{{ t('stats.connectionType') }}</ItemDescription><ItemTitle class="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 ? 'bg-warning' : 'bg-success'
|
||||
]"
|
||||
/>
|
||||
{{ 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>
|
||||
</ItemTitle></ItemContent></Item>
|
||||
<Item variant="muted" size="sm"><ItemContent><ItemDescription>{{ t('stats.transport') }}</ItemDescription><ItemTitle>{{ currentStats.transport }}</ItemTitle></ItemContent></Item>
|
||||
<Item variant="muted" size="sm"><ItemContent><ItemDescription>{{ t('stats.localCandidate') }}</ItemDescription><ItemTitle>{{ currentStats.localType }}</ItemTitle></ItemContent></Item>
|
||||
<Item variant="muted" size="sm"><ItemContent><ItemDescription>{{ t('stats.remoteCandidate') }}</ItemDescription><ItemTitle>{{ currentStats.remoteType }}</ItemTitle></ItemContent></Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive } from 'lucide-vue-next'
|
||||
|
||||
@@ -50,15 +51,15 @@ onMounted(() => {
|
||||
const statusColor = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'connected':
|
||||
return 'bg-green-500'
|
||||
return 'bg-success'
|
||||
case 'connecting':
|
||||
return 'bg-yellow-500 animate-pulse'
|
||||
return 'bg-warning animate-pulse'
|
||||
case 'disconnected':
|
||||
return 'bg-slate-400'
|
||||
return 'bg-muted-foreground'
|
||||
case 'error':
|
||||
return 'bg-red-500'
|
||||
return 'bg-destructive'
|
||||
default:
|
||||
return 'bg-slate-400'
|
||||
return 'bg-muted-foreground'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -127,15 +128,14 @@ const statusBadgeText = computed(() => {
|
||||
<HoverCard v-if="!prefersPopover" :open-delay="200" :close-delay="100">
|
||||
<HoverCardTrigger as-child>
|
||||
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
||||
:class="cn(
|
||||
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'h-auto flex-col items-start gap-0.5 text-left',
|
||||
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm 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'
|
||||
status === 'error' && 'border-destructive/50'
|
||||
)"
|
||||
>
|
||||
<template v-if="compact">
|
||||
@@ -156,7 +156,7 @@ const statusBadgeText = computed(() => {
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</button>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
|
||||
<HoverCardContent class="w-80" :align="hoverAlign">
|
||||
@@ -164,10 +164,10 @@ const statusBadgeText = computed(() => {
|
||||
<!-- 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'
|
||||
'rounded-md p-2',
|
||||
status === 'connected' ? 'bg-success/10 text-success' :
|
||||
status === 'error' ? 'bg-destructive/10 text-destructive' :
|
||||
'bg-muted text-muted-foreground'
|
||||
)">
|
||||
<component :is="StatusIcon" class="h-5 w-5" />
|
||||
</div>
|
||||
@@ -179,14 +179,14 @@ const statusBadgeText = computed(() => {
|
||||
: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'
|
||||
status === 'connected' ? 'text-success' :
|
||||
status === 'connecting' ? 'text-warning animate-spin' :
|
||||
status === 'error' ? 'text-destructive' :
|
||||
'text-muted-foreground'
|
||||
)"
|
||||
/>
|
||||
<Badge
|
||||
:variant="status === 'connected' ? 'default' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
:variant="status === 'connected' ? 'success' : status === 'connecting' ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ statusBadgeText }}
|
||||
@@ -198,24 +198,14 @@ const statusBadgeText = computed(() => {
|
||||
<!-- Details -->
|
||||
<div v-if="details && details.length > 0" class="space-y-2">
|
||||
<Separator />
|
||||
<div class="space-y-1.5">
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="(detail, index) in details"
|
||||
:key="index"
|
||||
class="flex items-start justify-between gap-3 text-xs"
|
||||
class="flex items-baseline justify-between gap-3 py-0.5 text-xs"
|
||||
>
|
||||
<span class="text-muted-foreground shrink-0">{{ detail.label }}</span>
|
||||
<span
|
||||
:class="cn(
|
||||
'font-medium text-right break-words min-w-0',
|
||||
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>
|
||||
<span class="shrink-0 text-muted-foreground">{{ detail.label }}</span>
|
||||
<span :class="cn('min-w-0 text-right text-xs font-medium', detail.status === 'ok' ? 'text-success' : detail.status === 'warning' ? 'text-warning' : detail.status === 'error' ? 'text-destructive' : 'text-foreground')">{{ detail.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,15 +216,14 @@ const statusBadgeText = computed(() => {
|
||||
<Popover v-else>
|
||||
<PopoverTrigger as-child>
|
||||
<!-- New layout: vertical with title on top, status+quickInfo on bottom -->
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
||||
:class="cn(
|
||||
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'h-auto flex-col items-start gap-0.5 text-left',
|
||||
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm 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'
|
||||
status === 'error' && 'border-destructive/50'
|
||||
)"
|
||||
>
|
||||
<template v-if="compact">
|
||||
@@ -255,7 +244,7 @@ const statusBadgeText = computed(() => {
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</button>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-[min(320px,90vw)]" :align="hoverAlign">
|
||||
@@ -263,10 +252,10 @@ const statusBadgeText = computed(() => {
|
||||
<!-- 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'
|
||||
'rounded-md p-2',
|
||||
status === 'connected' ? 'bg-success/10 text-success' :
|
||||
status === 'error' ? 'bg-destructive/10 text-destructive' :
|
||||
'bg-muted text-muted-foreground'
|
||||
)">
|
||||
<component :is="StatusIcon" class="h-5 w-5" />
|
||||
</div>
|
||||
@@ -278,14 +267,14 @@ const statusBadgeText = computed(() => {
|
||||
: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'
|
||||
status === 'connected' ? 'text-success' :
|
||||
status === 'connecting' ? 'text-warning animate-spin' :
|
||||
status === 'error' ? 'text-destructive' :
|
||||
'text-muted-foreground'
|
||||
)"
|
||||
/>
|
||||
<Badge
|
||||
:variant="status === 'connected' ? 'default' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
:variant="status === 'connected' ? 'success' : status === 'connecting' ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ statusBadgeText }}
|
||||
@@ -297,24 +286,14 @@ const statusBadgeText = computed(() => {
|
||||
<!-- Details -->
|
||||
<div v-if="details && details.length > 0" class="space-y-2">
|
||||
<Separator />
|
||||
<div class="space-y-1.5">
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="(detail, index) in details"
|
||||
:key="index"
|
||||
class="flex items-start justify-between gap-3 text-xs"
|
||||
class="flex items-baseline justify-between gap-3 py-0.5 text-xs"
|
||||
>
|
||||
<span class="text-muted-foreground shrink-0">{{ detail.label }}</span>
|
||||
<span
|
||||
:class="cn(
|
||||
'font-medium text-right break-words min-w-0',
|
||||
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>
|
||||
<span class="shrink-0 text-muted-foreground">{{ detail.label }}</span>
|
||||
<span :class="cn('min-w-0 text-right text-xs font-medium', detail.status === 'ok' ? 'text-success' : detail.status === 'warning' ? 'text-warning' : detail.status === 'error' ? 'text-destructive' : 'text-foreground')">{{ detail.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from 'vue-sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -15,9 +16,8 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Monitor, RefreshCw, Loader2, Settings, Zap, Scale, Image, AlertTriangle } from 'lucide-vue-next'
|
||||
import { Monitor, RefreshCw, Loader2, Zap, Scale, Image } from 'lucide-vue-next'
|
||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||
import {
|
||||
configApi,
|
||||
@@ -31,7 +31,6 @@ import { getVideoFormatState, isVideoFormatSelectable } from '@/lib/video-format
|
||||
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
||||
|
||||
@@ -63,7 +62,6 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const configStore = useConfigStore()
|
||||
const router = useRouter()
|
||||
|
||||
// Device list
|
||||
const devices = ref<VideoDevice[]>([])
|
||||
@@ -239,6 +237,14 @@ const selectedFps = ref<number>(30)
|
||||
const selectedBitratePreset = ref<'Speed' | 'Balanced' | 'Quality'>('Balanced')
|
||||
const isDirty = ref(false)
|
||||
|
||||
const selectedFormatStatus = computed<'recommended' | 'not_recommended' | 'unsupported' | null>(() => {
|
||||
if (!selectedFormat.value) return null
|
||||
if (isFormatUnsupported(selectedFormat.value)) return 'unsupported'
|
||||
if (isFormatRecommended(selectedFormat.value)) return 'recommended'
|
||||
if (isFormatNotRecommended(selectedFormat.value)) return 'not_recommended'
|
||||
return null
|
||||
})
|
||||
|
||||
const applying = ref(false)
|
||||
const applyingBitrate = ref(false)
|
||||
|
||||
@@ -317,10 +323,9 @@ const availableFps = computed(() => {
|
||||
return resolution?.fps || []
|
||||
})
|
||||
|
||||
const selectedFormatInfo = computed(() => {
|
||||
const format = availableFormatOptions.value.find(f => f.format === selectedFormat.value)
|
||||
return format
|
||||
})
|
||||
const selectedFormatInfo = computed(() =>
|
||||
availableFormatOptions.value.find(format => format.format === selectedFormat.value) ?? null
|
||||
)
|
||||
|
||||
const selectedCodecInfo = computed(() => {
|
||||
const codec = availableCodecs.value.find(c => c.id === props.videoMode)
|
||||
@@ -367,10 +372,6 @@ async function loadConstraints() {
|
||||
}
|
||||
}
|
||||
|
||||
function goToSettings() {
|
||||
router.push('/settings?tab=video')
|
||||
}
|
||||
|
||||
function initializeFromCurrent() {
|
||||
const config = currentConfig.value
|
||||
selectedDevice.value = config.device
|
||||
@@ -619,21 +620,24 @@ watch(
|
||||
|
||||
<!-- Mode Selection -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<Label class="text-xs">{{ t('actionbar.videoMode') }}</Label>
|
||||
<HelpTooltip :content="t('actionbar.videoModeHint')" icon-size="sm" side="right" />
|
||||
</div>
|
||||
<Select
|
||||
:model-value="props.videoMode"
|
||||
@update:model-value="handleVideoModeChange"
|
||||
:disabled="loadingCodecs || availableCodecs.length === 0 || isRtspCodecLocked"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectTrigger class="h-8 w-full 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'"
|
||||
? 'bg-info/10 text-info'
|
||||
: 'bg-warning/10 text-warning'"
|
||||
>
|
||||
{{ translateBackendName(selectedCodecInfo.backend) }}
|
||||
</span>
|
||||
@@ -655,8 +659,8 @@ watch(
|
||||
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'"
|
||||
? 'bg-info/10 text-info'
|
||||
: 'bg-warning/10 text-warning'"
|
||||
>
|
||||
{{ translateBackendName(codec.backend) }}
|
||||
</span>
|
||||
@@ -670,10 +674,7 @@ watch(
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="props.videoMode !== 'mjpeg'" class="text-xs text-muted-foreground">
|
||||
{{ t('actionbar.webrtcHint') }}
|
||||
</p>
|
||||
<p v-if="isCodecLocked" class="text-xs text-amber-600 dark:text-amber-400">
|
||||
<p v-if="isCodecLocked" class="text-xs text-warning">
|
||||
{{ codecLockMessage }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -727,30 +728,15 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
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 -->
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="videoParamWarningMessage"
|
||||
class="rounded-md border border-amber-500/30 bg-amber-500/10 px-2.5 py-2"
|
||||
>
|
||||
<p class="flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle class="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{{ videoParamWarningMessage }}</span>
|
||||
<p v-if="videoParamWarningMessage" class="text-xs text-warning">
|
||||
{{ videoParamWarningMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.deviceSettings') }}</h5>
|
||||
@@ -768,25 +754,22 @@ watch(
|
||||
<!-- Device Selection -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">{{ t('actionbar.videoDevice') }}</Label>
|
||||
<Select
|
||||
<NativeSelect
|
||||
:model-value="selectedDevice"
|
||||
@update:model-value="handleDeviceChange"
|
||||
:disabled="loadingDevices || devices.length === 0"
|
||||
class="h-8 w-full text-xs"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="loadingDevices ? t('common.loading') : t('actionbar.selectDevice')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
<NativeSelectOption value="">{{ loadingDevices ? t('common.loading') : t('actionbar.selectDevice') }}</NativeSelectOption>
|
||||
<NativeSelectOption
|
||||
v-for="device in devices"
|
||||
:key="device.path"
|
||||
:value="device.path"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ formatVideoDeviceLabel(device) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<!-- Format Selection -->
|
||||
@@ -797,27 +780,27 @@ watch(
|
||||
@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">
|
||||
<SelectTrigger class="h-8 w-full text-xs">
|
||||
<div v-if="selectedFormatInfo" class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="truncate">{{ selectedFormatInfo.description }}</span>
|
||||
<span
|
||||
v-if="selectedFormatInfo.state === 'unsupported'"
|
||||
class="shrink-0 text-muted-foreground"
|
||||
>
|
||||
{{ t('common.notSupportedYet') }}
|
||||
</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"
|
||||
v-if="selectedFormatStatus === 'recommended'"
|
||||
class="shrink-0 rounded bg-info/10 px-1 py-0.5 text-[10px] text-info"
|
||||
>
|
||||
{{ t('actionbar.recommended') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isFormatNotRecommended(selectedFormatInfo.format)"
|
||||
class="text-[10px] px-1 py-0.5 rounded bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300 shrink-0"
|
||||
v-else-if="selectedFormatStatus === 'not_recommended'"
|
||||
class="shrink-0 rounded bg-warning/10 px-1 py-0.5 text-[10px] text-warning"
|
||||
>
|
||||
{{ t('actionbar.notRecommended') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="selectedFormatStatus === 'unsupported'"
|
||||
class="shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ t('common.notSupportedYet') }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-muted-foreground">{{ t('actionbar.selectFormat') }}</span>
|
||||
</SelectTrigger>
|
||||
@@ -827,25 +810,19 @@ watch(
|
||||
:key="format.format"
|
||||
:value="format.format"
|
||||
:disabled="format.disabled"
|
||||
:class="['text-xs', { 'opacity-50': format.disabled }]"
|
||||
class="text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ format.description }}</span>
|
||||
<span
|
||||
v-if="format.state === 'unsupported'"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
{{ t('common.notSupportedYet') }}
|
||||
</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"
|
||||
class="rounded bg-info/10 px-1.5 py-0.5 text-[10px] text-info"
|
||||
>
|
||||
{{ t('actionbar.recommended') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isFormatNotRecommended(format.format)"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300"
|
||||
class="rounded bg-warning/10 px-1.5 py-0.5 text-[10px] text-warning"
|
||||
>
|
||||
{{ t('actionbar.notRecommended') }}
|
||||
</span>
|
||||
@@ -858,49 +835,43 @@ watch(
|
||||
<!-- Resolution Selection -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">{{ t('actionbar.videoResolution') }}</Label>
|
||||
<Select
|
||||
<NativeSelect
|
||||
:model-value="selectedResolution"
|
||||
@update:model-value="handleResolutionChange"
|
||||
:disabled="!selectedFormat || availableResolutions.length === 0"
|
||||
class="h-8 w-full text-xs"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('actionbar.selectResolution')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
<NativeSelectOption value="">{{ t('actionbar.selectResolution') }}</NativeSelectOption>
|
||||
<NativeSelectOption
|
||||
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>
|
||||
</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<!-- FPS Selection -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">{{ t('actionbar.videoFps') }}</Label>
|
||||
<Select
|
||||
<NativeSelect
|
||||
:model-value="String(selectedFps)"
|
||||
@update:model-value="handleFpsChange"
|
||||
:disabled="!selectedResolution || availableFps.length === 0"
|
||||
class="h-8 w-full text-xs"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('actionbar.selectFps')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
<NativeSelectOption value="">{{ t('actionbar.selectFps') }}</NativeSelectOption>
|
||||
<NativeSelectOption
|
||||
v-for="fps in availableFps"
|
||||
:key="fps"
|
||||
:value="String(fps)"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ formatFpsLabel(fps) }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<!-- Apply Button -->
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import Keyboard from 'simple-keyboard'
|
||||
import 'simple-keyboard/build/css/index.css'
|
||||
import { hidApi } from '@/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CanonicalKey } from '@/types/generated'
|
||||
import {
|
||||
keys,
|
||||
@@ -608,39 +609,43 @@ onUnmounted(() => {
|
||||
@touchstart="startDrag"
|
||||
>
|
||||
<div class="vkb-header-left">
|
||||
<button class="vkb-btn" @click="toggleAttached">
|
||||
<Button variant="ghost" size="sm" class="vkb-btn" @click="toggleAttached">
|
||||
{{ isAttached ? t('virtualKeyboard.detach') : t('virtualKeyboard.attach') }}
|
||||
</button>
|
||||
</Button>
|
||||
<div class="vkb-os-selector">
|
||||
<button
|
||||
<Button
|
||||
v-for="os in (['windows', 'mac', 'android'] as KeyboardOsType[])"
|
||||
:key="os"
|
||||
class="vkb-os-btn"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:class="{ 'vkb-os-btn--active': selectedOs === os }"
|
||||
@click.stop="switchOsLayout(os)"
|
||||
>
|
||||
{{ os === 'windows' ? 'Win' : os === 'mac' ? 'Mac' : 'Android' }}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="vkb-title">{{ t('virtualKeyboard.title') }}</span>
|
||||
<button class="vkb-btn" @click="close">
|
||||
<Button variant="ghost" size="sm" class="vkb-btn" @click="close">
|
||||
{{ t('virtualKeyboard.hide') }}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard body -->
|
||||
<div class="vkb-body">
|
||||
<!-- Media keys row -->
|
||||
<div v-if="props.consumerEnabled !== false" class="vkb-media-row">
|
||||
<button
|
||||
<Button
|
||||
v-for="key in mediaKeys"
|
||||
:key="key"
|
||||
class="vkb-media-btn"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="onMediaKeyPress(key)"
|
||||
>
|
||||
{{ mediaKeyLabels[key] || key }}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="vkb-keyboards">
|
||||
<div :id="`${keyboardId}-main`" class="kb-main-container"></div>
|
||||
@@ -660,12 +665,12 @@ onUnmounted(() => {
|
||||
|
||||
.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-radius: var(--radius-md);
|
||||
background: var(--keyboard-button-bg);
|
||||
color: var(--keyboard-button-color);
|
||||
border: 1px solid var(--keyboard-button-border);
|
||||
border-bottom-width: 2px;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
box-shadow: 0 1px 2px 0 color-mix(in oklch, var(--foreground) 5%, transparent);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 0 6px;
|
||||
@@ -678,12 +683,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button:hover {
|
||||
background: var(--keyboard-button-hover-bg, #f3f4f6);
|
||||
background: var(--keyboard-button-hover-bg);
|
||||
}
|
||||
|
||||
.vkb .simple-keyboard .hg-button:active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border-bottom-width: 1px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
@@ -706,10 +711,10 @@ onUnmounted(() => {
|
||||
|
||||
/* Pressed keys */
|
||||
.vkb .simple-keyboard .hg-button.down-key {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 600;
|
||||
border-color: #2563eb;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Space bar */
|
||||
@@ -843,17 +848,17 @@ onUnmounted(() => {
|
||||
: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;
|
||||
background: var(--keyboard-button-bg) !important;
|
||||
color: var(--keyboard-button-color) !important;
|
||||
border-color: var(--keyboard-button-border) !important;
|
||||
border-bottom-color: var(--keyboard-button-border) !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;
|
||||
background: var(--keyboard-button-hover-bg) !important;
|
||||
}
|
||||
|
||||
:root.dark .hg-theme-default .hg-button:active,
|
||||
@@ -862,17 +867,17 @@ html.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;
|
||||
background: var(--primary) !important;
|
||||
color: var(--primary-foreground) !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;
|
||||
background: var(--primary) !important;
|
||||
color: var(--primary-foreground) !important;
|
||||
border-color: var(--primary) !important;
|
||||
border-bottom-color: var(--primary) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -880,13 +885,13 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
.vkb {
|
||||
z-index: 100;
|
||||
user-select: none;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:global(.dark .vkb) {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
background: var(--background);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.vkb--attached {
|
||||
@@ -904,8 +909,8 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
min-width: 1200px;
|
||||
max-width: 1600px;
|
||||
width: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 25px 50px -12px color-mix(in oklch, var(--foreground) 25%, transparent);
|
||||
}
|
||||
|
||||
.vkb--dragging {
|
||||
@@ -918,20 +923,20 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--muted);
|
||||
min-height: 28px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:global(.dark .vkb-header) {
|
||||
background: #111827;
|
||||
border-color: #374151;
|
||||
background: var(--muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.vkb--floating .vkb-header {
|
||||
cursor: move;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
|
||||
.vkb-header-left {
|
||||
@@ -946,84 +951,84 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
:global(.dark .vkb-title) {
|
||||
color: #d1d5db;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.vkb-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.vkb-btn:hover {
|
||||
background: #f3f4f6;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
:global(.dark .vkb-btn) {
|
||||
color: #d1d5db;
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
:global(.dark .vkb-btn:hover) {
|
||||
background: #4b5563;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* OS selector */
|
||||
.vkb-os-selector {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
:global(.dark .vkb-os-selector) {
|
||||
background: #374151;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.vkb-os-btn {
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
color: var(--muted-foreground);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vkb-os-btn:hover {
|
||||
color: #374151;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.vkb-os-btn--active {
|
||||
background: white;
|
||||
color: #374151;
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
:global(.dark .vkb-os-btn) {
|
||||
color: #9ca3af;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
:global(.dark .vkb-os-btn:hover) {
|
||||
color: #d1d5db;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
:global(.dark .vkb-os-btn--active) {
|
||||
background: #4b5563;
|
||||
color: #f9fafb;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Keyboard body */
|
||||
@@ -1032,15 +1037,15 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
background: #f3f4f6;
|
||||
background: color-mix(in oklch, var(--muted) 65%, var(--background));
|
||||
}
|
||||
|
||||
:global(.dark .vkb-body) {
|
||||
background: #111827;
|
||||
background: color-mix(in oklch, var(--muted) 65%, var(--background));
|
||||
}
|
||||
|
||||
.vkb--floating .vkb-body {
|
||||
border-radius: 0 0 8px 8px;
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
}
|
||||
|
||||
/* Media keys row */
|
||||
@@ -1049,40 +1054,40 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:global(.dark .vkb-media-row) {
|
||||
border-color: #374151;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.vkb-media-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 16px;
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.vkb-media-btn:hover {
|
||||
background: #f3f4f6;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.vkb-media-btn:active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
:global(.dark .vkb-media-btn) {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: #f9fafb;
|
||||
background: var(--background);
|
||||
border-color: var(--border);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
:global(.dark .vkb-media-btn:hover) {
|
||||
background: #4b5563;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Keyboards container */
|
||||
|
||||
15
web/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
15
web/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AlertDialogActionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogAction } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction :class="cn(buttonVariants(), props.class)">
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AlertDialogCancelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogCancel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel :class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
type AlertDialogContentEmits,
|
||||
type AlertDialogContentProps,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</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"
|
||||
data-slot="alert-dialog-overlay"
|
||||
class="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"
|
||||
/>
|
||||
<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)"
|
||||
data-slot="alert-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 />
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { AlertDialogDescription, type AlertDialogDescriptionProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AlertDialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<AlertDialogDescription
|
||||
data-slot="alert-dialog-description"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', props.class)">
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col space-y-2 text-center sm:text-left', props.class)">
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { AlertDialogTitle, type AlertDialogTitleProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AlertDialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AlertDialogTitle } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle :class="cn('text-lg font-semibold', props.class)">
|
||||
<AlertDialogTitle
|
||||
data-slot="alert-dialog-title"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
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'
|
||||
export { default as AlertDialog } from "./AlertDialog.vue"
|
||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
||||
|
||||
21
web/src/components/ui/alert/Alert.vue
Normal file
21
web/src/components/ui/alert/Alert.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { AlertVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { alertVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: AlertVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert"
|
||||
:class="cn(alertVariants({ variant }), props.class)"
|
||||
role="alert"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
web/src/components/ui/alert/AlertDescription.vue
Normal file
17
web/src/components/ui/alert/AlertDescription.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="alert-description"
|
||||
:class="cn('text-muted-foreground col-start-2 text-sm [&_p]:leading-relaxed', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
web/src/components/ui/alert/AlertTitle.vue
Normal file
17
web/src/components/ui/alert/AlertTitle.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="alert-title"
|
||||
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
30
web/src/components/ui/alert/index.ts
Normal file
30
web/src/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Alert } from "./Alert.vue"
|
||||
export { default as AlertDescription } from "./AlertDescription.vue"
|
||||
export { default as AlertTitle } from "./AlertTitle.vue"
|
||||
|
||||
export const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
success:
|
||||
"border-success/35 bg-success/10 text-success [&>svg]:text-current *:data-[slot=alert-description]:text-success/90",
|
||||
warning:
|
||||
"border-warning/35 bg-warning/10 text-warning [&>svg]:text-current *:data-[slot=alert-description]:text-warning/90",
|
||||
info:
|
||||
"border-info/35 bg-info/10 text-info [&>svg]:text-current *:data-[slot=alert-description]:text-info/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||
@@ -4,7 +4,7 @@ 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",
|
||||
"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-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -14,6 +14,12 @@ export const badgeVariants = cva(
|
||||
"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",
|
||||
success:
|
||||
"border-transparent bg-success text-success-foreground [a&]:hover:bg-success/90 focus-visible:ring-success/20",
|
||||
warning:
|
||||
"border-transparent bg-warning text-warning-foreground [a&]:hover:bg-warning/90 focus-visible:ring-warning/20",
|
||||
info:
|
||||
"border-transparent bg-info text-info-foreground [a&]:hover:bg-info/90 focus-visible:ring-info/20",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
|
||||
22
web/src/components/ui/button-group/ButtonGroup.vue
Normal file
22
web/src/components/ui/button-group/ButtonGroup.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonGroupVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonGroupVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
orientation?: ButtonGroupVariants["orientation"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
:data-orientation="props.orientation"
|
||||
:class="cn(buttonGroupVariants({ orientation: props.orientation }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
23
web/src/components/ui/button-group/index.ts
Normal file
23
web/src/components/ui/button-group/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as ButtonGroup } from "./ButtonGroup.vue"
|
||||
|
||||
export const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonGroupVariants = VariantProps<typeof buttonGroupVariants>
|
||||
@@ -20,6 +20,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
|
||||
@@ -4,7 +4,7 @@ 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",
|
||||
"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-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -22,9 +22,11 @@ export const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
"xs": "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-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-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
|
||||
19
web/src/components/ui/collapsible/Collapsible.vue
Normal file
19
web/src/components/ui/collapsible/Collapsible.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { CollapsibleRootEmits, CollapsibleRootProps } from "reka-ui"
|
||||
import { CollapsibleRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<CollapsibleRootProps>()
|
||||
const emits = defineEmits<CollapsibleRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="collapsible"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
15
web/src/components/ui/collapsible/CollapsibleContent.vue
Normal file
15
web/src/components/ui/collapsible/CollapsibleContent.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { CollapsibleContentProps } from "reka-ui"
|
||||
import { CollapsibleContent } from "reka-ui"
|
||||
|
||||
const props = defineProps<CollapsibleContentProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</CollapsibleContent>
|
||||
</template>
|
||||
15
web/src/components/ui/collapsible/CollapsibleTrigger.vue
Normal file
15
web/src/components/ui/collapsible/CollapsibleTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { CollapsibleTriggerProps } from "reka-ui"
|
||||
import { CollapsibleTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<CollapsibleTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</CollapsibleTrigger>
|
||||
</template>
|
||||
3
web/src/components/ui/collapsible/index.ts
Normal file
3
web/src/components/ui/collapsible/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Collapsible } from "./Collapsible.vue"
|
||||
export { default as CollapsibleContent } from "./CollapsibleContent.vue"
|
||||
export { default as CollapsibleTrigger } from "./CollapsibleTrigger.vue"
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { DialogClose } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
showCloseButton?: boolean
|
||||
}>(), {
|
||||
showCloseButton: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -11,5 +18,10 @@ const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
<DialogClose v-if="showCloseButton" as-child>
|
||||
<Button variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItemIndicator,
|
||||
|
||||
@@ -24,7 +24,7 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
: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)"
|
||||
:class="cn(`relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!`, props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuRadioItem,
|
||||
|
||||
@@ -20,7 +20,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<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)"
|
||||
: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] max-w-(--reka-dropdown-menu-content-available-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuSubContent>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
DropdownMenuSubTrigger,
|
||||
useForwardProps,
|
||||
@@ -19,8 +19,9 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:data-inset="inset ? '' : undefined"
|
||||
: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',
|
||||
`relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!`,
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
|
||||
20
web/src/components/ui/empty/Empty.vue
Normal file
20
web/src/components/ui/empty/Empty.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<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="empty"
|
||||
:class="cn(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
web/src/components/ui/empty/EmptyDescription.vue
Normal file
20
web/src/components/ui/empty/EmptyDescription.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="empty-description"
|
||||
:class="cn(
|
||||
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
|
||||
$attrs.class ?? '',
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
20
web/src/components/ui/empty/EmptyHeader.vue
Normal file
20
web/src/components/ui/empty/EmptyHeader.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<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="empty-header"
|
||||
:class="cn(
|
||||
'flex max-w-sm flex-col items-center gap-2 text-center',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
web/src/components/ui/empty/EmptyMedia.vue
Normal file
21
web/src/components/ui/empty/EmptyMedia.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { EmptyMediaVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { emptyMediaVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: EmptyMediaVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
:data-variant="variant"
|
||||
:class="cn(emptyMediaVariants({ variant }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
24
web/src/components/ui/empty/index.ts
Normal file
24
web/src/components/ui/empty/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Empty } from "./Empty.vue"
|
||||
export { default as EmptyDescription } from "./EmptyDescription.vue"
|
||||
export { default as EmptyHeader } from "./EmptyHeader.vue"
|
||||
export { default as EmptyMedia } from "./EmptyMedia.vue"
|
||||
|
||||
export const emptyMediaVariants = cva(
|
||||
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type EmptyMediaVariants = VariantProps<typeof emptyMediaVariants>
|
||||
25
web/src/components/ui/field/Field.vue
Normal file
25
web/src/components/ui/field/Field.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { FieldVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fieldVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
orientation?: FieldVariants["orientation"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
:data-orientation="orientation"
|
||||
:class="cn(
|
||||
fieldVariants({ orientation }),
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
web/src/components/ui/field/FieldGroup.vue
Normal file
20
web/src/components/ui/field/FieldGroup.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<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="field-group"
|
||||
:class="cn(
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
23
web/src/components/ui/field/FieldLabel.vue
Normal file
23
web/src/components/ui/field/FieldLabel.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
:class="cn(
|
||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
|
||||
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
32
web/src/components/ui/field/index.ts
Normal file
32
web/src/components/ui/field/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type FieldVariants = VariantProps<typeof fieldVariants>
|
||||
|
||||
export { default as Field } from "./Field.vue"
|
||||
export { default as FieldGroup } from "./FieldGroup.vue"
|
||||
export { default as FieldLabel } from "./FieldLabel.vue"
|
||||
19
web/src/components/ui/hover-card/HoverCard.vue
Normal file
19
web/src/components/ui/hover-card/HoverCard.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { HoverCardRootEmits, HoverCardRootProps } from "reka-ui"
|
||||
import { HoverCardRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<HoverCardRootProps>()
|
||||
const emits = defineEmits<HoverCardRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="hover-card"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</HoverCardRoot>
|
||||
</template>
|
||||
@@ -1,28 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import type { HoverCardContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
HoverCardContent,
|
||||
HoverCardPortal,
|
||||
type HoverCardContentProps,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
return delegated
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<HoverCardContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</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
|
||||
)"
|
||||
data-slot="hover-card-content"
|
||||
v-bind="{ ...$attrs, ...forwardedProps }"
|
||||
: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 w-64 rounded-md border p-4 shadow-md outline-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</HoverCardContent>
|
||||
|
||||
15
web/src/components/ui/hover-card/HoverCardTrigger.vue
Normal file
15
web/src/components/ui/hover-card/HoverCardTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HoverCardTriggerProps } from "reka-ui"
|
||||
import { HoverCardTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<HoverCardTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardTrigger
|
||||
data-slot="hover-card-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</HoverCardTrigger>
|
||||
</template>
|
||||
@@ -1,5 +1,3 @@
|
||||
export {
|
||||
HoverCardRoot as HoverCard,
|
||||
HoverCardTrigger,
|
||||
} from 'reka-ui'
|
||||
export { default as HoverCardContent } from './HoverCardContent.vue'
|
||||
export { default as HoverCard } from "./HoverCard.vue"
|
||||
export { default as HoverCardContent } from "./HoverCardContent.vue"
|
||||
export { default as HoverCardTrigger } from "./HoverCardTrigger.vue"
|
||||
|
||||
@@ -25,7 +25,7 @@ const modelValue = useVModel(props, "modelValue", emits, {
|
||||
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]',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class,
|
||||
)"
|
||||
|
||||
29
web/src/components/ui/item/Item.vue
Normal file
29
web/src/components/ui/item/Item.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ItemVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { itemVariants } from "."
|
||||
|
||||
const props = withDefaults(defineProps<PrimitiveProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: ItemVariants["variant"]
|
||||
size?: ItemVariants["size"]
|
||||
}>(), {
|
||||
as: "div",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="item"
|
||||
:data-variant="variant"
|
||||
:data-size="size"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(itemVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
17
web/src/components/ui/item/ItemContent.vue
Normal file
17
web/src/components/ui/item/ItemContent.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="item-content"
|
||||
:class="cn('flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
web/src/components/ui/item/ItemDescription.vue
Normal file
21
web/src/components/ui/item/ItemDescription.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<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="item-description"
|
||||
:class="cn(
|
||||
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
17
web/src/components/ui/item/ItemTitle.vue
Normal file
17
web/src/components/ui/item/ItemTitle.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="item-title"
|
||||
:class="cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
48
web/src/components/ui/item/index.ts
Normal file
48
web/src/components/ui/item/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Item } from "./Item.vue"
|
||||
export { default as ItemContent } from "./ItemContent.vue"
|
||||
export { default as ItemDescription } from "./ItemDescription.vue"
|
||||
export { default as ItemTitle } from "./ItemTitle.vue"
|
||||
|
||||
export const itemVariants = cva(
|
||||
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border-border",
|
||||
muted: "bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "p-4 gap-4 ",
|
||||
sm: "py-3 px-4 gap-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ItemVariants = VariantProps<typeof itemVariants>
|
||||
export type ItemMediaVariants = VariantProps<typeof itemMediaVariants>
|
||||
21
web/src/components/ui/kbd/Kbd.vue
Normal file
21
web/src/components/ui/kbd/Kbd.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<kbd
|
||||
:class="cn(
|
||||
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
|
||||
'[&_svg:not([class*=\'size-\'])]:size-3',
|
||||
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</kbd>
|
||||
</template>
|
||||
1
web/src/components/ui/kbd/index.ts
Normal file
1
web/src/components/ui/kbd/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Kbd } from "./Kbd.vue"
|
||||
50
web/src/components/ui/native-select/NativeSelect.vue
Normal file
50
web/src/components/ui/native-select/NativeSelect.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { AcceptableValue } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { ChevronDownIcon } from "lucide-vue-next"
|
||||
import { reactiveOmit, useVModel } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<{ modelValue?: AcceptableValue | AcceptableValue[], class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: AcceptableValue]
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emit, {
|
||||
passive: true,
|
||||
defaultValue: "",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group/native-select relative w-full has-[select:disabled]:opacity-50"
|
||||
data-slot="native-select-wrapper"
|
||||
>
|
||||
<select
|
||||
v-bind="{ ...$attrs, ...delegatedProps }"
|
||||
v-model="modelValue"
|
||||
data-slot="native-select"
|
||||
:class="cn(
|
||||
'border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</select>
|
||||
<ChevronDownIcon
|
||||
class="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none"
|
||||
aria-hidden="true"
|
||||
data-slot="native-select-icon"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
12
web/src/components/ui/native-select/NativeSelectOption.vue
Normal file
12
web/src/components/ui/native-select/NativeSelectOption.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<option data-slot="native-select-option" :class="cn('bg-popover text-popover-foreground', props.class)">
|
||||
<slot />
|
||||
</option>
|
||||
</template>
|
||||
2
web/src/components/ui/native-select/index.ts
Normal file
2
web/src/components/ui/native-select/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as NativeSelect } from "./NativeSelect.vue"
|
||||
export { default as NativeSelectOption } from "./NativeSelectOption.vue"
|
||||
19
web/src/components/ui/popover/Popover.vue
Normal file
19
web/src/components/ui/popover/Popover.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverRootEmits, PopoverRootProps } from "reka-ui"
|
||||
import { PopoverRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverRootProps>()
|
||||
const emits = defineEmits<PopoverRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="popover"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
@@ -1,28 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
type PopoverContentProps,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
return delegated
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<PopoverContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
align: "center",
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</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
|
||||
)"
|
||||
data-slot="popover-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 w-72 max-w-(--reka-popover-content-available-width) rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
|
||||
15
web/src/components/ui/popover/PopoverTrigger.vue
Normal file
15
web/src/components/ui/popover/PopoverTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverTriggerProps } from "reka-ui"
|
||||
import { PopoverTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverTrigger
|
||||
data-slot="popover-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
@@ -1,5 +1,3 @@
|
||||
export {
|
||||
PopoverRoot as Popover,
|
||||
PopoverTrigger,
|
||||
} from 'reka-ui'
|
||||
export { default as PopoverContent } from './PopoverContent.vue'
|
||||
export { default as Popover } from "./Popover.vue"
|
||||
export { default as PopoverContent } from "./PopoverContent.vue"
|
||||
export { default as PopoverTrigger } from "./PopoverTrigger.vue"
|
||||
|
||||
@@ -15,9 +15,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
|
||||
<template>
|
||||
<RadioGroupRoot
|
||||
:class="cn('grid gap-2', props.class)"
|
||||
v-slot="slotProps"
|
||||
data-slot="radio-group"
|
||||
:class="cn('grid gap-3', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
<slot v-bind="slotProps" />
|
||||
</RadioGroupRoot>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { RadioGroupItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { CircleIcon } from "lucide-vue-next"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import {
|
||||
RadioGroupIndicator,
|
||||
RadioGroupItem,
|
||||
@@ -19,16 +19,22 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
|
||||
<template>
|
||||
<RadioGroupItem
|
||||
data-slot="radio-group-item"
|
||||
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',
|
||||
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-3 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
|
||||
data-slot="radio-group-indicator"
|
||||
class="relative flex items-center justify-center"
|
||||
>
|
||||
<slot>
|
||||
<CircleIcon class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</slot>
|
||||
</RadioGroupIndicator>
|
||||
</RadioGroupItem>
|
||||
</template>
|
||||
|
||||
@@ -1,40 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import type { ScrollAreaRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
ScrollAreaCorner,
|
||||
ScrollAreaRoot,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
type ScrollAreaRootProps,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import ScrollBar from "./ScrollBar.vue"
|
||||
|
||||
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
return delegated
|
||||
})
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
|
||||
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
|
||||
<ScrollAreaRoot
|
||||
data-slot="scroll-area"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('relative', props.class)"
|
||||
>
|
||||
<ScrollAreaViewport
|
||||
data-slot="scroll-area-viewport"
|
||||
class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-3 focus-visible:outline-1"
|
||||
>
|
||||
<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>
|
||||
<ScrollBar />
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
|
||||
32
web/src/components/ui/scroll-area/ScrollBar.vue
Normal file
32
web/src/components/ui/scroll-area/ScrollBar.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { ScrollAreaScrollbarProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes["class"] }>(), {
|
||||
orientation: "vertical",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn('flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical'
|
||||
&& 'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal'
|
||||
&& 'h-2.5 flex-col border-t border-t-transparent',
|
||||
props.class)"
|
||||
>
|
||||
<ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
class="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaScrollbar>
|
||||
</template>
|
||||
@@ -1 +1,2 @@
|
||||
export { default as ScrollArea } from './ScrollArea.vue'
|
||||
export { default as ScrollArea } from "./ScrollArea.vue"
|
||||
export { default as ScrollBar } from "./ScrollBar.vue"
|
||||
|
||||
@@ -9,7 +9,11 @@ const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot v-bind="forwarded">
|
||||
<slot />
|
||||
<SelectRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="select"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</SelectRoot>
|
||||
</template>
|
||||
|
||||
@@ -31,8 +31,10 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<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',
|
||||
data-slot="select-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 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
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,
|
||||
@@ -40,7 +42,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[--reka-select-trigger-height] w-full min-w-[--reka-select-trigger-width]')">
|
||||
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width) scroll-my-1')">
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<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")
|
||||
const props = defineProps<SelectGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup :class="cn('p-1 w-full', props.class)" v-bind="delegatedProps">
|
||||
<SelectGroup
|
||||
data-slot="select-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
@@ -20,17 +20,20 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
|
||||
<template>
|
||||
<SelectItem
|
||||
data-slot="select-item"
|
||||
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',
|
||||
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectItemIndicator>
|
||||
<Check class="h-4 w-4" />
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ const props = defineProps<SelectItemTextProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItemText v-bind="props">
|
||||
<SelectItemText
|
||||
data-slot="select-item-text"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,10 @@ const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel :class="cn('px-2 py-1.5 text-sm font-semibold', props.class)">
|
||||
<SelectLabel
|
||||
data-slot="select-label"
|
||||
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -14,9 +14,13 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)">
|
||||
<SelectScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronDown />
|
||||
<ChevronDown class="size-4" />
|
||||
</slot>
|
||||
</SelectScrollDownButton>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -14,9 +14,13 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton v-bind="forwardedProps" :class="cn('flex cursor-default items-center justify-center py-1', props.class)">
|
||||
<SelectScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronUp />
|
||||
<ChevronUp class="size-4" />
|
||||
</slot>
|
||||
</SelectScrollUpButton>
|
||||
</template>
|
||||
|
||||
@@ -11,5 +11,9 @@ const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator v-bind="delegatedProps" :class="cn('-mx-1 my-1 h-px bg-muted', props.class)" />
|
||||
<SelectSeparator
|
||||
data-slot="select-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
<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 { reactiveOmit } from "@vueuse/core"
|
||||
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 props = withDefaults(
|
||||
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
|
||||
{ size: "default" },
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
data-slot="select-trigger"
|
||||
:data-size="size"
|
||||
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',
|
||||
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="w-4 h-4 opacity-50 shrink-0" />
|
||||
<ChevronDown class="size-4 opacity-50" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,10 @@ const props = defineProps<SelectValueProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectValue v-bind="props">
|
||||
<SelectValue
|
||||
data-slot="select-value"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</SelectValue>
|
||||
</template>
|
||||
|
||||
19
web/src/components/ui/sheet/Sheet.vue
Normal file
19
web/src/components/ui/sheet/Sheet.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="sheet"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -1,69 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue'
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { X } from "lucide-vue-next"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
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'
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import SheetOverlay from "./SheetOverlay.vue"
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
)
|
||||
interface SheetContentProps extends DialogContentProps {
|
||||
class?: HTMLAttributes["class"]
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}
|
||||
|
||||
type SheetVariants = VariantProps<typeof sheetVariants>
|
||||
|
||||
const props = defineProps<DialogContentProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
side?: SheetVariants['side']
|
||||
}>()
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<SheetContentProps>(), {
|
||||
side: "right",
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, side: __, ...delegated } = props
|
||||
return delegated
|
||||
})
|
||||
const delegatedProps = reactiveOmit(props, "class", "side")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</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"
|
||||
/>
|
||||
<SheetOverlay />
|
||||
<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)"
|
||||
data-slot="sheet-content"
|
||||
:class="cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right'
|
||||
&& 'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left'
|
||||
&& 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top'
|
||||
&& 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom'
|
||||
&& 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
props.class)"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
>
|
||||
<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"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { DialogDescription, type DialogDescriptionProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<DialogDescription
|
||||
data-slot="sheet-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col space-y-2 text-center sm:text-left', props.class)">
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
:class="cn('flex flex-col gap-1.5 p-4', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
21
web/src/components/ui/sheet/SheetOverlay.vue
Normal file
21
web/src/components/ui/sheet/SheetOverlay.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="sheet-overlay"
|
||||
: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)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
@@ -1,13 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { DialogTitle, type DialogTitleProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogTitle } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle :class="cn('text-lg font-semibold text-foreground', props.class)">
|
||||
<DialogTitle
|
||||
data-slot="sheet-title"
|
||||
:class="cn('text-foreground font-semibold', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
export {
|
||||
DialogRoot as Sheet,
|
||||
DialogTrigger as SheetTrigger,
|
||||
DialogClose as SheetClose,
|
||||
} from 'reka-ui'
|
||||
export { default as SheetContent } from './SheetContent.vue'
|
||||
export { default as SheetHeader } from './SheetHeader.vue'
|
||||
export { default as SheetTitle } from './SheetTitle.vue'
|
||||
export { default as SheetDescription } from './SheetDescription.vue'
|
||||
export { default as Sheet } from "./Sheet.vue"
|
||||
export { default as SheetContent } from "./SheetContent.vue"
|
||||
export { default as SheetDescription } from "./SheetDescription.vue"
|
||||
export { default as SheetHeader } from "./SheetHeader.vue"
|
||||
export { default as SheetTitle } from "./SheetTitle.vue"
|
||||
|
||||
96
web/src/components/ui/sidebar/Sidebar.vue
Normal file
96
web/src/components/ui/sidebar/Sidebar.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import type { SidebarProps } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
|
||||
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
|
||||
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
|
||||
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from "./utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
side: "left",
|
||||
variant: "sidebar",
|
||||
collapsible: "offcanvas",
|
||||
})
|
||||
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="collapsible === 'none'"
|
||||
data-slot="sidebar"
|
||||
:class="cn('bg-sidebar text-sidebar-foreground flex w-(--sidebar-width) flex-col', props.class)"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<Sheet v-else-if="isMobile" :open="openMobile" v-bind="$attrs" @update:open="setOpenMobile">
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
:side="side"
|
||||
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
:style="{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
}"
|
||||
>
|
||||
<SheetHeader class="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="group peer text-sidebar-foreground hidden md:block"
|
||||
data-slot="sidebar"
|
||||
:data-state="state"
|
||||
:data-collapsible="state === 'collapsed' ? collapsible : ''"
|
||||
:data-variant="variant"
|
||||
:data-side="side"
|
||||
>
|
||||
<!-- This is what handles the sidebar gap on desktop -->
|
||||
<div
|
||||
:class="cn(
|
||||
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||
)"
|
||||
/>
|
||||
<div
|
||||
:class="cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
props.class,
|
||||
)"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user