mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
5a8722ff07
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
263 lines
8.1 KiB
TypeScript
263 lines
8.1 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
CommandDialog,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from '@/components/ui/command';
|
|
import { Kbd, KbdGroup } from '@/components/ui/kbd';
|
|
import { type SearchItem, searchItems } from '@/lib/utils/search-data';
|
|
import { FileText, Home, Settings, Shield } from 'lucide-react';
|
|
import { useUser } from '@/contexts/user-context';
|
|
import { useTranslations } from 'next-intl';
|
|
|
|
interface SearchDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
const categoryIcons = {
|
|
page: Home,
|
|
feature: FileText,
|
|
setting: Settings,
|
|
admin: Shield,
|
|
};
|
|
|
|
const categoryLabels = {
|
|
page: 'categoryPage',
|
|
feature: 'categoryFeature',
|
|
setting: 'categorySetting',
|
|
admin: 'categoryAdmin',
|
|
};
|
|
|
|
const getTips = (metaKey: string, t: (key: string) => string) => [
|
|
<>
|
|
<span className='text-muted-foreground/80 lowercase'>
|
|
{t('tipPrefix')}{' '}
|
|
</span>
|
|
<Kbd className='mx-1'>/</Kbd>
|
|
<span className='text-muted-foreground/80 lowercase'> {t('tipSlash')}</span>
|
|
</>,
|
|
<>
|
|
<span className='text-muted-foreground/80 lowercase'>
|
|
{t('tipPrefix')}{' '}
|
|
</span>
|
|
<Kbd className='mx-1'>↑</Kbd>
|
|
<Kbd className='mx-1'>↓</Kbd>
|
|
<span className='text-muted-foreground/80 lowercase'> {t('tipArrow')}</span>
|
|
</>,
|
|
<>
|
|
<span className='text-muted-foreground/80 lowercase'>
|
|
{t('tipPrefix')}{' '}
|
|
</span>
|
|
<Kbd className='mx-1'>{metaKey}</Kbd>
|
|
<span className='text-muted-foreground/80 lowercase'>+</span>
|
|
<Kbd className='mx-1'>↵</Kbd>
|
|
<span className='text-muted-foreground/80 lowercase'>
|
|
{' '}
|
|
{t('tipNewTab')}
|
|
</span>
|
|
</>,
|
|
<>
|
|
<span className='text-muted-foreground/80 lowercase'>
|
|
{t('tipDidYouKnow')}
|
|
</span>
|
|
</>,
|
|
];
|
|
|
|
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
|
const router = useRouter();
|
|
const { user } = useUser();
|
|
const t = useTranslations('layout.search');
|
|
const tLayout = useTranslations('layout');
|
|
const [search, setSearch] = useState('');
|
|
const [currentTip, setCurrentTip] = useState<React.ReactNode>(null);
|
|
const [results, setResults] = useState<SearchItem[]>([]);
|
|
const [metaKey, setMetaKey] = useState('⌘');
|
|
const [isCtrlPressed, setIsCtrlPressed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
typeof navigator !== 'undefined' &&
|
|
!navigator.userAgent?.includes('Mac')
|
|
) {
|
|
setMetaKey('Ctrl');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Control' || e.key === 'Meta') {
|
|
setIsCtrlPressed(true);
|
|
}
|
|
};
|
|
const handleKeyUp = (e: KeyboardEvent) => {
|
|
if (e.key === 'Control' || e.key === 'Meta') {
|
|
setIsCtrlPressed(false);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
window.addEventListener('keyup', handleKeyUp);
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown);
|
|
window.removeEventListener('keyup', handleKeyUp);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
const tips = getTips(metaKey, t);
|
|
const randomTip = tips[Math.floor(Math.random() * tips.length)];
|
|
setCurrentTip(randomTip);
|
|
}
|
|
}, [open, metaKey, t]);
|
|
|
|
useEffect(() => {
|
|
const items = searchItems(search, user?.is_admin, (key) => tLayout(key));
|
|
setResults(items);
|
|
}, [search, user?.is_admin, tLayout]);
|
|
|
|
const handleSelect = useCallback(
|
|
(item: SearchItem, openInNewTab = false) => {
|
|
onOpenChange(false);
|
|
if (openInNewTab) {
|
|
window.open(item.url, '_blank');
|
|
} else {
|
|
router.push(item.url);
|
|
}
|
|
setSearch('');
|
|
},
|
|
[onOpenChange, router],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const down = (e: KeyboardEvent) => {
|
|
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
onOpenChange(!open);
|
|
}
|
|
|
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
const target = e.target as HTMLElement;
|
|
const isEditing =
|
|
target.tagName === 'INPUT' ||
|
|
target.tagName === 'TEXTAREA' ||
|
|
target.tagName === 'SELECT' ||
|
|
target.isContentEditable ||
|
|
target.closest('[contenteditable="true"]');
|
|
|
|
if (!isEditing) {
|
|
e.preventDefault();
|
|
onOpenChange(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', down);
|
|
return () => document.removeEventListener('keydown', down);
|
|
}, [open, onOpenChange]);
|
|
|
|
// Group results by category
|
|
const groupedResults = results.reduce(
|
|
(acc, item) => {
|
|
if (!acc[item.category]) {
|
|
acc[item.category] = [];
|
|
}
|
|
acc[item.category].push(item);
|
|
return acc;
|
|
},
|
|
{} as Record<string, SearchItem[]>,
|
|
);
|
|
|
|
return (
|
|
<CommandDialog
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
shouldFilter={false}
|
|
className='max-w-[calc(100%-1.5rem)] sm:max-w-[500px] md:max-w-[540px]'
|
|
>
|
|
<CommandInput
|
|
placeholder={t('searchPlaceholder')}
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty>{t('noResults')}</CommandEmpty>
|
|
{Object.entries(groupedResults).map(([category, items]) => {
|
|
const Icon = categoryIcons[category as keyof typeof categoryIcons];
|
|
return (
|
|
<CommandGroup
|
|
key={category}
|
|
heading={t(
|
|
categoryLabels[category as keyof typeof categoryLabels],
|
|
)}
|
|
>
|
|
{items.map((item) => (
|
|
<CommandItem
|
|
key={item.id}
|
|
value={item.title}
|
|
onSelect={() => handleSelect(item, isCtrlPressed)}
|
|
className='flex items-center justify-between'
|
|
>
|
|
<div className='flex min-w-0 items-center gap-3'>
|
|
<div className='flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/70'>
|
|
<Icon className='size-4' />
|
|
</div>
|
|
<div className='flex min-w-0 flex-col'>
|
|
<span>
|
|
{item.matchRange ? (
|
|
<>
|
|
{item.title.substring(0, item.matchRange[0])}
|
|
<span className='text-primary font-bold'>
|
|
{item.title.substring(
|
|
item.matchRange[0],
|
|
item.matchRange[1] + 1,
|
|
)}
|
|
</span>
|
|
{item.title.substring(item.matchRange[1] + 1)}
|
|
</>
|
|
) : (
|
|
item.title
|
|
)}
|
|
</span>
|
|
<span className='truncate text-[11px] leading-4 text-muted-foreground'>
|
|
{item.description}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<span className='ml-3 shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] leading-none font-medium text-muted-foreground'>
|
|
{t(
|
|
categoryLabels[
|
|
item.category as keyof typeof categoryLabels
|
|
],
|
|
)}
|
|
</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
);
|
|
})}
|
|
</CommandList>
|
|
<div className='hidden border-t bg-muted/20 px-4 py-2.5 md:flex items-center gap-4 text-[10px] text-muted-foreground uppercase tracking-wider font-medium select-none'>
|
|
<div className='flex items-center gap-1'>
|
|
<KbdGroup>
|
|
{isCtrlPressed && <Kbd>{metaKey}</Kbd>}
|
|
<Kbd>↵</Kbd>
|
|
</KbdGroup>
|
|
<span>{isCtrlPressed ? t('openInNewTab') : t('open')}</span>
|
|
</div>
|
|
<div className='flex items-center gap-1'>
|
|
<Kbd>Esc</Kbd>
|
|
<span>{t('closeSearch')}</span>
|
|
</div>
|
|
<div className='ml-auto flex items-center gap-1'>{currentTip}</div>
|
|
</div>
|
|
</CommandDialog>
|
|
);
|
|
}
|