Skip to content

빌더 피드 QA 수정 및 댓글 기능 추가#662

Merged
HA-SEUNG-JEONG merged 16 commits into
developfrom
fix/qa
May 21, 2026
Merged

빌더 피드 QA 수정 및 댓글 기능 추가#662
HA-SEUNG-JEONG merged 16 commits into
developfrom
fix/qa

Conversation

@HA-SEUNG-JEONG

@HA-SEUNG-JEONG HA-SEUNG-JEONG commented May 21, 2026

Copy link
Copy Markdown
Contributor

Problem

QA 과정에서 발견된 빌더 피드 관련 버그 다수와, 댓글/대댓글 UI가 미구현 상태였다.

주요 문제:

  • 빌더 피드 본문에 코드블록(빈 줄 포함) 작성 시 내용이 흩어져 렌더링됨 — marked.parse()가 TipTap HTML의 <p> 태그를 CommonMark type 6 HTML block으로 처리, 빈 줄에서 block 종료 후 코드 내용을 일반 단락으로 파싱
  • 돌아보기 제출 이미지/링크가 빌더 피드 상세에 미표시
  • QnA 답변 평문 콘텐츠의 줄바꿈이 dangerouslySetInnerHTML로 유실
  • 댓글 대댓글 UI 및 ⋮ 메뉴(신고) 미구현
  • 운영자 PICK 칩 항상 노출, 필터 칩 디자인 불일치 등

Solution

  • 코드블록 렌더링: isHtmlContent()로 TipTap HTML 여부 판별 → HTML이면 marked.parse() 건너뛰고 DOMPurify 직접 sanitize
  • 돌아보기 artifact: feed.artifactUrl 이미지/링크 분기 렌더링 추가
  • QnA 줄바꿈: HTML이 아닌 평문 콘텐츠는 whitespace-pre-wrap으로 React text node 렌더링
  • 댓글/대댓글: 대댓글 입력 UI, ⋮ 메뉴(답글 달기/신고), 댓글 신고 연동 구현
  • 필터 칩: 운영자 PICK 피드 존재 여부 조건부 노출, Figma 정합 radius/색상 수정

Changes

Features

File Description
src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx 댓글 대댓글 UI, ⋮ 메뉴, 댓글 신고 기능 추가

Bug Fixes

File Description
src/components/common/ui/rich-text/markdown-content-core.tsx TipTap HTML을 marked 없이 직접 sanitize — 코드블록 렌더링 수정
src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx 돌아보기 artifact(이미지/링크) 표시, 닉네임-뱃지 간격 수정
src/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsx QnA 답변 평문 줄바꿈 수정
src/app/(landing)/class/[slug]/(learning)/_components/feed-tab.tsx 운영자 PICK 칩 조건부 노출, 필터 칩 디자인 정합
src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx 평문 줄바꿈 수정 (QnA 모달 동일 적용)
src/types/api/course.types.ts roleIds 타입 수정

Result

  • 코드블록 포함 피드가 <pre><code> 블록으로 정상 렌더링, highlight.js 문법 강조 적용
  • 돌아보기 제출 이미지/링크가 피드 상세에 노출
  • QnA 답변 줄바꿈이 정상 표시
  • 댓글에 대댓글 달기, ⋮ 메뉴로 신고 가능
  • 필터 칩이 Figma 디자인과 일치

Screenshots

Before After
코드블록 내용 흩어짐 <pre> 블록으로 정상 렌더링
돌아보기 artifact 미표시 이미지/링크 정상 노출
QnA 줄바꿈 없음 줄바꿈 정상

Test plan

  • 빌더 피드 코드블록(빈 줄 포함) 작성 후 상세에서 정상 렌더링 확인
  • 돌아보기 제출이 있는 피드 상세에서 이미지/링크 표시 확인
  • QnA 답변 평문 줄바꿈 확인 (Q1, Q2 비교)
  • 댓글에 대댓글 달기, ⋮ 메뉴 동작 확인
  • 운영자 PICK 피드 없을 때 칩 미노출 확인
  • 필터 칩 디자인 Figma 일치 확인

