diff --git a/.agents/skills/queuing-qa-reviewer/SKILL.md b/.agents/skills/queuing-qa-reviewer/SKILL.md index 468db1d9..9adb6a26 100644 --- a/.agents/skills/queuing-qa-reviewer/SKILL.md +++ b/.agents/skills/queuing-qa-reviewer/SKILL.md @@ -65,6 +65,7 @@ Do not use it as a vague second implementation pass. QA must compare concrete bo - Room reconnect tests prove join handshake -> single topic subscription -> room read invalidation order, and route cleanup proves explicit leave or cancelled-join cleanup. - Queue pagination tests prove first-page-only entry, cursor/revision pairing, `totalPendingCount`, conflict reset, and inclusion of every pending personal entry in reorder payloads regardless of `ownerOrdered`. - Drag-and-drop regressions require a repeated real or faithful pointer cycle that checks the original row count, computed visibility, and cleared inline transform/animation after every drop; a mocked overlay prop or callback-only test is insufficient evidence. +- Account exit QA must leave an identity GET in flight, complete logout/withdrawal, then resolve the old response. Assert that the GET was cancelled, `me` stays null, and account-specific suggestion caches are removed. Setting identity data to null alone does not cancel a pending query. - Account-specific failures require same-account and same-backend-state comparisons before assigning a frontend root cause. - Shared controls still behave consistently across home and search. - Hover-only controls remain reachable by focus where practical. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c91e389a..cdc84847 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -45,6 +45,7 @@ Vercel Edge Config -> Next.js request gate (`src/proxy.ts`) - Brand-only presentation such as the main logo may live in `src/shared/ui`; authenticated actions and room navigation remain in their owning auth and room features. - Domain-neutral management-menu focus, outside-click, Escape, placement, visual shell behavior, and opt-in viewport portal positioning live in `src/shared/ui/management-menu`; room and follow features provide only their allowed actions. - Room thumbnail file validation, temporary upload selection, and the shared temporary-upload mutation live in `src/features/room/hooks`; create and update flows own only their submit orchestration. +- Domain-neutral overflow marquee presentation lives in `src/shared/ui/overflow-marquee`; queue cards and playlist suggestions share hover, keyboard activation, and reduced-motion behavior. - Domain-neutral floating-panel chrome and drag handles live in `src/shared/ui/floating-panel`; room and follow features own placement state and panel-specific content. - ESLint rejects imports from `src/shared` into `src/features` or `src/app`, and rejects imports from the room feature back into the home feature. @@ -54,6 +55,7 @@ Vercel Edge Config -> Next.js request gate (`src/proxy.ts`) - STOMP subscriptions deliver real-time events; handlers must reconcile those events with query cache and screen state deliberately. - App-wide follow presence and room membership use separate STOMP clients because their authentication, ownership, and reconnect lifecycles are independent. A terminal room event such as `user.session-replaced` must not stop the follow presence transport. - Room membership owns `roomAccessToken` in memory plus room-scoped `sessionStorage`. The token is issued by `ROOM_JOINED`, authenticates room topic subscriptions and room-internal REST requests, survives transport reconnects, and is never part of a URL, log, or TanStack Query key. +- Room-session nickname projections live in the room domain. Timestamped room events reconcile REST caches and project chat display names; the session projection survives transport reconnects and is cleared on explicit leave. Stored chat history remains unchanged. - Local component state owns transient UI state such as modal visibility, hover state, inputs, and local panel behavior. - `FollowModal` owns the selected follow user and originating card trigger while its nested profile dialog is open, so close and block flows can restore focus without list-owned expansion state. - `localStorage` is reserved for persistence that must survive navigation or reload, such as scoped room interaction state. diff --git a/docs/agent-harness/incidents/2026-09-08-account-exit-late-response.md b/docs/agent-harness/incidents/2026-09-08-account-exit-late-response.md new file mode 100644 index 00000000..fc4ad742 --- /dev/null +++ b/docs/agent-harness/incidents/2026-09-08-account-exit-late-response.md @@ -0,0 +1,53 @@ +# 로그아웃 뒤 지연된 본인 조회가 계정을 복원하는 경합 + +## Problem +로그아웃·탈퇴 성공 직전 진행 중이던 me 조회가 늦게 완료되면 UI가 이전 계정을 다시 로그인 상태로 판단할 수 있었다. + +## Previous Behavior +성공 처리에서 me를 null로 저장하고 일부 계정별 캐시를 제거했지만 진행 중 조회는 유지했다. + +## Previous Code +```ts +qc.setQueryData(userKeys.me(), null); +``` + +## Updated Code +```ts +await qc.cancelQueries({ queryKey: userKeys.me(), exact: true }); +qc.setQueryData(userKeys.me(), null); +qc.removeQueries({ queryKey: trackSuggestionKeys.all() }); +``` +본인 API도 QueryFunctionContext.signal을 Axios까지 전달한다. + +## Problem in the Previous Code +setQueryData는 진행 중 queryFn을 취소하지 않는다. queryFn의 늦은 성공이 null보다 뒤에 캐시에 기록된다. + +## Evidence +- 독립 QA에서 실제 QueryClient의 me null → 과거 fetch resolve → 이전 계정 복원을 재현. +- `useAccountExit.test.tsx`에서 실제 logout/withdraw mutation hook과 지연 Promise를 결합하여 signal.aborted, 최종 me null, 검색 캐시 제거를 검증. +- 실서비스 계정으로 재현하지 않았음. 원인은 클라이언트 캐시 경합으로 한정. + +## Cause or Remaining Hypotheses +진행 중 조회를 취소하지 않은 캐시 초기화 순서가 확인된 원인. 서버 로그아웃 동작에 대한 문제는 주장하지 않는다. + +## Solution Options +- me만 null: 이후 성공 응답에 덮임. +- 모든 캐시 삭제: 영향 범위가 크고 불필요한 공용 캐시까지 버림. +- 본인 조회 취소 후 null 및 관련 개인 캐시 삭제: 선택. + +## Chosen Solution and Rationale +기존 계정 종료 mutation이 순서를 소유하도록 제한했다. signal 전파와 TanStack 취소를 함께 적용해 전송 및 캐시 반영을 중단한다. + +## Result +지연 응답이 계정을 복원하지 않으며 자주 신청한 곡·검색 결과가 다음 계정으로 노출되지 않는다. + +## Reusable Rule +계정 종료 시 진행 중 identity 조회를 먼저 취소하고 identity/개인 캐시를 정리한다. 캐시를 비우는 것만으로 취소가 되지 않는다. + +## Skill or Team Spec Updates +- queuing-qa-reviewer: 로그아웃/탈퇴 지연 identity 응답 검사 추가. + +## Verification +- 전체 `npm test`: 711개 통과(추가 후 최종 숫자는 실행 계획 QA 참조). +- `npm run lint`, `npm run build` 통과. +- 브라우저 연결 없음으로 실계정 수동 검증 미실행. diff --git a/docs/exec-plans/active/2026-09-08-track-search-realtime/api-contract.md b/docs/exec-plans/active/2026-09-08-track-search-realtime/api-contract.md new file mode 100644 index 00000000..9b145948 --- /dev/null +++ b/docs/exec-plans/active/2026-09-08-track-search-realtime/api-contract.md @@ -0,0 +1,23 @@ +# API 계약 + +2026-09-08 GitHub API로 Queuing-org/backend main의 Controller/Response 원문 확인. + +- `src/main/java/queuing/core/room/queue/presentation/controller/UserFrequentlyQueuedTrackController.java` +- `src/main/java/queuing/core/room/queue/presentation/controller/YoutubeVideoSearchController.java` +- 같은 queue 패키지 `presentation/response/FrequentlyQueuedTrackResponse.java`, `YoutubeVideoSearchResponse.java` +- `room/participant/application/event/payload/RoomParticipantNicknameChangedEventData.java` + +## 검색 +- GET `/api/v1/user-profiles/me/frequent-tracks`: 로그인 쿠키, 파라미터 없음. 현재 보관 재생 기록 기반 신청 횟수/최근 신청 순 최대 8개. provider/videoId/title/thumbnailUrl(nullable)/durationMs/requestCount. +- GET `/api/v1/youtube/videos?query=…&size=8`: 로그인 쿠키. trim 후 1~100자. videoId/title/channelTitle/thumbnailUrl/durationMs. 새 요청은 cursor 생략. +- 두 응답 모두 기존 `{ result: { items, hasNext: false, nextCursor: null } }` 계약. UI에서 8개로 상한을 한 번 더 적용. +- quota: HTTP 503, code `room.youtube-api-quota-exceeded`. Query 자동 retry/focus/reconnect 재조회 비활성. HTTP 429는 공유 Axios의 기존 Retry-After/최대 2회 정책을 유지. +- `AbortSignal`을 두 GET으로 전달. 로그인 계정 slug를 query key에 포함. 로그아웃/탈퇴는 진행 중 me 조회 취소 후 me null 및 검색 캐시 삭제. +- frequent staleTime 30초, YouTube staleTime 5분. 신청 결과/재생 기록 리셋 시 frequent 무효화. + +## 실시간 이름 +- 기존 room topic의 `ROOM_PARTICIPANT_NICKNAME_CHANGED`, roomSlug/timestamp/data.userSlug/data.nickname. +- 유한한 양수 timestamp, 정확한 현재 방, 비어 있지 않은 slug/nickname 검증. 동일 user timestamp <= 최신 이벤트는 무시. +- 참가자 전체 페이지, 개인/전체 큐, 현재 곡, 방장, 해당 프로필, me 이름만 패치. 재검증 결과에도 방 세션 최신 이름을 적용. +- 서버 조회에는 닉네임 버전이 없으므로 세션이 수신한 이벤트를 우선. 재연결 중 놓친 새 이벤트와 오래된 REST 응답을 완전히 구분할 수 없는 서버 계약상 한계가 있음. +- 채팅은 저장된 원문을 변경하지 않고 표시 배열에서만 senderSlug로 이름을 보정. 사용자별 추가 조회 없음. diff --git a/docs/exec-plans/active/2026-09-08-track-search-realtime/change-summary.md b/docs/exec-plans/active/2026-09-08-track-search-realtime/change-summary.md new file mode 100644 index 00000000..bd7f023f --- /dev/null +++ b/docs/exec-plans/active/2026-09-08-track-search-realtime/change-summary.md @@ -0,0 +1,7 @@ +# 변경 요약 +- d1205c6: 개인 큐 전체 페이지에서 현재 entryId만 제외. 같은 영상 별도 신청과 카운트 보존. +- 357562c: 친구 상태 문구·색상·폰트 복원, 프로필 크기와 단일 패딩 소유권, overflow 스크롤. +- 782fc42: 노래 검색·자주 신청한 음악·취소/IME/계정 캐시·공통 marquee와 회귀 테스트. +- 3cf2641: 방 실시간 이름 투영·전체 캐시 페이지 보정·기존/추가 채팅·재연결/퇴장 테스트. +- ARCHITECTURE: 공통 marquee 위치 및 방 범위 nickname projection 소유권 명시. +- incident: 로그아웃 지연 identity 응답 경합과 QA 규칙. diff --git a/docs/exec-plans/active/2026-09-08-track-search-realtime/delivery-state.md b/docs/exec-plans/active/2026-09-08-track-search-realtime/delivery-state.md new file mode 100644 index 00000000..4f950e32 --- /dev/null +++ b/docs/exec-plans/active/2026-09-08-track-search-realtime/delivery-state.md @@ -0,0 +1,11 @@ +# Delivery State +- status: ci-pending +- branch: dev +- base: main +- issue: 없음 +- pr: https://github.com/Queuing-org/frontend/pull/62 +- selected_skills: plan.md 참조 +- local_qa: pass — lint, 713 tests, build, 독립 QA +- ci: pending +- review_threads: 없음 +- next_action: PR #62 CI/리뷰 확인, 브라우저 연결 후 시각·터치 검증 diff --git a/docs/exec-plans/active/2026-09-08-track-search-realtime/handoff.md b/docs/exec-plans/active/2026-09-08-track-search-realtime/handoff.md new file mode 100644 index 00000000..22d45ee3 --- /dev/null +++ b/docs/exec-plans/active/2026-09-08-track-search-realtime/handoff.md @@ -0,0 +1,5 @@ +# Handoff +- 사용자 계획의 네 범위 구현·기능별 커밋 및 독립 QA pass 완료. +- 최종 lint/713 tests/build 통과. 실제 브라우저 연결이 없어 시각·터치 검증은 미실행. +- dev push 및 Draft PR #62 생성 완료: https://github.com/Queuing-org/frontend/pull/62. CI 대기 상태에서 후속 리뷰/시각 검증 진행. +- API와 UI 계약, 회귀 근거, 중간 실패는 같은 run의 api-contract.md/ui-flow.md/qa-report.md 참조. diff --git a/docs/exec-plans/active/2026-09-08-track-search-realtime/plan.md b/docs/exec-plans/active/2026-09-08-track-search-realtime/plan.md new file mode 100644 index 00000000..48fe9be2 --- /dev/null +++ b/docs/exec-plans/active/2026-09-08-track-search-realtime/plan.md @@ -0,0 +1,29 @@ +# 노래 신청 검색·방 실시간 표시·친구 UI 통합 개선 + +## 범위와 기준 +- 빈 입력: 자주 신청한 음악 8개. 제목: 300ms debounce YouTube 검색 8개. URL 선택 후 사연 유지. +- 계정별 캐시, 취소/IME/오류/키보드/portal 및 reduced motion 지원. +- 개인 큐 현재 entryId 중복 제거, 친구 상태 문구 복원, 프로필 300×431/240×344.8. +- 방 닉네임 이벤트 검증 및 최신 이름을 참가자/큐/프로필/세션/기존·추가 채팅에 반영. + +## 선택 스킬 +queuing-feature-delivery, queuing-orchestrator, queuing-api-boundary, queuing-ui-flow, frontend-architecture-guardrails, queuing-qa-reviewer, queuing-incident-curator, browser:control-in-app-browser. + +## 커밋 순서 +1. fix(room): 개인 큐 현재 곡 중복 제거 + 회귀 테스트 +2. fix(follow): 친구 상태·프로필 치수와 패딩 소유권 + 테스트 +3. feat(playlist): 음악 검색 콤보박스·공통 marquee·계정 캐시 + 테스트 +4. fix(room): 닉네임 이벤트와 표시 동기화 + 테스트 +5. docs(delivery): 통합 QA 및 전달 증거 + +## 검증 +npm run lint, npm test, npm run build, 브라우저 시각 검증, 독립 read-only QA. + +## 진행 +- dev clean 시작. 백엔드 main Controller/DTO 계약 확인 완료. +- 네 기능 구현 및 기능별 코드·테스트 커밋 완료. +- 독립 QA의 ArrowUp/로그아웃 경합 지적 수정 후 재검토 pass. +- 최종 전체 713개 테스트(작업자 4개), lint, 단독 build 통과. 검증 중간 실패와 재실행은 qa-report.md 참조. +- 브라우저 runtime은 연결 없음(browsers.list=[]). 실계정/시각 검증 미실행을 명시하고 draft 전달. +- 계정 종료 지연 응답 incident 및 QA 재사용 규칙 기록. + diff --git a/docs/exec-plans/active/2026-09-08-track-search-realtime/qa-report.md b/docs/exec-plans/active/2026-09-08-track-search-realtime/qa-report.md new file mode 100644 index 00000000..cb9d8767 --- /dev/null +++ b/docs/exec-plans/active/2026-09-08-track-search-realtime/qa-report.md @@ -0,0 +1,39 @@ +# QA Report + +## 판정 +pass — 독립 read-only QA 완료. 코드/자동 검증에 차단 사항 없음. 실제 브라우저 검증은 아래 제한으로 남김. + +## 최종 검증 +- `npm run lint`: 통과. +- `npm test -- --maxWorkers=4`: 160개 파일, 713개 테스트 통과(108.24초). +- `npm run build`: 단독 실행 통과. Next.js 16.1.1 Turbopack compile/TypeScript/static generation 완료. +- `git diff --check`: 통과. + +## 직접 검증한 회귀 +- 검색 API cursor 생략/size8/signal/응답 해제/8개 상한. +- 빈 입력 자동 포커스, 키보드 선택과 URL 변경·선정 이유 보존, 300ms debounce, IME 요청/Enter 차단. +- 검색 교체 시 abort, 역순 응답 미노출, 다른 계정 cache 분리, 미로그인 목록 비활성. +- SoundCloud 선택 차단, 첫 ArrowUp 마지막 지원 행, Escape 두 단계, Tab와 바깥 클릭, quota 자동 반복 없음. +- 로그아웃·탈퇴 mutation 성공 뒤 이전 me 응답이 도착해도 me null 및 검색 cache 제거 유지. +- 개인 큐 모든 페이지에서 현재 entryId만 제외, 같은 영상의 별도 신청 유지, 서버 totalPendingCount 유지, 곡 전환 재계산. +- 친구 room/online/offline 상태 및 알 수 없는 상태, 프로필 기본/축소/높이 제한 자동 테스트. +- 닉네임 participant/queue 전체 페이지, 현재 곡·방장·profile·me, 진행 중 GET 취소 및 후속 cache 응답 보정. +- 다른 방·잘못된 payload·중복·역순 이름 이벤트 무시. 기존 구독 유지, reconnect 시 보정 유지, leave 시 정리. +- 기존/추가 chat 투영에서 원문·내용·삭제 상태·식별자 보존. 기존 채팅 스크롤 테스트를 포함한 전체 suite 통과. + +## 독립 리뷰와 수정 +- reviewer `qa_review` 초기 fix: 최초 ArrowUp 인덱스 오류 및 logout/withdraw의 me 지연 응답 경합. +- 두 문제 수정 및 회귀 테스트 추가. reviewer 재검토 최종 pass. +- API의 quota는 HTTP 503임을 backend Controller 원문으로 확인하여 공통 429 정책과의 비충돌 검증. + +## 중간 실패와 재검증 +- 초기 테스트 실행: 기존 chat hook 목업의 messages 누락, 추가 frequent invalidation에 대한 옛 기대값, 신규 API test envelope 오류. 실제 계약에 맞게 테스트 수정 후 통과. +- 초기 전체 실행은 711개 통과. 마지막 두 회귀 테스트 추가 후 빌드/테스트 동시 실행에서 기존 RoomFormModal 테스트 타임아웃 발생. +- 해당 검증 프로세스만 중단하고 작업자 4개로 전체 테스트 후 단독 빌드를 수행하여 713개/빌드 모두 통과. 동시 실행 자원 경합과 일치하는 증거이며 애플리케이션 원인으로 주장하지 않음. +- 샌드박스 안 첫 build는 출력 없이 정지. 이번 작업 프로세스만 종료 후 escalated build에서 정상 완료. +- 초기 보조 `npx tsc --noEmit`은 테스트 목업 타입 오류와 초기 잘못된 wrapper import 등으로 실패. wrapper import는 수정됨. 전달 기준인 Next production TypeScript/build는 최종 통과했으며 전체 테스트 파일에 대한 standalone tsc 통과는 주장하지 않음. + +## 미실행·잔여 제한 +- Browser skill bootstrap 후 getForUrl은 No browser is available, browsers.list는 [] 반환. 연결 요청을 남겼지만 브라우저가 없어 실제 시각/터치/실계정 API·소켓 테스트 미실행. +- 지정 치수/색상/CSS·DOM 구조는 코드와 자동 테스트로 확인했으나 실제 픽셀·작은 화면 목록 scroll·프로필 하단·채팅 scroll 보존은 브라우저에서 후속 확인 필요. +- 닉네임 버전 없는 REST 계약상 reconnect 중 누락된 더 최신 이벤트와 오래된 REST 결과를 완전히 구분할 수 없음. 세션 수신 최신 이벤트 우선 원칙. diff --git a/docs/exec-plans/active/2026-09-08-track-search-realtime/ui-flow.md b/docs/exec-plans/active/2026-09-08-track-search-realtime/ui-flow.md new file mode 100644 index 00000000..e660302f --- /dev/null +++ b/docs/exec-plans/active/2026-09-08-track-search-realtime/ui-flow.md @@ -0,0 +1,12 @@ +# UI 흐름 + +- TrackSearchInput가 열림/IME/활성 행/위치만 소유한다. 신청 폼의 URL과 선정 이유는 기존 useAddTrackForm이 소유한다. +- 자동 포커스/빈 값은 frequent, trim 제목은 300ms 이후 YouTube. URL 및 URL처럼 보이는 잘못된 값은 검색하지 않는다. +- 목록 선택은 URL 변경과 닫기만 수행. 실제 전송은 기존 큐잉 버튼. +- body portal fixed 위치에 입력 너비와 하단 좌표를 사용하고 viewport 잔여 높이를 max-height로 적용. 방향키/Enter/Tab/바깥 클릭/Escape 지원. Escape는 열린 목록부터 소비한다. +- 행 48px, 아이콘 20px/간격12px/좌우16px. 검색 썸네일32px/radius4px. 제목16px regular, 채널14px #555555. 축소 모드80%. +- 공통 marquee는 hover와 활성 행에서만 순환. reduced-motion에서는 animation none, 수동 가로 스크롤. +- 입력/재생목록 영역에서 선정 이유까지 기존 40px CSS 간격 유지. +- 친구 상태는 확인된 online/room으로만 표시. 오프라인이면 stale room 숨김. +- 친구 프로필300×431/240×344.8, shell contentPadding=none으로 패딩 소유권 일원화. 패널 내용은 항상 세로 스크롤 가능하여 큰 통계도 접근 가능. +- 닉네임은 방 캐시와 profile fallback context, 고정 본인 참가자 표시 및 chat 표시 투영에 반영. 원본 chat/식별자/scrollToLatestKey 유지. diff --git a/docs/exec-plans/active/README.md b/docs/exec-plans/active/README.md index 5554322b..c3a183d2 100644 --- a/docs/exec-plans/active/README.md +++ b/docs/exec-plans/active/README.md @@ -1,5 +1,7 @@ # Active Execution Plans +- [2026-09-08-track-search-realtime](./2026-09-08-track-search-realtime/plan.md): ci-pending — Draft PR #62, 노래 검색·친구 UI·큐 중복·실시간 닉네임 통합 개선, 713 tests/lint/build/독립 QA 통과 + - [2026-09-05-room-queue-modal-polish](./2026-09-05-room-queue-modal-polish/plan.md): ci-pending — Draft PR #61, 내 노래 현재곡 중복·신청 폼 간격·방 modal dim/높이·Friends 상세 padding 교정 및 로컬 QA 통과 - [2026-09-02-youtube-playlist-import](./2026-09-02-youtube-playlist-import/plan.md): ci-pending — Draft PR #60, 재생목록 감지 시 현재 영상/재생목록 노래 추가 선택을 요구하고 후속 로컬 QA 통과 diff --git a/src/features/auth/logout/model/useLogout.ts b/src/features/auth/logout/model/useLogout.ts index bbe9cf75..201bb986 100644 --- a/src/features/auth/logout/model/useLogout.ts +++ b/src/features/auth/logout/model/useLogout.ts @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { logoutApi } from "../api/logout"; +import { trackSuggestionKeys } from "@/src/features/playlist/model/trackSuggestionKeys"; import { userKeys } from "@/src/features/user/model/queryKeys"; import { badgeKeys } from "@/src/features/badge/model/queryKeys"; @@ -11,8 +12,10 @@ export function useLogout() { return useMutation({ mutationFn: () => logoutApi(), - onSuccess: () => { + onSuccess: async () => { + await qc.cancelQueries({ queryKey: userKeys.me(), exact: true }); qc.setQueryData(userKeys.me(), null); + qc.removeQueries({ queryKey: trackSuggestionKeys.all() }); qc.removeQueries({ queryKey: badgeKeys.me() }); }, }); diff --git a/src/features/follow/ui/FollowPresenceCard.test.tsx b/src/features/follow/ui/FollowPresenceCard.test.tsx index e9112d44..6e780986 100644 --- a/src/features/follow/ui/FollowPresenceCard.test.tsx +++ b/src/features/follow/ui/FollowPresenceCard.test.tsx @@ -21,7 +21,7 @@ const baseUser: FollowUser = { }; describe("FollowPresenceCard", () => { - it("온라인 사용자가 참여 중인 방은 상태 문구 없이 화살표 링크만 제공한다", () => { + it("온라인 사용자가 참여 중인 방은 상태 문구와 화살표 링크를 제공한다", () => { render( { />, ); - expect(screen.queryByText("새벽 재즈 참여 중")).not.toBeInTheDocument(); + expect(screen.getByText("새벽 재즈 참여 중")).toBeInTheDocument(); expect( screen.getByRole("link", { name: "새벽 재즈 방으로 이동" }), ).toHaveAttribute("href", "/room/late-night-jazz"); @@ -41,17 +41,17 @@ describe("FollowPresenceCard", () => { ).toHaveAttribute("aria-describedby", tooltip.id); }); - it("방에 참여하지 않은 온라인 사용자는 접근성 라벨이 있는 초록점만 표시한다", () => { + it("방에 참여하지 않은 온라인 사용자는 접근성 라벨이 있는 초록점과 문구를 표시한다", () => { const { container } = render(); - expect(screen.queryByText("온라인")).not.toBeInTheDocument(); + expect(screen.getByText("온라인")).toBeInTheDocument(); expect(screen.queryByRole("link")).not.toBeInTheDocument(); expect( container.querySelector('[data-online="true"][aria-label="온라인"]'), ).toBeInTheDocument(); }); - it("오프라인이면 stale room을 숨기고 접근성 라벨이 있는 빨간점만 표시한다", () => { + it("오프라인이면 stale room을 숨기고 접근성 라벨이 있는 빨간점과 문구를 표시한다", () => { const { container } = render( { />, ); - expect(screen.queryByText("오프라인")).not.toBeInTheDocument(); + expect(screen.getByText("오프라인")).toBeInTheDocument(); expect(screen.queryByText("새벽 재즈 참여 중")).not.toBeInTheDocument(); expect(screen.queryByRole("link")).not.toBeInTheDocument(); expect( diff --git a/src/features/follow/ui/FollowPresenceCard.tsx b/src/features/follow/ui/FollowPresenceCard.tsx index 525846c4..d50ece30 100644 --- a/src/features/follow/ui/FollowPresenceCard.tsx +++ b/src/features/follow/ui/FollowPresenceCard.tsx @@ -17,6 +17,7 @@ export default function FollowPresenceCard({ return ( onSelect(user, trigger) : undefined } diff --git a/src/features/follow/ui/FollowProfileModal.module.css b/src/features/follow/ui/FollowProfileModal.module.css index 88f0f976..81bad9d8 100644 --- a/src/features/follow/ui/FollowProfileModal.module.css +++ b/src/features/follow/ui/FollowProfileModal.module.css @@ -20,8 +20,7 @@ } .profilePanelContent { - padding: 0; - overflow: hidden; + overflow-y: auto; } .dialog[data-height-constrained="true"] .profilePanelContent { @@ -33,7 +32,7 @@ display: flex; box-sizing: border-box; flex-direction: column; - height: 100%; + min-height: 100%; padding: 16px 18px 56px; background: #ffffff; color: #3c3c3c; @@ -58,6 +57,8 @@ width: 100%; min-width: 0; height: 28px; + flex-shrink: 0; + white-space: nowrap; padding: 4px 28px; border: 0.5px solid #e9ebee; border-radius: 4px; diff --git a/src/features/follow/ui/FollowProfileModal.test.tsx b/src/features/follow/ui/FollowProfileModal.test.tsx index 1db6a861..02aaff6d 100644 --- a/src/features/follow/ui/FollowProfileModal.test.tsx +++ b/src/features/follow/ui/FollowProfileModal.test.tsx @@ -105,7 +105,7 @@ describe("FollowProfileModal", () => { expect(dialog.firstElementChild).toHaveStyle({ height: "160px" }); }); - it("모바일에서는 12px 상하 안전 여백을 제외하고 380px 기본 높이를 유지한다", () => { + it("모바일에서는 12px 상하 안전 여백을 제외하고 431px 기본 높이를 유지한다", () => { Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 }); Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 }); @@ -121,7 +121,7 @@ describe("FollowProfileModal", () => { name: "공개 닉네임 프로필 상세", }); expect(dialog).not.toHaveAttribute("data-height-constrained"); - expect(dialog.firstElementChild).toHaveStyle({ height: "380px" }); + expect(dialog.firstElementChild).toHaveStyle({ height: "431px" }); }); it("프로필 통계와 음악력 값을 2줄 텍스트로 표시한다", () => { @@ -155,7 +155,7 @@ describe("FollowProfileModal", () => { expect( screen.getByRole("dialog", { name: "공개 닉네임 프로필 상세" }) .firstElementChild, - ).toHaveStyle({ height: "304px", width: "240px" }); + ).toHaveStyle({ height: "344.8px", width: "240px" }); expect(screen.queryByRole("button", { name: "음악력 올리기" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "음악력 내리기" })).not.toBeInTheDocument(); }); diff --git a/src/features/follow/ui/FollowProfileModal.tsx b/src/features/follow/ui/FollowProfileModal.tsx index 8d0a7202..e25f7647 100644 --- a/src/features/follow/ui/FollowProfileModal.tsx +++ b/src/features/follow/ui/FollowProfileModal.tsx @@ -30,8 +30,8 @@ import { import styles from "./FollowProfileModal.module.css"; const PROFILE_PANEL_SIZE = { - compact: { height: 304, width: 240 }, - normal: { height: 380, width: 300 }, + compact: { height: 344.8, width: 240 }, + normal: { height: 431, width: 300 }, } as const; const PROFILE_PANEL_SAFE_MARGIN = { compact: 142.4, @@ -156,6 +156,7 @@ export default function FollowProfileModal({ onBlocked, onClose, user }: Props) > diff --git a/src/features/follow/ui/FollowUserCard.module.css b/src/features/follow/ui/FollowUserCard.module.css index bb05d5b8..1d1ad9cb 100644 --- a/src/features/follow/ui/FollowUserCard.module.css +++ b/src/features/follow/ui/FollowUserCard.module.css @@ -101,12 +101,12 @@ .status { color: #8d8d8d; font-size: 12px; - font-weight: var(--fw-medium); + font-weight: var(--fw-regular); line-height: 1.4; } .status[data-in-room="true"] { - color: #2f86ed; + color: #3b82f6; } .roomAction { diff --git a/src/features/follow/ui/FollowUserCard.tsx b/src/features/follow/ui/FollowUserCard.tsx index dea34776..116a56ab 100644 --- a/src/features/follow/ui/FollowUserCard.tsx +++ b/src/features/follow/ui/FollowUserCard.tsx @@ -16,6 +16,7 @@ type Props = { href: string; label: string; }; + statusLabel?: string; trailingAction?: ReactNode; }; @@ -26,6 +27,7 @@ export default function FollowUserCard({ profileImageUrl, roomLink, trailingAction, + statusLabel, }: Props) { const roomTooltipId = useId(); const profileImageSrc = profileImageUrl || "/Basic_Profile.png"; @@ -52,6 +54,7 @@ export default function FollowUserCard({ {nickname} + {statusLabel ? {statusLabel} : null} ); diff --git a/src/features/playlist/add-track/hooks/useAddTrackAction.ts b/src/features/playlist/add-track/hooks/useAddTrackAction.ts index 5cb3f8af..8fd22e18 100644 --- a/src/features/playlist/add-track/hooks/useAddTrackAction.ts +++ b/src/features/playlist/add-track/hooks/useAddTrackAction.ts @@ -5,6 +5,7 @@ import { type InfiniteData, useQueryClient } from "@tanstack/react-query"; import type { StompSubscription } from "@stomp/stompjs"; import { publishAddTrack } from "@/src/features/playlist/api/websocket/publishAddTrack"; import { fetchRoomQueuePage } from "@/src/features/playlist/api/fetchRoomQueue"; +import { trackSuggestionKeys } from "@/src/features/playlist/model/trackSuggestionKeys"; import { playlistKeys } from "@/src/features/playlist/model/queryKeys"; import type { RoomQueuePage, @@ -152,6 +153,7 @@ export function useAddTrackAction(slug: string, roomAccessToken: string) { scheduleQueryInvalidation({ queryClient, queryKeys: [ + trackSuggestionKeys.frequentRoot(), playlistKeys.roomQueuePrefix(roomSlug), playlistKeys.roomPlaybackPrefix(roomSlug), ], diff --git a/src/features/playlist/add-track/ui/AddTrackFormFields.test.tsx b/src/features/playlist/add-track/ui/AddTrackFormFields.test.tsx index 3ec5df5c..980c255c 100644 --- a/src/features/playlist/add-track/ui/AddTrackFormFields.test.tsx +++ b/src/features/playlist/add-track/ui/AddTrackFormFields.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; +import { createTestQueryClient, createTestQueryClientWrapper } from "@/src/shared/test/queryClient"; import AddTrackFormFields from "./AddTrackFormFields"; function renderFields( @@ -26,6 +27,7 @@ function renderFields( submitting={false} value="" />, + { wrapper: createTestQueryClientWrapper(createTestQueryClient()) }, ); return { onQueueModeChange }; } @@ -47,7 +49,7 @@ describe("AddTrackFormFields", () => { expect( screen.getByPlaceholderText( - "함께 듣고 싶은 영상 또는 재생목록 URL을 붙여넣으세요", + "노래 제목을 검색하거나 영상·재생목록 URL을 붙여넣으세요", ), ).toBeInTheDocument(); }); @@ -56,7 +58,7 @@ describe("AddTrackFormFields", () => { renderFields("url"); const input = screen.getByPlaceholderText( - "함께 듣고 싶은 영상 또는 재생목록 URL을 붙여넣으세요", + "노래 제목을 검색하거나 영상·재생목록 URL을 붙여넣으세요", ); expect(input).toHaveAttribute("aria-invalid", "true"); expect(input).toHaveAttribute("aria-describedby", "add-track-error"); diff --git a/src/features/playlist/add-track/ui/AddTrackFormFields.tsx b/src/features/playlist/add-track/ui/AddTrackFormFields.tsx index 43a1c96e..c8df3daa 100644 --- a/src/features/playlist/add-track/ui/AddTrackFormFields.tsx +++ b/src/features/playlist/add-track/ui/AddTrackFormFields.tsx @@ -1,5 +1,6 @@ "use client"; +import TrackSearchInput from "./TrackSearchInput"; import styles from "./AddTrackModal.module.css"; import type { AddTrackErrorField } from "../hooks/useAddTrackForm"; import type { YouTubeQueueMode } from "../model/parseYouTubeQueueSource"; @@ -39,7 +40,7 @@ export default function AddTrackFormFields({ <> {playlistDetected ? ( diff --git a/src/features/playlist/add-track/ui/TrackSearchInput.module.css b/src/features/playlist/add-track/ui/TrackSearchInput.module.css new file mode 100644 index 00000000..4f8bc0e9 --- /dev/null +++ b/src/features/playlist/add-track/ui/TrackSearchInput.module.css @@ -0,0 +1,94 @@ +.dropdown { + position: fixed; + z-index: 60; + overflow-y: auto; + overscroll-behavior: contain; + border: 1px solid #d9d9d9; + border-radius: 8px; + background: #fff; + color: #3c3c3c; + box-shadow: 0 8px 24px #0002; +} +.row { + display: flex; + align-items: center; + height: 48px; + padding: 0 16px; + gap: 12px; + cursor: pointer; +} +.row:hover, +.row[aria-selected="true"] { + background: #e9ebee; +} +.row[aria-disabled="true"] { + cursor: default; + opacity: 0.55; +} +.history { + flex: 0 0 auto; + width: 20px; + height: 20px; +} +.thumbnail { + flex: 0 0 auto; + width: 32px; + height: 32px; + border-radius: 4px; + object-fit: cover; +} +.meta { + flex: 1; + min-width: 0; +} +.title { + font-size: 16px; + font-weight: var(--fw-regular); + line-height: 1.3; +} +.title mark { + background: transparent; + color: #3b82f6; + font-weight: inherit; +} +.channel { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + font-weight: var(--fw-regular); + color: #555555; + line-height: 1.3; +} +.status { + padding: 16px; + font-size: 14px; +} +@media (min-width: 481px) and (max-height: 900px) { + .row { + height: 38.4px; + padding: 0 12.8px; + gap: 9.6px; + } + .history { + width: 16px; + height: 16px; + } + .thumbnail { + width: 25.6px; + height: 25.6px; + border-radius: 3.2px; + } + .title { + font-size: 12.8px; + } + .channel, + .status { + font-size: 11.2px; + } +} + +.titleText { + display: block; +} diff --git a/src/features/playlist/add-track/ui/TrackSearchInput.test.tsx b/src/features/playlist/add-track/ui/TrackSearchInput.test.tsx new file mode 100644 index 00000000..8dff9723 --- /dev/null +++ b/src/features/playlist/add-track/ui/TrackSearchInput.test.tsx @@ -0,0 +1,222 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createTestQueryClient, + createTestQueryClientWrapper, +} from "@/src/shared/test/queryClient"; +import { ApiError } from "@/src/shared/api/api-error"; +import { + fetchFrequentTracks, + searchYouTubeVideos, +} from "../../api/fetchTrackSuggestions"; +import TrackSearchInput from "./TrackSearchInput"; +const auth = vi.hoisted(() => ({ slug: "me" as string | null })); +vi.mock("@/src/features/user/session/hooks/useMe", () => ({ + useMe: () => ({ + data: auth.slug ? { slug: auth.slug } : null, + isError: false, + }), +})); +vi.mock("../../api/fetchTrackSuggestions", () => ({ + fetchFrequentTracks: vi.fn(), + searchYouTubeVideos: vi.fn(), +})); +const frequent = { + videoId: "video", + provider: "YOUTUBE" as const, + title: "자주 듣는 곡", + thumbnailUrl: null, + durationMs: 1000, + requestCount: 2, +}; +const video = { + videoId: "result", + title: "검색 결과", + channelTitle: "채널", + thumbnailUrl: "https://img.youtube.com/vi/result/default.jpg", + durationMs: 1000, +}; +function Harness() { + const [value, setValue] = useState(""); + return ( + <> + +