Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
628a971
[qa] fix : QA 버그 12종 일괄 수정
HA-SEUNG-JEONG May 21, 2026
d6dcef8
[qa] fix : 필터 칩 inactive 색상 Figma 정확히 맞춤 (#999)
HA-SEUNG-JEONG May 21, 2026
b08ac5c
[qa] fix : 피드 필터 칩 Figma 정합 — 운영자 PICK 제거 및 radius 수정
HA-SEUNG-JEONG May 21, 2026
37a2344
[qa] fix : 운영자 PICK 피드 존재 시에만 칩 조건부 노출
HA-SEUNG-JEONG May 21, 2026
676bd65
[qa] fix : useAuth roleIds 타입 오류 수정
HA-SEUNG-JEONG May 21, 2026
e86f752
[qa] fix : 돌아보기 제출 이미지/링크가 빌더 피드 상세에서 표시되지 않던 버그 수정
HA-SEUNG-JEONG May 21, 2026
01b1b34
[qa] feat : 빌더 피드 댓글 대댓글 UI 및 ⋮ 메뉴 추가
HA-SEUNG-JEONG May 21, 2026
c7d1563
[qa] fix : QnA 답변 평문 콘텐츠 줄바꿈 미적용 버그 수정
HA-SEUNG-JEONG May 21, 2026
91b41e6
[qa] fix : 빌더 피드 코드블록 렌더링 버그 수정 — TipTap HTML을 marked 없이 직접 sanitize
HA-SEUNG-JEONG May 21, 2026
56c4eaf
[qa] fix : 빌더 피드 상세 페이지 닉네임-뱃지 간격 적용
HA-SEUNG-JEONG May 21, 2026
725805b
[qa] fix : 마지막 레슨 제출 CTA를 완주 축하 페이지 이동 버튼으로 변경
HA-SEUNG-JEONG May 21, 2026
704cb53
[qa] fix : [qa] chore : lucide-react 아이콘 대체 금지 룰 및 답변완료 아이콘 에셋 추가
HA-SEUNG-JEONG May 21, 2026
9b68472
[qa] fix : [qa] style : QnA 리스트 배지 Figma 정합 — pill border, 답변완료 그린 배지…
HA-SEUNG-JEONG May 21, 2026
5c58fb5
[qa] fix : [qa] chore : R2 remotePatterns 와일드카드 적용 및 QnA 배지 색상 토큰 추가
HA-SEUNG-JEONG May 21, 2026
eb21dac
[qa] fix : QnA XSS sanitize 누락, 운영진 role 판별 오류, 필터 상태 불일치 수정
HA-SEUNG-JEONG May 21, 2026
db30d83
[qa] fix : [qa] style : dangerouslySetInnerHTML biome-ignore 주석 제거
HA-SEUNG-JEONG May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .claude/rules/no-lucide-icon-substitution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# No lucide-react Icon Substitution for Design Assets

## Rule

When a Figma design specifies a custom icon (non-standard shape, brand icon, or project-specific glyph), **do NOT substitute it with a lucide-react icon**.

## What This Means

If the designer used a custom SVG icon that isn't in lucide-react:

```tsx
// ❌ Wrong — lucide-react substitute when Figma has a custom icon
import { CircleCheck } from 'lucide-react';
<CircleCheck className="h-250 w-250" />

// ✅ Correct — inline SVG from Figma asset
<svg width="20" height="20" viewBox="..." fill="currentColor" aria-hidden="true">
<path d="..." />
</svg>
```

## How to Get the Real Icon

1. In Figma MCP, call `get_design_context` on the parent node
2. Find the icon child node (usually named with the icon type)
3. Call `get_screenshot` on that child node to get the asset URL
4. Download immediately: `curl -s -o public/<route>/<name>.svg "<asset-url>"`
5. Verify: `file public/<route>/<name>.svg` — must say SVG, not HTML
6. Use as inline SVG with `fill="currentColor"` for color inheritance

## When lucide-react IS Allowed

lucide-react is the standard icon library for generic UI icons (arrows, close buttons, chevrons, etc.) that have no custom Figma counterpart. Use it freely for:
- Navigation icons (ChevronLeft, ChevronRight, X, ArrowLeft, etc.)
- Form icons (Search, Eye, EyeOff, etc.)
- Generic utility icons not specified as custom assets in Figma

## The Problem This Prevents

lucide-react substitutes silently change the visual identity of design-specific icons. The designer chose a specific icon shape intentionally — substituting it breaks brand consistency.
1 change: 1 addition & 0 deletions e2e/class/builder-feed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function makeFeedDetail(): { content: BuilderFeedDetailResponse } {
lessonId: 101,
content: '피드 상세 내용입니다.',
imageUrls: [],
artifactUrl: null,
author: { memberId: 2, nickname: '테스터', role: 'STUDENT' },
likeCount: 5,
commentCount: 1,
Expand Down
3 changes: 1 addition & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,7 @@ const nextConfig: NextConfig = {
},
{
protocol: 'https',
hostname:
'uploaded-files-qa.32cd2fa416bea795bf67cbf65411103b.r2.cloudflarestorage.com',
hostname: '**.r2.cloudflarestorage.com',
pathname: '/**',
},
...(isProd
Expand Down
3 changes: 3 additions & 0 deletions public/class/qna-answered-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { Heart, MessageCircle, X } from 'lucide-react';
import { Heart, Link as LinkIcon, MessageCircle, X } from 'lucide-react';
import dynamic from 'next/dynamic';
import Image from 'next/image';

Expand All @@ -22,6 +22,10 @@ interface Props {
onClose: () => void;
}

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

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.


export function LessonBuilderFeedDetailModal({ feedId, onClose }: Props) {
const { data: feed, isLoading } = useGetBuilderFeedDetail(feedId ?? 0);

Expand Down Expand Up @@ -91,6 +95,30 @@ export function LessonBuilderFeedDetailModal({ feedId, onClose }: Props) {
</p>
</div>

{/* Artifact — retrospective screenshot or link */}
{feed.artifactUrl &&
(isImageUrl(feed.artifactUrl) ? (
<div className="relative aspect-video overflow-hidden rounded-100 bg-gray-200">
<Image
src={feed.artifactUrl}
alt="제출 스크린샷"
fill
unoptimized
className="object-contain"
/>
</div>
) : (
<a
href={feed.artifactUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-100 break-all font-designer-14m text-background-brand-default hover:underline"
>
<LinkIcon className="h-200 w-200 shrink-0" />
{feed.artifactUrl}
</a>
))}

{/* Content */}
<MarkdownContentCore content={feed.content} />

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import DOMPurify from 'dompurify';
import {
Heart,
HelpCircle,
Expand Down Expand Up @@ -67,11 +68,25 @@ function stripHtml(html: string): string {
}

function HtmlContent({ html }: { html: string }) {
const isHtml = /<[a-z]/i.test(html);

if (!isHtml) {
return (
<div className="tiptap-editor">
<div className="tiptap whitespace-pre-wrap font-designer-16r leading-relaxed text-gray-800">
{html}
</div>
</div>
);
}

const sanitized =
typeof window !== 'undefined' ? DOMPurify.sanitize(html) : html;
return (
<div className="tiptap-editor">
<div
className="tiptap font-designer-16r leading-relaxed text-gray-800"
dangerouslySetInnerHTML={{ __html: html }}
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface LessonFormProps {
artifactSubmissionRequired: boolean;
alreadySubmitted: boolean;
submitting: boolean;
isLastLesson?: boolean;
onSubmit: (data: LessonFormData) => void;
}

Expand Down Expand Up @@ -100,6 +101,7 @@ export function LessonReviewForm({
artifactSubmissionRequired,
alreadySubmitted,
submitting,
isLastLesson = false,
onSubmit,
}: LessonFormProps) {
const isQuiz = retrospectivePurpose === 'SUBJECTIVE_QUIZ';
Expand Down Expand Up @@ -348,7 +350,9 @@ export function LessonReviewForm({
? '이미 제출했어요'
: submitting
? '제출 중...'
: '제출하고 다음 Lesson 하러 가기'}
: isLastLesson
? '완주하고 축하 페이지로 가기'
: '제출하고 다음 Lesson 하러 가기'}
</button>

<LessonScreenshotModal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export function LessonScreenshotModal({ open, onClose, onConfirm }: Props) {
if (!open) return null;

async function handleFileSelect(file: File) {
const MAX_SIZE = 5 * 1024 * 1024;
if (file.size > MAX_SIZE) {
showToast(
`이미지 파일 크기는 5MB 이하만 첨부할 수 있어요. (현재: ${(file.size / 1024 / 1024).toFixed(1)}MB)`,
'error',
);
return;
}
setUploading(true);
try {
const url = await uploadCommunityMarkdownImage(file);
Expand Down
11 changes: 11 additions & 0 deletions src/app/(class-lesson)/class/[slug]/lesson/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@ export default function LessonPage({
[drawerChapters],
);

const isLastLesson = useMemo(() => {
const allLessons = drawerChapters.flatMap((c) => c.lessons);
if (allLessons.length === 0) return false;
const isLast = allLessons.at(-1)?.lessonId === lessonId;
const allOthersCompleted = allLessons.every(
(l) => l.lessonId === lessonId || l.status === 'COMPLETED',
);
return isLast && allOthersCompleted;
}, [drawerChapters, lessonId]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const alreadySubmitted = lesson?.retrospectiveSubmitted ?? false;

function toggleChapter(chapterId: number) {
Expand Down Expand Up @@ -257,6 +267,7 @@ export default function LessonPage({
}
alreadySubmitted={alreadySubmitted}
submitting={submitRetrospective.isPending}
isLastLesson={isLastLesson}
onSubmit={handleSubmit}
/>
) : null}
Expand Down
30 changes: 24 additions & 6 deletions src/app/(landing)/class/[slug]/(learning)/_components/feed-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ChevronDown } from 'lucide-react';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { cn } from '@/components/common/ui/(shadcn)/lib/utils';
import { PlanSelectionModal } from '@/components/pages/class/plan-selection-modal';
import { useAuth } from '@/features/auth/model/use-auth';
Expand Down Expand Up @@ -32,8 +32,6 @@ const SORT_API_MAP: Record<SortOption, 'LATEST' | 'POPULAR'> = {
인기순: 'POPULAR',
};

const FILTER_OPTIONS: FeedFilter[] = ['전체', '운영자 PICK', '내 피드'];

const FILTER_API_MAP: Record<FeedFilter, 'ALL' | 'OPERATOR_PICK' | 'MY'> = {
전체: 'ALL',
'운영자 PICK': 'OPERATOR_PICK',
Expand Down Expand Up @@ -65,6 +63,26 @@ export function FeedTab() {
size: PAGE_SIZE,
});

const { data: operatorPickProbe } = useGetBuilderFeeds({
courseId,
filter: 'OPERATOR_PICK',
size: 1,
});
const hasOperatorPick = (operatorPickProbe?.totalCount ?? 0) > 0;

useEffect(() => {
if (!hasOperatorPick && filter === '운영자 PICK') {
setFilter('전체');
setCurrentPage(0);
}
}, [hasOperatorPick, filter]);

const filterOptions: FeedFilter[] = [
'전체',
...(hasOperatorPick ? (['운영자 PICK'] as FeedFilter[]) : []),
'내 피드',
];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const lessonOptions = useMemo(
() =>
curriculum?.chapters.flatMap((ch) =>
Expand Down Expand Up @@ -132,16 +150,16 @@ export function FeedTab() {
<div className="mb-400 flex items-center justify-between">
{/* Filter chips */}
<div className="flex gap-225">
{FILTER_OPTIONS.map((f) => (
{filterOptions.map((f) => (
<button
key={f}
type="button"
onClick={() => handleFilterChange(f)}
className={cn(
'rounded-875 px-325 py-125 font-designer-20b transition-colors',
'rounded-full px-325 py-125 font-designer-20b transition-colors',
filter === f
? 'border border-background-brand-default text-background-brand-default'
: 'border border-gray-400 text-gray-400',
: 'border border-gray-450 text-gray-450',
)}
>
{f}
Expand Down
20 changes: 12 additions & 8 deletions src/app/(landing)/class/[slug]/(learning)/_components/qna-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ChevronDown, Search } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { cn } from '@/components/common/ui/(shadcn)/lib/utils';
import { QnaAnsweredIcon } from '@/components/common/ui/icons/course-icons';
import {
useGetCourseDetail,
useGetCourseQnas,
Expand Down Expand Up @@ -82,17 +83,17 @@ export function QnaTab() {
{/* ── Controls row ── */}
<div className="mb-500 flex flex-wrap items-center gap-200">
{/* Filter tabs */}
<div className="flex items-center gap-150">
<div className="flex items-center gap-225">
{FILTER_TABS.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setFilter(tab.value)}
className={cn(
'rounded-full border px-325 py-125 font-designer-14m transition-colors',
'rounded-full border px-325 py-125 font-designer-20b transition-colors',
filter === tab.value
? 'border-rose-500 text-rose-500'
: 'border-border-subtle text-gray-500 hover:border-gray-400',
? 'border-background-brand-default text-background-brand-default'
: 'border-gray-450 text-gray-450',
)}
>
{tab.label}
Expand Down Expand Up @@ -197,18 +198,21 @@ export function QnaTab() {
<div className="mb-200 flex items-center gap-150">
<span
className={cn(
'rounded-full border px-175 py-75 font-designer-14m',
'flex items-center gap-50 rounded-full border px-250 py-125 font-designer-14m',
q.answerStatus === 'ANSWERED'
? 'border-rose-500 text-rose-500'
? 'border-qna-answered bg-qna-answered-bg text-qna-answered'
: 'border-border-subtle text-gray-500',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
>
{q.answerStatus === 'ANSWERED' && (
<QnaAnsweredIcon className="h-250 w-250" />
)}
{q.answerStatus === 'ANSWERED' ? '답변완료' : '답변 대기'}
</span>
<span className="flex items-center gap-75 font-designer-14r text-gray-500">
<span className="flex items-center gap-75 rounded-full border border-border-subtle px-250 py-125 font-designer-14m text-gray-500">
나도 궁금해요 {q.curiousCount}
</span>
<span className="flex items-center gap-75 font-designer-14r text-gray-500">
<span className="flex items-center gap-75 rounded-full border border-border-subtle px-250 py-125 font-designer-14m text-gray-500">
유용해요 {q.usefulCount}
</span>
</div>
Expand Down
Loading
Loading