diff --git a/package-lock.json b/package-lock.json index 2065998f..46c60bfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@tanstack/react-query": "^5.90.5", "@tanstack/react-query-devtools": "^5.90.2", "axios": "^1.13.1", + "badwords-ko": "^1.0.4", "clsx": "^2.1.1", "framer-motion": "^12.23.24", "jotai": "^2.15.1", @@ -6400,6 +6401,12 @@ "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, + "node_modules/badwords-ko": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/badwords-ko/-/badwords-ko-1.0.4.tgz", + "integrity": "sha512-l2gJiVkIXd1nyyRI6aXEujq+aCiMfCyREUdK68mUnH6iZy8+mvXvkvOLO6Nv6MXiRKgvdUQEmSeuVno38MX+wA==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/package.json b/package.json index c947c29b..502435ad 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@tanstack/react-query": "^5.90.5", "@tanstack/react-query-devtools": "^5.90.2", "axios": "^1.13.1", + "badwords-ko": "^1.0.4", "clsx": "^2.1.1", "framer-motion": "^12.23.24", "jotai": "^2.15.1", diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx index 342cc978..287d1b06 100644 --- a/src/app/(auth)/signup/page.tsx +++ b/src/app/(auth)/signup/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import SingUpInFormWrapper from "../_components/form_wrapper"; import { useForm } from "react-hook-form"; import { @@ -12,6 +12,7 @@ import { Button, Icon, TextInput, LoadingSpinner } from "@/components"; import { useSignupQuery } from "@/hooks/auth/use-signup-query"; import type { SignupRequest } from "@/api/auth/signup-action"; import SimpleSignUpIn from "../_components/simple-signUpIn"; +import { hasProfanity } from "@/utils/profanityFilter"; const Page = () => { const [showPassword, setShowPassword] = useState(false); @@ -57,6 +58,10 @@ const Page = () => { aria-describedby={errors.nickname ? "nickname-error" : undefined} {...register("nickname", { required: "닉네임을 입력해주세요.", + validate: { + noProfanity: (value) => + !hasProfanity(value) || "부적절한 닉네임입니다.", + }, })} /> @@ -75,6 +80,15 @@ const Page = () => { value: EMAIL_REGEX, message: "이메일 형식이 올바르지 않습니다.", }, + validate: { + noProfanityInEmail: (value) => { + const localPart = value.split("@")[0]; + return ( + !hasProfanity(localPart) || + "이메일에 부적절한 단어가 포함되어 있습니다." + ); + }, + }, })} /> diff --git a/src/app/boards/[articleId]/_components/article-comments/article-comments.tsx b/src/app/boards/[articleId]/_components/article-comments/article-comments.tsx index fc0bf951..6c820181 100644 --- a/src/app/boards/[articleId]/_components/article-comments/article-comments.tsx +++ b/src/app/boards/[articleId]/_components/article-comments/article-comments.tsx @@ -13,6 +13,7 @@ import usePatchArticleComment from "@/hooks/api/articles/use-patch-article-comme import useDeleteArticleComment from "@/hooks/api/articles/use-delete-article-comment"; import LikeButton from "@/components/lottie/LikeButton"; import DefaultProfile from "@/assets/icons/ic-user.svg"; +import { filterProfanity } from "@/utils/profanityFilter"; interface ArticleCommentsProps { article: Article; @@ -61,10 +62,12 @@ const ArticleComments = ({ article }: ArticleCommentsProps) => { }, [fetchNextPage, hasNextPage, isFetchingNextPage]); const handleCommentSubmit = (content: string) => { + const filteredContent = filterProfanity(content); + mutate({ articleId: article.id, data: { - content, + content: filteredContent, }, }); }; @@ -135,9 +138,10 @@ const ArticleComments = ({ article }: ArticleCommentsProps) => {
- patchComment({ commentId, content }) - } + onEdit={(commentId, content) => { + const filteredContent = filterProfanity(content); + patchComment({ commentId, content: filteredContent }); + }} onDelete={(commentId) => deleteComment({ commentId, articleId: article.id }) } diff --git a/src/app/boards/[articleId]/edit/_components/article-edit-contents/article-edit-contents.tsx b/src/app/boards/[articleId]/edit/_components/article-edit-contents/article-edit-contents.tsx index f9fc0d23..db5f54ea 100644 --- a/src/app/boards/[articleId]/edit/_components/article-edit-contents/article-edit-contents.tsx +++ b/src/app/boards/[articleId]/edit/_components/article-edit-contents/article-edit-contents.tsx @@ -12,6 +12,7 @@ import { } from "@/components/index"; import usePatchArticle from "@/hooks/api/articles/use-patch-article"; import { Article } from "@/types/article"; +import { filterProfanity } from "@/utils/profanityFilter"; interface ArticleEditContentsProps { article: Article; @@ -44,12 +45,15 @@ const ArticleEditContents = ({ article }: ArticleEditContentsProps) => { }, []); const onSubmit = (data: EditFormData) => { + const filteredTitle = filterProfanity(data.title); + const filteredContent = filterProfanity(data.content); + mutate( { articleId: article.id, data: { - title: data.title, - content: data.content, + title: filteredTitle, + content: filteredContent, image: images[0] || undefined, }, }, diff --git a/src/app/boards/page.tsx b/src/app/boards/page.tsx index 534de692..e2faa787 100644 --- a/src/app/boards/page.tsx +++ b/src/app/boards/page.tsx @@ -24,8 +24,8 @@ const Page = () => { - diff --git a/src/app/boards/write/_components/article-write-contents/article-write-contents.tsx b/src/app/boards/write/_components/article-write-contents/article-write-contents.tsx index 474459e3..48387be3 100644 --- a/src/app/boards/write/_components/article-write-contents/article-write-contents.tsx +++ b/src/app/boards/write/_components/article-write-contents/article-write-contents.tsx @@ -11,6 +11,7 @@ import { LoadingSpinner, } from "@/components/index"; import { usePostArticle } from "@/hooks/api/articles/use-post-article"; +import { filterProfanity } from "@/utils/profanityFilter"; interface WriteFormData { title: string; @@ -32,10 +33,13 @@ const ArticleWriteContents = () => { }, []); const onSubmit = (data: WriteFormData) => { + const filteredTitle = filterProfanity(data.title); + const filteredContent = filterProfanity(data.content); + mutate( { - title: data.title, - content: data.content, + title: filteredTitle, + content: filteredContent, image: images[0] || undefined, }, { diff --git a/src/app/globals.css b/src/app/globals.css index 1b876629..4c834ba7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -27,3 +27,26 @@ ::backdrop { background-color: rgba(0, 0, 0, 0.5); } + +#ch-plugin { + bottom: 20px !important; + animation: channelFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +@media (max-width: 743px) { + #ch-plugin { + right: calc(20% + 60px) !important; + } +} + +@media (min-width: 744px) { + #ch-plugin { + right: calc(20% + 72px) !important; + } +} + +@media (min-width: 1280px) { + #ch-plugin { + right: calc(20% + 72px) !important; + } +} diff --git a/src/app/mypage/_components/user-setting-contents.tsx b/src/app/mypage/_components/user-setting-contents.tsx index 8347389c..09f26f39 100644 --- a/src/app/mypage/_components/user-setting-contents.tsx +++ b/src/app/mypage/_components/user-setting-contents.tsx @@ -17,8 +17,11 @@ import usePatchUserPassword from "@/hooks/api/user/use-patch-user-password"; import usePatchUser from "@/hooks/api/user/use-patch-user"; import { useGetUserInfoQuery } from "@/hooks/api/user/use-get-user-info-query"; import isSocialLogin from "@/utils/auth-helper"; +import { hasProfanity } from "@/utils/profanityFilter"; +import useToast from "@/hooks/use-toast"; const UserSettingContents = () => { + const toast = useToast(); const { Modal: DeleteModal, openPrompt: openDeleteModal, @@ -59,13 +62,13 @@ const UserSettingContents = () => { profileImage !== (userInfo?.image || ""); const handleSaveChanges = () => { - if (!nickname.trim()) { - return; - } - const updates: { nickname?: string; image?: string } = {}; if (nickname !== (userInfo?.nickname || "")) { + if (hasProfanity(nickname)) { + toast.error("부적절한 닉네임입니다. 닉네임을 변경해주세요."); + return; + } updates.nickname = nickname; } diff --git a/src/components/calendar-time/calendar-time.tsx b/src/components/calendar-time/calendar-time.tsx index 959eedd9..ca0342b2 100644 --- a/src/components/calendar-time/calendar-time.tsx +++ b/src/components/calendar-time/calendar-time.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button } from "@/components/index"; +import Button from "../button/button"; import { TIME_LIST } from "@/constants/time-list"; import cn from "@/utils/clsx"; import { useEffect, useState } from "react"; diff --git a/src/components/calendar/calendar.tsx b/src/components/calendar/calendar.tsx index 99637bed..93427a34 100644 --- a/src/components/calendar/calendar.tsx +++ b/src/components/calendar/calendar.tsx @@ -1,6 +1,7 @@ "use client"; -import { Button, Icon } from "@/components/index"; +import Button from "../button/button"; +import Icon from "../icon/Icon"; import cn from "@/utils/clsx"; import type { CSSProperties } from "react"; import { useState } from "react"; diff --git a/src/components/channel-talk/index.tsx b/src/components/channel-talk/index.tsx new file mode 100644 index 00000000..4a6bc8d1 --- /dev/null +++ b/src/components/channel-talk/index.tsx @@ -0,0 +1,197 @@ +declare global { + interface Window { + ChannelIO?: IChannelIO; + ChannelIOInitialized?: boolean; + } +} + +interface IChannelIO { + c?: (...args: any) => void; + q?: [methodName: string, ...args: any[]][]; + (...args: any): void; +} + +interface BootOption { + appearance?: string; + customLauncherSelector?: string; + hideChannelButtonOnBoot?: boolean; + hidePopup?: boolean; + language?: string; + memberHash?: string; + memberId?: string; + pluginKey: string; + profile?: Profile; + trackDefaultEvent?: boolean; + trackUtmSource?: boolean; + unsubscribe?: boolean; + unsubscribeEmail?: boolean; + unsubscribeTexting?: boolean; + zIndex?: number; +} + +interface Callback { + (error: Error | null, user: CallbackUser | null): void; +} + +interface CallbackUser { + alert: number; + avatarUrl: string; + id: string; + language: string; + memberId: string; + name?: string; + profile?: Profile | null; + tags?: string[] | null; + unsubscribeEmail: boolean; + unsubscribeTexting: boolean; +} + +interface UpdateUserInfo { + language?: string; + profile?: Profile | null; + profileOnce?: Profile; + tags?: string[] | null; + unsubscribeEmail?: boolean; + unsubscribeTexting?: boolean; +} + +interface Profile { + [key: string]: string | number | boolean | null | undefined; +} + +interface FollowUpProfile { + name?: string | null; + mobileNumber?: string | null; + email?: string | null; +} + +interface EventProperty { + [key: string]: string | number | boolean | null | undefined; +} + +type Appearance = "light" | "dark" | "system" | null; + +class ChannelService { + loadScript() { + (function () { + var w = window; + if (w.ChannelIO) { + return w.console.error("ChannelIO script included twice."); + } + var ch: IChannelIO = function () { + ch.c?.(arguments); + }; + ch.q = []; + ch.c = function (args) { + ch.q?.push(args); + }; + w.ChannelIO = ch; + function l() { + if (w.ChannelIOInitialized) { + return; + } + w.ChannelIOInitialized = true; + var s = document.createElement("script"); + s.type = "text/javascript"; + s.async = true; + s.src = "https://cdn.channel.io/plugin/ch-plugin-web.js"; + var x = document.getElementsByTagName("script")[0]; + if (x.parentNode) { + x.parentNode.insertBefore(s, x); + } + } + if (document.readyState === "complete") { + l(); + } else { + w.addEventListener("DOMContentLoaded", l); + w.addEventListener("load", l); + } + })(); + } + + boot(option: BootOption, callback?: Callback) { + window.ChannelIO?.("boot", option, callback); + } + + shutdown() { + window.ChannelIO?.("shutdown"); + } + + showMessenger() { + window.ChannelIO?.("showMessenger"); + } + + hideMessenger() { + window.ChannelIO?.("hideMessenger"); + } + + openChat(chatId?: string | number, message?: string) { + window.ChannelIO?.("openChat", chatId, message); + } + + track(eventName: string, eventProperty?: EventProperty) { + window.ChannelIO?.("track", eventName, eventProperty); + } + + onShowMessenger(callback: () => void) { + window.ChannelIO?.("onShowMessenger", callback); + } + + onHideMessenger(callback: () => void) { + window.ChannelIO?.("onHideMessenger", callback); + } + + onBadgeChanged(callback: (unread: number, alert: number) => void) { + window.ChannelIO?.("onBadgeChanged", callback); + } + + onChatCreated(callback: () => void) { + window.ChannelIO?.("onChatCreated", callback); + } + + onFollowUpChanged(callback: (profile: FollowUpProfile) => void) { + window.ChannelIO?.("onFollowUpChanged", callback); + } + + onUrlClicked(callback: (url: string) => void) { + window.ChannelIO?.("onUrlClicked", callback); + } + + clearCallbacks() { + window.ChannelIO?.("clearCallbacks"); + } + + updateUser(userInfo: UpdateUserInfo, callback?: Callback) { + window.ChannelIO?.("updateUser", userInfo, callback); + } + + addTags(tags: string[], callback?: Callback) { + window.ChannelIO?.("addTags", tags, callback); + } + + removeTags(tags: string[], callback?: Callback) { + window.ChannelIO?.("removeTags", tags, callback); + } + + setPage(page: string) { + window.ChannelIO?.("setPage", page); + } + + resetPage() { + window.ChannelIO?.("resetPage"); + } + + showChannelButton() { + window.ChannelIO?.("showChannelButton"); + } + + hideChannelButton() { + window.ChannelIO?.("hideChannelButton"); + } + + setAppearance(appearance: Appearance) { + window.ChannelIO?.("setAppearance", appearance); + } +} + +export default new ChannelService(); diff --git a/src/components/reply/reply.stories.tsx b/src/components/reply/reply.stories.tsx index 2c9540a8..5a6753c5 100644 --- a/src/components/reply/reply.stories.tsx +++ b/src/components/reply/reply.stories.tsx @@ -1,5 +1,14 @@ import { Meta, StoryObj } from "@storybook/nextjs"; import Reply from "./reply"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); const meta = { title: "Components/Reply", @@ -21,9 +30,11 @@ const meta = { }, decorators: [ (Story) => ( -
- -
+ +
+ +
+
), ], } satisfies Meta; diff --git a/src/components/task-card/task-card.stories.tsx b/src/components/task-card/task-card.stories.tsx index f7f51500..7a5476a9 100644 --- a/src/components/task-card/task-card.stories.tsx +++ b/src/components/task-card/task-card.stories.tsx @@ -1,5 +1,14 @@ import type { Meta, StoryObj } from "@storybook/react"; import TaskCard from "./task-card"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); const meta = { title: "Components/TaskCard", @@ -14,6 +23,13 @@ const meta = { completed: { control: "number" }, taskList: { control: "object" }, }, + decorators: [ + (Story) => ( + + + + ), + ], } satisfies Meta; export default meta; diff --git a/src/constants/custom-profanity.ts b/src/constants/custom-profanity.ts new file mode 100644 index 00000000..54b5f44d --- /dev/null +++ b/src/constants/custom-profanity.ts @@ -0,0 +1,34 @@ +/** + * 커스텀 비속어 목록 + * 영어 표기 한글 비속어 등 추가로 필터링할 단어들을 관리 + */ +export const customProfanityWords: string[] = [ + "sibal", + "shibal", + "sival", + "shival", + "ssibal", + "ssival", + "seki", + "gae", + "gaeseki", + "gaesekki", + "gaesekiya", + "gsk", + "gesseki", + "geseki", + "jot", + "johd", + "jott", + "fuck", + "shit", + "fk", + "fuk", + "fuc", + "dick", + "bitch", + "damn", + "rotoRL", + "qudtls", + "whssk", +]; diff --git a/src/providers.tsx b/src/providers.tsx index 73eeb9e9..8c494fb4 100644 --- a/src/providers.tsx +++ b/src/providers.tsx @@ -6,6 +6,9 @@ import { QueryClientProvider, } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import ChannelService from "./components/channel-talk"; function makeQueryClient() { return new QueryClient({ @@ -30,6 +33,20 @@ function getQueryClient() { const QueryProviders = ({ children }: { children: React.ReactNode }) => { const queryClient = getQueryClient(); + const pathname = usePathname(); + + useEffect(() => { + ChannelService.loadScript(); + }, []); + + useEffect(() => { + const isTasklistPage = pathname?.includes("/tasklist"); + + ChannelService.boot({ + pluginKey: "4be26d42-5350-46c1-b390-93e0d142750e", + hideChannelButtonOnBoot: isTasklistPage, + }); + }, [pathname]); return ( diff --git a/src/utils/profanityFilter.ts b/src/utils/profanityFilter.ts new file mode 100644 index 00000000..17317f4a --- /dev/null +++ b/src/utils/profanityFilter.ts @@ -0,0 +1,43 @@ +// @ts-ignore - badwords-ko has no type definitions +import Filter from "badwords-ko"; +import { customProfanityWords } from "@/constants/custom-profanity"; + +const filter = new Filter(); + +if (customProfanityWords.length > 0) { + filter.addWords(...customProfanityWords); +} + +/** + * 텍스트에서 욕설을 *로 치환하는 함수 + * @param text 필터링할 텍스트 + * @returns 욕설이 *로 치환된 텍스트 + */ +export const filterProfanity = (text: string): string => { + return filter.clean(text); +}; + +/** + * 텍스트에 욕설이 포함되어 있는지 확인하는 함수 + * @param text 확인할 텍스트 + * @returns 욕설 포함 여부 + */ +export const hasProfanity = (text: string): boolean => { + return filter.isProfane(text); +}; + +/** + * 사용자 정의 욕설을 추가하는 함수 + * @param words 추가할 욕설 단어 배열 + */ +export const addBadWords = (words: string[]): void => { + filter.addWords(...words); +}; + +/** + * 욕설 목록에서 단어를 제거하는 함수 + * @param words 제거할 단어 배열 + */ +export const removeBadWords = (words: string[]): void => { + filter.removeWords(...words); +};