diff --git a/apps/web/public/sitemap-0.xml b/apps/web/public/sitemap-0.xml index ee74551..dd0e594 100644 --- a/apps/web/public/sitemap-0.xml +++ b/apps/web/public/sitemap-0.xml @@ -1,6 +1,6 @@ -https://www.singcode.kr/manifest.webmanifest2026-08-31T07:03:47.228Zweekly0.7 -https://www.singcode.kr/patch-notes2026-08-31T07:03:47.229Zweekly0.7 -https://www.singcode.kr2026-08-31T07:03:47.229Zweekly0.7 +https://www.singcode.kr/manifest.webmanifest2026-09-08T09:58:23.352Zweekly0.7 +https://www.singcode.kr/patch-notes2026-09-08T09:58:23.354Zweekly0.7 +https://www.singcode.kr2026-09-08T09:58:23.354Zweekly0.7 \ No newline at end of file diff --git a/apps/web/src/app/popular/ArtistVotePanel.tsx b/apps/web/src/app/popular/ArtistVotePanel.tsx index c8fb99b..efc66fc 100644 --- a/apps/web/src/app/popular/ArtistVotePanel.tsx +++ b/apps/web/src/app/popular/ArtistVotePanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import SearchAutocomplete from '@/app/search/SearchAutocomplete'; import { Button } from '@/components/ui/button'; @@ -14,6 +14,7 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; +import useAutocompleteNavigation from '@/hooks/useAutocompleteNavigation'; import { useMyArtistVotesQuery, useSaveArtistVotesMutation } from '@/queries/artistVoteQuery'; import { useArtistSearchQuery } from '@/queries/artistsQuery'; import { useUserQuery } from '@/queries/userQuery'; @@ -25,6 +26,8 @@ import ArtistVoteRow from './ArtistVoteRow'; const STEP = 10; +const ARTIST_VOTE_LISTBOX_ID = 'artist-vote-autocomplete-listbox'; + /** * 이번 달(아직 확정되지 않은 달) 화면. 랭킹 대신 내가 이번 달에 투표한 아티스트를 편집한다. * 검색으로 고른 아티스트는 0P로 목록에 담기기만 하고, 값을 고친 뒤 엔터(또는 저장 버튼)로 @@ -50,14 +53,20 @@ export default function ArtistVotePanel() { const { mutate: saveVotes, isPending } = useSaveArtistVotesMutation(); const { data: searchResults = [], isFetching: isSearching } = useArtistSearchQuery(query); - const autoCompleteList = searchResults.map(artist => ({ - // 한국어 표기가 원어 표기와 같으면(예: 'IVE') 같은 글자를 두 번 보여줄 뿐이라 생략한다. - label: - artist.name_ko && artist.name_ko !== artist.name - ? `${artist.name} (${artist.name_ko})` - : artist.name, - value: artist.name, - })); + // useMemo 로 참조를 고정한다. 매 렌더 새 배열을 만들면 + // useAutocompleteNavigation 이 목록이 바뀐 것으로 보고 활성 후보를 계속 지운다. + const autoCompleteList = useMemo( + () => + searchResults.map(artist => ({ + // 한국어 표기가 원어 표기와 같으면(예: 'IVE') 같은 글자를 두 번 보여줄 뿐이라 생략한다. + label: + artist.name_ko && artist.name_ko !== artist.name + ? `${artist.name} (${artist.name_ko})` + : artist.name, + value: artist.name, + })), + [searchResults], + ); // 자동완성은 결과가 없으면 아무것도 그리지 않아 검색이 동작하는지조차 알기 어렵다. // 검색어를 넣었는데 후보가 없으면 그 사실을 그대로 알려준다. const isSearchEmpty = query.trim().length > 0 && !isSearching && autoCompleteList.length === 0; @@ -88,6 +97,16 @@ export default function ArtistVotePanel() { setIsFocusAuto(true); }; + const isAutocompleteOpen = isFocusAuto && autoCompleteList.length > 0; + + const { activeCandidate, handleKeyDown, inputAriaProps, listboxProps } = + useAutocompleteNavigation({ + listboxId: ARTIST_VOTE_LISTBOX_ID, + candidates: autoCompleteList, + isOpen: isAutocompleteOpen, + onClose: () => setIsFocusAuto(false), + }); + const handleSelectArtist = (name: string) => { setQuery(''); setIsFocusAuto(false); @@ -98,6 +117,12 @@ export default function ArtistVotePanel() { setAddedArtists(prev => [...prev, selected ?? { name, name_ko: null }]); }; + // 엔터는 키보드로 고른 후보를 목록에 담는다. 고른 후보가 없으면 아무 일도 하지 않는다. + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key !== 'Enter' || !activeCandidate) return; + handleSelectArtist(activeCandidate.value); + }; + const handleChange = (artist: string, amount: number) => { setDraft(prev => ({ ...prev, [artist]: Math.max(0, amount) })); }; @@ -157,11 +182,18 @@ export default function ArtistVotePanel() { placeholder="아티스트 검색" value={query} onChange={event => handleChangeQuery(event.target.value)} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} onFocus={() => setIsFocusAuto(true)} onBlur={() => setIsFocusAuto(false)} + {...inputAriaProps} /> {isFocusAuto && ( - + )} diff --git a/apps/web/src/app/search/HomePage.tsx b/apps/web/src/app/search/HomePage.tsx index a6abae7..14d5984 100644 --- a/apps/web/src/app/search/HomePage.tsx +++ b/apps/web/src/app/search/HomePage.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { TOUR_DEMO_SEARCH_TERM, TOUR_DEMO_SONG } from '@/constants/tourDemoSong'; +import useAutocompleteNavigation from '@/hooks/useAutocompleteNavigation'; import useSaveSongModal from '@/hooks/useSaveSongModal'; import useSearchSong from '@/hooks/useSearchSong'; import useSearchTourController from '@/hooks/useSearchTourController'; @@ -26,6 +27,8 @@ import SearchResultCard from './SearchResultCard'; import SearchStatus from './SearchStatus'; import SearchTour from './SearchTour'; +const AUTOCOMPLETE_LISTBOX_ID = 'search-autocomplete-listbox'; + export default function SearchPage() { const { search, @@ -114,12 +117,27 @@ export default function SearchPage() { const isNumberKeypadVisible = searchType === 'number' && (isNumberKeypadOpen || searchSongs.length === 0); - // 엔터 키 처리 + const isAutocompleteOpen = isFocusAuto && autoCompleteList.length > 0; + + const { activeCandidate, handleKeyDown, inputAriaProps, listboxProps } = + useAutocompleteNavigation({ + listboxId: AUTOCOMPLETE_LISTBOX_ID, + candidates: autoCompleteList, + isOpen: isAutocompleteOpen, + onClose: () => setIsFocusAuto(false), + }); + + // 엔터 키 처리 — 활성 후보가 있으면 그 후보로, 없으면 입력값 그대로 검색한다. const handleKeyUp = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { + if (e.key !== 'Enter') return; + + if (activeCandidate) { + setSearch(activeCandidate.value); + handleSearch(activeCandidate.value); + } else { handleSearch(); - setIsFocusAuto(false); } + setIsFocusAuto(false); }; const handleSearchClick = () => { @@ -304,14 +322,18 @@ export default function SearchPage() { className="pl-8" value={search} onChange={handleChangeSearch} + onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onFocus={() => setIsFocusAuto(true)} onBlur={() => setIsFocusAuto(false)} + {...inputAriaProps} /> - {isFocusAuto && searchType !== 'number' && ( + {/* 검색 타입별 노출 여부는 useSearchSong이 autoCompleteList를 비우는 것으로 정한다 */} + {isFocusAuto && ( )} diff --git a/apps/web/src/app/search/SearchAutocomplete.tsx b/apps/web/src/app/search/SearchAutocomplete.tsx index 7d3bfba..5a84375 100644 --- a/apps/web/src/app/search/SearchAutocomplete.tsx +++ b/apps/web/src/app/search/SearchAutocomplete.tsx @@ -1,17 +1,26 @@ 'use client'; +import { getAutocompleteOptionId } from '@/hooks/useAutocompleteNavigation'; import { cn } from '@/utils/cn'; import { type SearchCandidate } from '@/utils/getArtistAlias'; interface SearchAutocompleteProps { autoCompleteList: SearchCandidate[]; onSelect: (value: string) => void; + /** 키보드로 이동 중인 후보. 아무것도 고르지 않은 상태는 -1 */ + activeIndex: number; + onActiveIndexChange: (index: number) => void; + /** 입력창의 aria-controls / aria-activedescendant 가 가리키는 id */ + listboxId: string; className?: string; } export default function SearchAutocomplete({ autoCompleteList, onSelect, + activeIndex, + onActiveIndexChange, + listboxId, className, }: SearchAutocompleteProps) { if (autoCompleteList.length === 0) return null; @@ -23,17 +32,25 @@ export default function SearchAutocomplete({ className, )} > -
    + {/* listbox 안의 항목은 option 이어야 한다. button 을 두면 포커스가 입력창을 떠나 + aria-activedescendant 방식(포커스는 입력창에 두고 활성 후보만 가리키는 것)이 깨진다. */} +
      {autoCompleteList.map((item, index) => ( -
    • - +
    • e.preventDefault()} + onMouseEnter={() => onActiveIndexChange(index)} + onClick={() => onSelect(item.value)} + > + {item.label}
    • ))}
    diff --git a/apps/web/src/hooks/useAutocompleteNavigation.ts b/apps/web/src/hooks/useAutocompleteNavigation.ts new file mode 100644 index 0000000..cf07fce --- /dev/null +++ b/apps/web/src/hooks/useAutocompleteNavigation.ts @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; + +import { type SearchCandidate } from '@/utils/getArtistAlias'; + +// 입력창(aria-activedescendant)과 옵션(id)이 같은 규칙으로 id를 만들어야 +// 스크린리더가 "지금 읽어야 할 후보"를 찾을 수 있다. +export const getAutocompleteOptionId = (listboxId: string, index: number) => + `${listboxId}-option-${index}`; + +interface Params { + /** 드롭다운 ul 의 id. 화면마다 달라야 한다 */ + listboxId: string; + /** + * 후보 목록. 참조가 매 렌더 바뀌면 활성 후보가 계속 초기화되므로 + * 호출부에서 useMemo 로 감싸 넘긴다. + */ + candidates: SearchCandidate[]; + isOpen: boolean; + /** Esc 로 닫을 때 */ + onClose: () => void; +} + +/** + * 자동완성 드롭다운의 키보드 내비게이션(↑/↓/Esc)과 ARIA 속성을 담당한다. + * + * 엔터 처리는 화면마다 의미가 달라(검색 실행 / 목록에 담기) 여기서 하지 않는다. + * 호출부가 activeCandidate 를 보고 직접 정한다. + */ +export default function useAutocompleteNavigation({ + listboxId, + candidates, + isOpen, + onClose, +}: Params) { + // 키보드로 이동 중인 후보. 아무것도 고르지 않은 상태는 -1 + const [activeIndex, setActiveIndex] = useState(-1); + + // 후보 목록이 바뀌거나 드롭다운이 닫히면 활성 후보를 되돌린다. + // 남겨두면 목록이 줄어든 뒤 엉뚱한 후보가 엔터로 선택된다. + useEffect(() => { + setActiveIndex(-1); + }, [candidates, isOpen]); + + const activeCandidate = isOpen && activeIndex >= 0 ? (candidates[activeIndex] ?? null) : null; + + // ↑/↓/Esc 는 keydown 에서 처리한다. keyup 은 이미 커서가 움직인 뒤라 늦다. + const handleKeyDown = (e: React.KeyboardEvent) => { + // 한글 조합 중의 방향키는 IME 의 몫이다. + if (e.nativeEvent.isComposing || !isOpen || candidates.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); // 커서가 입력 끝으로 튀는 기본 동작을 막는다 + setActiveIndex(prev => (prev + 1) % candidates.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(prev => (prev <= 0 ? candidates.length - 1 : prev - 1)); + } else if (e.key === 'Escape') { + onClose(); + } + }; + + // 입력창에 그대로 펼쳐 넣는다 (WAI-ARIA combobox 패턴) + const inputAriaProps = { + role: 'combobox' as const, + 'aria-expanded': isOpen, + 'aria-controls': isOpen ? listboxId : undefined, + 'aria-activedescendant': + activeIndex >= 0 && isOpen ? getAutocompleteOptionId(listboxId, activeIndex) : undefined, + 'aria-autocomplete': 'list' as const, + }; + + // SearchAutocomplete 에 그대로 펼쳐 넣는다 + const listboxProps = { + listboxId, + activeIndex, + onActiveIndexChange: setActiveIndex, + }; + + return { + activeIndex, + setActiveIndex, + activeCandidate, + handleKeyDown, + inputAriaProps, + listboxProps, + }; +} diff --git a/apps/web/src/hooks/useSearchSong.ts b/apps/web/src/hooks/useSearchSong.ts index 898210b..f290adb 100644 --- a/apps/web/src/hooks/useSearchSong.ts +++ b/apps/web/src/hooks/useSearchSong.ts @@ -1,4 +1,4 @@ -import { useCallback, useDeferredValue, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { usePostSearchLogMutation } from '@/queries/searchLogQuery'; @@ -43,46 +43,80 @@ export default function useSearchSong() { const { mutate: postSearchLog } = usePostSearchLogMutation(); + // 검색 결과 도착을 기다리는 인기 검색어 로그 후보 + const [pendingLog, setPendingLog] = useState<{ text: string; seq: number } | null>(null); + const seqRef = useRef(0); + const { setFooterAnimateKey } = useFooterAnimateStore(); const { addToHistory } = useSearchHistoryStore(); const { addGuestToSingSong, removeGuestToSingSong } = useGuestToSingStore(); - const deferredSearch = useDeferredValue(search); + // 아티스트 별칭 사전이라 제목·번호 탭에서는 후보를 만들지 않는다. + // 드롭다운만 숨기면 절반만 막힌다 — handleSearch의 별칭 치환은 이 목록을 직접 보므로, + // 제목 탭에서 "원오크"를 직접 타이핑해 엔터를 눌러도 ONE OK ROCK으로 바뀌어 0건이 됐다. + // 목록 자체를 비워 드롭다운과 치환을 한 곳에서 함께 끈다. + const canUseArtistAlias = searchType === 'all' || searchType === 'artist'; + // handleSearch가 이 목록에서 별칭 치환을 하므로 search를 그대로 따라가야 한다. + // useDeferredValue를 끼우면 목록이 한 박자 늦어, 붙여넣기 직후 엔터처럼 지연이 큰 + // 순간에 "검색할 문자열"과 "그 문자열의 후보"가 어긋나 치환이 조용히 빠진다. + // 사전 규모가 수백 개라 미룰 만큼 무겁지도 않다. const autoCompleteList = useMemo( - () => getAutoCompleteSuggestions(deferredSearch), - [deferredSearch], + () => (canUseArtistAlias ? getAutoCompleteSuggestions(search) : []), + [search, canUseArtistAlias], ); - const handleSearch = (termOverride?: string, typeOverride?: SearchType) => { + // overrideText: 자동완성 후보를 키보드/마우스로 고른 경우 그 값으로 바로 검색한다. + // setSearch 는 다음 렌더에야 반영되므로, 인자로 받지 않으면 직전 입력값으로 검색이 나간다. + const handleSearch = (overrideText?: string) => { // trim 제거 - const trimSearch = (termOverride ?? search).trim(); + const trimSearch = (overrideText ?? search).trim(); if (!trimSearch) { setQuery(''); return; } - let parsedSearch = trimSearch; - - if (autoCompleteList.length === 1) { - if (autoCompleteList[0].label === trimSearch) { - // 자동완성 리스트가 하나(정확히 일치하면)고 label도 일치하면 해당 alias의 value로 자동 치환 - parsedSearch = autoCompleteList[0].value; - } - } + // 입력한 말이 별칭과 정확히 겹치면 그 별칭의 공식 명칭으로 바꿔 검색한다. + // 후보 개수로 판단하면("리스트가 하나일 때만") 같은 아티스트의 별칭끼리 접두가 + // 겹치는 순간 치환이 통째로 건너뛰어졌다 — "원오크"는 "원오크락"과 함께 걸려 + // 후보가 2개가 되는데, ONE OK ROCK은 artist_ko가 비어 있어 치환 없이는 0건이었다. + const exactMatch = autoCompleteList.find( + candidate => candidate.label.toLowerCase() === trimSearch.toLowerCase(), + ); + const parsedSearch = exactMatch ? exactMatch.value : trimSearch; // 중간 띄어쓰기는 제거하지 않고 그대로 전달한다. // 검색어의 공백 처리(토큰 분리 → %로 치환)는 검색 API(/api/search)가 담당한다. if (parsedSearch) { setQuery(parsedSearch); setSearch(parsedSearch); - setQueryType(typeOverride ?? searchType); + setQueryType(searchType); addToHistory(parsedSearch); - postSearchLog(parsedSearch); + // 인기 검색어 로그는 여기서 바로 남기지 않는다. + // 결과가 0건인 오타·존재하지 않는 곡까지 집계되면 인기 검색어가 오염된다. + // 검색 결과가 도착한 뒤 1건이라도 있을 때만 아래 effect가 기록한다. + // seq는 같은 검색어를 연달아 검색해도 effect가 다시 돌게 하는 용도다. + seqRef.current += 1; + setPendingLog({ text: parsedSearch, seq: seqRef.current }); } }; + // 검색 결과가 1건 이상일 때만 인기 검색어 로그를 남긴다. + useEffect(() => { + if (!pendingLog) return; + // 이번 검색어의 결과가 아직 도착하지 않았으면 대기한다. + if (query !== pendingLog.text || isPendingSearch) return; + + setPendingLog(null); + if (isError) return; + + const hasResult = searchResults?.pages.some(page => page.data.length > 0) ?? false; + if (hasResult) { + postSearchLog(pendingLog.text); + } + }, [pendingLog, query, isPendingSearch, isError, searchResults, postSearchLog]); + const handleSearchTypeChange = (value: SearchType) => { setSearchType(value); }; diff --git a/apps/web/src/utils/getArtistAlias.ts b/apps/web/src/utils/getArtistAlias.ts index 21eeb56..5c1bcf6 100644 --- a/apps/web/src/utils/getArtistAlias.ts +++ b/apps/web/src/utils/getArtistAlias.ts @@ -4,17 +4,37 @@ import { artistAlias } from '@repo/constants'; export type SearchCandidate = { label: string; value: string }; -const createCandidateList = (): SearchCandidate[] => { - const list: SearchCandidate[] = []; +// getChoseong은 공백을 그대로 남긴다 — getChoseong('요네즈 켄시') === 'ㅇㄴㅈ ㅋㅅ'. +// 사람은 초성을 붙여서 치므로("ㅇㄴㅈㅋㅅ") 양쪽에서 공백을 지우고 비교해야 +// 이름 중간의 공백을 넘어가도 초성 검색이 이어진다. 전사된 일본 아티스트명은 +// 대부분 "성 이름" 꼴이라 이걸 안 하면 초성 검색이 첫 단어에서 끊긴다. +const removeSpaces = (value: string) => value.replace(/\s+/g, ''); + +type IndexedCandidate = SearchCandidate & { + /** 소문자로 맞춘 라벨 — 입력할 때마다 다시 만들지 않도록 미리 계산한다 */ + searchLabel: string; + /** 공백을 지운 초성. 한글이 없는 라벨(YOASOBI, 米津玄師)은 빈 문자열이 된다 */ + choseong: string; +}; + +const createCandidateList = (): IndexedCandidate[] => { + const list: IndexedCandidate[] = []; + + const pushCandidate = (label: string, value: string) => { + list.push({ + label, + value, + searchLabel: label.toLowerCase(), + choseong: removeSpaces(getChoseong(label)), + }); + }; Object.entries(artistAlias).forEach(([officialName, aliases]) => { // 공식 명칭 검색 후보에 추가 - list.push({ label: officialName, value: officialName }); + pushCandidate(officialName, officialName); // 별명들 검색 후보에 추가 - aliases.forEach(alias => { - list.push({ label: alias, value: officialName }); - }); + aliases.forEach(alias => pushCandidate(alias, officialName)); }); return list; @@ -27,13 +47,18 @@ export const getAutoCompleteSuggestions = (query: string): SearchCandidate[] => if (!query) return []; const normalizedQuery = query.toLowerCase().trim(); // 대소문자 무시 + // 공백만 입력한 경우 — 빈 문자열은 모든 후보의 접두사라 후보 10개가 그대로 뜬다 + if (!normalizedQuery) return []; + + const choseongQuery = removeSpaces(normalizedQuery); // 배열 필터링 (여기가 핵심) // includes: 중간에 포함된 것도 찾음 ("라시" -> "아라시") // startsWith: 앞에서부터 일치하는 것만 찾음 ("아" -> "아라시") -> 보통 자동완성은 이걸 씀 return SEARCH_CANDIDATES.filter( candidate => - candidate.label.toLowerCase().startsWith(normalizedQuery) || - getChoseong(candidate.label).startsWith(normalizedQuery), + candidate.searchLabel.startsWith(normalizedQuery) || + // 한글이 없는 라벨은 초성이 빈 문자열이라 아무 입력에나 걸리지 않도록 걸러낸다 + (candidate.choseong !== '' && candidate.choseong.startsWith(choseongQuery)), ).slice(0, 10); // 성능을 위해 상위 10개만 자름 }; diff --git a/packages/constants/src/artistAlias.ts b/packages/constants/src/artistAlias.ts index 00c4838..59aaa96 100644 --- a/packages/constants/src/artistAlias.ts +++ b/packages/constants/src/artistAlias.ts @@ -16,11 +16,11 @@ export const artistAlias = { "Creepy Nuts": ["크리피 너츠"], // [Gemini 추천 아티스트 추가] 한국 인기 최상위 - Ado: ["아도", "우세와", "신시대"], - imase: ["이마세", "나이트댄서"], + Ado: ["아도"], + imase: ["이마세"], "ONE OK ROCK": ["원오크락", "원오크", "원옥"], "X JAPAN": ["엑스재팬", "엑스제팬"], - "tuki.": ["츠키", "투키", "만찬가"], + "tuki.": ["츠키", "투키"], HoneyWorks: ["허니웍스"], "L'Arc~en~Ciel": ["라르크 앙 시엘", "라르크"], 松田聖子: ["마츠다 세이코"], @@ -36,7 +36,7 @@ export const artistAlias = { 浜崎あゆみ: ["하마사키 아유미"], 水樹奈々: ["미즈키 나나"], "モーニング娘。": ["모닝구 무스메"], - Official髭男dism: ["오피셜히게단디즘", "히게단", "프리텐더"], + Official髭男dism: ["오피셜히게단디즘", "히게단"], ヨルシカ: ["요루시카"], 中島美嘉: ["나카시마 미카"], 宇多田ヒカル: ["우타다 히카루"],