diff --git a/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx index b6cfb563a..a13cf9b54 100644 --- a/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx +++ b/src/app/(class-lesson)/class/[slug]/lesson/[id]/_components/lesson-qna-submission-modal.tsx @@ -4,6 +4,7 @@ import { ImagePlus, Info, X } from 'lucide-react'; import Image from 'next/image'; import { useEffect, useRef, useState } from 'react'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; +import { normalizeImageFileForUpload } from '@/components/common/ui/editor/image-utils'; import MarkdownEditor from '@/components/common/ui/editor/markdown-editor'; import { uploadCommunityMarkdownImage } from '@/features/community/model/community-markdown-image-upload'; import { useCreateLessonQna } from '@/hooks/queries/course/course-api'; @@ -89,9 +90,10 @@ export function LessonQnaSubmissionModal({ } setIsUploadingImage(true); try { - const publicUrl = await uploadCommunityMarkdownImage(file); + const normalizedFile = await normalizeImageFileForUpload(file); + const publicUrl = await uploadCommunityMarkdownImage(normalizedFile); const key = new URL(publicUrl).pathname.slice(1); - const previewUrl = URL.createObjectURL(file); + const previewUrl = URL.createObjectURL(normalizedFile); setImages((prev) => [...prev, { previewUrl, key }]); } catch { showToast('이미지 업로드에 실패했습니다.', 'error'); diff --git a/src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx b/src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx index 3c4c8040b..cced6259a 100644 --- a/src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx +++ b/src/app/(landing)/class/[slug]/(learning)/feed/[id]/page.tsx @@ -15,10 +15,7 @@ import { FeedShareIcon, } from '@/components/common/ui/icons/course-icons'; import MarkdownContentCore from '@/components/common/ui/rich-text/markdown-content-core'; -import { - ROLE_LABELS, - RoleBadge, -} from '@/components/pages/class/utils/builder-feed-utils'; +import { RoleBadge } from '@/components/pages/class/utils/builder-feed-utils'; import { useAuth } from '@/features/auth/model/use-auth'; import { useCreateFeedComment, @@ -30,6 +27,7 @@ import { useToggleFeedLike, } from '@/hooks/queries/course/course-api'; import { useToastStore } from '@/stores/use-toast-store'; +import { stripHtml } from '@/utils/markdown-content-text'; function isImageUrl(url: string): boolean { return /\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(url); @@ -691,7 +689,7 @@ export default function FeedDetailPage({

- {f.content} + {stripHtml(f.content)}

diff --git a/src/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsx b/src/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsx index cdf2fbc56..33254f16f 100644 --- a/src/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsx +++ b/src/app/(landing)/class/[slug]/(learning)/qa/[id]/page.tsx @@ -16,6 +16,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { use, useRef, useState } from 'react'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; +import { normalizeImageFileForUpload } from '@/components/common/ui/editor/image-utils'; import MarkdownEditor from '@/components/common/ui/editor/markdown-editor'; import { RoleBadge } from '@/components/pages/class/utils/builder-feed-utils'; import { useAuth } from '@/features/auth/model/use-auth'; @@ -34,16 +35,13 @@ import { import { useToastStore } from '@/stores/use-toast-store'; import { AUTH_ROLE_IDS } from '@/types/auth/domain'; import { analyzeError } from '@/utils/error-handler'; +import { stripHtml } from '@/utils/markdown-content-text'; function formatDate(dateStr: string) { const d = new Date(dateStr); return `${d.getMonth() + 1}월 ${d.getDate()}일`; } -function stripHtml(html: string): string { - return html.replace(/<[^>]*>/g, '').trim(); -} - function HtmlContent({ html }: { html: string }) { const isHtml = /<[a-z]/i.test(html); @@ -268,9 +266,10 @@ export default function QnaDetailPage({ } setIsUploadingAnswerImage(true); try { - const publicUrl = await uploadCommunityMarkdownImage(file); + const normalizedFile = await normalizeImageFileForUpload(file); + const publicUrl = await uploadCommunityMarkdownImage(normalizedFile); const key = new URL(publicUrl).pathname.slice(1); - const previewUrl = URL.createObjectURL(file); + const previewUrl = URL.createObjectURL(normalizedFile); setAnswerImages((prev) => [...prev, { previewUrl, key }]); } catch { showToast('이미지 업로드에 실패했습니다.', 'error'); diff --git a/src/components/common/ui/editor/image-utils.ts b/src/components/common/ui/editor/image-utils.ts index 8c9a1aa01..2eb60e6c6 100644 --- a/src/components/common/ui/editor/image-utils.ts +++ b/src/components/common/ui/editor/image-utils.ts @@ -37,15 +37,6 @@ export const MARKDOWN_IMAGE_DEFAULT_ALLOWED_EXTENSIONS = [ 'heif', ] as const; -const IMAGE_MIME_TO_EXT = { - 'image/gif': 'gif', - 'image/heic': 'heic', - 'image/heif': 'heif', - 'image/jpeg': 'jpg', - 'image/png': 'png', - 'image/webp': 'webp', -} as const; - const IMAGE_MIME_TO_EXTS = { 'image/gif': ['gif'], 'image/heic': ['heic'], @@ -123,7 +114,9 @@ const normalizeImageMimeType = ( export const getExtensionFromMime = (mimeType: string): string => { const normalizedMimeType = normalizeImageMimeType(mimeType); return ( - (normalizedMimeType ? IMAGE_MIME_TO_EXT[normalizedMimeType] : undefined) ?? + (normalizedMimeType + ? IMAGE_MIME_TO_EXTS[normalizedMimeType][0] + : undefined) ?? mimeType.split('/')[1]?.toLowerCase() ?? '' ); @@ -245,20 +238,17 @@ const convertHeicImageFileToJpeg = async (file: File) => { }; export const normalizeImageFileForUpload = async (file: File) => { - const detectedMimeType = detectImageMimeTypeFromHeader( - new Uint8Array(await file.slice(0, IMAGE_HEADER_BYTE_LENGTH).arrayBuffer()), + const headerBytes = new Uint8Array( + await file.slice(0, IMAGE_HEADER_BYTE_LENGTH).arrayBuffer(), ); + const detectedMimeType = detectImageMimeTypeFromHeader(headerBytes); const reportedMimeType = normalizeImageMimeType(file.type); + const resolvedMimeType = detectedMimeType ?? reportedMimeType; - if (isHeicLikeImageMimeType(detectedMimeType)) { - return convertHeicImageFileToJpeg(file); - } - - if (!detectedMimeType && isHeicLikeImageMimeType(reportedMimeType)) { + if (isHeicLikeImageMimeType(resolvedMimeType)) { return convertHeicImageFileToJpeg(file); } - const resolvedMimeType = detectedMimeType ?? reportedMimeType; if (!resolvedMimeType) { return file; } @@ -273,10 +263,7 @@ export const normalizeImageFileForUpload = async (file: File) => { return new File( [file], replaceFileExtension(file.name, getExtensionFromMime(resolvedMimeType)), - { - type: resolvedMimeType, - lastModified: file.lastModified, - }, + { type: resolvedMimeType, lastModified: file.lastModified }, ); }; @@ -298,8 +285,13 @@ export const getImageFileNormalizationErrorMessage = ( * toImageInputAccept(['jpg', 'png', 'webp']) // '.jpg,.png,.webp' */ export const toImageInputAccept = (extensions: readonly string[]) => { - const mimeTypes = Object.entries(IMAGE_MIME_TO_EXT) - .filter(([, ext]) => extensions.includes(ext)) + const mimeTypes = ( + Object.entries(IMAGE_MIME_TO_EXTS) as [ + SupportedImageMimeType, + readonly string[], + ][] + ) + .filter(([, exts]) => exts.some((ext) => extensions.includes(ext))) .map(([mime]) => mime); const extensionParts = extensions.map((extension) => `.${extension}`);