diff --git a/src/components/common/ui/editor/editor-visible-text-counter.tsx b/src/components/common/ui/editor/editor-visible-text-counter.tsx new file mode 100644 index 000000000..e4c5ec93c --- /dev/null +++ b/src/components/common/ui/editor/editor-visible-text-counter.tsx @@ -0,0 +1,29 @@ +import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; + +interface EditorVisibleTextCounterProps { + currentLength: number; + helperText?: string; + maxLength: number; +} + +export default function EditorVisibleTextCounter({ + currentLength, + helperText, + maxLength, +}: EditorVisibleTextCounterProps) { + return ( +
+ {helperText ? ( +

{helperText}

+ ) : null} +

+ {currentLength.toLocaleString()} / {maxLength.toLocaleString()} +

+
+ ); +} diff --git a/src/components/common/ui/editor/markdown-editor.tsx b/src/components/common/ui/editor/markdown-editor.tsx index d59c0dec6..d38e13e96 100644 --- a/src/components/common/ui/editor/markdown-editor.tsx +++ b/src/components/common/ui/editor/markdown-editor.tsx @@ -32,7 +32,9 @@ import { import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; import Button from '@/components/common/ui/button'; import { normalizeMarkdownContent } from '@/utils/markdown-content-normalize'; +import { getRichContentVisibleTextLength } from '@/utils/markdown-content-text'; import { hasClipboardImageHint } from './clipboard-utils'; +import EditorVisibleTextCounter from './editor-visible-text-counter'; import { InstantCodeBlockExtension, lowlight, @@ -67,6 +69,10 @@ interface MarkdownEditorProps { uploadImage?: (file: File) => Promise; normalizeContent?: (content: unknown) => string; imageConfig?: MarkdownEditorImageConfig; + visibleTextCounter?: { + helperText?: string; + maxLength: number; + }; 'aria-invalid'?: boolean; 'aria-describedby'?: string; } @@ -85,6 +91,7 @@ function MarkdownEditor({ uploadImage, normalizeContent = normalizeMarkdownContent, imageConfig, + visibleTextCounter, 'aria-invalid': ariaInvalid, 'aria-describedby': ariaDescribedBy, }: MarkdownEditorProps) { @@ -97,6 +104,9 @@ function MarkdownEditor({ const editorRef = useRef(null); const isInternalUpdate = useRef(false); const normalizedValue = normalizeContent(value); + const currentVisibleTextLength = useMemo(() => { + return getRichContentVisibleTextLength(normalizedValue); + }, [normalizedValue]); /** * 유효한 에디터 인스턴스를 반환합니다. @@ -705,6 +715,14 @@ function MarkdownEditor({ )} + {visibleTextCounter ? ( + + ) : null} + {imageInsertError && (

{imageInsertError} diff --git a/src/components/common/ui/rich-text/markdown-editor-core.tsx b/src/components/common/ui/rich-text/markdown-editor-core.tsx index 9cfab9468..e8064de78 100644 --- a/src/components/common/ui/rich-text/markdown-editor-core.tsx +++ b/src/components/common/ui/rich-text/markdown-editor-core.tsx @@ -43,6 +43,7 @@ import { } from 'react'; import { cn } from '@/components/common/ui/(shadcn)/lib/utils'; import Button from '@/components/common/ui/button'; +import EditorVisibleTextCounter from '@/components/common/ui/editor/editor-visible-text-counter'; import { MARKDOWN_IMAGE_DEFAULT_WIDTH, MARKDOWN_IMAGE_MAX_WIDTH, @@ -64,6 +65,7 @@ import { extractImageUrls, getFileExtension, } from '@/lib/rich-text/markdown-utils'; +import { getRichContentVisibleTextLength } from '@/utils/markdown-content-text'; const lowlight = createLowlight(common); lowlight.register('kotlin', kotlin); @@ -137,6 +139,10 @@ export interface MarkdownEditorCoreProps { value: string; onChange: (next: string) => void; placeholder?: string; + visibleTextCounter?: { + helperText?: string; + maxLength: number; + }; allowedImageExtensions: readonly string[]; maxImageCount: number; maxImageFileSize: number; @@ -162,6 +168,7 @@ function MarkdownEditorCore({ value, onChange, placeholder, + visibleTextCounter, allowedImageExtensions, maxImageCount, maxImageFileSize, @@ -177,6 +184,9 @@ function MarkdownEditorCore({ (renderCount: number) => renderCount + 1, 0, ); + const currentVisibleTextLength = useMemo(() => { + return getRichContentVisibleTextLength(value); + }, [value]); const allowedExtensionsLabel = useMemo(() => { return allowedImageExtensions.join('/'); @@ -753,6 +763,14 @@ function MarkdownEditorCore({ )} + {visibleTextCounter ? ( + + ) : null} + {imageInsertError && (

{imageInsertError} diff --git a/src/features/community/model/use-community-mutation.ts b/src/features/community/model/use-community-mutation.ts index f35423ab3..cb3b46986 100644 --- a/src/features/community/model/use-community-mutation.ts +++ b/src/features/community/model/use-community-mutation.ts @@ -295,8 +295,8 @@ export const useCreateCommunityPostMutation = () => { idempotencyKey: Parameters[1]; }) => createCommunityPost(request, idempotencyKey), onError: () => {}, - onSuccess: async () => { - await invalidateCommunityFeedQueries(queryClient); + onSuccess: () => { + invalidateCommunityFeedQueries(queryClient).catch(() => {}); }, }); }; @@ -315,11 +315,11 @@ export const useUpdateCommunityPostMutation = () => { request: Parameters[2]; }) => updateCommunityPost(postId, revision, request), onError: () => {}, - onSuccess: async (_, variables) => { - await Promise.all([ + onSuccess: (_, variables) => { + Promise.all([ invalidateCommunityFeedQueries(queryClient), invalidateCommunityPostQueries(queryClient, variables.postId), - ]); + ]).catch(() => {}); }, }); }; diff --git a/src/features/community/model/use-community-qna-mutation.ts b/src/features/community/model/use-community-qna-mutation.ts index 4395547fa..bbf7f6246 100644 --- a/src/features/community/model/use-community-qna-mutation.ts +++ b/src/features/community/model/use-community-qna-mutation.ts @@ -72,14 +72,14 @@ export const useCreateCommunityQnaQuestionMutation = () => { mapCommunityQnaQuestionDetail, ), onError: () => {}, - onSuccess: async (response) => { - await Promise.all([ + onSuccess: (response) => { + Promise.all([ invalidateCommunityQnaQuestionListQueries(queryClient), invalidateCommunityQnaQuestionAggregateQueries( queryClient, response.id, ), - ]); + ]).catch(() => {}); }, }); }; @@ -101,14 +101,14 @@ export const useUpdateCommunityQnaQuestionMutation = () => { mapCommunityQnaQuestionDetail, ), onError: () => {}, - onSuccess: async (_, variables) => { - await Promise.all([ + onSuccess: (_, variables) => { + Promise.all([ invalidateCommunityQnaQuestionListQueries(queryClient), invalidateCommunityQnaQuestionAggregateQueries( queryClient, variables.questionId, ), - ]); + ]).catch(() => {}); }, }); }; diff --git a/src/features/community/model/use-community-qna-question-write-controller.ts b/src/features/community/model/use-community-qna-question-write-controller.ts index 1aa880f7c..b23d152e9 100644 --- a/src/features/community/model/use-community-qna-question-write-controller.ts +++ b/src/features/community/model/use-community-qna-question-write-controller.ts @@ -196,7 +196,6 @@ export const useCommunityQnaQuestionWriteController = ({ request, }); - showToast('질문을 수정했습니다.'); router.push(buildCommunityQuestionHref(updatedQuestion.id, returnPage)); return; @@ -207,7 +206,6 @@ export const useCommunityQnaQuestionWriteController = ({ idempotencyKey: createCommunityQnaIdempotencyKey('community-question'), }); - showToast('질문을 등록했습니다.'); router.push(buildCommunityQuestionHref(createdQuestion.id, returnPage)); } catch (error) { const contentErrorMessage = getCommunityWriteContentErrorMessage(error); diff --git a/src/features/community/model/use-community-write-controller.ts b/src/features/community/model/use-community-write-controller.ts index 43370a1ad..87c87c81a 100644 --- a/src/features/community/model/use-community-write-controller.ts +++ b/src/features/community/model/use-community-write-controller.ts @@ -232,7 +232,6 @@ export const useCommunityWriteController = ({ }, }); - showToast('글을 수정했습니다.'); router.push(buildCommunityPostHref(updatedPost.postId, returnPage)); return; @@ -248,7 +247,6 @@ export const useCommunityWriteController = ({ createCommunityQnaIdempotencyKey('community-question'), }); - showToast('질문을 등록했습니다.'); router.push(buildCommunityQuestionHref(createdQuestion.id, returnPage)); return; @@ -263,7 +261,6 @@ export const useCommunityWriteController = ({ idempotencyKey: createCommunityIdempotencyKey('community-post'), }); - showToast('글을 등록했습니다.'); router.push(buildCommunityPostHref(createdPost.postId, returnPage)); } catch (error) { const contentErrorMessage = getCommunityWriteContentErrorMessage(error); diff --git a/src/features/community/ui/community-markdown-editor.tsx b/src/features/community/ui/community-markdown-editor.tsx index a2ca45724..d3de96daa 100644 --- a/src/features/community/ui/community-markdown-editor.tsx +++ b/src/features/community/ui/community-markdown-editor.tsx @@ -17,6 +17,10 @@ interface CommunityMarkdownEditorProps { value: string; onChange: (next: string) => void; placeholder?: string; + visibleTextCounter?: { + helperText?: string; + maxLength: number; + }; requestImageUploadTicket?: MarkdownEditorCoreProps['requestImageUploadTicket']; uploadImageFile?: MarkdownEditorCoreProps['uploadImageFile']; } @@ -25,6 +29,7 @@ export default function CommunityMarkdownEditor({ value, onChange, placeholder, + visibleTextCounter, requestImageUploadTicket = requestCommunityMarkdownImageUploadTicket, uploadImageFile = uploadCommunityMarkdownImageFile, }: CommunityMarkdownEditorProps) { @@ -33,6 +38,7 @@ export default function CommunityMarkdownEditor({ value={value} onChange={onChange} placeholder={placeholder ?? '글 내용을 작성해 주세요.'} + visibleTextCounter={visibleTextCounter} allowedImageExtensions={COMMUNITY_MARKDOWN_ALLOWED_IMAGE_EXTENSIONS} maxImageCount={COMMUNITY_MARKDOWN_MAX_IMAGE_COUNT} maxImageFileSize={COMMUNITY_MARKDOWN_MAX_IMAGE_FILE_SIZE} diff --git a/src/features/community/ui/community-meta-badge.test.ts b/src/features/community/ui/community-meta-badge.test.ts new file mode 100644 index 000000000..b0636a1b3 --- /dev/null +++ b/src/features/community/ui/community-meta-badge.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { COMMUNITY_MEMBER_ROLE } from '@/types/community/domain'; +import { getCommunityRoleMeta } from './community-meta-badge'; + +describe('getCommunityRoleMeta', () => { + it('keeps developer members on the developer badge', () => { + expect(getCommunityRoleMeta(COMMUNITY_MEMBER_ROLE.DEVELOPER)).toEqual({ + color: 'blue', + label: '개발자', + }); + }); + + it('shows mentors with the same badge as developers', () => { + expect(getCommunityRoleMeta(COMMUNITY_MEMBER_ROLE.MENTOR)).toEqual({ + color: 'blue', + label: '개발자', + }); + }); + + it('treats non-developer roles as newcomer badges', () => { + expect(getCommunityRoleMeta(COMMUNITY_MEMBER_ROLE.NEWCOMER)).toEqual({ + color: 'gray', + label: 'IT문자', + }); + expect(getCommunityRoleMeta(COMMUNITY_MEMBER_ROLE.UNKNOWN)).toEqual({ + color: 'gray', + label: 'IT문자', + }); + }); +}); diff --git a/src/features/community/ui/community-meta-badge.tsx b/src/features/community/ui/community-meta-badge.tsx index 2db0362f8..c7e7e433e 100644 --- a/src/features/community/ui/community-meta-badge.tsx +++ b/src/features/community/ui/community-meta-badge.tsx @@ -48,8 +48,9 @@ const BOARD_META: Record< export const getCommunityBoardMeta = (board: CommunityPostBoard) => BOARD_META[board]; -const ROLE_META: Record< - CommunityMemberRole, +const COMMUNITY_ROLE_BADGE_META: Record< + | typeof COMMUNITY_MEMBER_ROLE.NEWCOMER + | typeof COMMUNITY_MEMBER_ROLE.DEVELOPER, { color: BadgeColor; label: string } > = { [COMMUNITY_MEMBER_ROLE.NEWCOMER]: { @@ -60,18 +61,17 @@ const ROLE_META: Record< color: 'blue', label: '개발자', }, - [COMMUNITY_MEMBER_ROLE.MENTOR]: { - color: 'orange', - label: '멘토', - }, - [COMMUNITY_MEMBER_ROLE.UNKNOWN]: { - color: 'gray', - label: '사용자', - }, } as const; +const COMMUNITY_DEVELOPER_ROLE_SET = new Set([ + COMMUNITY_MEMBER_ROLE.DEVELOPER, + COMMUNITY_MEMBER_ROLE.MENTOR, +]); + export const getCommunityRoleMeta = (role: CommunityMemberRole) => - ROLE_META[role]; + COMMUNITY_DEVELOPER_ROLE_SET.has(role) + ? COMMUNITY_ROLE_BADGE_META[COMMUNITY_MEMBER_ROLE.DEVELOPER] + : COMMUNITY_ROLE_BADGE_META[COMMUNITY_MEMBER_ROLE.NEWCOMER]; export function CommunityBoardBadge({ board, diff --git a/src/features/community/ui/community-qna-answer-compose-section.tsx b/src/features/community/ui/community-qna-answer-compose-section.tsx index 04a6932e4..6f107d502 100644 --- a/src/features/community/ui/community-qna-answer-compose-section.tsx +++ b/src/features/community/ui/community-qna-answer-compose-section.tsx @@ -10,6 +10,7 @@ import type { CommunityQnaAnswerItem, CommunityQnaQuestionViewer, } from '@/types/community/qna-domain'; +import { COMMUNITY_QNA_ANSWER_CONTENT_MAX_VISIBLE_LENGTH } from '@/types/schemas/community-qna-answer-write-schema'; import CommunityMarkdownEditor from './community-markdown-editor'; import CommunitySectionShell from './community-section-shell'; @@ -77,6 +78,9 @@ export default function CommunityQnaAnswerComposeSection({ value={field.value} onChange={field.onChange} placeholder="답변 내용을 작성해 주세요." + visibleTextCounter={{ + maxLength: COMMUNITY_QNA_ANSWER_CONTENT_MAX_VISIBLE_LENGTH, + }} /> )} /> diff --git a/src/features/community/ui/pages/community-qna-question-write-page-client.tsx b/src/features/community/ui/pages/community-qna-question-write-page-client.tsx index 788558e9a..637f315c1 100644 --- a/src/features/community/ui/pages/community-qna-question-write-page-client.tsx +++ b/src/features/community/ui/pages/community-qna-question-write-page-client.tsx @@ -12,7 +12,10 @@ import { useCommunityQnaQuestionWriteController, type CommunityQnaQuestionWriteMode, } from '@/features/community/model/use-community-qna-question-write-controller'; -import { COMMUNITY_WRITE_TITLE_MAX_LENGTH } from '@/types/schemas/community-write-schema'; +import { + COMMUNITY_WRITE_CONTENT_MAX_VISIBLE_LENGTH, + COMMUNITY_WRITE_TITLE_MAX_LENGTH, +} from '@/types/schemas/community-write-schema'; import CommunityMarkdownEditor from '../community-markdown-editor'; import CommunitySectionShell from '../community-section-shell'; @@ -131,6 +134,9 @@ export default function CommunityQnaQuestionWritePageClient({ value={field.value} onChange={field.onChange} placeholder="질문 내용을 자세히 작성해 주세요." + visibleTextCounter={{ + maxLength: COMMUNITY_WRITE_CONTENT_MAX_VISIBLE_LENGTH, + }} /> )} /> diff --git a/src/features/community/ui/pages/community-write-page-client.tsx b/src/features/community/ui/pages/community-write-page-client.tsx index cde521a50..c6bef5a26 100644 --- a/src/features/community/ui/pages/community-write-page-client.tsx +++ b/src/features/community/ui/pages/community-write-page-client.tsx @@ -14,7 +14,10 @@ import { type CommunityWriteMode, } from '@/features/community/model/use-community-write-controller'; import type { CommunityBoard } from '@/types/community/domain'; -import { COMMUNITY_WRITE_TITLE_MAX_LENGTH } from '@/types/schemas/community-write-schema'; +import { + COMMUNITY_WRITE_CONTENT_MAX_VISIBLE_LENGTH, + COMMUNITY_WRITE_TITLE_MAX_LENGTH, +} from '@/types/schemas/community-write-schema'; import CommunityMarkdownEditor from '../community-markdown-editor'; import CommunitySectionShell from '../community-section-shell'; @@ -150,6 +153,9 @@ export default function CommunityWritePageClient({ value={field.value} onChange={field.onChange} placeholder="글 내용을 작성해 주세요." + visibleTextCounter={{ + maxLength: COMMUNITY_WRITE_CONTENT_MAX_VISIBLE_LENGTH, + }} /> )} /> diff --git a/src/features/mentoring/ui/registration/markdown/mentor-markdown-editor.tsx b/src/features/mentoring/ui/registration/markdown/mentor-markdown-editor.tsx index f1fb1c021..d712a824e 100644 --- a/src/features/mentoring/ui/registration/markdown/mentor-markdown-editor.tsx +++ b/src/features/mentoring/ui/registration/markdown/mentor-markdown-editor.tsx @@ -17,12 +17,17 @@ interface MentorMarkdownEditorProps { value: string; onChange: (next: string) => void; placeholder?: string; + visibleTextCounter?: { + helperText?: string; + maxLength: number; + }; } function MentorMarkdownEditor({ value, onChange, placeholder, + visibleTextCounter, }: MentorMarkdownEditorProps) { const handleUploadImageFile = useCallback(async (file: File) => { const { name: fileName } = file; @@ -44,6 +49,7 @@ function MentorMarkdownEditor({ onChange={onChange} placeholder={placeholder} normalizeContent={normalizeMentorMarkdownContent} + visibleTextCounter={visibleTextCounter} imageConfig={{ allowedImageExtensions: MENTOR_MARKDOWN_ALLOWED_IMAGE_EXTENSIONS, maxImageCount: MENTOR_MARKDOWN_MAX_IMAGE_COUNT, diff --git a/src/features/mentoring/ui/registration/step-content/mentor-registration-description-step.tsx b/src/features/mentoring/ui/registration/step-content/mentor-registration-description-step.tsx index 48b02f182..0067b025a 100644 --- a/src/features/mentoring/ui/registration/step-content/mentor-registration-description-step.tsx +++ b/src/features/mentoring/ui/registration/step-content/mentor-registration-description-step.tsx @@ -7,6 +7,10 @@ import FormSectionCard from '@/components/common/ui/form/form-section-card'; import MentorMarkdownEditor from '@/features/mentoring/ui/registration/markdown/mentor-markdown-editor'; import FieldRequirementBadge from '@/features/mentoring/ui/registration/mentor-field-requirement-badge'; import InterviewQuestionsTextarea from '@/features/mentoring/ui/registration/mentor-interview-questions-textarea'; +import { + MENTOR_DESCRIPTION_MAX_LENGTH, + MENTOR_DESCRIPTION_MIN_LENGTH, +} from '@/types/schemas/mentor-registration-schema'; import type { MentorRegistrationDescriptionStepProps } from './mentor-registration-step-content.types'; export default function MentorRegistrationDescriptionStep({ @@ -47,6 +51,10 @@ export default function MentorRegistrationDescriptionStep({ value={mentorDescriptionField.value ?? ''} onChange={mentorDescriptionField.onChange} placeholder="멘토 소개, 전문 분야, 상담 범위를 자유롭게 작성해주세요." + visibleTextCounter={{ + helperText: `최소 ${MENTOR_DESCRIPTION_MIN_LENGTH.toLocaleString()}자`, + maxLength: MENTOR_DESCRIPTION_MAX_LENGTH, + }} />

diff --git a/src/types/schemas/community-qna-answer-write-schema.ts b/src/types/schemas/community-qna-answer-write-schema.ts index 23a24face..4e5b83001 100644 --- a/src/types/schemas/community-qna-answer-write-schema.ts +++ b/src/types/schemas/community-qna-answer-write-schema.ts @@ -1,60 +1,19 @@ import { z } from 'zod'; -import { - COMMUNITY_MARKDOWN_ALLOWED_IMAGE_EXTENSIONS_LABEL, - COMMUNITY_MARKDOWN_MAX_IMAGE_COUNT, - extractImageUrls, - hasAllowedCommunityMarkdownImageExtension, - hasMeaningfulCommunityMarkdownContent, - hasUnsafeCommunityMarkdownHtml, - isCommunityMarkdownImageUrl, -} from '@/types/community/markdown'; +import { validateCommunityWriteContent } from './community-write-schema'; + +export const COMMUNITY_QNA_ANSWER_CONTENT_MAX_VISIBLE_LENGTH = 3_000; export const communityQnaAnswerWriteSchema = z .object({ content: z.string(), }) .superRefine((values, ctx) => { - if (hasUnsafeCommunityMarkdownHtml(values.content)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: '허용되지 않는 HTML 또는 스크립트는 사용할 수 없습니다.', - }); - } - - if (!hasMeaningfulCommunityMarkdownContent(values.content)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: '본문을 입력해주세요.', - }); - } - - const imageUrls = extractImageUrls(values.content); - - if (imageUrls.length > COMMUNITY_MARKDOWN_MAX_IMAGE_COUNT) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: `이미지는 최대 ${COMMUNITY_MARKDOWN_MAX_IMAGE_COUNT}개까지 첨부할 수 있습니다.`, - }); - } - - if (!imageUrls.every(isCommunityMarkdownImageUrl)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: '이미지 주소는 업로드된 안전한 URL만 사용할 수 있습니다.', - }); - } - - if (!imageUrls.every(hasAllowedCommunityMarkdownImageExtension)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: `이미지는 ${COMMUNITY_MARKDOWN_ALLOWED_IMAGE_EXTENSIONS_LABEL} 형식만 사용할 수 있습니다.`, - }); - } + validateCommunityWriteContent({ + content: values.content, + ctx, + maxVisibleTextLength: COMMUNITY_QNA_ANSWER_CONTENT_MAX_VISIBLE_LENGTH, + maxVisibleTextMessage: `답변 본문은 보이는 글자수 기준 ${COMMUNITY_QNA_ANSWER_CONTENT_MAX_VISIBLE_LENGTH.toLocaleString()}자 이하여야 합니다.`, + }); }); export type CommunityQnaAnswerWriteFormValues = z.infer< diff --git a/src/types/schemas/community-qna-question-write-schema.ts b/src/types/schemas/community-qna-question-write-schema.ts index de19c6e46..6a3ad7c1a 100644 --- a/src/types/schemas/community-qna-question-write-schema.ts +++ b/src/types/schemas/community-qna-question-write-schema.ts @@ -1,14 +1,9 @@ import { z } from 'zod'; import { - COMMUNITY_MARKDOWN_ALLOWED_IMAGE_EXTENSIONS_LABEL, - COMMUNITY_MARKDOWN_MAX_IMAGE_COUNT, - extractImageUrls, - hasAllowedCommunityMarkdownImageExtension, - hasMeaningfulCommunityMarkdownContent, - hasUnsafeCommunityMarkdownHtml, - isCommunityMarkdownImageUrl, -} from '@/types/community/markdown'; -import { COMMUNITY_WRITE_TITLE_MAX_LENGTH } from './community-write-schema'; + COMMUNITY_WRITE_CONTENT_MAX_VISIBLE_LENGTH, + COMMUNITY_WRITE_TITLE_MAX_LENGTH, + validateCommunityWriteContent, +} from './community-write-schema'; export const communityQnaQuestionWriteSchema = z .object({ @@ -23,47 +18,12 @@ export const communityQnaQuestionWriteSchema = z content: z.string(), }) .superRefine((values, ctx) => { - if (hasUnsafeCommunityMarkdownHtml(values.content)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: '허용되지 않는 HTML 또는 스크립트는 사용할 수 없습니다.', - }); - } - - if (!hasMeaningfulCommunityMarkdownContent(values.content)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: '본문을 입력해주세요.', - }); - } - - const imageUrls = extractImageUrls(values.content); - - if (imageUrls.length > COMMUNITY_MARKDOWN_MAX_IMAGE_COUNT) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: `이미지는 최대 ${COMMUNITY_MARKDOWN_MAX_IMAGE_COUNT}개까지 첨부할 수 있습니다.`, - }); - } - - if (!imageUrls.every(isCommunityMarkdownImageUrl)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: '이미지 주소는 업로드된 안전한 URL만 사용할 수 있습니다.', - }); - } - - if (!imageUrls.every(hasAllowedCommunityMarkdownImageExtension)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: `이미지는 ${COMMUNITY_MARKDOWN_ALLOWED_IMAGE_EXTENSIONS_LABEL} 형식만 사용할 수 있습니다.`, - }); - } + validateCommunityWriteContent({ + content: values.content, + ctx, + maxVisibleTextLength: COMMUNITY_WRITE_CONTENT_MAX_VISIBLE_LENGTH, + maxVisibleTextMessage: `본문은 보이는 글자수 기준 ${COMMUNITY_WRITE_CONTENT_MAX_VISIBLE_LENGTH.toLocaleString()}자 이하여야 합니다.`, + }); }); export type CommunityQnaQuestionWriteFormValues = z.infer< diff --git a/src/types/schemas/community-write-schema.ts b/src/types/schemas/community-write-schema.ts index fc4988631..f85e83ff4 100644 --- a/src/types/schemas/community-write-schema.ts +++ b/src/types/schemas/community-write-schema.ts @@ -9,8 +9,10 @@ import { hasUnsafeCommunityMarkdownHtml, isCommunityMarkdownImageUrl, } from '@/types/community/markdown'; +import { getRichContentVisibleTextLength } from '@/utils/markdown-content-text'; export const COMMUNITY_WRITE_TITLE_MAX_LENGTH = 120; +export const COMMUNITY_WRITE_CONTENT_MAX_VISIBLE_LENGTH = 5_000; const COMMUNITY_BOARD_VALUES = [ COMMUNITY_BOARD.QNA, @@ -19,6 +21,70 @@ const COMMUNITY_BOARD_VALUES = [ COMMUNITY_BOARD.KNOWLEDGE, ] as const; +interface ValidateCommunityWriteContentParams { + content: string; + ctx: z.RefinementCtx; + maxVisibleTextLength: number; + maxVisibleTextMessage: string; +} + +export const validateCommunityWriteContent = ({ + content, + ctx, + maxVisibleTextLength, + maxVisibleTextMessage, +}: ValidateCommunityWriteContentParams) => { + if (hasUnsafeCommunityMarkdownHtml(content)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['content'], + message: '허용되지 않는 HTML 또는 스크립트는 사용할 수 없습니다.', + }); + } + + if (!hasMeaningfulCommunityMarkdownContent(content)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['content'], + message: '본문을 입력해주세요.', + }); + } + + const imageUrls = extractImageUrls(content); + + if (imageUrls.length > COMMUNITY_MARKDOWN_MAX_IMAGE_COUNT) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['content'], + message: `이미지는 최대 ${COMMUNITY_MARKDOWN_MAX_IMAGE_COUNT}개까지 첨부할 수 있습니다.`, + }); + } + + if (!imageUrls.every(isCommunityMarkdownImageUrl)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['content'], + message: '이미지 주소는 업로드된 안전한 URL만 사용할 수 있습니다.', + }); + } + + if (!imageUrls.every(hasAllowedCommunityMarkdownImageExtension)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['content'], + message: `이미지는 ${COMMUNITY_MARKDOWN_ALLOWED_IMAGE_EXTENSIONS_LABEL} 형식만 사용할 수 있습니다.`, + }); + } + + if (getRichContentVisibleTextLength(content) > maxVisibleTextLength) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['content'], + message: maxVisibleTextMessage, + }); + } +}; + export const communityWriteSchema = z .object({ board: z.enum(COMMUNITY_BOARD_VALUES), @@ -33,47 +99,12 @@ export const communityWriteSchema = z content: z.string(), }) .superRefine((values, ctx) => { - if (hasUnsafeCommunityMarkdownHtml(values.content)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: '허용되지 않는 HTML 또는 스크립트는 사용할 수 없습니다.', - }); - } - - if (!hasMeaningfulCommunityMarkdownContent(values.content)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: '본문을 입력해주세요.', - }); - } - - const imageUrls = extractImageUrls(values.content); - - if (imageUrls.length > COMMUNITY_MARKDOWN_MAX_IMAGE_COUNT) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: `이미지는 최대 ${COMMUNITY_MARKDOWN_MAX_IMAGE_COUNT}개까지 첨부할 수 있습니다.`, - }); - } - - if (!imageUrls.every(isCommunityMarkdownImageUrl)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: '이미지 주소는 업로드된 안전한 URL만 사용할 수 있습니다.', - }); - } - - if (!imageUrls.every(hasAllowedCommunityMarkdownImageExtension)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['content'], - message: `이미지는 ${COMMUNITY_MARKDOWN_ALLOWED_IMAGE_EXTENSIONS_LABEL} 형식만 사용할 수 있습니다.`, - }); - } + validateCommunityWriteContent({ + content: values.content, + ctx, + maxVisibleTextLength: COMMUNITY_WRITE_CONTENT_MAX_VISIBLE_LENGTH, + maxVisibleTextMessage: `본문은 보이는 글자수 기준 ${COMMUNITY_WRITE_CONTENT_MAX_VISIBLE_LENGTH.toLocaleString()}자 이하여야 합니다.`, + }); }); export type CommunityWriteFormValues = z.infer; diff --git a/src/types/schemas/mentor-registration-schema.ts b/src/types/schemas/mentor-registration-schema.ts index 502939a11..2134b0cc0 100644 --- a/src/types/schemas/mentor-registration-schema.ts +++ b/src/types/schemas/mentor-registration-schema.ts @@ -18,6 +18,7 @@ import { MENTOR_CAREER_ENTRY_MAX_COUNT, WEEKDAY_KEYS, } from '@/types/mentoring/settings'; +import { getRichContentVisibleTextLength } from '@/utils/markdown-content-text'; export const MENTORING_TITLE_MIN_LENGTH = 10; export const MENTORING_TITLE_MAX_LENGTH = 40; @@ -29,7 +30,7 @@ export const CAREER_ENTRY_MAX_COUNT = MENTOR_CAREER_ENTRY_MAX_COUNT; export const MAJOR_HISTORY_ENTRY_MAX_LENGTH = 60; export const SCHEDULE_DAY_MAX_SLOT_COUNT = 48; export const MENTOR_DESCRIPTION_MIN_LENGTH = 30; -export const MENTOR_DESCRIPTION_MAX_LENGTH = 30_000; +export const MENTOR_DESCRIPTION_MAX_LENGTH = 5_000; export const INTERVIEW_QUESTION_MIN_LENGTH = 8; export const INTERVIEW_QUESTION_MAX_LENGTH = 120; export const INTERVIEW_QUESTION_MAX_COUNT = 8; @@ -356,14 +357,7 @@ export const mentorRegistrationSchema = z scheduleDrafts: scheduleDraftsSchema.default( createEmptyMentorScheduleDrafts, ), - detailedDescription: z - .string() - .trim() - .max( - MENTOR_DESCRIPTION_MAX_LENGTH, - `멘토 소개는 ${MENTOR_DESCRIPTION_MAX_LENGTH.toLocaleString()}자 이하로 입력해주세요.`, - ) - .default(''), + detailedDescription: z.string().trim().default(''), interviewQuestions: z .array( z @@ -392,6 +386,9 @@ export const mentorRegistrationSchema = z const normalizedDescription = normalizeMentorMarkdownContent( values.detailedDescription, ); + const mentorDescriptionVisibleTextLength = getRichContentVisibleTextLength( + normalizedDescription, + ); const validateEnabledPriceRange = ({ enabled, price, @@ -422,12 +419,13 @@ export const mentorRegistrationSchema = z if ( normalizedDescription.length > 0 && - normalizedDescription.length < MENTOR_DESCRIPTION_MIN_LENGTH + (mentorDescriptionVisibleTextLength < MENTOR_DESCRIPTION_MIN_LENGTH || + mentorDescriptionVisibleTextLength > MENTOR_DESCRIPTION_MAX_LENGTH) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['detailedDescription'], - message: `멘토 소개는 ${MENTOR_DESCRIPTION_MIN_LENGTH}자 이상 입력해주세요.`, + message: `멘토 소개는 보이는 글자수 기준 ${MENTOR_DESCRIPTION_MIN_LENGTH.toLocaleString()}자 이상 ${MENTOR_DESCRIPTION_MAX_LENGTH.toLocaleString()}자 이하여야 합니다.`, }); }