Conversation
📝 WalkthroughWalkthrough드래그 앤 드롭 기반의 대기열 재정렬 기능, 방 프로필 패널 UI, YouTube 플레이어 DOM 소유권 충돌 해결, 관련 상태 관리 인프라를 추가합니다. Changes
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 분 Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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.
| if (!res.data.result) { | ||
| throw new ApiError({ | ||
| message: "큐 순서를 변경하지 못했습니다.", | ||
| status: 200, | ||
| }); | ||
| } |
There was a problem hiding this comment.
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.
| useEffect(() => { | ||
| setPendingEntries(entries.filter(isPendingQueueEntry)); | ||
| }, [entries]); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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상태 관리 개선이 필요합니다.현재 코드의 두 가지 문제점:
Lazy initializer 부재:
useState의 초기값 식(entries.filter(...))이 매 렌더링마다 평가되고 버려집니다. 함수 initializer로 감싸서 첫 렌더링에만 실행되도록 최적화하세요.Derived state 패턴:
pendingEntries는entriesprop에서 파생된 상태이며, 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
⛔ Files ignored due to path filters (2)
package-lock.jsonis excluded by!**/package-lock.jsonpublic/icons/home_exit.svgis excluded by!**/*.svg
📒 Files selected for processing (31)
package.jsonplayer-removechild-analysis.mdsrc/app/room/[slug]/page.tsxsrc/entities/playlist/api/moveMyQueueEntry.tssrc/entities/playlist/model/types.tssrc/entities/playlist/model/useMoveMyQueueEntry.tssrc/entities/user/model/types.tssrc/features/playlist/player/ui/YouTubePlayer.tsxsrc/features/room/profile/model/types.tssrc/features/room/profile/ui/RoomProfilePanel.module.csssrc/features/room/profile/ui/RoomProfilePanel.tsxsrc/features/room/queue/model/roomQueue.tssrc/features/room/queue/ui/RoomQueueCard.module.csssrc/features/room/queue/ui/RoomQueueCard.tsxsrc/features/room/queue/ui/RoomQueueList.module.csssrc/features/room/queue/ui/RoomQueueList.tsxsrc/features/room/queue/ui/RoomQueuePanel.module.csssrc/features/room/queue/ui/RoomQueuePanel.tsxsrc/features/room/queue/ui/RoomQueueSortableList.module.csssrc/features/room/queue/ui/RoomQueueSortableList.tsxsrc/features/room/queue/ui/RoomQueueTabs.module.csssrc/features/room/queue/ui/RoomQueueTabs.tsxsrc/shared/ui/radial-control/RadialControl.module.csssrc/widgets/home/ui/HomeControlPanelShell.module.csssrc/widgets/home/ui/HomeControlPanelShell.tsxsrc/widgets/home/ui/HomeScreen.module.csssrc/widgets/home/ui/HomeScreen.tsxsrc/widgets/room/model/useFloatingWidgetsState.tssrc/widgets/room/ui/FloatingRoomPanelShell.tsxsrc/widgets/room/ui/RoomFloatingWidgets.module.csssrc/widgets/room/ui/RoomFloatingWidgets.tsx
| `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> | ||
| ); | ||
| } | ||
| ``` |
There was a problem hiding this comment.
문서가 현재 구현 상태와 섞여 있어 오해를 남깁니다.
여기서는 아직도 "!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); |
There was a problem hiding this comment.
플레이어 생성 플래그가 방 전환 뒤에도 남아 이전 방 화면이 잔류할 수 있습니다.
hasCreatedPlayer는 한 번 true가 되면 다시 내려가지 않아서, 같은 YouTubePlayer 인스턴스가 다른 room slug에서도 재사용될 때 videoId가 null이어도 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.
| font-weight: var(--fw-bold); | ||
| } | ||
|
|
||
| .cardValue { | ||
| margin-top: 6px; | ||
| color: #8d8d8d; | ||
| font-size: 14px; | ||
| font-weight: var(--font-semibold); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 목적: 폰트 웨이트 CSS 변수 정의/사용 일치 여부 확인(읽기 전용)
rg -n -- '--fw-bold|--fw-semibold|--font-semibold' srcRepository: 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.
| 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; | ||
|
|
There was a problem hiding this comment.
중복 요청 방지 상태가 패널 생명주기에 묶여 있습니다.
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.
| 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; |
There was a problem hiding this comment.
userId가 nullable일 때 내 요청곡 판별이 오탐(false negative) 됩니다.
src/entities/playlist/model/types.ts에서 entry.addedBy.userId는 number | null인데, 현재 로직은 currentUser.userId가 number인 경우 nickname fallback 없이 즉시 ID 비교만 수행합니다. 이 경우 서버 데이터에 addedBy.userId가 null로 오는 항목이 "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.
| <button | ||
| ref={dragHandleRef} | ||
| type="button" | ||
| className={[ | ||
| styles.dragHandle, | ||
| dragHandleProps?.className, | ||
| ] | ||
| .filter(Boolean) | ||
| .join(" ")} | ||
| aria-label="곡 순서 변경" | ||
| {...dragHandleProps} | ||
| > |
There was a problem hiding this comment.
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.
| <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.
| 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>; | ||
| } |
There was a problem hiding this comment.
로딩/에러/빈 상태 메시지에 라이브 리전이 필요합니다.
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.
| 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.
| .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; | ||
| } |
There was a problem hiding this comment.
탭 버튼의 키보드 포커스 가시성이 누락되었습니다.
: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.
| <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> |
There was a problem hiding this comment.
탭 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.
| <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; |
There was a problem hiding this comment.
키프레임 이름 규칙 위반으로 린트 실패합니다.
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.
Summary by CodeRabbit
릴리스 노트
새로운 기능
버그 수정