Skip to content

feat: 대기열 순서 변경 및 방 프로필 패널 추가#18

Open
aryu1217 wants to merge 4 commits intomainfrom
feat/websocket-dev
Open

feat: 대기열 순서 변경 및 방 프로필 패널 추가#18
aryu1217 wants to merge 4 commits intomainfrom
feat/websocket-dev

Conversation

@aryu1217
Copy link
Copy Markdown
Member

@aryu1217 aryu1217 commented Apr 13, 2026

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 드래그 앤 드롭으로 재생 목록 순서 변경 가능
    • 현재 곡 요청자의 프로필 정보 표시
    • 전체 곡/내 신청곡 탭으로 재생 목록 필터링
    • 홈 화면 메뉴 및 필터 토글 패널 추가
  • 버그 수정

    • 영상 플레이어 DOM 충돌 문제 해결

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 13, 2026

📝 Walkthrough

Walkthrough

드래그 앤 드롭 기반의 대기열 재정렬 기능, 방 프로필 패널 UI, YouTube 플레이어 DOM 소유권 충돌 해결, 관련 상태 관리 인프라를 추가합니다. @dnd-kit 의존성, 대기열 이동 API/훅, 정렬 가능한 대기열 UI, 새로운 프로필 패널 컴포넌트, YouTube 플레이어 DOM 격리, 방 페이지 통합을 포함합니다.

Changes

Cohort / File(s) Summary
Dependencies & Analysis
package.json, player-removechild-analysis.md
@dnd-kit 라이브러리 추가(@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities); YouTube 플레이어 removeChild 오류 근본 원인 분석 및 해결 제안 문서화
Room Page Integration
src/app/room/[slug]/page.tsx
현재 요청자 프로필 파생 함수 추가, 대기열 캐시 무효화 로직, RoomFloatingWidgets에 프로필/곡 정보/비밀번호 전달
Playlist Movement API & Types
src/entities/playlist/api/moveMyQueueEntry.ts, src/entities/playlist/model/types.ts, src/entities/playlist/model/useMoveMyQueueEntry.ts
대기열 항목 이동 API 엔드포인트, 타입 정의, 낙관적 업데이트 및 캐시 관리를 포함한 React Query 훅 추가
User Model
src/entities/user/model/types.ts
User 인터페이스에 선택적 userId 필드 추가
YouTube Player DOM Fix
src/features/playlist/player/ui/YouTubePlayer.tsx
DOM 마운트 포인트 격리, 플레이어 생명주기 제어 흐름 개선, React와 YouTube API 간의 DOM 소유권 충돌 해결
Room Profile Feature
src/features/room/profile/model/types.ts, src/features/room/profile/ui/RoomProfilePanel.module.css, src/features/room/profile/ui/RoomProfilePanel.tsx
현재 요청자 프로필 타입 정의, 스타일 정의, 친구 요청 기능이 통합된 프로필 패널 컴포넌트
Room Queue Utilities & UI
src/features/room/queue/model/roomQueue.ts, src/features/room/queue/ui/RoomQueue*.tsx, src/features/room/queue/ui/RoomQueue*.module.css
대기열 상태 매핑, 사용자 필터링 유틸, 기본 대기열 목록 렌더링, 탭 인터페이스, 드래그 가능한 정렬 목록 UI
Radial Control
src/shared/ui/radial-control/RadialControl.module.css
포인터 커서 추가
Home UI Components
src/widgets/home/ui/HomeControlPanelShell.tsx, src/widgets/home/ui/HomeControlPanelShell.module.css, src/widgets/home/ui/HomeScreen.tsx, src/widgets/home/ui/HomeScreen.module.css
메뉴/필터 패널 셸 컴포넌트, 토글 기능이 있는 개선된 홈 화면
Room Floating Widgets
src/widgets/room/model/useFloatingWidgetsState.ts, src/widgets/room/ui/FloatingRoomPanelShell.tsx, src/widgets/room/ui/RoomFloatingWidgets.tsx, src/widgets/room/ui/RoomFloatingWidgets.module.css
프로필/대기열 패널 높이 조정, 콘텐츠 클래스 지원, 실제 패널 컴포넌트 렌더링

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant RoomQueuePanel
    participant RoomQueueSortableList
    participant useMoveMyQueueEntry
    participant API as moveMyQueueEntry API
    participant QueryCache as React Query Cache

    User->>RoomQueueSortableList: Drag queue entry
    RoomQueueSortableList->>RoomQueueSortableList: Compute new order<br/>(arrayMove)
    RoomQueueSortableList->>RoomQueuePanel: Call onMove<br/>(movedEntryId,<br/>beforeEntryId,<br/>orderedPendingEntryIds)
    
    RoomQueuePanel->>useMoveMyQueueEntry: mutate({slug,<br/>password,<br/>movedEntryId,<br/>beforeEntryId,<br/>orderedPendingEntryIds})
    
    activate useMoveMyQueueEntry
    useMoveMyQueueEntry->>QueryCache: onMutate:<br/>Cancel roomQueue queries
    QueryCache-->>useMoveMyQueueEntry: Previous data snapshot
    useMoveMyQueueEntry->>QueryCache: Update cache:<br/>applyPendingEntryOrder
    useMoveMyQueueEntry->>API: PATCH /rooms/{slug}/playlist/me/move
    
    alt Success
        API-->>useMoveMyQueueEntry: { result: true }
        useMoveMyQueueEntry->>QueryCache: onSuccess:<br/>Invalidate roomQueue<br/>& roomState
    else Failure
        API-->>useMoveMyQueueEntry: Error response
        useMoveMyQueueEntry->>QueryCache: onError:<br/>Restore snapshot
        useMoveMyQueueEntry->>RoomQueuePanel: Error message
    end
    deactivate useMoveMyQueueEntry
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 분

Possibly related PRs

Poem

🐰 대기열을 재배치하는 마법아,
드래그로 노래 순서를 춤추듯 바꾸네!
DOM 충돌도 해결하고, 프로필도 더하고,
방의 모든 위젯이 춤을 추면서 완성되다. ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning PR 설명이 템플릿 구조를 따르지 않았습니다. 요구되는 섹션(Title, Summary, Linked Issue, Checklist, Attachments, Notes)이 누락되었고, Summary, Checklist, Linked Issue 필드가 비어있습니다. PR 템플릿에 따라 다음을 추가해주세요: (1) Title에 간단한 설명 추가, (2) Summary 섹션에 변경사항 1~3줄 요약, (3) Linked Issue에 관련 이슈 번호 추가, (4) Checklist 섹션에 이슈의 체크리스트 복사 및 완료 항목 표시.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed 풀 리퀘스트 제목은 대기열 순서 변경 및 방 프로필 패널 추가라는 실제 변경사항을 명확하게 반영하고 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/websocket-dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces several new features and a critical bug fix for the YouTube player. It implements a drag-and-drop sortable queue for users to reorder their requested tracks using @dnd-kit, adds a new profile panel for the current requester, and integrates menu and filter panels into the home screen's radial control. A significant refactor of the YouTubePlayer component was performed to resolve a removeChild DOM exception by decoupling the React-managed wrapper from the YouTube Iframe API's host element. Review feedback suggests refining the error handling in the queue movement API to avoid using a 200 status for business failures and reconsidering the state synchronization logic in the sortable list to prevent UI glitches during external updates.

