SearchCommand.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. 'use client';
  2. // D6 §5.2 — Ctrl+K 커맨드 팔레트 (shadcn Command / cmdk)
  3. // 종목 자동완성(/api/stocks/suggest) + 글 검색(Forum) 합성 (d0 §3-D 확정).
  4. // 종목 선택 → /stock/{code} 즉시 이동, 글 검색 → /latest?search=subject&keyword=... 이동.
  5. // 헤더 인라인 combobox(HeaderSearch)와 별개로, 전역 Ctrl+K/⌘K 로 열리는 팔레트.
  6. import { useCallback, useEffect, useRef, useState } from 'react';
  7. import { useRouter } from 'next/navigation';
  8. import { fetchApi } from '@/lib/utils/client';
  9. import { StockSuggestItem, StockSuggestResponse } from '@/types/stock';
  10. import {
  11. CommandDialog,
  12. CommandInput,
  13. CommandList,
  14. CommandEmpty,
  15. CommandGroup,
  16. CommandItem,
  17. CommandShortcut,
  18. CommandSeparator,
  19. } from '@/components/ui/command';
  20. const DEBOUNCE_MS = 250;
  21. export default function SearchCommand()
  22. {
  23. const router = useRouter();
  24. const [open, setOpen] = useState(false);
  25. const [keyword, setKeyword] = useState('');
  26. const [items, setItems] = useState<StockSuggestItem[]>([]);
  27. const debounceRef = useRef<ReturnType<typeof setTimeout>|null>(null);
  28. const requestSeq = useRef(0);
  29. // 전역 단축키: Ctrl+K / ⌘K → 팔레트 토글
  30. useEffect(() => {
  31. const handler = (e: KeyboardEvent) => {
  32. if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
  33. e.preventDefault();
  34. setOpen(prev => !prev);
  35. }
  36. };
  37. window.addEventListener('keydown', handler);
  38. return () => {
  39. window.removeEventListener('keydown', handler);
  40. };
  41. }, []);
  42. // 닫힐 때 입력/결과 초기화
  43. useEffect(() => {
  44. if (!open) {
  45. setKeyword('');
  46. setItems([]);
  47. requestSeq.current++;
  48. }
  49. }, [open]);
  50. useEffect(() => {
  51. return () => {
  52. if (debounceRef.current) {
  53. clearTimeout(debounceRef.current);
  54. }
  55. };
  56. }, []);
  57. const requestSuggest = useCallback(async (q: string) => {
  58. const seq = ++requestSeq.current;
  59. const res = await fetchApi<StockSuggestResponse>(`/api/stocks/suggest?q=${encodeURIComponent(q)}`, { silent: true });
  60. // out-of-order 응답 무시
  61. if (seq !== requestSeq.current) {
  62. return;
  63. }
  64. setItems(res.success ? (res.data?.list ?? []) : []);
  65. }, []);
  66. const handleValueChange = useCallback((value: string) => {
  67. setKeyword(value);
  68. if (debounceRef.current) {
  69. clearTimeout(debounceRef.current);
  70. }
  71. const trimmed = value.trim();
  72. if (!trimmed) {
  73. requestSeq.current++;
  74. setItems([]);
  75. return;
  76. }
  77. debounceRef.current = setTimeout(() => {
  78. requestSuggest(trimmed);
  79. }, DEBOUNCE_MS);
  80. }, [requestSuggest]);
  81. const goStock = useCallback((code: string) => {
  82. setOpen(false);
  83. router.push(`/stock/${code}`);
  84. }, [router]);
  85. const goStockSearch = useCallback((q: string) => {
  86. setOpen(false);
  87. router.push(`/stock?q=${encodeURIComponent(q)}`);
  88. }, [router]);
  89. const goPostSearch = useCallback((q: string) => {
  90. setOpen(false);
  91. router.push(`/latest?search=subject&keyword=${encodeURIComponent(q)}`);
  92. }, [router]);
  93. const trimmed = keyword.trim();
  94. return (
  95. <CommandDialog open={open} onOpenChange={setOpen} label='통합 검색' shouldFilter={false}>
  96. <CommandInput
  97. placeholder='종목·글 검색 (예: 삼성전자, 005930)'
  98. value={keyword}
  99. onValueChange={handleValueChange}
  100. />
  101. <CommandList>
  102. {!trimmed && (
  103. <CommandEmpty>검색어를 입력하세요.</CommandEmpty>
  104. )}
  105. {trimmed && items.length === 0 && (
  106. <CommandEmpty>일치하는 종목이 없습니다.</CommandEmpty>
  107. )}
  108. {items.length > 0 && (
  109. <CommandGroup heading='종목'>
  110. {items.map(item => (
  111. <CommandItem
  112. key={item.code}
  113. value={`stock-${item.code}`}
  114. onSelect={() => goStock(item.code)}
  115. >
  116. <span className='flex-1 truncate'>{item.name}</span>
  117. <span className='tabular-nums text-muted-foreground'>{item.code}</span>
  118. <CommandShortcut>{item.market}</CommandShortcut>
  119. </CommandItem>
  120. ))}
  121. </CommandGroup>
  122. )}
  123. {trimmed && (
  124. <>
  125. <CommandSeparator />
  126. <CommandGroup heading='검색'>
  127. <CommandItem value='search-stock' onSelect={() => goStockSearch(trimmed)}>
  128. 종목 목록에서 &lsquo;{trimmed}&rsquo; 검색
  129. </CommandItem>
  130. <CommandItem value='search-post' onSelect={() => goPostSearch(trimmed)}>
  131. 게시글에서 &lsquo;{trimmed}&rsquo; 검색
  132. </CommandItem>
  133. </CommandGroup>
  134. </>
  135. )}
  136. </CommandList>
  137. </CommandDialog>
  138. );
  139. }