🤖 Generated with Claude Code

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 피드 댓글에 답글 기능 추가
    • 아티팩트를 이미지/링크로 구분하여 표시
    • 운영자 PICK 필터가 실제 데이터 존재 시에만 노출
    • 관리자 전용 답변 버튼
  • 개선사항

    • 스크린샷 업로드 파일 크기 제한 (5MB)
    • HTML이 아닌 텍스트의 안전한 렌더링
    • 레슨 목록 정렬 개선
    • 마크다운 붙여넣기 처리 개선
    • 홈 네비게이션 링크 업데이트
  • UI/UX

    • 완료된 레슨 배지에 아이콘 추가
    • 마지막 레슨 완주 버튼 텍스트 변경
    • 로드맵 탭 UI 개선

Review Change Stack

HA-SEUNG-JEONG and others added 10 commits May 21, 2026 16:30
- GNB 마이 클래스 링크 /my-page → /my-class 수정
- 완료 레슨 도장 핑크 활성화, blink 제거
- 완료-완료 커넥터 라인 핑크 실선, 레슨 order 정렬
- 스크린샷 5MB 초과 토스트 안내 추가
- S3 이미지 업로드 FormData → raw file body 수정 (무한로딩 해결)
- 레슨/질문 상세 탭바 sticky 위치 수정
- 유용해요/나도궁금해요 배타적 토글 버그 수정
- 운영진 아닌 사용자 답변하기 floating 버튼 차단
- 마크다운 에디터 ctrl+v 단일 줄 패턴 삽입 시 줄바꿈 생성 버그 수정
- QnA 탭 필터 chip UI Figma 디자인 통일

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
gray-400(#a4a7ae)이 Figma 지정값 #999와 불일치.
gray-450 토큰 추가 후 feed-tab, qna-tab 양쪽 적용.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
운영자 PICK 칩은 Figma 설계에 없어 제거.
rounded-875(토큰 없음) → rounded-full로 수정.
feed/qna 두 탭 모두 적용.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OPERATOR_PICK probe 쿼리로 totalCount 확인 후
feeds가 있을 때만 칩 렌더링. 없으면 전체/내 피드만 표시.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
roleIds가 UseAuthReturn이 아닌 DecodedToken(data) 안에 있음.
data?.roleIds로 접근하도록 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HTML 태그 없는 평문 content를 dangerouslySetInnerHTML로 렌더링 시
\n이 공백으로 처리되어 줄바꿈이 사라지던 문제 수정.
isHtml 판별 후 평문은 whitespace-pre-wrap + React 텍스트 노드로 렌더링.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel

vercel Bot commented May 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
study-platform-client-dev Ready Ready Preview, Comment May 21, 2026 1:34pm

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@HA-SEUNG-JEONG has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 36 minutes and 8 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6b3d5c4e-1d45-4b52-bc3b-9381d20c8a9d

📥 Commits

Reviewing files that changed from the base of the PR and between 9b68472 and db30d83.

📒 Files selected for processing (8)
  • next.config.ts
  • src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx
  • src/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsx
  • src/app/(landing)/class/[slug]/(learning)/_components/feed-tab.tsx
  • src/app/(landing)/class/[slug]/(learning)/_components/qna-tab.tsx
  • src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx
  • src/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsx
  • src/app/global.css
📝 Walkthrough

Walkthrough

이 PR은 학습 플랫폼의 피드 및 QnA 기능을 강화합니다. 아티팩트 URL을 피드 응답 타입에 추가하고, 이미지/링크로 조건부 렌더링합니다. 피드 댓글에 답글 시스템과 신고 기능을 추가하며, QnA 페이지에서 HTML 렌더링 안전성을 개선하고 관리자 권한 제어를 도입합니다. 레슨 완료 처리, 동적 필터, UI 개선, 마크다운 렌더링 최적화를 함께 포함합니다.

Changes

학습 플랫폼 기능 강화

Layer / File(s) Summary
피드 아티팩트 URL 타입 및 렌더링 지원
src/types/api/course.types.ts, e2e/class/builder-feed.spec.ts, src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx, src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx
BuilderFeedDetailResponseartifactUrl: string | null 필드를 추가하고, 테스트 mock 데이터를 함께 확장합니다. 두 피드 페이지에서 isImageUrl 헬퍼로 이미지 여부를 판별하여, 이미지면 next/image로, 기타 URL이면 Link 아이콘과 함께 새 탭 링크로 렌더링합니다.
피드 댓글 및 답글 상호작용
src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx
replyingToId, replyText, commentMenuOpenId, reportCommentId 상태를 추가하고, 댓글별 메뉴 관리 및 신고 추적을 구현합니다. 댓글 하단에 답글쓰기 토글과 입력 UI를 추가하며, 답글 내용에서 @멘션 패턴을 분리 표시합니다.
QnA 안전 HTML 렌더링 및 권한 제어
src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx, src/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsx
HTML 태그 패턴 검사 로직을 추가하여, 태그가 없으면 텍스트로 안전 렌더링하고 태그가 있으면 기존 방식으로 처리합니다. useAuth 훅으로 관리자 권한을 확인하여 "답변하기" 버튼을 조건부 표시합니다.
레슨 완료 및 마지막 레슨 표시
src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-review-form.tsx, src/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsx, src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-screenshot-modal.tsx
LessonFormPropsisLastLesson?: boolean을 추가하고, 마지막 레슨 여부에 따라 제출 버튼 문구를 "완주하고 축하 페이지로 가기" 또는 "제출하고 다음 Lesson 하러 가기"로 분기합니다. 스크린샷 업로드 시 5MB 파일 크기 제한을 추가합니다.
동적 필터, UI 스타일 및 레이아웃 개선
src/app/(landing)/class/[slug]/(learning)/_components/feed-tab.tsx, src/app/(landing)/class/[slug]/(learning)/_components/qna-tab.tsx, src/components/pages/class/lesson-stamp.tsx, src/components/pages/class/roadmap-tab.tsx
운영자 PICK 필터를 동적으로 조회하여 필터 칩을 조건부 노출합니다. QnA 탭에서 답변 완료 배지에 아이콘을 추가하고 스타일을 개선합니다. 레슨 스탐프는 완료 상태에서도 활성 이미지를 사용하도록 변경하고, 로드맵에서 레슨 목록을 챕터/레슨 순서로 정렬합니다.
마크다운 및 HTML 렌더링 최적화
src/components/common/ui/rich-text/markdown-content-core.tsx, src/components/common/ui/editor/markdown-editor.tsx
isHtmlContent 유틸로 콘텐츠가 HTML인지 판별하여, HTML이면 그대로 사용하고 아니면 marked.parse로 변환하도록 조건화합니다. 마크다운 붙여넣기 시 줄바꿈 조건을 추가하여 단일 라인 마크다운 처리를 개선합니다.
테마 토큰 및 네비게이션 업데이트
src/app/global.css, src/components/common/layout/home-header-client.tsx
CSS 커스텀 프로퍼티 --color-gray-450 (#999999)과 --spacing-425 (34px)를 추가합니다. 사용자 네비게이션 링크 경로를 /my-page에서 /my-class로 변경합니다.

AI 코드 규칙 추가

Layer / File(s) Summary
lucide-react 아이콘 대체 금지 규칙
.claude/rules/no-lucide-icon-substitution.md
Figma 디자인에 지정된 커스텀 아이콘(표준 lucide-react에 없는 글리프)은 lucide-react 아이콘으로 대체하면 안 됨을 명시합니다. 올바른 예시(인라인 SVG), Figma MCP를 통한 자산 획득 절차, fill="currentColor" 설정을 안내합니다.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 피드엔 아티팩트 달고,
댓글 아래 답글 가닥,
QnA는 안전하게,
스타일도 신나게,
마크다운 최적화 척척! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목이 PR의 주요 변경사항을 명확하게 요약하고 있습니다. QA 수정 및 댓글 기능 추가는 전체 PR의 핵심 목표와 일치합니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/qa

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.

HA-SEUNG-JEONG and others added 2 commits May 21, 2026 22:02

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx (1)

69-87: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

HTML 여부만 판별하고 raw HTML을 그대로 주입하면 저장형 XSS가 열립니다.

/<[a-z]/i에 걸린 사용자 입력이 그대로 dangerouslySetInnerHTML로 들어갑니다. QnA 내용이 사용자 작성 데이터라면 <img onerror=...> 같은 페이로드도 렌더링되므로, 여기서는 HTML 여부 판별이 아니라 sanitize 보장이 필요합니다. 서버에서 정제된 HTML만 받도록 계약을 두거나, 클라이언트에서 정제한 결과만 주입해주세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/app/`(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx
around lines 69 - 87, HtmlContent currently detects HTML via /<[a-z]/i and
injects user input into dangerouslySetInnerHTML (in HtmlContent), which allows
stored XSS; fix by sanitizing the HTML before injection (either require server
to return pre-sanitized HTML or run a sanitizer on the client inside
HtmlContent), e.g. call a vetted sanitizer (DOMPurify or sanitize-html) to strip
unsafe tags/attributes/event handlers and only allow an explicit safe list, then
pass the sanitized string to dangerouslySetInnerHTML (or avoid
dangerouslySetInnerHTML entirely and render text when HTML is not trusted).
src/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsx (1)

46-63: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

여기도 HTML 분기 후 raw 주입이 그대로 남아 있어서 XSS 위험이 같습니다.

텍스트 분기 추가로 줄바꿈 문제는 해결되지만, 태그가 포함된 입력은 여전히 정제 없이 dangerouslySetInnerHTML로 렌더링됩니다. 상세 페이지도 동일하게 sanitize된 HTML만 주입하도록 맞춰야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(landing)/class/[slug]/(learning)/qa/[id]/page.tsx around lines 46 -
63, HtmlContent currently detects HTML and directly uses
dangerouslySetInnerHTML, which leaves an XSS surface; update HtmlContent to
sanitize the html string before passing it to dangerouslySetInnerHTML (e.g.,
call a shared sanitizeHtml or DOMPurify.sanitize utility) and use the sanitized
output in both the HTML branch and any detail page rendering that also uses
dangerouslySetInnerHTML so all injected HTML is cleaned consistently; reference
the HtmlContent component and the dangerouslySetInnerHTML usage and ensure you
add/ reuse a sanitizeHtml helper and import it where needed.
🧹 Nitpick comments (3)
src/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsx (1)

111-115: 💤 Low value

noUncheckedIndexedAccess 환경 대비 안전한 인덱스 접근 권장.

allLessons[allLessons.length - 1].lessonId 접근은 길이 체크로 런타임 안전성은 확보되지만, TS의 noUncheckedIndexedAccess 옵션이 켜져 있으면 타입 에러가 납니다. at(-1)을 사용하거나 옵셔널 체이닝으로 방어해 두면 향후 옵션 변경에도 안전합니다.

♻️ 제안 변경
   const isLastLesson = useMemo(() => {
     const allLessons = drawerChapters.flatMap((c) => c.lessons);
-    if (allLessons.length === 0) return false;
-    return allLessons[allLessons.length - 1].lessonId === lessonId;
+    return allLessons.at(-1)?.lessonId === lessonId;
   }, [drawerChapters, lessonId]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(class-lesson)/class/[slug]/lesson/[id]/page.tsx around lines 111 -
115, The isLastLesson memo computes allLessons from drawerChapters and then
indexes allLessons[allLessons.length - 1].lessonId which triggers TypeScript
errors under noUncheckedIndexedAccess; update the check to safely access the
last element (e.g., use allLessons.at(-1) with optional chaining or check last =
allLessons[allLessons.length - 1] and use last?.lessonId) so the return becomes
a boolean without unsafe indexing; modify the isLastLesson callback (and any
references to allLessons indexing) to use optional access to the last element
while preserving the existing length check and dependency list [drawerChapters,
lessonId].
src/components/common/ui/rich-text/markdown-content-core.tsx (1)

215-223: 💤 Low value

삼항식 내 IIFE 패턴을 단순화 고려

marked.parse의 반환 타입 좁히기를 위해 즉시 실행 함수를 삼항 연산자 내부에 두면 가독성이 떨어집니다. marked.parse는 기본 옵션에서 동기 호출이므로 반환이 string임을 안전하게 가정할 수 있고, 별도 헬퍼나 사전 분기로 추출하면 의도가 더 명확해집니다.

♻️ 제안 리팩토링 예시
-    const html = isHtmlContent(contentWithEmbeds)
-      ? contentWithEmbeds
-      : (() => {
-          const rendered = marked.parse(contentWithEmbeds, {
-            breaks: true,
-            gfm: true,
-          });
-          return typeof rendered === 'string' ? rendered : '';
-        })();
+    let html: string;
+    if (isHtmlContent(contentWithEmbeds)) {
+      html = contentWithEmbeds;
+    } else {
+      const rendered = marked.parse(contentWithEmbeds, {
+        breaks: true,
+        gfm: true,
+      });
+      html = typeof rendered === 'string' ? rendered : '';
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/common/ui/rich-text/markdown-content-core.tsx` around lines
215 - 223, The ternary currently embeds an IIFE to narrow marked.parse's return
type, which reduces readability; instead, precompute the rendered markdown
before the ternary (e.g., call marked.parse(contentWithEmbeds, { breaks: true,
gfm: true }) into a local variable like rendered or extract a helper function
renderMarkdown) and then use that variable in the assignment to html together
with isHtmlContent(contentWithEmbeds); ensure you coerce or default the parsed
value to a string (e.g., fallback to '') so html gets a string in all cases.
e2e/class/builder-feed.spec.ts (1)

101-117: ⚡ Quick win

새 아티팩트 분기(image/link)도 이 스펙에서 한 번은 태워 주세요.

지금 fixture는 artifactUrl: null만 넣어서 이번 PR의 핵심인 아티팩트 렌더링 분기를 전혀 검증하지 못합니다. 이미지 URL 1개와 일반 링크 1개 케이스를 각각 추가해 두면 회귀를 훨씬 잘 막을 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/class/builder-feed.spec.ts` around lines 101 - 117, The fixture returned
by makeFeedDetail always sets artifactUrl: null and doesn't exercise the new
artifact rendering branches; update makeFeedDetail (or add two variants like
makeFeedDetailWithImage and makeFeedDetailWithLink) so tests include one feed
where content.imageUrls contains at least one image URL and another where
artifactUrl is a non-null link string, ensuring the feedId/content structure and
author fields remain identical to the existing makeFeedDetail shape so
downstream assertions still work.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/app/`(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx:
- Around line 25-27: The current isImageUrl(url: string) in
lesson-builder-feed-detail-modal.tsx only checks file extensions and lets
feed.artifactUrl flow directly into Next <Image>, which can fail if the host is
not allowed by next.config.remotePatterns; create a shared utility (e.g.,
isAllowedImageUrl or validateArtifactImage) that (1) checks extension (reuse
isImageUrl), (2) verifies the URL host against the existing
COMMUNITY_ALLOWED_IMAGE_HOSTS list, and (3) returns a normalized result/flag so
both lesson-builder-feed-detail-modal.tsx and feed/[id]/page.tsx can use it to
conditionally render <Image src=...> only when allowed and otherwise render a
safe <a> fallback; update both components to call this util instead of using
isImageUrl directly.

In `@src/app/`(class-lesson)/class/[slug]/lesson/[id]/page.tsx:
- Around line 111-115: The button text logic currently uses isLastLesson
(computed from drawerChapters and lessonId) which can mismatch the actual
routing controlled by data.isCourseCompleted; update the button text decision to
rely on a server-provided field (e.g., data.wouldCompleteCourse /
wouldCompleteCourse) when available, falling back to checking for any incomplete
prerequisite lessons (inspect drawerChapters order and their lessons' completion
flags) before showing the "완주하고 축하 페이지로 가기" copy; adjust references in the
component so the UI uses wouldCompleteCourse (or the computed
"noIncompletePredecessors" boolean) instead of only isLastLesson, and ensure
data.isCourseCompleted remains the final routing guard.

In `@src/app/`(landing)/class/[slug]/(learning)/_components/feed-tab.tsx:
- Around line 73-77: When hasOperatorPick becomes false ensure the selected
filter state cannot remain '운영자 PICK': add an effect in the component that
watches hasOperatorPick and the current filter (the state variable named filter
and its setter setFilter) and, if hasOperatorPick === false and filter === '운영자
PICK', call setFilter('전체') to reset it; implement this logic near the
FeedFilter-related code (e.g., alongside filterOptions and where filter is
defined) so the UI chip list and filter state stay in sync.

In `@src/app/`(landing)/class/[slug]/(learning)/_components/qna-tab.tsx:
- Around line 201-204: The arbitrary hex Tailwind classes used for the answered
badge in qna-tab.tsx (the ternary branch that yields 'border-[`#02c76e`]
bg-[`#dafbe7`] text-[`#02c76e`]') must be replaced with the project's semantic token
classes from global.css; locate the conditional on q.answerStatus === 'ANSWERED'
and swap each arbitrary value (border, bg, text) for the corresponding semantic
token classes (e.g., the project's success-border, success-bg, success-text
token names) so the badge uses defined tokens instead of hex arbitrary values;
ensure you replace all three occurrences (border, background, text) and keep the
surrounding static classes ('flex items-center gap-50 rounded-full px-250 py-125
font-designer-14m') intact.

In `@src/app/`(landing)/class/[slug]/(learning)/feed/[id]/page.tsx:
- Around line 435-455: The code currently determines operator status by
comparing c.author.role to the Korean label string '운영진', which is inconsistent
with the rest of the codebase that uses a shared role enum/labels; import and
use the canonical ROLE_LABELS (or the shared role constant used elsewhere) and
change the check in the isOperator assignment from c.author.role === '운영진' to
compare against the source-of-truth value (e.g., c.author.role ===
ROLE_LABELS.OPERATOR or ROLE_LABELS['operator'] depending on how ROLE_LABELS is
exported), and make the identical change in the other place in this file where
operator styling is computed for replies so both usages use the same
ROLE_LABELS-based comparison.

---

Outside diff comments:
In
`@src/app/`(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx:
- Around line 69-87: HtmlContent currently detects HTML via /<[a-z]/i and
injects user input into dangerouslySetInnerHTML (in HtmlContent), which allows
stored XSS; fix by sanitizing the HTML before injection (either require server
to return pre-sanitized HTML or run a sanitizer on the client inside
HtmlContent), e.g. call a vetted sanitizer (DOMPurify or sanitize-html) to strip
unsafe tags/attributes/event handlers and only allow an explicit safe list, then
pass the sanitized string to dangerouslySetInnerHTML (or avoid
dangerouslySetInnerHTML entirely and render text when HTML is not trusted).

In `@src/app/`(landing)/class/[slug]/(learning)/qa/[id]/page.tsx:
- Around line 46-63: HtmlContent currently detects HTML and directly uses
dangerouslySetInnerHTML, which leaves an XSS surface; update HtmlContent to
sanitize the html string before passing it to dangerouslySetInnerHTML (e.g.,
call a shared sanitizeHtml or DOMPurify.sanitize utility) and use the sanitized
output in both the HTML branch and any detail page rendering that also uses
dangerouslySetInnerHTML so all injected HTML is cleaned consistently; reference
the HtmlContent component and the dangerouslySetInnerHTML usage and ensure you
add/ reuse a sanitizeHtml helper and import it where needed.

---

Nitpick comments:
In `@e2e/class/builder-feed.spec.ts`:
- Around line 101-117: The fixture returned by makeFeedDetail always sets
artifactUrl: null and doesn't exercise the new artifact rendering branches;
update makeFeedDetail (or add two variants like makeFeedDetailWithImage and
makeFeedDetailWithLink) so tests include one feed where content.imageUrls
contains at least one image URL and another where artifactUrl is a non-null link
string, ensuring the feedId/content structure and author fields remain identical
to the existing makeFeedDetail shape so downstream assertions still work.

In `@src/app/`(class-lesson)/class/[slug]/lesson/[id]/page.tsx:
- Around line 111-115: The isLastLesson memo computes allLessons from
drawerChapters and then indexes allLessons[allLessons.length - 1].lessonId which
triggers TypeScript errors under noUncheckedIndexedAccess; update the check to
safely access the last element (e.g., use allLessons.at(-1) with optional
chaining or check last = allLessons[allLessons.length - 1] and use
last?.lessonId) so the return becomes a boolean without unsafe indexing; modify
the isLastLesson callback (and any references to allLessons indexing) to use
optional access to the last element while preserving the existing length check
and dependency list [drawerChapters, lessonId].

In `@src/components/common/ui/rich-text/markdown-content-core.tsx`:
- Around line 215-223: The ternary currently embeds an IIFE to narrow
marked.parse's return type, which reduces readability; instead, precompute the
rendered markdown before the ternary (e.g., call marked.parse(contentWithEmbeds,
{ breaks: true, gfm: true }) into a local variable like rendered or extract a
helper function renderMarkdown) and then use that variable in the assignment to
html together with isHtmlContent(contentWithEmbeds); ensure you coerce or
default the parsed value to a string (e.g., fallback to '') so html gets a
string in all cases.
🪄 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: 874b75d9-dbfd-4a06-b3a3-d57351892865

📥 Commits

Reviewing files that changed from the base of the PR and between d5c01d6 and 9b68472.

⛔ Files ignored due to path filters (1)
  • public/class/qna-answered-icon.svg is excluded by !**/*.svg
📒 Files selected for processing (18)
  • .claude/rules/no-lucide-icon-substitution.md
  • e2e/class/builder-feed.spec.ts
  • src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx
  • src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-detail-modal.tsx
  • src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-review-form.tsx
  • src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-screenshot-modal.tsx
  • src/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsx
  • src/app/(landing)/class/[slug]/(learning)/_components/feed-tab.tsx
  • src/app/(landing)/class/[slug]/(learning)/_components/qna-tab.tsx
  • src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx
  • src/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsx
  • src/app/global.css
  • src/components/common/layout/home-header-client.tsx
  • src/components/common/ui/editor/markdown-editor.tsx
  • src/components/common/ui/rich-text/markdown-content-core.tsx
  • src/components/pages/class/lesson-stamp.tsx
  • src/components/pages/class/roadmap-tab.tsx
  • src/types/api/course.types.ts

Comment on lines +25 to +27
function isImageUrl(url: string): boolean {
return /\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(url);
}

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 | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== next.config.* ==="
fd -i 'next.config.*' -x sh -c 'echo "--- $1 ---"; sed -n "1,240p" "$1"' sh {}

echo
echo "=== existing image-url guards / remotePatterns references ==="
rg -n --glob '**/*.{ts,tsx,js,jsx}' 'AllowedImageUrl|is.*AllowedImageUrl|remotePatterns' src

echo
echo "=== builder-feed image render call sites ==="
rg -n --glob '**/*.{ts,tsx}' 'artifactUrl|imageUrls|<Image' src/app src/components

Repository: code-zero-to-one/study-platform-client

Length of output: 6993


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "=== existing image-url guards / remotePatterns references (non-fatal) ==="
# rg returns 1 when no matches; don't fail
rg -n --glob '**/*.{ts,tsx,js,jsx}' 'AllowedImageUrl|is.*AllowedImageUrl|remotePatterns|next/image|<Image' src || true

echo
echo "=== locate isImageUrl helper usages ==="
rg -n --glob '**/*.{ts,tsx,js,jsx}' 'function isImageUrl|isImageUrl\(' src || true

echo
echo "=== builder-feed image render call sites (artifactUrl + Image) ==="
rg -n --glob '**/*.{ts,tsx}' 'artifactUrl' src/app/(class-lesson)/class/\[slug\]/lesson/\[id\]/_components/lesson-builder-feed-detail-modal.tsx || true
rg -n --glob '**/*.{ts,tsx}' 'artifactUrl' src/app/(landing)/class/\[slug\]/\(learning\)/feed/\[id\]/page.tsx || true
rg -n --glob '**/*.{ts,tsx}' '<Image' src/app/(class-lesson)/class/\[slug\]/lesson/\[id\]/_components/lesson-builder-feed-detail-modal.tsx src/app/(landing)/class/\[slug\]/\(learning\)/feed/\[id\]/page.tsx || true

echo
echo "=== show next.config images.remotePatterns ==="
node -e "const cfg=require('./next.config.ts'); console.log(Object.keys(cfg));" 2>/dev/null || true
sed -n '1,220p' next.config.ts | sed -n '/images:/,/experimental:/p' | sed -n '1,140p'

Repository: code-zero-to-one/study-platform-client

Length of output: 21184


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "=== lesson-builder-feed-detail-modal.tsx (around isImageUrl + artifact render) ==="
FILE="src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx"
nl -ba "$FILE" | sed -n '1,140p'

echo
echo "=== landing feed page.tsx (around artifact render) ==="
FILE2="src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx"
nl -ba "$FILE2" | sed -n '1,220p'

echo
echo "=== any shared utilities around image url validation ==="
rg -n --glob '**/*.{ts,tsx,js,jsx}' '(image.*(url|host|domain|remote)|remotePatterns|AllowedImage)' src/utils src/components src/hooks || true

Repository: code-zero-to-one/study-platform-client

Length of output: 2106


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE1="src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx"
FILE2="src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx"

echo "=== lesson-builder-feed-detail-modal.tsx: isImageUrl + artifact render around ==="
awk '{printf "%6d\t%s\n", NR, $0}' "$FILE1" | sed -n '15,160p'

echo
echo "=== landing feed page.tsx: isImageUrl + artifact render around ==="
awk '{printf "%6d\t%s\n", NR, $0}' "$FILE2" | sed -n '1,120p'
echo "----"
awk '{printf "%6d\t%s\n", NR, $0}' "$FILE2" | sed -n '300,430p'

echo
echo "=== next.config.ts images.remotePatterns hosts (condensed) ==="
sed -n '/images:/,/experimental:/p' next.config.ts | sed -n '1,200p' | rg -n 'hostname|protocol|pathname'

echo
echo "=== URL guard utilities search (host/allowed) ==="
rg -n --glob '**/*.{ts,tsx,js,jsx}' 'isAllowedUrl|allowed.*host|host.*allow|remotePatterns|nextConfig\.images\.remotePatterns|image.*(host|domain)' src || true

echo
echo "=== where feed.artifactUrl is rendered via next/image ==="
rg -n --glob '**/*.{ts,tsx}' 'artifactUrl|<Image' "$FILE1" "$FILE2"

Repository: code-zero-to-one/study-platform-client

Length of output: 21810


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/features/community/ui/community-qna-question-card.tsx"
echo "=== community-qna-question-card.tsx: allowed image host guard around matching lines ==="
awk '{printf "%6d\t%s\n", NR, $0}' "$FILE" | sed -n '1,140p'

echo
echo "=== grep for COMMUNITY_ALLOWED_IMAGE_HOSTS declaration ==="
rg -n 'COMMUNITY_ALLOWED_IMAGE_HOSTS' "$FILE" || true

echo
echo "=== src/utils for remote image host allowlist/util ==="
rg -n --glob '**/*.{ts,tsx}' '(ALLOWED.*HOST|ALLOWED.*IMAGE_HOST|IMAGE.*HOST.*ALLOWED|COMMUNITY_ALLOWED_IMAGE_HOSTS|isAllowedUrl\(|hasAllowedImageUrl|allowed.*image.*host)' src || true

Repository: code-zero-to-one/study-platform-client

Length of output: 6612


아티팩트 feed.artifactUrl는 Next <Image>에 넣기 전에 공통 허용 호스트/URL 가드를 거치게 해주세요.

  • lesson-builder-feed-detail-modal.tsxfeed/[id]/page.tsx에서 isImageUrl()은 확장자만 검사하고(.jpg/.png/...) feed.artifactUrl을 그대로 <Image src={feed.artifactUrl} />에 넣고 있어요.
  • next.config.tsimages.remotePatterns는 특정 호스트들만 허용하지만, 아티팩트 URL 호스트가 그 목록 밖이면 Next <Image>가 src를 거부해 렌더 실패가 날 수 있습니다.
  • 두 화면 모두 같은 분기(artifactUrl<Image> vs <a>) 로직이 중복되어 있어 공통 유틸로 “허용 호스트 + 이미지 판별 + fallback(허용 X면 <a> 처리)”을 묶는 편이 안전합니다.
  • 커뮤니티 쪽에는 허용 호스트 기반 가드(COMMUNITY_ALLOWED_IMAGE_HOSTS)가 존재하지만, 아티팩트 경로에서는 재사용되지 않습니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/app/`(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-builder-feed-detail-modal.tsx
around lines 25 - 27, The current isImageUrl(url: string) in
lesson-builder-feed-detail-modal.tsx only checks file extensions and lets
feed.artifactUrl flow directly into Next <Image>, which can fail if the host is
not allowed by next.config.remotePatterns; create a shared utility (e.g.,
isAllowedImageUrl or validateArtifactImage) that (1) checks extension (reuse
isImageUrl), (2) verifies the URL host against the existing
COMMUNITY_ALLOWED_IMAGE_HOSTS list, and (3) returns a normalized result/flag so
both lesson-builder-feed-detail-modal.tsx and feed/[id]/page.tsx can use it to
conditionally render <Image src=...> only when allowed and otherwise render a
safe <a> fallback; update both components to call this util instead of using
isImageUrl directly.

Comment thread src/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsx
Comment thread src/app/(landing)/class/[slug]/(learning)/_components/feed-tab.tsx
Comment thread src/app/(landing)/class/[slug]/(learning)/_components/qna-tab.tsx
Comment thread src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx Outdated
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