Comment on lines +30 to +35
if (!res.data.result) {
throw new ApiError({
message: "큐 순서를 변경하지 못했습니다.",
status: 200,
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ApiError is being thrown with a status: 200. Typically, ApiError classes are designed to handle HTTP error statuses (4xx, 5xx). If the API returns a 200 OK but the business logic failed (indicated by !res.data.result), it might be better to use a specific error type or a custom error code that distinguishes business failures from transport/protocol errors. Using 200 might bypass some global error handling logic that filters by status code.

Comment on lines +119 to +121
useEffect(() => {
setPendingEntries(entries.filter(isPendingQueueEntry));
}, [entries]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Syncing props to state using useEffect is generally discouraged in React as it can lead to inconsistent UI states. If the entries prop updates (e.g., via a WebSocket event) while a user is actively dragging an item, this effect will trigger and overwrite the local pendingEntries state, potentially causing the drag interaction to glitch or the list to jump. Consider using the entries prop directly or managing the reordering state in a way that accounts for external updates during interaction.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (10)
src/widgets/home/ui/HomeControlPanelShell.tsx (1)

65-69: 옵션 리스트 key를 섹션 컨텍스트와 결합해 안정성을 높이세요.

key={option}는 데이터가 확장될 때(동일 섹션 내 중복 옵션) 충돌 가능성이 있습니다. 섹션 정보까지 포함한 key로 두는 편이 안전합니다.

♻️ 제안 수정안
-            {section.options.map((option, index) => (
+            {section.options.map((option, index) => (
               <span
-                key={option}
+                key={`${section.title}-${option}-${index}`}
                 className={`${styles.optionChip} ${index === 0 ? styles.activeChip : ""}`}
               >

As per coding guidelines "- 리스트 key 안정성, 불필요한 re-render 유발 props(익명 함수/객체) 지적."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/widgets/home/ui/HomeControlPanelShell.tsx` around lines 65 - 69, The list
key for the mapped option chips in HomeControlPanelShell is currently just
key={option}, which can collide when options repeat; update the key in the
section.options.map callback to include the section context (e.g., combine a
unique section identifier such as section.id or section.title with option and/or
index) so keys are stable and unique across sections—locate the map inside
HomeControlPanelShell rendering where optionChip/activeChip classes are applied
and replace the single-value key with the combined identifier.
src/widgets/home/ui/HomeScreen.tsx (2)

9-20: 패널 variant 타입을 단일 소스로 통합하세요.

HomePanelKey가 로컬에서 중복 선언되어 HomeControlPanelShell의 variant 정의와 분리되어 있습니다. 한쪽만 수정될 때 타입 드리프트가 생길 수 있습니다.

♻️ 제안 수정안
 import HomeControlPanelShell, {
   HOME_CONTROL_PANEL_IDS,
+  type HomeControlPanelVariant,
 } from "./HomeControlPanelShell";
@@
-type HomePanelKey = "menu" | "filter";
+type HomePanelKey = HomeControlPanelVariant;

As per coding guidelines "- 유지보수/확장성 관점에서 모듈 경계(의존성 방향, 책임 분리)가 적절한지 최우선으로 확인."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/widgets/home/ui/HomeScreen.tsx` around lines 9 - 20, HomePanelKey is
re-declared locally and can drift from HomeControlPanelShell's variant
definition; remove the local HomePanelKey type and import the variant/type
exported by HomeControlPanelShell (e.g., the variant name or exported type) and
use that in the component: update the useState generic and any references
(setOpenPanel, openPanel) to the imported type from HomeControlPanelShell so the
panel variant is a single source of truth.

61-72: 상/하단 토글 버튼 중복 JSX를 분리하세요.

menu/filter 토글 버튼이 거의 동일한 구조와 속성을 반복하고 있어, 추후 접근성/스타일 변경 시 한쪽만 누락될 위험이 있습니다. 공통 토글 컴포넌트(또는 렌더 헬퍼)로 분리하는 편이 유지보수에 유리합니다.

As per coding guidelines "- 파일/함수 책임이 과도하면 응집도/결합도 기준으로 분리/통합 개선안을 제시."

Also applies to: 109-120

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/widgets/home/ui/HomeScreen.tsx` around lines 61 - 72, The JSX for the top
and bottom toggle buttons is duplicated—extract a reusable ToggleButton
component (or render helper) that accepts props: id (from
HOME_CONTROL_PANEL_IDS), panelKey ("menu" or "filter"), labelWhenClosed
("MENU"/other), labelWhenOpen ("X"), openPanel, togglePanel, and className
(styles.controlToggle), and replace both inline buttons with this component;
ensure it forwards type="button", aria-controls, aria-expanded (openPanel ===
panelKey), data-active, and onClick={() => togglePanel(panelKey)} so
accessibility and behavior remain identical.
src/entities/user/model/types.ts (1)

2-2: 도메인 타입으로 userId를 정규화하여 방어 코드 제거하기

userId?: number | null은 undefined/null/number 3가지 상태를 허용하여 호출부에서 typeof userId === "number" 같은 방어 코드를 강요합니다. RoomProfilePanel.tsx(36~38줄), roomQueue.ts(46줄), page.tsx(102줄) 등에서 반복되는 분기 처리를 제거하려면, UserId 같은 도메인 타입을 도입하고 API 경계에서 undefined를 null로 정규화하는 것이 낫습니다.

예: userId: UserId | null로 선언하면 typeof 체크 대신 논리적으로 더 명확한 코드가 됩니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/entities/user/model/types.ts` at line 2, Replace the three-state userId
type with a normalized domain type and convert undefined to null at the API
boundary: introduce a UserId type alias (e.g. type UserId = number) in
src/entities/user/model/types.ts and change the property signature from userId?:
number | null to userId: UserId | null; update API input/serialization code to
normalize missing/undefined userId values to null, and then remove the defensive
typeof checks in callsites such as RoomProfilePanel (RoomProfilePanel.tsx),
roomQueue (roomQueue.ts), and page.tsx by treating userId as either a concrete
UserId or null.
src/entities/playlist/api/moveMyQueueEntry.ts (1)

13-38: 응답 판정을 명시적으로 하고 API 계약 경계를 분리해 주세요.

Line 30의 !res.data.result는 truthy/falsy 판정이라 런타임 계약이 느슨합니다. result === true를 명시하고, API 응답 해석을 작은 toDomain 단계로 분리하면 유지보수가 쉬워집니다.

명시적 판정 + 경계 분리 예시
 export async function moveMyQueueEntry({
@@
 }: MoveMyQueueEntryParams): Promise<boolean> {
   const res = await axiosInstance.patch<ApiResponse<boolean>>(
@@
   );
 
-  if (!res.data.result) {
+  if (res.data.result !== true) {
     throw new ApiError({
       message: "큐 순서를 변경하지 못했습니다.",
       status: 200,
     });
   }
 
-  return res.data.result;
+  return true;
 }

As per coding guidelines, "API 모듈은 DTO 타입 + 네트워크 호출 + toDomain 매핑까지 책임지는 구조를 우선 권장."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/entities/playlist/api/moveMyQueueEntry.ts` around lines 13 - 38, The API
client currently uses a loose truthy/falsy check on res.data.result in
moveMyQueueEntry; change the check to result === true and extract a small
toDomain mapper (e.g., a function named playlistMoveResponseToDomain or
toDomain) that validates the ApiResponse<boolean> DTO and returns a strict
boolean domain result; update moveMyQueueEntry to call axiosInstance, pass the
DTO to the toDomain mapper, and throw ApiError only when the mapper indicates an
explicit false or invalid response, ensuring the network/DTO parsing and domain
interpretation are separated.
src/features/room/queue/ui/RoomQueueSortableList.module.css (1)

6-23: 리스트 리셋 스타일 중복을 공통 클래스로 묶는 게 좋습니다.

Line 6-23에서 list-style: none, padding: 0, overflow-x: hidden이 반복됩니다. 공통 유틸 클래스로 추출하면 수정 지점이 줄어듭니다.

As per coding guidelines, "파일/함수 책임이 과도하면 응집도/결합도 기준으로 분리/통합 개선안을 제시."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/room/queue/ui/RoomQueueSortableList.module.css` around lines 6 -
23, Extract the repeated reset styles into a shared utility class (e.g.,
.resetList) and apply it to the existing classes to remove duplication: move
list-style: none, padding: 0, and overflow-x: hidden into .resetList and then
have .fixedTopList, .sortableList, and .fixedList only declare their unique
properties (like margin) while including the shared .resetList rules; update
references or markup if necessary to ensure the three classes use the new
utility class.
src/entities/playlist/model/types.ts (1)

17-23: 엔트리 ID를 도메인 타입으로 분리해 주세요.

string 그대로 두면 다른 문자열 식별자와 혼용되기 쉽습니다. PlaylistEntryId 같은 타입으로 의미를 분리하는 편이 안전합니다.

타입 분리 예시
+export type PlaylistEntryId = string & { readonly __brand: "PlaylistEntryId" };

 export type MoveMyQueueEntryPayload = {
-  movedEntryId: string;
-  beforeEntryId: string | null;
+  movedEntryId: PlaylistEntryId;
+  beforeEntryId: PlaylistEntryId | null;
 };

As per coding guidelines, "TypeScript에서 any/과도한 as 캐스팅을 지양하고, 도메인 타입(예: ProfileId 같은 ID 타입)으로 의미를 드러내는지 확인."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/entities/playlist/model/types.ts` around lines 17 - 23, Replace the raw
string IDs in MoveMyQueueEntryPayload with a domain-specific ID type: introduce
and export a PlaylistEntryId type (e.g., a branded/string alias) and change
movedEntryId: string to movedEntryId: PlaylistEntryId and beforeEntryId: string
| null to beforeEntryId: PlaylistEntryId | null; keep MoveMyQueueEntryParams as
PlaylistProtectedRequestParams & MoveMyQueueEntryPayload so callers and other
modules use the new PlaylistEntryId type consistently.
src/app/room/[slug]/page.tsx (1)

101-113: 닉네임 fallback으로 slug를 보정하는 부분은 계약 확인이 필요합니다.

여기서는 requester.userId가 없으면 participant.nickname만으로 매칭해서 slug까지 채우고 있습니다. 제공된 타입만 보면 닉네임 유일성이 드러나지 않아서, 이 가정이 깨지면 currentRequester가 다른 참가자의 slug를 가리키고 이후 RoomProfilePanel에서 잘못된 대상에게 요청을 보낼 수 있습니다. slug는 안정적인 식별자 매칭에서만 채우고, 닉네임 fallback은 표시용 정보로만 제한하는 편이 안전합니다. 닉네임 유일성이 서버 계약으로 보장되는지 확인 부탁드립니다. As per coding guidelines, "TypeScript에서 any/과도한 as 캐스팅을 지양하고, 도메인 타입(예: ProfileId 같은 ID 타입)으로 의미를 드러내는지 확인."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/room/`[slug]/page.tsx around lines 101 - 113, The current logic uses
participant.nickname matching to populate slug which is unsafe; update
matchedParticipant so it only finds by requester.userId (i.e., only match
participants when requester.userId !== null) and do not fallback to
nickname-based matching to populate slug; keep nickname for display only
(requester.nickname) and set slug to null when userId is absent. Adjust the code
around matchedParticipant, requester.userId, requester.nickname and the returned
slug/avatarUrl so slug is only filled from a participant found via userId, and
confirm/type-annotate IDs (avoid any/as casts) to reflect a domain ID type
(e.g., ProfileId) and add a note to verify server guarantees about nickname
uniqueness if you want to enable nickname-based slug resolution later.
src/features/room/queue/ui/RoomQueueSortableList.tsx (2)

97-100: 엔트리 분류 책임을 UI에서 모델 유틸로 분리하는 것을 권장합니다.

active/pending/fixed 분류 규칙이 컴포넌트 렌더 로직과 동기화 effect에 나뉘어 있어, 상태 규칙 변경 시 드리프트 위험이 있습니다. partitionQueueEntries(entries) 같은 모델 함수로 분리하면 응집도와 재사용성이 좋아집니다.

As per coding guidelines **/*: "유지보수/확장성 관점에서 모듈 경계(의존성 방향, 책임 분리)가 적절한지 최우선으로 확인."

Also applies to: 119-121

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/room/queue/ui/RoomQueueSortableList.tsx` around lines 97 - 100,
Extract the entry-classification logic out of the component into a model utility
function (e.g., partitionQueueEntries(entries)) that accepts entries and returns
categorized groups (active, pending, fixed); replace the current inline filters
(activeFixedEntries, fixedEntries) and any other duplicated filters (same logic
used near the secondary classification at lines with active/pending/fixed) to
call partitionQueueEntries(entries) in both render and any sync effects so the
rules live in one place. Ensure the new util uses existing predicates like
isPendingQueueEntry and entry.status.isActive and update imports/usages in
RoomQueueSortableList to use the returned groups.

93-95: pendingEntries 상태 관리 개선이 필요합니다.

현재 코드의 두 가지 문제점:

  1. Lazy initializer 부재: useState의 초기값 식(entries.filter(...))이 매 렌더링마다 평가되고 버려집니다. 함수 initializer로 감싸서 첫 렌더링에만 실행되도록 최적화하세요.

  2. Derived state 패턴: pendingEntriesentries prop에서 파생된 상태이며, useEffect(line 120)로 entries 변경을 감지해 동기화하고 있습니다. 이는 불필요한 상태 중복과 동기화 로직을 만듭니다.

    • 드래그/드롭 순서 변경(line 152)만 상태로 관리하고, 필터링은 계산된 값으로 변경하는 것을 검토하세요.
♻️ 즉시 적용 가능한 개선
  const [pendingEntries, setPendingEntries] = useState<PlaylistEntry[]>(
-   entries.filter(isPendingQueueEntry),
+   () => entries.filter(isPendingQueueEntry),
  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/room/queue/ui/RoomQueueSortableList.tsx` around lines 93 - 95,
Replace the derived state pattern: remove the useState declaration for
pendingEntries and its setPendingEntries updater and the useEffect that syncs it
from entries; instead compute pendingEntries on render with a lazy evaluation by
using entries.filter(isPendingQueueEntry) (i.e., treat it as a derived variable,
not state). If you need to persist drag/drop ordering, keep the ordering state
used by onDragEnd (or a separate order state) but apply filtering from entries
when rendering; also ensure any initializer that used entries.filter is
converted to a render-time computed value and that there are no remaining calls
to setPendingEntries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@player-removechild-analysis.md`:
- Around line 74-95: The documentation mixes pre-change behavior with the
current implementation; update the text and code blocks in the analysis so they
accurately reflect the current YouTubePlayer.tsx (which now uses
ensurePlayerHost() and a fixed wrapper) or explicitly mark the snippets as
"Before change"/"수정 전": locate references to videoId, destroyPlayer(), and the
placeholder subtree and either convert those code blocks and section headings to
"수정 전" (pre-change) labels or replace them with the updated implementation
details that mention ensurePlayerHost() and the fixed wrapper structure; keep
the intent clear and consistent across the entire section (including the other
affected lines around 202-208).

In `@src/features/playlist/player/ui/YouTubePlayer.tsx`:
- Line 166: The hasCreatedPlayer boolean (useState in YouTubePlayer) is never
reset across room (slug) changes, causing a previously created iframe to persist
when videoId becomes null; either reset that state when the room changes or
force React to remount the component per room. Update the usage so the parent
(page.tsx) supplies a unique key like slug to the <YouTubePlayer /> (or within
YouTubePlayer listen for slug/room prop and call setHasCreatedPlayer(false) on
change), and ensure props videoId, playbackStatus/currentTime null states
trigger the empty state rendering instead of relying on a stale hasCreatedPlayer
flag.

In `@src/features/room/profile/ui/RoomProfilePanel.module.css`:
- Around line 97-104: The .cardValue rule uses an undefined CSS variable
--font-semibold causing font-weight not to apply; change the font-weight
declaration in .cardValue from var(--font-semibold) to var(--fw-semibold) so it
matches the globals.css variable and applies the intended weight.

In `@src/features/room/profile/ui/RoomProfilePanel.tsx`:
- Around line 48-63: The panel-local flags lastRequestedKey and lastMutationKey
in RoomProfilePanel are causing duplicate-request vulnerability because they
reset on unmount; move this request-tracking out of the component (e.g. lift
into the parent that mounts RoomProfilePanel such as RoomFloatingWidgets or
persist in the request/query cache or server-side relationship state) and use
the existing currentRequesterKey/currentRequester values to check/infer
hasRequestedFollow/shouldShowError; update useSendFriendRequest usage so mutate
sets the shared tracking state (or updates the query cache) instead of local
setLastRequestedKey/setLastMutationKey, and remove the local useState usage so
the panel no longer resets these flags on unmount.

In `@src/features/room/queue/model/roomQueue.ts`:
- Around line 38-50: The function isEntryRequestedByUser incorrectly assumes
entry.addedBy.userId is always a number when currentUser.userId is a number;
change the logic in isEntryRequestedByUser (used with PlaylistEntry and User) to
first check whether entry.addedBy.userId is a number and if so compare IDs,
otherwise fall back to comparing entry.addedBy.nickname with
currentUser.nickname; ensure you still handle the null/undefined currentUser
case and avoid doing a numeric ID compare against a null addedBy.userId.

In `@src/features/room/queue/ui/RoomQueueCard.module.css`:
- Around line 140-144: The CSS uses the value "currentColor" in the background
and box-shadow properties which violates the stylelint value-keyword-case rule;
update all occurrences of "currentColor" to the lowercase "currentcolor" (e.g.,
in the background and the three box-shadow value tokens) to satisfy the linter
and avoid pipeline failures.

In `@src/features/room/queue/ui/RoomQueueCard.tsx`:
- Around line 53-64: The button currently spreads dragHandleProps last which
lets external props override our defaults; change the prop order in the button
render so you spread dragHandleProps first, then apply/override fixed props
(ref={dragHandleRef}, type="button", aria-label="곡 순서 변경") and set className to
the merged value that combines styles.dragHandle and dragHandleProps?.className
(so styles.dragHandle is always present). In other words, move
{...dragHandleProps} before the explicit attributes and ensure className is
computed and passed after merging to prevent external props from overwriting the
default class and accessibility attributes.

In `@src/features/room/queue/ui/RoomQueueList.tsx`:
- Around line 22-32: In RoomQueueList update the status/alert divs so screen
readers are notified: add role="status" (or aria-live="polite") to the loading
and empty-state divs that render when isLoading or entries.length === 0, and add
role="alert" (or aria-live="assertive") to the error div that renders when
errorMessage is set; modify the divs that use styles.state and the variables
isLoading, errorMessage, entries.length and emptyMessage accordingly so the
appropriate role/aria-live attributes are included.

In `@src/features/room/queue/ui/RoomQueueTabs.module.css`:
- Around line 9-23: The .tab class lacks a :focus-visible rule so keyboard users
can't see focus; update the CSS for .tab by adding a :focus-visible selector
(e.g., .tab:focus-visible) that applies a visible, accessible focus style such
as a high-contrast outline or box-shadow and ensures outline-offset and
border-radius match existing styling, and make sure the rule doesn't disturb
mouse focus behavior (use :focus-visible rather than :focus) so only keyboard
users see the indicator.

In `@src/features/room/queue/ui/RoomQueueTabs.tsx`:
- Around line 20-42: The current RoomQueueTabs tab buttons declare role="tab"
but lack required ARIA tab-pattern behavior; update RoomQueueTabs to either
implement the full tab pattern (add aria-controls linking each tab button to its
panel, manage tabIndex so only the active tab is focusable, and implement
keyboard arrow navigation to move focus/selection) or convert the elements to
simple toggle buttons by removing role="tab", using role="button" (or no role),
and replace aria-selected with aria-pressed (wired to activeTab ===
"all"/"mine") while keeping onClick/onChange; reference the activeTab prop,
onChange handler, and the two button elements when making the change.

In `@src/widgets/home/ui/HomeControlPanelShell.module.css`:
- Line 10: The lint failure is due to the keyframes name panelEnter not
following kebab-case; rename the `@keyframes` declaration (symbol: panelEnter) to
kebab-case (e.g., panel-enter) and update the usage in the animation property
(currently "animation: panelEnter 180ms ease;") to match the new name so both
the `@keyframes` block and the animation reference stay in sync.

---

Nitpick comments:
In `@src/app/room/`[slug]/page.tsx:
- Around line 101-113: The current logic uses participant.nickname matching to
populate slug which is unsafe; update matchedParticipant so it only finds by
requester.userId (i.e., only match participants when requester.userId !== null)
and do not fallback to nickname-based matching to populate slug; keep nickname
for display only (requester.nickname) and set slug to null when userId is
absent. Adjust the code around matchedParticipant, requester.userId,
requester.nickname and the returned slug/avatarUrl so slug is only filled from a
participant found via userId, and confirm/type-annotate IDs (avoid any/as casts)
to reflect a domain ID type (e.g., ProfileId) and add a note to verify server
guarantees about nickname uniqueness if you want to enable nickname-based slug
resolution later.

In `@src/entities/playlist/api/moveMyQueueEntry.ts`:
- Around line 13-38: The API client currently uses a loose truthy/falsy check on
res.data.result in moveMyQueueEntry; change the check to result === true and
extract a small toDomain mapper (e.g., a function named
playlistMoveResponseToDomain or toDomain) that validates the
ApiResponse<boolean> DTO and returns a strict boolean domain result; update
moveMyQueueEntry to call axiosInstance, pass the DTO to the toDomain mapper, and
throw ApiError only when the mapper indicates an explicit false or invalid
response, ensuring the network/DTO parsing and domain interpretation are
separated.

In `@src/entities/playlist/model/types.ts`:
- Around line 17-23: Replace the raw string IDs in MoveMyQueueEntryPayload with
a domain-specific ID type: introduce and export a PlaylistEntryId type (e.g., a
branded/string alias) and change movedEntryId: string to movedEntryId:
PlaylistEntryId and beforeEntryId: string | null to beforeEntryId:
PlaylistEntryId | null; keep MoveMyQueueEntryParams as
PlaylistProtectedRequestParams & MoveMyQueueEntryPayload so callers and other
modules use the new PlaylistEntryId type consistently.

In `@src/entities/user/model/types.ts`:
- Line 2: Replace the three-state userId type with a normalized domain type and
convert undefined to null at the API boundary: introduce a UserId type alias
(e.g. type UserId = number) in src/entities/user/model/types.ts and change the
property signature from userId?: number | null to userId: UserId | null; update
API input/serialization code to normalize missing/undefined userId values to
null, and then remove the defensive typeof checks in callsites such as
RoomProfilePanel (RoomProfilePanel.tsx), roomQueue (roomQueue.ts), and page.tsx
by treating userId as either a concrete UserId or null.

In `@src/features/room/queue/ui/RoomQueueSortableList.module.css`:
- Around line 6-23: Extract the repeated reset styles into a shared utility
class (e.g., .resetList) and apply it to the existing classes to remove
duplication: move list-style: none, padding: 0, and overflow-x: hidden into
.resetList and then have .fixedTopList, .sortableList, and .fixedList only
declare their unique properties (like margin) while including the shared
.resetList rules; update references or markup if necessary to ensure the three
classes use the new utility class.

In `@src/features/room/queue/ui/RoomQueueSortableList.tsx`:
- Around line 97-100: Extract the entry-classification logic out of the
component into a model utility function (e.g., partitionQueueEntries(entries))
that accepts entries and returns categorized groups (active, pending, fixed);
replace the current inline filters (activeFixedEntries, fixedEntries) and any
other duplicated filters (same logic used near the secondary classification at
lines with active/pending/fixed) to call partitionQueueEntries(entries) in both
render and any sync effects so the rules live in one place. Ensure the new util
uses existing predicates like isPendingQueueEntry and entry.status.isActive and
update imports/usages in RoomQueueSortableList to use the returned groups.
- Around line 93-95: Replace the derived state pattern: remove the useState
declaration for pendingEntries and its setPendingEntries updater and the
useEffect that syncs it from entries; instead compute pendingEntries on render
with a lazy evaluation by using entries.filter(isPendingQueueEntry) (i.e., treat
it as a derived variable, not state). If you need to persist drag/drop ordering,
keep the ordering state used by onDragEnd (or a separate order state) but apply
filtering from entries when rendering; also ensure any initializer that used
entries.filter is converted to a render-time computed value and that there are
no remaining calls to setPendingEntries.

In `@src/widgets/home/ui/HomeControlPanelShell.tsx`:
- Around line 65-69: The list key for the mapped option chips in
HomeControlPanelShell is currently just key={option}, which can collide when
options repeat; update the key in the section.options.map callback to include
the section context (e.g., combine a unique section identifier such as
section.id or section.title with option and/or index) so keys are stable and
unique across sections—locate the map inside HomeControlPanelShell rendering
where optionChip/activeChip classes are applied and replace the single-value key
with the combined identifier.

In `@src/widgets/home/ui/HomeScreen.tsx`:
- Around line 9-20: HomePanelKey is re-declared locally and can drift from
HomeControlPanelShell's variant definition; remove the local HomePanelKey type
and import the variant/type exported by HomeControlPanelShell (e.g., the variant
name or exported type) and use that in the component: update the useState
generic and any references (setOpenPanel, openPanel) to the imported type from
HomeControlPanelShell so the panel variant is a single source of truth.
- Around line 61-72: The JSX for the top and bottom toggle buttons is
duplicated—extract a reusable ToggleButton component (or render helper) that
accepts props: id (from HOME_CONTROL_PANEL_IDS), panelKey ("menu" or "filter"),
labelWhenClosed ("MENU"/other), labelWhenOpen ("X"), openPanel, togglePanel, and
className (styles.controlToggle), and replace both inline buttons with this
component; ensure it forwards type="button", aria-controls, aria-expanded
(openPanel === panelKey), data-active, and onClick={() => togglePanel(panelKey)}
so accessibility and behavior remain identical.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dff640a0-d8a3-4c2d-b89c-2269f766b84f

📥 Commits

Reviewing files that changed from the base of the PR and between c9ae410 and 28e609c.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json
  • public/icons/home_exit.svg is excluded by !**/*.svg
📒 Files selected for processing (31)
  • package.json
  • player-removechild-analysis.md
  • src/app/room/[slug]/page.tsx
  • src/entities/playlist/api/moveMyQueueEntry.ts
  • src/entities/playlist/model/types.ts
  • src/entities/playlist/model/useMoveMyQueueEntry.ts
  • src/entities/user/model/types.ts
  • src/features/playlist/player/ui/YouTubePlayer.tsx
  • src/features/room/profile/model/types.ts
  • src/features/room/profile/ui/RoomProfilePanel.module.css
  • src/features/room/profile/ui/RoomProfilePanel.tsx
  • src/features/room/queue/model/roomQueue.ts
  • src/features/room/queue/ui/RoomQueueCard.module.css
  • src/features/room/queue/ui/RoomQueueCard.tsx
  • src/features/room/queue/ui/RoomQueueList.module.css
  • src/features/room/queue/ui/RoomQueueList.tsx
  • src/features/room/queue/ui/RoomQueuePanel.module.css
  • src/features/room/queue/ui/RoomQueuePanel.tsx
  • src/features/room/queue/ui/RoomQueueSortableList.module.css
  • src/features/room/queue/ui/RoomQueueSortableList.tsx
  • src/features/room/queue/ui/RoomQueueTabs.module.css
  • src/features/room/queue/ui/RoomQueueTabs.tsx
  • src/shared/ui/radial-control/RadialControl.module.css
  • src/widgets/home/ui/HomeControlPanelShell.module.css
  • src/widgets/home/ui/HomeControlPanelShell.tsx
  • src/widgets/home/ui/HomeScreen.module.css
  • src/widgets/home/ui/HomeScreen.tsx
  • src/widgets/room/model/useFloatingWidgetsState.ts
  • src/widgets/room/ui/FloatingRoomPanelShell.tsx
  • src/widgets/room/ui/RoomFloatingWidgets.module.css
  • src/widgets/room/ui/RoomFloatingWidgets.tsx

Comment on lines +74 to +95
`src/features/playlist/player/ui/YouTubePlayer.tsx`에서는 `videoId`가 없으면 플레이어를 정리하고 placeholder를 렌더링한다.

```ts
useEffect(() => {
if (!videoId) {
destroyPlayer();
return;
}

// player setup...
}, [applyDesiredPlayback, destroyPlayer, onPlaybackStateChange, onPlayerReady, videoId]);
```

```tsx
if (!videoId) {
return (
<div className="flex aspect-video w-full items-center justify-center rounded-xl border border-dashed border-gray-300 bg-gray-50 text-sm text-gray-500">
재생할 유튜브 영상이 아직 없습니다.
</div>
);
}
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

문서가 현재 구현 상태와 섞여 있어 오해를 남깁니다.

여기서는 아직도 "!videoId면 placeholder로 subtree를 교체한다"는 현재형 설명이 나오고, 마지막에는 "아직 코드는 수정하지 않았다"고 적혀 있습니다. 그런데 같은 PR의 src/features/playlist/player/ui/YouTubePlayer.tsx는 이미 ensurePlayerHost()와 고정 wrapper 구조로 바뀌었습니다. 수정 전 분석 문서라면 섹션 제목과 코드 블록에 수정 전 표시를 붙여 두는 편이 안전합니다.

Also applies to: 202-208

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@player-removechild-analysis.md` around lines 74 - 95, The documentation mixes
pre-change behavior with the current implementation; update the text and code
blocks in the analysis so they accurately reflect the current YouTubePlayer.tsx
(which now uses ensurePlayerHost() and a fixed wrapper) or explicitly mark the
snippets as "Before change"/"수정 전": locate references to videoId,
destroyPlayer(), and the placeholder subtree and either convert those code
blocks and section headings to "수정 전" (pre-change) labels or replace them with
the updated implementation details that mention ensurePlayerHost() and the fixed
wrapper structure; keep the intent clear and consistent across the entire
section (including the other affected lines around 202-208).

const onPlayerReadyRef = useRef(onPlayerReady);
const onPlaybackStateChangeRef = useRef(onPlaybackStateChange);
const [playerError, setPlayerError] = useState<string | null>(null);
const [hasCreatedPlayer, setHasCreatedPlayer] = useState(false);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

플레이어 생성 플래그가 방 전환 뒤에도 남아 이전 방 화면이 잔류할 수 있습니다.

hasCreatedPlayer는 한 번 true가 되면 다시 내려가지 않아서, 같은 YouTubePlayer 인스턴스가 다른 room slug에서도 재사용될 때 videoIdnull이어도 empty state가 나오지 않습니다. src/app/room/[slug]/page.tsx가 slug 변경을 effect로 처리하는 구조라서, 방 A에서 재생 후 방 B로 이동했는데 B에 현재 곡이 없으면 A의 마지막 iframe/frame이 계속 보일 수 있습니다. 방 경계에서 player를 reset하도록 만들거나, page 쪽에서 room slug를 key로 줘서 인스턴스를 끊어 주는 편이 안전합니다.

💡 방 단위로 인스턴스를 분리하는 예시
// src/app/room/[slug]/page.tsx
<YouTubePlayer
  key={slug}
  videoId={currentVideoId}
  playbackStatus={playbackStatus?.status ?? null}
  currentTimeMs={playbackStatus?.currentTime ?? null}
/>

Also applies to: 359-360

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/playlist/player/ui/YouTubePlayer.tsx` at line 166, The
hasCreatedPlayer boolean (useState in YouTubePlayer) is never reset across room
(slug) changes, causing a previously created iframe to persist when videoId
becomes null; either reset that state when the room changes or force React to
remount the component per room. Update the usage so the parent (page.tsx)
supplies a unique key like slug to the <YouTubePlayer /> (or within
YouTubePlayer listen for slug/room prop and call setHasCreatedPlayer(false) on
change), and ensure props videoId, playbackStatus/currentTime null states
trigger the empty state rendering instead of relying on a stale hasCreatedPlayer
flag.

Comment on lines +97 to +104
font-weight: var(--fw-bold);
}

.cardValue {
margin-top: 6px;
color: #8d8d8d;
font-size: 14px;
font-weight: var(--font-semibold);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 목적: 폰트 웨이트 CSS 변수 정의/사용 일치 여부 확인(읽기 전용)
rg -n -- '--fw-bold|--fw-semibold|--font-semibold' src

Repository: Queuing-org/frontend

Length of output: 920


Line 104의 CSS 변수명이 정의되지 않은 오타입니다.

Line 97은 --fw-bold를 사용하는데, Line 104만 --font-semibold를 사용합니다. globals.css에 정의된 변수는 --fw-semibold: 600;--fw-bold: 700;뿐이므로, --font-semibold는 미정의 상태입니다. Line 104의 font-weight가 적용되지 않습니다.

수정: var(--font-semibold)var(--fw-semibold)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/room/profile/ui/RoomProfilePanel.module.css` around lines 97 -
104, The .cardValue rule uses an undefined CSS variable --font-semibold causing
font-weight not to apply; change the font-weight declaration in .cardValue from
var(--font-semibold) to var(--fw-semibold) so it matches the globals.css
variable and applies the intended weight.

Comment on lines +48 to +63
const { error, isPending, mutate, reset } = useSendFriendRequest();
const [lastRequestedKey, setLastRequestedKey] = useState<string | null>(null);
const [lastMutationKey, setLastMutationKey] = useState<string | null>(null);
const currentRequesterKey = currentRequester
? `${currentRequester.slug ?? ""}:${currentRequester.userId ?? ""}:${currentRequester.nickname}`
: null;

const isSelf = isCurrentUserProfile(currentRequester, me);
const canFollow = !!currentRequester?.slug && !isSelf;
const hasRequestedFollow =
currentRequesterKey !== null && lastRequestedKey === currentRequesterKey;
const shouldShowError =
!!error &&
currentRequesterKey !== null &&
lastMutationKey === currentRequesterKey;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

중복 요청 방지 상태가 패널 생명주기에 묶여 있습니다.

lastRequestedKey/lastMutationKey를 이 컴포넌트 local state로 들고 있어서, RoomFloatingWidgets에서 패널을 닫았다 다시 열면 값이 초기화됩니다. 그러면 같은 대상에게 버튼이 다시 살아나고 중복 요청을 보낼 수 있습니다. 이 상태는 서버에서 조회한 관계/요청 상태로 결정하거나, 최소한 패널 unmount 바깥의 상위 상태나 query cache로 올리는 편이 안전합니다. As per coding guidelines, "React에서 상태 위치 미스, 불필요한 리렌더링, derived state 남발, useEffect 남용을 지적하고 개선안을 제시."

Also applies to: 64-83

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/room/profile/ui/RoomProfilePanel.tsx` around lines 48 - 63, The
panel-local flags lastRequestedKey and lastMutationKey in RoomProfilePanel are
causing duplicate-request vulnerability because they reset on unmount; move this
request-tracking out of the component (e.g. lift into the parent that mounts
RoomProfilePanel such as RoomFloatingWidgets or persist in the request/query
cache or server-side relationship state) and use the existing
currentRequesterKey/currentRequester values to check/infer
hasRequestedFollow/shouldShowError; update useSendFriendRequest usage so mutate
sets the shared tracking state (or updates the query cache) instead of local
setLastRequestedKey/setLastMutationKey, and remove the local useState usage so
the panel no longer resets these flags on unmount.

Comment on lines +38 to +50
export function isEntryRequestedByUser(
entry: PlaylistEntry,
currentUser: User | null | undefined,
) {
if (!currentUser) {
return false;
}

if (typeof currentUser.userId === "number") {
return entry.addedBy.userId === currentUser.userId;
}

return entry.addedBy.nickname === currentUser.nickname;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

userId가 nullable일 때 내 요청곡 판별이 오탐(false negative) 됩니다.

src/entities/playlist/model/types.ts에서 entry.addedBy.userIdnumber | null인데, 현재 로직은 currentUser.userId가 number인 경우 nickname fallback 없이 즉시 ID 비교만 수행합니다. 이 경우 서버 데이터에 addedBy.userIdnull로 오는 항목이 "mine"에서 누락됩니다.

🐛 제안 코드
 export function isEntryRequestedByUser(
   entry: PlaylistEntry,
   currentUser: User | null | undefined,
 ) {
   if (!currentUser) {
     return false;
   }

   if (typeof currentUser.userId === "number") {
-    return entry.addedBy.userId === currentUser.userId;
+    if (typeof entry.addedBy.userId === "number") {
+      return entry.addedBy.userId === currentUser.userId;
+    }
   }

   return entry.addedBy.nickname === currentUser.nickname;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/room/queue/model/roomQueue.ts` around lines 38 - 50, The
function isEntryRequestedByUser incorrectly assumes entry.addedBy.userId is
always a number when currentUser.userId is a number; change the logic in
isEntryRequestedByUser (used with PlaylistEntry and User) to first check whether
entry.addedBy.userId is a number and if so compare IDs, otherwise fall back to
comparing entry.addedBy.nickname with currentUser.nickname; ensure you still
handle the null/undefined currentUser case and avoid doing a numeric ID compare
against a null addedBy.userId.

Comment on lines +53 to +64
<button
ref={dragHandleRef}
type="button"
className={[
styles.dragHandle,
dragHandleProps?.className,
]
.filter(Boolean)
.join(" ")}
aria-label="곡 순서 변경"
{...dragHandleProps}
>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

dragHandleProps를 마지막에 펼쳐서 기본 속성이 덮어써집니다.

지금 순서에서는 merge한 className이 다시 dragHandleProps.className으로 덮여서 styles.dragHandle이 빠질 수 있고, type/aria-label도 외부 prop이 이겨 버립니다. drag handle의 기본 스타일과 접근성 속성은 마지막에 고정되도록 순서를 바꿔 주세요.

💡 수정 예시
+  const { className: dragHandleClassName, ...dragHandleButtonProps } =
+    dragHandleProps ?? {};
+
   {showDragHandle ? (
     <button
       ref={dragHandleRef}
-      type="button"
-      className={[
-        styles.dragHandle,
-        dragHandleProps?.className,
-      ]
-        .filter(Boolean)
-        .join(" ")}
-      aria-label="곡 순서 변경"
-      {...dragHandleProps}
+      {...dragHandleButtonProps}
+      type="button"
+      className={[styles.dragHandle, dragHandleClassName]
+        .filter(Boolean)
+        .join(" ")}
+      aria-label="곡 순서 변경"
     >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
ref={dragHandleRef}
type="button"
className={[
styles.dragHandle,
dragHandleProps?.className,
]
.filter(Boolean)
.join(" ")}
aria-label="곡 순서 변경"
{...dragHandleProps}
>
const { className: dragHandleClassName, ...dragHandleButtonProps } =
dragHandleProps ?? {};
<button
ref={dragHandleRef}
{...dragHandleButtonProps}
type="button"
className={[styles.dragHandle, dragHandleClassName]
.filter(Boolean)
.join(" ")}
aria-label="곡 순서 변경"
>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/room/queue/ui/RoomQueueCard.tsx` around lines 53 - 64, The
button currently spreads dragHandleProps last which lets external props override
our defaults; change the prop order in the button render so you spread
dragHandleProps first, then apply/override fixed props (ref={dragHandleRef},
type="button", aria-label="곡 순서 변경") and set className to the merged value that
combines styles.dragHandle and dragHandleProps?.className (so styles.dragHandle
is always present). In other words, move {...dragHandleProps} before the
explicit attributes and ensure className is computed and passed after merging to
prevent external props from overwriting the default class and accessibility
attributes.

Comment on lines +22 to +32
if (isLoading) {
return <div className={styles.state}>플레이리스트를 불러오는 중입니다.</div>;
}

if (errorMessage) {
return <div className={styles.state}>{errorMessage}</div>;
}

if (entries.length === 0) {
return <div className={styles.state}>{emptyMessage}</div>;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

로딩/에러/빈 상태 메시지에 라이브 리전이 필요합니다.

Line 22-32의 상태 메시지는 화면에는 보이지만 스크린리더 알림이 약합니다. role="status"/role="alert"를 추가해 상태 변화를 전달해 주세요.

접근성 보강 예시
   if (isLoading) {
-    return <div className={styles.state}>플레이리스트를 불러오는 중입니다.</div>;
+    return (
+      <div className={styles.state} role="status" aria-live="polite">
+        플레이리스트를 불러오는 중입니다.
+      </div>
+    );
   }

   if (errorMessage) {
-    return <div className={styles.state}>{errorMessage}</div>;
+    return (
+      <div className={styles.state} role="alert">
+        {errorMessage}
+      </div>
+    );
   }

   if (entries.length === 0) {
-    return <div className={styles.state}>{emptyMessage}</div>;
+    return (
+      <div className={styles.state} role="status" aria-live="polite">
+        {emptyMessage}
+      </div>
+    );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isLoading) {
return <div className={styles.state}>플레이리스트를 불러오는 중입니다.</div>;
}
if (errorMessage) {
return <div className={styles.state}>{errorMessage}</div>;
}
if (entries.length === 0) {
return <div className={styles.state}>{emptyMessage}</div>;
}
if (isLoading) {
return (
<div className={styles.state} role="status" aria-live="polite">
플레이리스트를 불러오는 중입니다.
</div>
);
}
if (errorMessage) {
return (
<div className={styles.state} role="alert">
{errorMessage}
</div>
);
}
if (entries.length === 0) {
return (
<div className={styles.state} role="status" aria-live="polite">
{emptyMessage}
</div>
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/room/queue/ui/RoomQueueList.tsx` around lines 22 - 32, In
RoomQueueList update the status/alert divs so screen readers are notified: add
role="status" (or aria-live="polite") to the loading and empty-state divs that
render when isLoading or entries.length === 0, and add role="alert" (or
aria-live="assertive") to the error div that renders when errorMessage is set;
modify the divs that use styles.state and the variables isLoading, errorMessage,
entries.length and emptyMessage accordingly so the appropriate role/aria-live
attributes are included.

Comment on lines +9 to +23
.tab {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
padding: 0px 12px 8px;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.54);
font-size: 14px;
font-weight: var(--fw-semibold);
cursor: pointer;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

탭 버튼의 키보드 포커스 가시성이 누락되었습니다.

:focus-visible 스타일이 없어 키보드 사용자에게 현재 포커스 위치가 명확하지 않습니다.

🔧 제안 수정안
 .tab {
   position: relative;
   display: flex;
@@
   font-weight: var(--fw-semibold);
   cursor: pointer;
 }
+
+.tab:focus-visible {
+  outline: 2px solid rgba(255, 255, 255, 0.6);
+  outline-offset: -2px;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/room/queue/ui/RoomQueueTabs.module.css` around lines 9 - 23, The
.tab class lacks a :focus-visible rule so keyboard users can't see focus; update
the CSS for .tab by adding a :focus-visible selector (e.g., .tab:focus-visible)
that applies a visible, accessible focus style such as a high-contrast outline
or box-shadow and ensures outline-offset and border-radius match existing
styling, and make sure the rule doesn't disturb mouse focus behavior (use
:focus-visible rather than :focus) so only keyboard users see the indicator.

Comment on lines +20 to +42
<div className={styles.tabs} role="tablist" aria-label="플레이리스트 탭">
<button
type="button"
role="tab"
className={styles.tab}
aria-selected={activeTab === "all"}
data-active={activeTab === "all"}
onClick={() => onChange("all")}
>
전체 트랙
<span className={styles.tabCount}>{allCount}</span>
</button>
<button
type="button"
role="tab"
className={styles.tab}
aria-selected={activeTab === "mine"}
data-active={activeTab === "mine"}
onClick={() => onChange("mine")}
>
내 신청곡
<span className={styles.tabCount}>{myCount}</span>
</button>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

탭 ARIA 패턴이 불완전합니다.

Line 20-42는 tab 역할을 선언했지만, 탭 패턴 필수 요소(aria-controls 연결, tabIndex 관리, 화살표 키 이동)가 없습니다. 지금 구조가 “필터 토글”이라면 tab role을 제거하고 aria-pressed를 쓰는 편이 더 안전합니다.

단순 토글 버튼 패턴으로 정리하는 예시
-    <div className={styles.tabs} role="tablist" aria-label="플레이리스트 탭">
+    <div className={styles.tabs} aria-label="플레이리스트 필터">
       <button
         type="button"
-        role="tab"
         className={styles.tab}
-        aria-selected={activeTab === "all"}
+        aria-pressed={activeTab === "all"}
         data-active={activeTab === "all"}
         onClick={() => onChange("all")}
       >
@@
       <button
         type="button"
-        role="tab"
         className={styles.tab}
-        aria-selected={activeTab === "mine"}
+        aria-pressed={activeTab === "mine"}
         data-active={activeTab === "mine"}
         onClick={() => onChange("mine")}
       >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className={styles.tabs} role="tablist" aria-label="플레이리스트 탭">
<button
type="button"
role="tab"
className={styles.tab}
aria-selected={activeTab === "all"}
data-active={activeTab === "all"}
onClick={() => onChange("all")}
>
전체 트랙
<span className={styles.tabCount}>{allCount}</span>
</button>
<button
type="button"
role="tab"
className={styles.tab}
aria-selected={activeTab === "mine"}
data-active={activeTab === "mine"}
onClick={() => onChange("mine")}
>
신청곡
<span className={styles.tabCount}>{myCount}</span>
</button>
<div className={styles.tabs} aria-label="플레이리스트 필터">
<button
type="button"
className={styles.tab}
aria-pressed={activeTab === "all"}
data-active={activeTab === "all"}
onClick={() => onChange("all")}
>
전체 트랙
<span className={styles.tabCount}>{allCount}</span>
</button>
<button
type="button"
className={styles.tab}
aria-pressed={activeTab === "mine"}
data-active={activeTab === "mine"}
onClick={() => onChange("mine")}
>
신청곡
<span className={styles.tabCount}>{myCount}</span>
</button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/room/queue/ui/RoomQueueTabs.tsx` around lines 20 - 42, The
current RoomQueueTabs tab buttons declare role="tab" but lack required ARIA
tab-pattern behavior; update RoomQueueTabs to either implement the full tab
pattern (add aria-controls linking each tab button to its panel, manage tabIndex
so only the active tab is focusable, and implement keyboard arrow navigation to
move focus/selection) or convert the elements to simple toggle buttons by
removing role="tab", using role="button" (or no role), and replace aria-selected
with aria-pressed (wired to activeTab === "all"/"mine") while keeping
onClick/onChange; reference the activeTab prop, onChange handler, and the two
button elements when making the change.

box-shadow: 0 18px 40px rgba(0, 0, 0, 0.26);
color: #f6f6f6;
backdrop-filter: blur(14px);
animation: panelEnter 180ms ease;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

키프레임 이름 규칙 위반으로 린트 실패합니다.

Line 73의 panelEnter는 현재 규칙(keyframes-name-pattern) 기준으로 kebab-case가 필요합니다. 참조하는 Line 10도 같이 맞춰야 합니다.

🛠️ 제안 수정안
 .panel {
@@
-  animation: panelEnter 180ms ease;
+  animation: panel-enter 180ms ease;
 }
@@
-@keyframes panelEnter {
+@keyframes panel-enter {

Also applies to: 73-73

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/widgets/home/ui/HomeControlPanelShell.module.css` at line 10, The lint
failure is due to the keyframes name panelEnter not following kebab-case; rename
the `@keyframes` declaration (symbol: panelEnter) to kebab-case (e.g.,
panel-enter) and update the usage in the animation property (currently
"animation: panelEnter 180ms ease;") to match the new name so both the
`@keyframes` block and the animation reference stay in sync.

@aryu1217 aryu1217 changed the title Feat/websocket dev feat: 대기열 순서 변경 및 방 프로필 패널 추가 Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant