WebUI 前端 & 后端超级大重构

This commit is contained in:
DrSmoothl
2026-03-14 21:06:36 +08:00
parent 6ca5a2939e
commit 172615f18a
69 changed files with 3128 additions and 6581 deletions

View File

@@ -14,13 +14,13 @@ import {
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useBackendConnections } from '@/hooks/useBackendConnections'
import { isElectron } from '@/lib/runtime'
import type { BackendConnection } from '@/types/electron'
@@ -78,7 +78,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md sm:max-w-[425px]">
<DialogContent className="max-w-md sm:max-w-106.25">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
@@ -88,7 +88,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<ScrollArea className="max-h-[60vh] pr-4">
<DialogBody className="pr-4">
<div className="flex flex-col gap-3 py-4">
{backends.map((backend) => {
const isActive = backend.id === activeId
@@ -100,7 +100,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
}`}
>
<div className="flex flex-1 items-center gap-3 overflow-hidden">
<div className="flex-shrink-0">
<div className="shrink-0">
{isActive ? (
<Check className="h-5 w-5 text-blue-500" />
) : (
@@ -156,7 +156,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
)
})}
</div>
</ScrollArea>
</DialogBody>
)}
<div className="flex justify-end pt-4 border-t">
@@ -173,7 +173,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
{/* Edit/Add Dialog */}
<Dialog open={!!editConn} onOpenChange={(open) => !open && setEditConn(null)}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-106.25" confirmOnEnter>
<DialogHeader>
<DialogTitle>{editConn?.id ? '编辑连接' : '添加连接'}</DialogTitle>
</DialogHeader>
@@ -212,6 +212,7 @@ export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
!editConn?.url ||
!/^https?:\/\//.test(editConn.url)
}
data-dialog-action="confirm"
>
</Button>

View File

@@ -22,6 +22,7 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ShortcutKbd } from '@/components/ui/kbd'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Checkbox } from '@/components/ui/checkbox'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -1689,19 +1690,19 @@ if (isCurrent) {
{/* 底部快捷键提示(桌面端) */}
<div className="hidden sm:flex items-center justify-center gap-6 px-6 py-3 border-t text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<kbd className="px-2 py-1 bg-muted rounded text-xs"></kbd>
<ShortcutKbd size="sm" keys={['left']} />
<span></span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-2 py-1 bg-muted rounded text-xs"></kbd>
<ShortcutKbd size="sm" keys={['right']} />
<span></span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-2 py-1 bg-muted rounded text-xs"></kbd>
<ShortcutKbd size="sm" keys={['up']} />
<span></span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-2 py-1 bg-muted rounded text-xs"></kbd>
<ShortcutKbd size="sm" keys={['down']} />
<span></span>
</div>
<span className="text-muted-foreground/50">|</span>

View File

@@ -1,5 +1,4 @@
import { Link } from '@tanstack/react-router'
import { BookOpen, ChevronLeft, Globe, LogOut, Menu, Moon, PieChart, Search, Server, Sun } from 'lucide-react'
import { BookOpen, ChevronLeft, Globe, LogOut, Menu, Moon, Search, Server, Sun } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -13,7 +12,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Kbd } from '@/components/ui/kbd'
import { ShortcutKbd } from '@/components/ui/kbd'
import { toggleThemeWithTransition } from '@/components/use-theme'
import { useBackground } from '@/hooks/use-background'
import { logout } from '@/lib/fetch-with-auth'
@@ -99,7 +98,7 @@ export function Header({
title={t('header.toggleConnection')}
>
<Server className="h-4 w-4" />
<span className="hidden sm:inline text-xs text-muted-foreground truncate max-w-[100px]">
<span className="hidden sm:inline text-xs text-muted-foreground truncate max-w-25">
{activeBackendName}
</span>
</Button>
@@ -107,19 +106,6 @@ export function Header({
<div className="h-6 w-px bg-border" />
</>
)}
{/* 年度总结入口 */}
<Link to="/annual-report">
<Button
variant="ghost"
size="sm"
className="gap-2 bg-gradient-to-r from-pink-500/10 to-purple-500/10 hover:from-pink-500/20 hover:to-purple-500/20 border border-pink-500/20"
title={t('header.viewAnnualSummary')}
>
<PieChart className="h-4 w-4 text-pink-500" />
<span className="hidden sm:inline bg-gradient-to-r from-pink-500 to-purple-500 bg-clip-text text-transparent font-medium">{t('header.annualSummary')}</span>
</Button>
</Link>
{/* 搜索框 */}
<button
onClick={() => onSearchOpenChange(true)}
@@ -128,9 +114,7 @@ export function Header({
>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true" />
<span className="text-sm text-muted-foreground">{t('header.searchPlaceholder')}</span>
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
<span className="text-xs"></span>K
</Kbd>
<ShortcutKbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2" keys={['mod', 'k']} />
</button>
{/* 搜索对话框 */}

View File

@@ -13,6 +13,7 @@ import { useAuthGuard } from '@/hooks/use-auth'
import { useBackground } from '@/hooks/use-background'
import { TitleBar } from '@/components/electron/TitleBar'
import { matchesShortcut } from '@/lib/keyboard'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
import { menuSections } from './constants'
@@ -49,7 +50,7 @@ export function Layout({ children }: LayoutProps) {
// 搜索快捷键监听Cmd/Ctrl + K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
if (matchesShortcut(e, ['mod', 'k'])) {
e.preventDefault()
setSearchOpen(true)
}
@@ -68,9 +69,8 @@ export function Layout({ children }: LayoutProps) {
}
}
const unsubscribe = router.subscribe('onResolved', () => {
const pathname = router.state.location.pathname
const pageTitle = pathToLabel[pathname] ?? 'MaiBot Dashboard'
return router.subscribe('onResolved', () => {
const pageTitle = pathToLabel[router.state.location.pathname] ?? 'MaiBot Dashboard'
const fullTitle = pageTitle === 'MaiBot Dashboard'
? 'MaiBot Dashboard'
: `${pageTitle} — MaiBot Dashboard`
@@ -90,8 +90,6 @@ export function Layout({ children }: LayoutProps) {
})
}
})
return unsubscribe
}, [router, announce, t])
// 获取实际应用的主题(处理 system 情况)

View File

@@ -6,25 +6,25 @@ export const menuSections: MenuSection[] = [
{
title: 'sidebar.groups.overview',
items: [
{ icon: Home, label: 'sidebar.menu.home', path: '/' },
{ icon: Home, label: 'sidebar.menu.home', path: '/', searchDescription: 'search.items.homeDesc' },
],
},
{
title: 'sidebar.groups.botConfig',
items: [
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot' },
{ icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', tourId: 'sidebar-model-provider' },
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', tourId: 'sidebar-model-management' },
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' },
{ icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', searchDescription: 'search.items.modelProviderDesc', tourId: 'sidebar-model-provider' },
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' },
{ icon: Sliders, label: 'sidebar.menu.adapterConfig', path: '/config/adapter' },
],
},
{
title: 'sidebar.groups.botResources',
items: [
{ icon: Smile, label: 'sidebar.menu.emojiManagement', path: '/resource/emoji' },
{ icon: MessageSquare, label: 'sidebar.menu.expressionManagement', path: '/resource/expression' },
{ icon: Hash, label: 'sidebar.menu.slangManagement', path: '/resource/jargon' },
{ icon: UserCircle, label: 'sidebar.menu.personInfo', path: '/resource/person' },
{ icon: Smile, label: 'sidebar.menu.emojiManagement', path: '/resource/emoji', searchDescription: 'search.items.emojiDesc' },
{ icon: MessageSquare, label: 'sidebar.menu.expressionManagement', path: '/resource/expression', searchDescription: 'search.items.expressionDesc' },
{ icon: Hash, label: 'sidebar.menu.slangManagement', path: '/resource/jargon', searchDescription: 'search.items.jargonDesc' },
{ icon: UserCircle, label: 'sidebar.menu.personInfo', path: '/resource/person', searchDescription: 'search.items.personDesc' },
{ icon: Network, label: 'sidebar.menu.knowledgeGraph', path: '/resource/knowledge-graph' },
{ icon: Database, label: 'sidebar.menu.knowledgeBase', path: '/resource/knowledge-base' },
],
@@ -32,10 +32,10 @@ export const menuSections: MenuSection[] = [
{
title: 'sidebar.groups.extensionsMonitor',
items: [
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins' },
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' },
{ icon: LayoutGrid, label: 'sidebar.menu.configTemplate', path: '/config/pack-market' },
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs' },
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' },
{ icon: Activity, label: 'sidebar.menu.plannerMonitor', path: '/planner-monitor' },
{ icon: MessageSquare, label: 'sidebar.menu.localChat', path: '/chat' },
],
@@ -43,7 +43,7 @@ export const menuSections: MenuSection[] = [
{
title: 'sidebar.groups.system',
items: [
{ icon: Settings, label: 'sidebar.menu.settings', path: '/settings' },
{ icon: Settings, label: 'sidebar.menu.settings', path: '/settings', searchDescription: 'search.items.settingsDesc' },
],
},
]

View File

@@ -9,6 +9,7 @@ export interface MenuItem {
icon: ComponentType<LucideProps>
label: string
path: string
searchDescription?: string
tourId?: string
}

View File

@@ -1,16 +1,20 @@
import { useState, useCallback, useMemo } from 'react'
import { Search, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, BarChart3, Package, Settings, Home, Hash } from 'lucide-react'
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { Search } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import type { LucideProps } from 'lucide-react'
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { ShortcutKbd } from '@/components/ui/kbd'
import { menuSections } from '@/components/layout/constants'
import { registeredRoutePaths } from '@/router'
import { cn } from '@/lib/utils'
interface SearchDialogProps {
@@ -19,7 +23,7 @@ interface SearchDialogProps {
}
interface SearchItem {
icon: React.ComponentType<{ className?: string }>
icon: React.ComponentType<LucideProps>
title: string
description: string
path: string
@@ -29,95 +33,37 @@ interface SearchItem {
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
const { t } = useTranslation()
const searchItems: SearchItem[] = useMemo(() => [
{
icon: Home,
title: t('search.items.home'),
description: t('search.items.homeDesc'),
path: '/',
category: t('search.categories.overview'),
},
{
icon: FileText,
title: t('search.items.botConfig'),
description: t('search.items.botConfigDesc'),
path: '/config/bot',
category: t('search.categories.config'),
},
{
icon: Server,
title: t('search.items.modelProvider'),
description: t('search.items.modelProviderDesc'),
path: '/config/modelProvider',
category: t('search.categories.config'),
},
{
icon: Boxes,
title: t('search.items.model'),
description: t('search.items.modelDesc'),
path: '/config/model',
category: t('search.categories.config'),
},
{
icon: Smile,
title: t('search.items.emoji'),
description: t('search.items.emojiDesc'),
path: '/resource/emoji',
category: t('search.categories.resources'),
},
{
icon: MessageSquare,
title: t('search.items.expression'),
description: t('search.items.expressionDesc'),
path: '/resource/expression',
category: t('search.categories.resources'),
},
{
icon: UserCircle,
title: t('search.items.person'),
description: t('search.items.personDesc'),
path: '/resource/person',
category: t('search.categories.resources'),
},
{
icon: Hash,
title: t('search.items.jargon'),
description: t('search.items.jargonDesc'),
path: '/resource/jargon',
category: t('search.categories.resources'),
},
{
icon: BarChart3,
title: t('search.items.statistics'),
description: t('search.items.statisticsDesc'),
path: '/statistics',
category: t('search.categories.monitor'),
},
{
icon: Package,
title: t('search.items.plugins'),
description: t('search.items.pluginsDesc'),
path: '/plugins',
category: t('search.categories.extensions'),
},
{
icon: FileSearch,
title: t('search.items.logs'),
description: t('search.items.logsDesc'),
path: '/logs',
category: t('search.categories.monitor'),
},
{
icon: Settings,
title: t('search.items.settings'),
description: t('search.items.settingsDesc'),
path: '/settings',
category: t('search.categories.system'),
},
], [t])
useEffect(() => {
if (!open) {
return
}
const frameId = window.requestAnimationFrame(() => {
inputRef.current?.focus()
})
return () => window.cancelAnimationFrame(frameId)
}, [open])
const searchItems: SearchItem[] = useMemo(
() =>
menuSections.flatMap((section) =>
section.items
.filter((item) => registeredRoutePaths.has(item.path))
.map((item) => ({
icon: item.icon,
title: t(item.label),
description: item.searchDescription ? t(item.searchDescription) : item.path,
path: item.path,
category: t(section.title),
}))
),
[t]
)
// 过滤搜索结果
const filteredItems = searchItems.filter(
@@ -155,12 +101,13 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl p-0 gap-0">
<DialogContent className="max-w-2xl p-0 gap-0" confirmOnEnter>
<DialogHeader className="px-4 pt-4 pb-0">
<DialogTitle className="sr-only">{t('search.title')}</DialogTitle>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
@@ -169,13 +116,12 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
onKeyDown={handleKeyDown}
placeholder={t('search.placeholder')}
className="h-12 pl-11 text-base border-0 focus-visible:ring-0 shadow-none"
autoFocus
/>
</div>
</DialogHeader>
<div className="border-t">
<ScrollArea className="h-[400px]">
<DialogBody className="h-100" viewportClassName="px-0">
{filteredItems.length > 0 ? (
<div className="p-2">
{filteredItems.map((item, index) => {
@@ -192,7 +138,7 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
: 'hover:bg-accent/50'
)}
>
<Icon className="h-5 w-5 flex-shrink-0" />
<Icon className="h-5 w-5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{item.title}</div>
<div className="text-xs text-muted-foreground truncate">
@@ -214,22 +160,22 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
</p>
</div>
)}
</ScrollArea>
</DialogBody>
</div>
<div className="border-t px-4 py-3 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded border"></kbd>
<kbd className="px-1.5 py-0.5 bg-muted rounded border"></kbd>
<ShortcutKbd size="sm" keys={['up']} />
<ShortcutKbd size="sm" keys={['down']} />
{t('search.navigate')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Enter</kbd>
<ShortcutKbd size="sm" keys={['enter']} />
{t('search.select')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Esc</kbd>
<ShortcutKbd size="sm" keys={['esc']} />
{t('search.close')}
</span>
</div>

View File

@@ -24,6 +24,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
@@ -34,7 +35,6 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import { toast } from '@/hooks/use-toast'
import {
createPack,
@@ -340,7 +340,7 @@ export function SharePackDialog({ trigger }: SharePackDialogProps) {
)}
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogContent className="max-w-2xl flex flex-col" confirmOnEnter>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="w-5 h-5" />
@@ -353,7 +353,7 @@ export function SharePackDialog({ trigger }: SharePackDialogProps) {
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[calc(85vh-220px)] pr-4">
<DialogBody>
{loading ? (
<div className="py-8 text-center">
<Loader2 className="w-8 h-8 mx-auto animate-spin text-primary" />
@@ -639,7 +639,7 @@ export function SharePackDialog({ trigger }: SharePackDialogProps) {
)}
</>
)}
</ScrollArea>
</DialogBody>
<DialogFooter className="flex justify-between pt-4 border-t">
<div>
@@ -662,6 +662,7 @@ export function SharePackDialog({ trigger }: SharePackDialogProps) {
</Button>
{step < totalSteps ? (
<Button
data-dialog-action="confirm"
onClick={() => setStep(step + 1)}
disabled={
loading ||
@@ -671,7 +672,7 @@ export function SharePackDialog({ trigger }: SharePackDialogProps) {
</Button>
) : (
<Button onClick={handleSubmit} disabled={submitting}>
<Button data-dialog-action="confirm" onClick={handleSubmit} disabled={submitting}>
{submitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
</Button>

View File

@@ -1,8 +1,13 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { cn } from "@/lib/utils"
import { X } from "lucide-react"
import { isEditableTarget, matchesShortcut } from "@/lib/keyboard"
import { ScrollArea } from "@/components/ui/scroll-area"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
@@ -32,22 +37,48 @@ interface DialogContentProps
preventOutsideClose?: boolean
/** 隐藏默认关闭按钮(当使用自定义关闭按钮时) */
hideCloseButton?: boolean
/** 回车触发主操作按钮 */
confirmOnEnter?: boolean
}
interface DialogBodyProps extends React.ComponentPropsWithoutRef<typeof ScrollArea> {
allowHorizontalScroll?: boolean
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, children, preventOutsideClose = false, hideCloseButton = false, ...props }, ref) => (
>(({ className, children, preventOutsideClose = false, hideCloseButton = false, confirmOnEnter = false, onKeyDownCapture, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={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",
"fixed left-[50%] top-[50%] z-50 grid w-[min(calc(100vw-2rem),var(--dialog-width,32rem))] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-hidden 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",
className
)}
onPointerDownOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
onInteractOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
onKeyDownCapture={(event) => {
onKeyDownCapture?.(event)
if (
!confirmOnEnter ||
event.defaultPrevented ||
!matchesShortcut(event, ['enter']) ||
event.nativeEvent.isComposing ||
isEditableTarget(event.target)
) {
return
}
const confirmButton = event.currentTarget.querySelector<HTMLElement>('[data-dialog-action="confirm"]:not([disabled])')
if (!confirmButton) {
return
}
event.preventDefault()
confirmButton.click()
}}
{...props}
>
{children}
@@ -62,6 +93,22 @@ const DialogContent = React.forwardRef<
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogBody = React.forwardRef<HTMLDivElement, DialogBodyProps>(
({ className, children, allowHorizontalScroll = false, contentClassName, scrollbars, viewportClassName, ...props }, ref) => (
<ScrollArea
ref={ref as never}
className={cn("min-h-0 flex-1", className)}
contentClassName={cn(allowHorizontalScroll && "min-w-full w-max", contentClassName)}
scrollbars={scrollbars ?? (allowHorizontalScroll ? "both" : "vertical")}
viewportClassName={cn("pr-4", viewportClassName)}
{...props}
>
{children}
</ScrollArea>
)
)
DialogBody.displayName = "DialogBody"
const DialogHeader = ({
className,
...props
@@ -125,6 +172,7 @@ export {
DialogClose,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
DialogTitle,
DialogDescription,

View File

@@ -1,6 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { getPlatformModifierAriaLabel, getShortcutKeyLabel, type ShortcutKey } from "@/lib/keyboard"
import { cn } from "@/lib/utils"
const kbdVariants = cva(
@@ -25,6 +26,10 @@ export interface KbdProps
abbrTitle?: string
}
interface ShortcutKbdProps extends Omit<KbdProps, "children"> {
keys: ShortcutKey[]
}
const Kbd = React.forwardRef<HTMLElement, KbdProps>(
({ className, size, abbrTitle, children, ...props }, ref) => {
return (
@@ -40,4 +45,20 @@ const Kbd = React.forwardRef<HTMLElement, KbdProps>(
)
Kbd.displayName = "Kbd"
export { Kbd }
function ShortcutKbd({ keys, className, size, ...props }: ShortcutKbdProps) {
return (
<span className={cn("inline-flex items-center gap-1", className)}>
{keys.map((key) => {
const label = getShortcutKeyLabel(key)
const abbrTitle = key === 'mod' ? getPlatformModifierAriaLabel() : undefined
return (
<Kbd key={`${key}-${label}`} size={size} abbrTitle={abbrTitle} {...props}>
{label}
</Kbd>
)
})}
</span>
)
}
export { Kbd, ShortcutKbd }

View File

@@ -5,22 +5,25 @@ import { cn } from "@/lib/utils"
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
viewportRef?: React.RefObject<HTMLDivElement | null>
viewportClassName?: string
contentClassName?: string
scrollbars?: "vertical" | "horizontal" | "both"
}
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
ScrollAreaProps
>(({ className, children, viewportRef, ...props }, ref) => (
>(({ className, children, viewportRef, viewportClassName, contentClassName, scrollbars = "both", ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport ref={viewportRef} className="h-full w-full rounded-[inherit]">
{children}
<ScrollAreaPrimitive.Viewport ref={viewportRef} className={cn("h-full w-full rounded-[inherit]", viewportClassName)}>
<div className={contentClassName}>{children}</div>
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollBar orientation="horizontal" />
{scrollbars !== "horizontal" && <ScrollBar />}
{scrollbars !== "vertical" && <ScrollBar orientation="horizontal" />}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
@@ -36,9 +39,9 @@ const ScrollBar = React.forwardRef<
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
"h-full w-2.5 border-l border-l-transparent p-px",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
"h-2.5 flex-col border-t border-t-transparent p-px",
className
)}
{...props}