Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/web/public/sitemap-0.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://www.singcode.kr/manifest.webmanifest</loc><lastmod>2026-08-31T07:03:47.228Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr/patch-notes</loc><lastmod>2026-08-31T07:03:47.229Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr</loc><lastmod>2026-08-31T07:03:47.229Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr/manifest.webmanifest</loc><lastmod>2026-09-08T09:58:23.352Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr/patch-notes</loc><lastmod>2026-09-08T09:58:23.354Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
<url><loc>https://www.singcode.kr</loc><lastmod>2026-09-08T09:58:23.354Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url>
</urlset>
52 changes: 42 additions & 10 deletions apps/web/src/app/popular/ArtistVotePanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -25,6 +26,8 @@ import ArtistVoteRow from './ArtistVoteRow';

const STEP = 10;

const ARTIST_VOTE_LISTBOX_ID = 'artist-vote-autocomplete-listbox';

/**
* 이번 달(아직 확정되지 않은 달) 화면. 랭킹 대신 내가 이번 달에 투표한 아티스트를 편집한다.
* 검색으로 고른 아티스트는 0P로 목록에 담기기만 하고, 값을 고친 뒤 엔터(또는 저장 버튼)로
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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) }));
};
Expand Down Expand Up @@ -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 && (
<SearchAutocomplete autoCompleteList={autoCompleteList} onSelect={handleSelectArtist} />
<SearchAutocomplete
autoCompleteList={autoCompleteList}
onSelect={handleSelectArtist}
{...listboxProps}
/>
)}
</div>

Expand Down
30 changes: 26 additions & 4 deletions apps/web/src/app/search/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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 && (
<SearchAutocomplete
autoCompleteList={autoCompleteList}
onSelect={handleAutocompleteClick}
{...listboxProps}
/>
)}
</div>
Expand Down
37 changes: 27 additions & 10 deletions apps/web/src/app/search/SearchAutocomplete.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,17 +32,25 @@ export default function SearchAutocomplete({
className,
)}
>
<ul className="py-1">
{/* listbox 안의 항목은 option 이어야 한다. button 을 두면 포커스가 입력창을 떠나
aria-activedescendant 방식(포커스는 입력창에 두고 활성 후보만 가리키는 것)이 깨진다. */}
<ul id={listboxId} role="listbox" aria-label="아티스트 검색어 추천" className="py-1">
{autoCompleteList.map((item, index) => (
<li key={index}>
<button
type="button"
className="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 px-4 py-2 text-left text-sm select-none"
onClick={() => onSelect(item.value)}
onMouseDown={e => e.preventDefault()}
>
<span>{item.label}</span>
</button>
<li
key={`${item.value}-${item.label}`}
id={getAutocompleteOptionId(listboxId, index)}
role="option"
aria-selected={index === activeIndex}
className={cn(
'flex w-full cursor-pointer items-center gap-2 px-4 py-2 text-left text-sm select-none',
index === activeIndex && 'bg-accent text-accent-foreground',
)}
// 클릭으로 입력창의 포커스가 빠지면 드롭다운이 먼저 닫혀 선택이 취소된다.
onMouseDown={e => e.preventDefault()}
onMouseEnter={() => onActiveIndexChange(index)}
onClick={() => onSelect(item.value)}
>
<span>{item.label}</span>
</li>
))}
</ul>
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/hooks/useAutocompleteNavigation.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading