Skip to content

Commit 74e5897

Browse files
authored
feat: Implement Korean profanity filter for words theme (#39)
* chore: Add korcen Korean profanity filter library for content moderation - Detect various Korean slang patterns (syllable separation, special chars) - Prevent sensitive content from being stored in Git history * feat: Add type and constant for WordsTheme * feat: Refactor theme validation utility to use korcen filter - Replace static word lists with korcen's pattern detection - Keep basic validation rules (length, chars, numbers) - Add real-time validation feedback - Improve validation message handling * feat: Improve theme validation with korcen and add new logic(state, type, ui etc) for user feedback - Add real-time input validation with error handling - Improve validation message display with visual feedback - Update input field styles based on validation state - Add clear validation rules display in UI
1 parent a8b3c4a commit 74e5897

File tree

7 files changed

+165
-33
lines changed

7 files changed

+165
-33
lines changed

client/src/components/room-setting/Setting.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { HTMLAttributes, memo, useCallback, useEffect, useState } from 'react';
22
import { RoomSettings } from '@troublepainter/core';
33
import { SettingContent } from '@/components/room-setting/SettingContent';
4-
import { WordsThemeModalContentContent } from '@/components/room-setting/WordsThemeModalContent';
4+
import { WordsThemeModalContent } from '@/components/room-setting/WordsThemeModalContent';
55
import { Button } from '@/components/ui/Button';
66
import { Modal } from '@/components/ui/Modal';
77
import { SHORTCUT_KEYS } from '@/constants/shortcutKeys';
@@ -93,7 +93,7 @@ const Setting = memo(({ className, ...props }: HTMLAttributes<HTMLDivElement>) =
9393
handleKeyDown={handleKeyDown} // handleKeyDown 추가
9494
className="min-w-72 max-w-lg"
9595
>
96-
<WordsThemeModalContentContent isModalOpened={isModalOpened} closeModal={closeModal} />
96+
<WordsThemeModalContent isModalOpened={isModalOpened} closeModal={closeModal} />
9797
</Modal>
9898
<SettingContent
9999
settings={ROOM_SETTINGS}
Lines changed: 110 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,155 @@
1-
import { useEffect, useState } from 'react';
1+
import { useEffect, useState, useCallback } from 'react';
22
import { Button } from '@/components/ui/Button';
33
import { Input } from '@/components/ui/Input';
44
import { gameSocketHandlers } from '@/handlers/socket/gameSocket.handler';
55
import { useGameSocketStore } from '@/stores/socket/gameSocket.store';
6+
import { useToastStore } from '@/stores/toast.store';
7+
import { WordsThemeValidationMessage } from '@/types/wordsTheme.types';
8+
import { cn } from '@/utils/cn';
9+
import { validateWordsTheme } from '@/utils/wordsThemeValidation';
610

7-
interface WordsThemeModalContentContentProps {
11+
interface WordsThemeModalContentProps {
812
isModalOpened: boolean;
913
closeModal: () => void;
1014
}
1115

12-
const WordsThemeModalContentContent = ({ isModalOpened, closeModal }: WordsThemeModalContentContentProps) => {
16+
const WordsThemeModalContent = ({ isModalOpened, closeModal }: WordsThemeModalContentProps) => {
1317
const roomSettings = useGameSocketStore((state) => state.roomSettings);
1418
const actions = useGameSocketStore((state) => state.actions);
1519
const [wordsTheme, setWordsTheme] = useState(roomSettings?.wordsTheme || '');
20+
const [validationMessages, setValidationMessages] = useState<WordsThemeValidationMessage[]>([]);
21+
const [isSubmitting, setIsSubmitting] = useState(false);
22+
const addToast = useToastStore((state) => state.actions.addToast);
1623

1724
useEffect(() => {
18-
// 모달이 열릴 때마다 현재 제시어 테마로 초기화
1925
if (isModalOpened) {
2026
setWordsTheme(roomSettings?.wordsTheme || '');
2127
}
2228
}, [isModalOpened, roomSettings?.wordsTheme]);
2329

30+
// 실시간 입력 검증
31+
const handleThemeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
32+
const value = e.target.value.replace(/\s+/g, ' ');
33+
setWordsTheme(value);
34+
setValidationMessages(validateWordsTheme(value));
35+
}, []);
36+
2437
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
2538
e.preventDefault();
26-
if (!wordsTheme.trim()) return;
39+
if (isSubmitting) return;
2740

28-
const trimmedWordsTheme = wordsTheme.trim();
41+
// 현재 validationMessages 상태를 활용하여 검증
42+
const hasErrors = validationMessages.some((msg) => msg.type === 'error');
43+
if (hasErrors || !wordsTheme.trim()) {
44+
addToast({
45+
title: '입력 오류',
46+
description: '모든 입력 조건을 만족해야 합니다.',
47+
variant: 'error',
48+
duration: 3000,
49+
});
50+
return;
51+
}
2952

30-
// 서버에 업데이트 요청
31-
await gameSocketHandlers.updateSettings({
32-
settings: { wordsTheme: trimmedWordsTheme },
33-
});
53+
try {
54+
setIsSubmitting(true);
3455

35-
// 로컬 상태 업데이트
36-
if (roomSettings) {
37-
actions.updateRoomSettings({
38-
...roomSettings,
39-
wordsTheme: trimmedWordsTheme,
56+
await gameSocketHandlers.updateSettings({
57+
settings: { wordsTheme: wordsTheme.trim() },
58+
});
59+
60+
if (roomSettings) {
61+
actions.updateRoomSettings({
62+
...roomSettings,
63+
wordsTheme: wordsTheme.trim(),
64+
});
65+
}
66+
67+
addToast({
68+
title: '테마 설정 완료',
69+
description: `제시어 테마가 '${wordsTheme.trim()}'(으)로 설정되었습니다.`,
70+
variant: 'success',
71+
duration: 2000,
4072
});
41-
}
4273

43-
closeModal();
74+
closeModal();
75+
} catch (err) {
76+
console.error(err);
77+
addToast({
78+
title: '설정 실패',
79+
description: '테마 설정 중 오류가 발생했습니다. 다시 시도해주세요.',
80+
variant: 'error',
81+
});
82+
} finally {
83+
setIsSubmitting(false);
84+
}
4485
};
4586

87+
// 제출 가능 여부 확인
88+
const isSubmitDisabled = validationMessages.some((msg) => msg.type === 'error') || !wordsTheme.trim() || isSubmitting;
89+
4690
return (
47-
<form onSubmit={(e: React.FormEvent<HTMLFormElement>) => void handleSubmit(e)} className="flex flex-col gap-3">
91+
<form onSubmit={(e: React.FormEvent<HTMLFormElement>) => void handleSubmit(e)} className="flex flex-col">
4892
<span className="text-center text-lg text-eastbay-800">
4993
게임에서 사용될 제시어의 테마를 설정해보세요!
5094
<br />
5195
<span className="text-base text-eastbay-600">예시) 동물, 음식, 직업, 캐릭터, 스포츠 등 1가지 테마 입력</span>
5296
</span>
5397

54-
<Input
55-
placeholder="동물, 음식, 직업, 캐릭터, 스포츠 등"
56-
value={wordsTheme}
57-
onChange={(e) => setWordsTheme(e.target.value)}
58-
/>
98+
<div className="space-y-2">
99+
<Input
100+
placeholder="동물, 음식, 직업, 캐릭터, 스포츠 등"
101+
value={wordsTheme}
102+
onChange={handleThemeChange}
103+
maxLength={20}
104+
disabled={isSubmitting}
105+
className={cn(
106+
validationMessages.some((msg) => msg.type === 'error') && 'border-red-500',
107+
validationMessages.some((msg) => msg.type === 'success') && 'border-green-500',
108+
)}
109+
/>
110+
111+
{/* 입력 조건 안내 */}
112+
<div className="rounded-md bg-violet-50 p-3 text-sm">
113+
<p className="font-medium">입력 조건:</p>
114+
<ul className="ml-4 list-disc space-y-1 text-eastbay-700">
115+
<li>2자 이상 20자 이하로 입력해주세요</li>
116+
<li>초성만 사용할 수 없습니다</li>
117+
<li>일부 특수문자(.,:? 등)만 사용할 수 있습니다</li>
118+
<li>부적절한 단어는 사용할 수 없습니다</li>
119+
</ul>
120+
</div>
59121

60-
{/* 입력 가이드 메시지 추가 */}
61-
<span className="text-center text-base text-eastbay-500">입력한 테마를 바탕으로 AI가 제시어를 생성합니다.</span>
122+
{/* 실시간 검증 메시지 */}
123+
<div className="space-y-1">
124+
{validationMessages.map((msg, index) => (
125+
<p
126+
key={index}
127+
className={cn(
128+
'text-sm',
129+
msg.type === 'error' && 'text-red-500',
130+
msg.type === 'warning' && 'text-yellow-600',
131+
msg.type === 'success' && 'text-green-500',
132+
)}
133+
>
134+
{msg.type === 'error' && '❌ '}
135+
{msg.type === 'warning' && '⚠️ '}
136+
{msg.type === 'success' && '✅ '}
137+
{msg.message}
138+
</p>
139+
))}
140+
</div>
141+
</div>
62142

63-
<div className="flex gap-2">
64-
<Button type="button" onClick={closeModal} variant="secondary" className="flex-1">
143+
<div className="mt-3 flex gap-2">
144+
<Button type="button" onClick={closeModal} variant="secondary" className="flex-1" disabled={isSubmitting}>
65145
취소
66146
</Button>
67-
<Button type="submit" disabled={!wordsTheme.trim()} className="flex-1">
68-
확인
147+
<Button type="submit" disabled={isSubmitDisabled} className="flex-1">
148+
{isSubmitting ? '처리 중...' : '확인'}
69149
</Button>
70150
</div>
71151
</form>
72152
);
73153
};
74154

75-
export { WordsThemeModalContentContent };
155+
export { WordsThemeModalContent };
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// 단순한 값이 아니라 배열, 정규식 등이라 const 객체 리터럴 사용
2+
export const VALIDATION_REGEX = {
3+
KOREAN_INITIAL_SOUND: /[-]/,
4+
SPECIAL_CHARS: /[!@#$%^&*()+\-=\[\]{};'"\\|<>]/,
5+
NUMBERS_ONLY: /^\d+$/,
6+
} as const;
7+
8+
// 부적절한 단어 목록은 외부 라이브러리로 대체
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface WordsThemeValidationMessage {
2+
type: 'error' | 'warning' | 'success';
3+
message: string;
4+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { check } from 'korcen';
2+
import { VALIDATION_REGEX } from '@/constants/wordsThemeValidation';
3+
import { WordsThemeValidationMessage } from '@/types/wordsTheme.types';
4+
5+
export const validateWordsTheme = (theme: string): WordsThemeValidationMessage[] => {
6+
if (!theme.trim()) {
7+
return [{ type: 'warning', message: '테마를 입력해주세요.' }];
8+
}
9+
10+
// 기본 유효성 검사
11+
const validations: Array<[boolean, string]> = [
12+
[theme.length < 2, '테마는 최소 2자 이상이어야 합니다.'],
13+
[theme.length > 20, '테마는 20자를 초과할 수 없습니다.'],
14+
[VALIDATION_REGEX.KOREAN_INITIAL_SOUND.test(theme), '초성은 테마에 사용할 수 없습니다.'],
15+
[VALIDATION_REGEX.SPECIAL_CHARS.test(theme), '일부 특수문자는 사용할 수 없습니다.'],
16+
[VALIDATION_REGEX.NUMBERS_ONLY.test(theme), '숫자로만 이루어진 테마는 사용할 수 없습니다.'],
17+
];
18+
19+
// korcen을 사용한 비속어 검사
20+
const detectedBadWord = check(theme);
21+
if (detectedBadWord) {
22+
return [{ type: 'error', message: '부적절한 단어가 포함되어 있습니다.' }];
23+
}
24+
25+
// 기본 유효성 검사 결과 처리
26+
const errors = validations
27+
.filter(([condition]) => condition)
28+
.map(([, message]) => ({ type: 'error' as const, message }));
29+
30+
return errors.length ? errors : [{ type: 'success', message: '올바른 테마입니다!' }];
31+
};

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"typedoc-plugin-extras": "^3.1.0"
2222
},
2323
"dependencies": {
24-
"@troublepainter/core": "workspace:*"
24+
"@troublepainter/core": "workspace:*",
25+
"korcen": "^0.2.4"
2526
}
2627
}

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)