| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- 'use client';
- // D6 §5.2 — Ctrl+K 커맨드 팔레트 (shadcn Command / cmdk)
- // 종목 자동완성(/api/stocks/suggest) + 글 검색(Forum) 합성 (d0 §3-D 확정).
- // 종목 선택 → /stock/{code} 즉시 이동, 글 검색 → /latest?search=subject&keyword=... 이동.
- // 헤더 인라인 combobox(HeaderSearch)와 별개로, 전역 Ctrl+K/⌘K 로 열리는 팔레트.
- import { useCallback, useEffect, useRef, useState } from 'react';
- import { useRouter } from 'next/navigation';
- import { fetchApi } from '@/lib/utils/client';
- import { StockSuggestItem, StockSuggestResponse } from '@/types/stock';
- import {
- CommandDialog,
- CommandInput,
- CommandList,
- CommandEmpty,
- CommandGroup,
- CommandItem,
- CommandShortcut,
- CommandSeparator,
- } from '@/components/ui/command';
- const DEBOUNCE_MS = 250;
- export default function SearchCommand()
- {
- const router = useRouter();
- const [open, setOpen] = useState(false);
- const [keyword, setKeyword] = useState('');
- const [items, setItems] = useState<StockSuggestItem[]>([]);
- const debounceRef = useRef<ReturnType<typeof setTimeout>|null>(null);
- const requestSeq = useRef(0);
- // 전역 단축키: Ctrl+K / ⌘K → 팔레트 토글
- useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
- e.preventDefault();
- setOpen(prev => !prev);
- }
- };
- window.addEventListener('keydown', handler);
- return () => {
- window.removeEventListener('keydown', handler);
- };
- }, []);
- // 닫힐 때 입력/결과 초기화
- useEffect(() => {
- if (!open) {
- setKeyword('');
- setItems([]);
- requestSeq.current++;
- }
- }, [open]);
- useEffect(() => {
- return () => {
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
- };
- }, []);
- const requestSuggest = useCallback(async (q: string) => {
- const seq = ++requestSeq.current;
- const res = await fetchApi<StockSuggestResponse>(`/api/stocks/suggest?q=${encodeURIComponent(q)}`, { silent: true });
- // out-of-order 응답 무시
- if (seq !== requestSeq.current) {
- return;
- }
- setItems(res.success ? (res.data?.list ?? []) : []);
- }, []);
- const handleValueChange = useCallback((value: string) => {
- setKeyword(value);
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
- const trimmed = value.trim();
- if (!trimmed) {
- requestSeq.current++;
- setItems([]);
- return;
- }
- debounceRef.current = setTimeout(() => {
- requestSuggest(trimmed);
- }, DEBOUNCE_MS);
- }, [requestSuggest]);
- const goStock = useCallback((code: string) => {
- setOpen(false);
- router.push(`/stock/${code}`);
- }, [router]);
- const goStockSearch = useCallback((q: string) => {
- setOpen(false);
- router.push(`/stock?q=${encodeURIComponent(q)}`);
- }, [router]);
- const goPostSearch = useCallback((q: string) => {
- setOpen(false);
- router.push(`/latest?search=subject&keyword=${encodeURIComponent(q)}`);
- }, [router]);
- const trimmed = keyword.trim();
- return (
- <CommandDialog open={open} onOpenChange={setOpen} label='통합 검색' shouldFilter={false}>
- <CommandInput
- placeholder='종목·글 검색 (예: 삼성전자, 005930)'
- value={keyword}
- onValueChange={handleValueChange}
- />
- <CommandList>
- {!trimmed && (
- <CommandEmpty>검색어를 입력하세요.</CommandEmpty>
- )}
- {trimmed && items.length === 0 && (
- <CommandEmpty>일치하는 종목이 없습니다.</CommandEmpty>
- )}
- {items.length > 0 && (
- <CommandGroup heading='종목'>
- {items.map(item => (
- <CommandItem
- key={item.code}
- value={`stock-${item.code}`}
- onSelect={() => goStock(item.code)}
- >
- <span className='flex-1 truncate'>{item.name}</span>
- <span className='tabular-nums text-muted-foreground'>{item.code}</span>
- <CommandShortcut>{item.market}</CommandShortcut>
- </CommandItem>
- ))}
- </CommandGroup>
- )}
- {trimmed && (
- <>
- <CommandSeparator />
- <CommandGroup heading='검색'>
- <CommandItem value='search-stock' onSelect={() => goStockSearch(trimmed)}>
- 종목 목록에서 ‘{trimmed}’ 검색
- </CommandItem>
- <CommandItem value='search-post' onSelect={() => goPostSearch(trimmed)}>
- 게시글에서 ‘{trimmed}’ 검색
- </CommandItem>
- </CommandGroup>
- </>
- )}
- </CommandList>
- </CommandDialog>
- );
- }
|