feat:为webui配置名提供中文翻译,并修改优化布局
This commit is contained in:
@@ -24,6 +24,7 @@ export interface DynamicConfigFormProps {
|
||||
/** 嵌套层级:0 = tab 内容层,1 = section 内容层,2+ = 更深嵌套 */
|
||||
level?: number
|
||||
advancedVisible?: boolean
|
||||
sectionColumns?: 1 | 2
|
||||
}
|
||||
|
||||
function buildFieldPath(basePath: string, fieldName: string) {
|
||||
@@ -126,6 +127,7 @@ function DynamicConfigSection({
|
||||
hooks={hooks}
|
||||
level={level}
|
||||
advancedVisible={hasAdvanced ? advancedVisible : undefined}
|
||||
sectionColumns={1}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -150,6 +152,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
hooks = fieldHooks,
|
||||
level = 0,
|
||||
advancedVisible,
|
||||
sectionColumns = 1,
|
||||
}) => {
|
||||
const [localAdvancedVisible, setLocalAdvancedVisible] = React.useState(false)
|
||||
const resolvedAdvancedVisible = advancedVisible ?? localAdvancedVisible
|
||||
@@ -161,10 +164,12 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
|
||||
const renderField = (field: FieldSchema) => {
|
||||
const fieldPath = buildFieldPath(basePath, field.name)
|
||||
const nestedSchema = schema.nested?.[field.name]
|
||||
|
||||
if (hooks.has(fieldPath)) {
|
||||
const hookEntry = hooks.get(fieldPath)
|
||||
if (!hookEntry) return null
|
||||
if (hookEntry.type === 'hidden') return null
|
||||
|
||||
const HookComponent = hookEntry.component
|
||||
|
||||
@@ -174,7 +179,9 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
fieldPath={fieldPath}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
onParentChange={onChange}
|
||||
schema={field}
|
||||
nestedSchema={nestedSchema}
|
||||
parentValues={values}
|
||||
/>
|
||||
)
|
||||
@@ -185,7 +192,9 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
fieldPath={fieldPath}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
onParentChange={onChange}
|
||||
schema={field}
|
||||
nestedSchema={nestedSchema}
|
||||
parentValues={values}
|
||||
>
|
||||
<DynamicField
|
||||
@@ -208,11 +217,27 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const topLevelFields = schema.fields.filter(
|
||||
(field) => !schema.nested?.[field.name],
|
||||
const shouldRenderFieldInline = (field: FieldSchema) => {
|
||||
const fieldPath = buildFieldPath(basePath, field.name)
|
||||
if (hooks.get(fieldPath)?.type === 'hidden') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!schema.nested?.[field.name]) {
|
||||
return true
|
||||
}
|
||||
|
||||
return hooks.get(fieldPath)?.type === 'replace'
|
||||
}
|
||||
|
||||
const inlineFields = schema.fields.filter(shouldRenderFieldInline)
|
||||
const inlineNestedFieldNames = new Set(
|
||||
inlineFields
|
||||
.filter((field) => Boolean(schema.nested?.[field.name]))
|
||||
.map((field) => field.name),
|
||||
)
|
||||
const normalFields = topLevelFields.filter((field) => !field.advanced)
|
||||
const advancedFields = topLevelFields.filter((field) => field.advanced)
|
||||
const normalFields = inlineFields.filter((field) => !field.advanced)
|
||||
const advancedFields = inlineFields.filter((field) => field.advanced)
|
||||
const visibleFields = resolvedAdvancedVisible
|
||||
? [...normalFields, ...advancedFields]
|
||||
: normalFields
|
||||
@@ -244,23 +269,32 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
return rows
|
||||
}
|
||||
|
||||
const renderRows = (rows: FieldSchema[][]) => (
|
||||
<>
|
||||
{rows.map((row) => (
|
||||
row.length > 1 ? (
|
||||
<div
|
||||
key={row.map((field) => field.name).join('|')}
|
||||
className="grid gap-4 py-1 md:grid-cols-[repeat(var(--field-row-count),minmax(0,1fr))]"
|
||||
style={{ '--field-row-count': row.length } as React.CSSProperties}
|
||||
>
|
||||
{row.map((field) => (
|
||||
<div key={field.name}>{renderField(field)}</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div key={row[0].name} className="py-1">{renderField(row[0])}</div>
|
||||
)
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
const renderFieldList = (fields: FieldSchema[]) => (
|
||||
<>
|
||||
{groupFieldsByRow(fields).map((row, index) => (
|
||||
<React.Fragment key={row.map((field) => field.name).join('|')}>
|
||||
{index > 0 && <Separator className="my-2 bg-border/50" />}
|
||||
{row.length > 1 ? (
|
||||
<div
|
||||
className="grid gap-4 py-1 md:grid-cols-[repeat(var(--field-row-count),minmax(0,1fr))]"
|
||||
style={{ '--field-row-count': row.length } as React.CSSProperties}
|
||||
>
|
||||
{row.map((field) => (
|
||||
<div key={field.name}>{renderField(field)}</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">{renderField(row[0])}</div>
|
||||
)}
|
||||
{renderRows([row])}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
@@ -268,7 +302,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{topLevelFields.length > 0 && (
|
||||
{inlineFields.length > 0 && (
|
||||
<div>
|
||||
{advancedVisible === undefined && advancedFields.length > 0 && (
|
||||
<div className="flex justify-end pb-2">
|
||||
@@ -283,7 +317,9 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
)}
|
||||
|
||||
{schema.nested &&
|
||||
Object.entries(schema.nested)
|
||||
(() => {
|
||||
const nestedSections = Object.entries(schema.nested)
|
||||
.filter(([key]) => !inlineNestedFieldNames.has(key))
|
||||
.map(([key, nestedSchema]) => {
|
||||
const nestedField = fieldMap.get(key)
|
||||
const nestedFieldPath = buildFieldPath(basePath, key)
|
||||
@@ -299,8 +335,9 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
<HookComponent
|
||||
fieldPath={nestedFieldPath}
|
||||
value={values[key]}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
schema={nestedField ?? nestedSchema}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
onParentChange={onChange}
|
||||
schema={nestedField ?? nestedSchema}
|
||||
nestedSchema={nestedSchema}
|
||||
parentValues={values}
|
||||
/>
|
||||
@@ -314,6 +351,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
fieldPath={nestedFieldPath}
|
||||
value={values[key]}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
onParentChange={onChange}
|
||||
schema={nestedField ?? nestedSchema}
|
||||
nestedSchema={nestedSchema}
|
||||
parentValues={values}
|
||||
@@ -325,6 +363,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
sectionColumns={1}
|
||||
/>
|
||||
</HookComponent>
|
||||
</div>
|
||||
@@ -376,11 +415,27 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
sectionColumns={1}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
})
|
||||
|
||||
const visibleNestedSections = nestedSections.filter(
|
||||
(section): section is React.ReactElement => Boolean(section),
|
||||
)
|
||||
|
||||
if (level === 0 && sectionColumns === 2 && visibleNestedSections.length > 1) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{visibleNestedSections}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return visibleNestedSections
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as LucideIcons from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { KeyValueEditor } from "@/components/ui/key-value-editor"
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { resolveFieldLabel } from "@/lib/config-label"
|
||||
import type { FieldSchema } from "@/types/config-schema"
|
||||
|
||||
export interface DynamicFieldProps {
|
||||
@@ -37,6 +39,8 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { i18n } = useTranslation()
|
||||
const fieldLabel = resolveFieldLabel(schema, i18n.language)
|
||||
const isNumericField = schema.type === 'integer' || schema.type === 'number'
|
||||
|
||||
const parseNumericValue = (rawValue: unknown, fallbackValue: unknown = 0) => {
|
||||
@@ -126,17 +130,17 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
const inlineDescription = hasOptionDescriptions ? '' : schema.description
|
||||
|
||||
const renderFieldHeader = () => (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Label
|
||||
className={cn(
|
||||
"inline-flex min-h-7 items-center gap-1.5 rounded-md border px-2 py-1 text-sm font-medium shadow-sm",
|
||||
"inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[15px] font-semibold leading-6",
|
||||
schema.advanced
|
||||
? "border-amber-300 bg-amber-50 text-amber-950 dark:border-amber-500/60 dark:bg-amber-500/15 dark:text-amber-100"
|
||||
: "bg-muted/60 text-foreground",
|
||||
? "text-amber-800 dark:text-amber-200"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span className="break-all">{schema.label}</span>
|
||||
<span>{fieldLabel}</span>
|
||||
{schema.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
{inlineDescription && (
|
||||
@@ -357,7 +361,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
return (
|
||||
<Select value={strValue} onValueChange={(val) => onChange(val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${schema.label}`} />
|
||||
<SelectValue placeholder={`Select ${fieldLabel}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hasOptionDescriptions ? (
|
||||
@@ -415,13 +419,13 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
if (supportsInlineRight) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2 py-2 sm:flex-row sm:items-center sm:justify-between"
|
||||
className="flex flex-col gap-2 py-2 sm:flex-row sm:items-center"
|
||||
style={{ '--field-input-width': schema['x-input-width'] ?? '12rem' } as React.CSSProperties}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{renderFieldHeader()}
|
||||
</div>
|
||||
<div className="w-full shrink-0 sm:w-[var(--field-input-width)]">
|
||||
<div className="min-w-20 flex-1 sm:ml-auto sm:max-w-[var(--field-input-width)]">
|
||||
{renderInputComponent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,7 +130,7 @@ export function Layout({ children }: LayoutProps) {
|
||||
key="settings-sidebar"
|
||||
className="relative z-40 hidden shrink-0 lg:block"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: sidebarOpen ? 256 : 64, opacity: 1 }}
|
||||
animate={{ width: sidebarOpen ? 240 : 64, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
|
||||
@@ -32,8 +32,8 @@ export function Sidebar({
|
||||
'fixed inset-y-0 left-0 z-50 isolate flex flex-col border-r transition-all duration-300 lg:relative lg:z-0 lg:h-full',
|
||||
inheritsPageBackground ? 'bg-transparent' : 'bg-card',
|
||||
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
|
||||
'w-64 lg:w-auto',
|
||||
sidebarOpen ? 'lg:w-64' : 'lg:w-16',
|
||||
'w-60 lg:w-auto',
|
||||
sidebarOpen ? 'lg:w-60' : 'lg:w-16',
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { FileText, Search, SlidersHorizontal } from 'lucide-react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
@@ -15,7 +15,10 @@ import { Input } from '@/components/ui/input'
|
||||
import { ShortcutKbd } from '@/components/ui/kbd'
|
||||
import { menuSections } from '@/components/layout/constants'
|
||||
import { registeredRoutePaths } from '@/router'
|
||||
import { getBotConfigSchema, getModelConfigSchema } from '@/lib/config-api'
|
||||
import { getAllLocalizedText, resolveFieldLabel } from '@/lib/config-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean
|
||||
@@ -23,19 +26,115 @@ interface SearchDialogProps {
|
||||
}
|
||||
|
||||
interface SearchItem {
|
||||
id: string
|
||||
icon: React.ComponentType<LucideProps>
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
category: string
|
||||
keywords: string
|
||||
}
|
||||
|
||||
function resolveSchemaTitle(schema: ConfigSchema, fallback: string) {
|
||||
return schema.uiLabel || schema.classDoc || schema.className || fallback
|
||||
}
|
||||
|
||||
function unwrapConfigSchema(payload: unknown): ConfigSchema | null {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
if ('fields' in payload) {
|
||||
return payload as ConfigSchema
|
||||
}
|
||||
|
||||
if ('schema' in payload) {
|
||||
const schema = (payload as { schema?: unknown }).schema
|
||||
if (schema && typeof schema === 'object' && 'fields' in schema) {
|
||||
return schema as ConfigSchema
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getModelConfigPath(fieldPath: string) {
|
||||
return fieldPath.startsWith('api_providers') ? '/config/modelProvider' : '/config/model'
|
||||
}
|
||||
|
||||
function buildFieldSearchText(field: FieldSchema, fieldPath: string, sectionTitle: string, language?: string) {
|
||||
const options = field.options?.join(' ') ?? ''
|
||||
const optionDescriptions = field['x-option-descriptions']
|
||||
? Object.entries(field['x-option-descriptions'])
|
||||
.map(([key, value]) => `${key} ${value}`)
|
||||
.join(' ')
|
||||
: ''
|
||||
|
||||
return [
|
||||
resolveFieldLabel(field, language),
|
||||
...getAllLocalizedText(field.label),
|
||||
field.name,
|
||||
fieldPath,
|
||||
field.description,
|
||||
sectionTitle,
|
||||
field.type,
|
||||
options,
|
||||
optionDescriptions,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
function collectConfigFields(
|
||||
schema: ConfigSchema,
|
||||
sourceLabel: string,
|
||||
basePath: string,
|
||||
routePath: (fieldPath: string) => string,
|
||||
language?: string,
|
||||
): SearchItem[] {
|
||||
const items: SearchItem[] = []
|
||||
|
||||
const walk = (currentSchema: ConfigSchema, pathPrefix: string, sectionTrail: string[]) => {
|
||||
const sectionTitle = resolveSchemaTitle(currentSchema, sourceLabel)
|
||||
const nextTrail = [...sectionTrail, sectionTitle].filter(Boolean)
|
||||
|
||||
for (const field of currentSchema.fields) {
|
||||
const fieldPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name
|
||||
const nestedSchema = currentSchema.nested?.[field.name]
|
||||
const fieldTitle = resolveFieldLabel(field, language)
|
||||
const description = field.description || nextTrail.join(' / ') || fieldPath
|
||||
const fullPath = basePath ? `${basePath}.${fieldPath}` : fieldPath
|
||||
const route = routePath(fullPath)
|
||||
|
||||
items.push({
|
||||
id: `config:${sourceLabel}:${fullPath}`,
|
||||
icon: sourceLabel === '模型配置' ? SlidersHorizontal : FileText,
|
||||
title: fieldTitle,
|
||||
description: `${sourceLabel} / ${nextTrail.join(' / ')} / ${fullPath} · ${description}`,
|
||||
path: route,
|
||||
category: '配置项',
|
||||
keywords: buildFieldSearchText(field, fullPath, nextTrail.join(' / '), language),
|
||||
})
|
||||
|
||||
if (nestedSchema) {
|
||||
walk(nestedSchema, fieldPath, nextTrail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(schema, '', [])
|
||||
return items
|
||||
}
|
||||
|
||||
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [configSearchItems, setConfigSearchItems] = useState<SearchItem[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const { i18n, t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setConfigSearchItems([])
|
||||
}, [i18n.language])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -49,29 +148,91 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || configSearchItems.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const loadConfigSearchItems = async () => {
|
||||
const [botSchemaResult, modelSchemaResult] = await Promise.all([
|
||||
getBotConfigSchema(),
|
||||
getModelConfigSchema(),
|
||||
])
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextItems: SearchItem[] = []
|
||||
if (botSchemaResult.success) {
|
||||
const botSchema = unwrapConfigSchema(botSchemaResult.data)
|
||||
if (botSchema) {
|
||||
nextItems.push(...collectConfigFields(
|
||||
botSchema,
|
||||
'Bot 配置',
|
||||
'',
|
||||
() => '/config/bot',
|
||||
i18n.language,
|
||||
))
|
||||
}
|
||||
}
|
||||
if (modelSchemaResult.success) {
|
||||
const modelSchema = unwrapConfigSchema(modelSchemaResult.data)
|
||||
if (modelSchema) {
|
||||
nextItems.push(...collectConfigFields(
|
||||
modelSchema,
|
||||
'模型配置',
|
||||
'',
|
||||
getModelConfigPath,
|
||||
i18n.language,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
setConfigSearchItems(nextItems)
|
||||
}
|
||||
|
||||
loadConfigSearchItems().catch(() => {
|
||||
if (!cancelled) {
|
||||
setConfigSearchItems([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [configSearchItems.length, i18n.language, open])
|
||||
|
||||
const searchItems: SearchItem[] = useMemo(
|
||||
() =>
|
||||
menuSections.flatMap((section) =>
|
||||
section.items
|
||||
.filter((item) => registeredRoutePaths.has(item.path))
|
||||
.map((item) => ({
|
||||
id: `route:${item.path}`,
|
||||
icon: item.icon,
|
||||
title: t(item.label),
|
||||
description: item.searchDescription ? t(item.searchDescription) : item.path,
|
||||
path: item.path,
|
||||
category: t(section.title),
|
||||
keywords: [
|
||||
t(item.label),
|
||||
item.path,
|
||||
item.searchDescription ? t(item.searchDescription) : '',
|
||||
t(section.title),
|
||||
].join(' '),
|
||||
}))
|
||||
),
|
||||
[t]
|
||||
)
|
||||
|
||||
// 过滤搜索结果
|
||||
const filteredItems = searchItems.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase()
|
||||
const filteredItems = (normalizedQuery ? [...searchItems, ...configSearchItems] : searchItems)
|
||||
.filter((item) => item.keywords.toLowerCase().includes(normalizedQuery))
|
||||
.slice(0, 80)
|
||||
|
||||
// 导航到页面
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
@@ -87,9 +248,11 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (filteredItems.length === 0) return
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredItems.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (filteredItems.length === 0) return
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length)
|
||||
} else if (e.key === 'Enter' && filteredItems[selectedIndex]) {
|
||||
e.preventDefault()
|
||||
@@ -128,7 +291,7 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
key={item.id}
|
||||
onClick={() => handleNavigate(item.path)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user