+
+
+
+
+ {isLiked ? (
+ deleteLike()}
+ />
+ ) : (
+ addLike()}
+ />
+ )}
+
+
{feedDetailData?.likeCount}
+
+
-
- ๋๊ธ {feedDetailData?.commentCount}
-
+
{feedDetailData?.commentCount}
+
-
- {allComments.length > 0 ? (
- allComments.map((comment) => {
- const isCommentOwner = comment.writerId === commentOwnerId;
+
+ {allComments.length > 0 ? (
+ allComments.map(
+ ({
+ writerId,
+ commentId,
+ content,
+ writerNickname,
+ createdAt,
+ profileImage,
+ replyCount,
+ images,
+ }) => {
+ if (commentId == null) {
+ return null;
+ }
return (
onDeleteClick(String(comment.commentId))}
+ key={commentId}
+ comment={{
+ content: content,
+ writerNickName: writerNickname,
+ createdAt: getTimeAgo(createdAt),
+ profileImage: profileImage,
+ isCommentOwner: writerId === commentOwnerId,
+ onDeleteClick: () => onCommentDeleteClick(commentId),
+ }}
+ replyCount={replyCount ?? 0}
+ images={images?.length ? images : undefined}
+ postId={postId}
+ commentId={commentId}
+ commentOwnerId={commentOwnerId}
+ onCommentReplyDeleteClick={onCommentReplyDeleteClick}
/>
);
- })
- ) : (
-
-
-
-
+ },
+ )
+ ) : (
+
-
-
+
+ )}
+
+
+
);
};
diff --git a/apps/client/src/widgets/community/components/user-comment-reply/user-comment-reply.css.ts b/apps/client/src/widgets/community/components/user-comment-reply/user-comment-reply.css.ts
new file mode 100644
index 000000000..07b197e5f
--- /dev/null
+++ b/apps/client/src/widgets/community/components/user-comment-reply/user-comment-reply.css.ts
@@ -0,0 +1,83 @@
+import { style } from '@vanilla-extract/css';
+import { recipe } from '@vanilla-extract/recipes';
+
+import { themeVars } from '@bds/ui/styles';
+
+export const container = recipe({
+ base: {
+ display: 'flex',
+ flexDirection: 'column',
+ padding: '1.2rem 0',
+ gap: '0.4rem',
+ },
+ variants: {
+ isEditingReply: {
+ true: {
+ borderRadius: '12px',
+ border: `1px solid ${themeVars.color.primary500}`,
+ },
+ false: {},
+ },
+ },
+});
+
+export const userInfoContainer = style({
+ display: 'flex',
+ padding: '0 1.6rem',
+ justifyContent: 'space-between',
+});
+
+export const leftContainer = style({
+ display: 'flex',
+ gap: '0.6rem',
+});
+
+export const userInfo = style({
+ display: 'flex',
+ gap: '1.2rem',
+});
+
+export const nickName = style({
+ ...themeVars.fontStyles.title_sb_16,
+ color: themeVars.color.gray800,
+});
+
+export const createdAt = style({
+ ...themeVars.fontStyles.body1_m_12,
+ color: themeVars.color.gray600,
+});
+
+export const iconButtonContainer = style({
+ display: 'flex',
+ alignItems: 'center',
+ paddingLeft: '6.6rem',
+});
+
+export const commentContainer = style({
+ padding: '0 3.4rem',
+});
+
+export const comment = style({
+ ...themeVars.fontStyles.body1_m_16,
+ color: themeVars.color.gray900,
+});
+
+export const imageContainer = style({
+ padding: '0 2.8rem',
+});
+
+export const replyImage = style({
+ width: '100%',
+ borderRadius: '1.2rem',
+});
+
+export const deleteText = style({
+ display: 'flex',
+ justifyContent: 'end',
+});
+
+export const editingReply = style({
+ ...themeVars.fontStyles.body1_m_12,
+ color: themeVars.color.gray800,
+ padding: '0 3.4rem',
+});
diff --git a/apps/client/src/widgets/community/components/user-comment-reply/user-comment-reply.tsx b/apps/client/src/widgets/community/components/user-comment-reply/user-comment-reply.tsx
new file mode 100644
index 000000000..6406e9ce5
--- /dev/null
+++ b/apps/client/src/widgets/community/components/user-comment-reply/user-comment-reply.tsx
@@ -0,0 +1,143 @@
+import { Avatar, TextButton } from '@bds/ui';
+import { Icon } from '@bds/ui/icons';
+
+import FilterDropDown from '@widgets/community/components/filter-dropdown/filter-dropdown';
+import { useChangeInputMode } from '@widgets/community/context/input-mode-context';
+import { ReplyImage } from '@widgets/community/types/reply-image.type';
+
+import { Image } from '@shared/types/type';
+
+import * as styles from './user-comment-reply.css';
+
+interface UserCommentReplyProps {
+ profileImage?: string;
+ writerNickName?: string;
+ createdAt: string;
+ content?: string;
+ images?: Image[];
+ parentCommentId: number;
+ commentReplyId: number;
+ onClickDelete?: (commentReplyId: number) => void;
+ isReplyOwner?: boolean;
+}
+
+const UserCommentReply = ({
+ profileImage,
+ writerNickName,
+ createdAt,
+ content,
+ images,
+ parentCommentId,
+ commentReplyId,
+ onClickDelete,
+ isReplyOwner,
+}: UserCommentReplyProps) => {
+ const { mode, dispatch } = useChangeInputMode();
+
+ const handleEditReply = () => {
+ const replyImages = (images ?? [])
+ .filter((img): img is ReplyImage => !!img.imageUrl)
+ .map((img) => {
+ const id = img.imageId ?? img.commentReplyImageId;
+ return { imageId: id, imageUrl: img.imageUrl };
+ });
+
+ dispatch({
+ type: 'REPLY_EDIT',
+ commentId: parentCommentId,
+ commentReplyId,
+ initialContent: content ?? '',
+ images: replyImages,
+ });
+ };
+
+ const getId = (
+ img: { imageId?: number; commentReplyImageId?: number } | undefined,
+ ) => img?.imageId ?? img?.commentReplyImageId;
+
+ const isEditingReply =
+ mode.type === 'reply' &&
+ mode.action === 'edit' &&
+ mode.commentReplyId === commentReplyId;
+
+ const deletedSet = new Set(isEditingReply ? (mode.deleteImageIds ?? []) : []);
+
+ const visibleImages = ((images ?? []) as ReplyImage[]).filter((img) => {
+ const id = getId(img);
+ return id == null ? true : !deletedSet.has(id);
+ });
+
+ const firstVisibleId = getId(visibleImages[0]);
+
+ const handleDeleteImage = (id?: number) => () => {
+ if (id == null) {
+ return;
+ }
+ dispatch({ type: 'REPLY_EDIT_DELETE_IMAGE', imageId: id });
+ };
+
+ return (
+
+
+
+
+
+
+
+
{writerNickName}
+
{createdAt}
+
+
+
+
+ {isReplyOwner && (
+ }
+ isIconRotate={false}
+ >
+
+ ์์
+
+ onClickDelete && onClickDelete(commentReplyId)}
+ >
+ ์ญ์
+
+
+ )}
+
+
+
+ {images && images.length > 0 && (
+
+ {visibleImages.map((img, index) => (
+

+ ))}
+ {isEditingReply && (
+
+
+ ์ญ์
+
+
+ )}
+
+ )}
+ {isEditingReply &&
์์ ์ค...
}
+
+ );
+};
+
+export default UserCommentReply;
diff --git a/apps/client/src/widgets/community/components/user-comment/user-comment.css.ts b/apps/client/src/widgets/community/components/user-comment/user-comment.css.ts
index c470935f1..7c4dc4c4a 100644
--- a/apps/client/src/widgets/community/components/user-comment/user-comment.css.ts
+++ b/apps/client/src/widgets/community/components/user-comment/user-comment.css.ts
@@ -1,47 +1,78 @@
import { style } from '@vanilla-extract/css';
+import { recipe } from '@vanilla-extract/recipes';
import { themeVars } from '@bds/ui/styles';
export const container = style({
display: 'flex',
flexDirection: 'column',
- padding: '1.2rem 1.6rem',
- borderRadius: '12px',
- width: '100%',
- gap: '1.2rem',
- backgroundColor: themeVars.color.whiteBackground,
+ gap: '0.8rem',
+ paddingBottom: '1.2rem',
});
-export const userInfoContainer = style({
- display: 'flex',
- justifyContent: 'space-between',
- backgroundColor: 'transparent',
+export const root = style({
+ position: 'relative',
});
-export const userInfo = style({
- display: 'flex',
- gap: '1.2rem',
- alignItems: 'center',
- backgroundColor: 'transparent',
+export const userInfoContainer = recipe({
+ base: {
+ display: 'flex',
+ flexDirection: 'column',
+ padding: '1.2rem 1.6rem',
+ borderRadius: '12px',
+ width: '100%',
+ gap: '0.4rem',
+ backgroundColor: themeVars.color.whiteBackground,
+ },
+ variants: {
+ isEditingComment: {
+ true: {
+ border: `1px solid ${themeVars.color.primary500}`,
+ },
+ false: {},
+ },
+ },
});
-export const nickName = style({
- ...themeVars.fontStyles.title_sb_16,
- color: themeVars.color.gray900,
+export const replyButtonContainer = style({
+ paddingLeft: '1.6rem',
});
-export const timestamp = style({
+export const replyContainer = style({
+ display: 'flex',
+ alignItems: 'center',
+ width: 'fit-content',
+});
+
+export const reply = style({
...themeVars.fontStyles.body1_m_12,
- color: themeVars.color.gray600,
+ color: themeVars.color.gray800,
});
-export const button = style({
- display: 'flex',
- alignItems: 'center',
+export const iconRotate = recipe({
+ base: {
+ transition: 'transform 0.1s ease-in-out',
+ },
+ variants: {
+ rotated: {
+ true: {
+ transform: 'rotate(-180deg)',
+ },
+ false: {
+ transform: 'rotate(0deg)',
+ },
+ },
+ },
+ defaultVariants: {
+ rotated: false,
+ },
});
-export const comment = style({
- ...themeVars.fontStyles.body1_m_16,
- color: themeVars.color.gray900,
- backgroundColor: 'transparent',
+export const virtualRef = style({
+ position: 'absolute',
+ left: 0,
+ bottom: 0,
+ width: '1px',
+ height: '1px',
+ pointerEvents: 'none',
});
diff --git a/apps/client/src/widgets/community/components/user-comment/user-comment.tsx b/apps/client/src/widgets/community/components/user-comment/user-comment.tsx
index 0142845d7..79d1270d2 100644
--- a/apps/client/src/widgets/community/components/user-comment/user-comment.tsx
+++ b/apps/client/src/widgets/community/components/user-comment/user-comment.tsx
@@ -1,48 +1,147 @@
-import { Avatar, TextButton } from '@bds/ui';
+import { useInfiniteQuery } from '@tanstack/react-query';
+
+import { TextButton } from '@bds/ui';
+import { Icon } from '@bds/ui/icons';
+
+import UserCommentInfo from '@widgets/community/components/user-comment-info/user-comment-info';
+import UserCommentReply from '@widgets/community/components/user-comment-reply/user-comment-reply';
+import { useChangeInputMode } from '@widgets/community/context/input-mode-context';
+import { CommentType } from '@widgets/community/types/community-comment.type.ts';
+
+import { COMMUNITY_QUERY_OPTIONS } from '@shared/api/domain/community/queries';
+import { useIntersectionObserver } from '@shared/hooks/use-intersection-observer';
+import { useToggle } from '@shared/hooks/use-toggle';
+import { Image } from '@shared/types/type.ts';
+import { getTimeAgo } from '@shared/utils/utils';
import * as styles from './user-comment.css';
interface UserCommentProps {
- content?: string;
- writerNickName?: string;
- createdAt?: string;
- onClickDelete?: VoidFunction;
- profileImage?: string;
- isCommentOwner: boolean;
+ comment: CommentType;
+ replyCount: number;
+ images?: Image[];
+ postId: string;
+ commentId: number;
+ onCommentReplyDeleteClick?: (commentId: number, replyId: number) => void;
+ commentOwnerId?: number;
}
-const DELETE_CONTENT = '์ญ์ ';
-
const UserComment = ({
- content,
- writerNickName,
- createdAt,
- onClickDelete,
- profileImage,
- isCommentOwner,
+ comment,
+ replyCount,
+ images,
+ postId,
+ commentId,
+ onCommentReplyDeleteClick,
+ commentOwnerId,
}: UserCommentProps) => {
+ const { mode, dispatch } = useChangeInputMode();
+ const [isRepliesOpen, toggleReplies] = useToggle();
+
+ const {
+ data: commentReply,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useInfiniteQuery({
+ ...COMMUNITY_QUERY_OPTIONS.COMMENT_REPLY(postId, commentId),
+ enabled: isRepliesOpen && !!commentId,
+ retry: false,
+ });
+
+ const commentsObserverRef = useIntersectionObserver(() => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, true);
+
+ const allCommentReply =
+ commentReply?.pages.flatMap((page) => page?.data?.content ?? []) ?? [];
+
+ const handleSubmitReply = () => {
+ dispatch({ type: 'REPLY_CREATE', parentCommentId: commentId });
+ };
+
+ const isEditingComment =
+ mode.type === 'comment' &&
+ mode.action === 'edit' &&
+ mode.commentId === commentId;
+
return (
-
-
-
-
-
-
{writerNickName}
-
{createdAt}
-
-
-
- {isCommentOwner ? (
-
- {DELETE_CONTENT}
+
+
+
+
+
+
+
+ {isEditingComment ? '์์ ์ค...' : '๋ต๊ธ ๋ฌ๊ธฐ'}
- ) : (
- ''
- )}
+
-
-
{content}
-
+ {replyCount > 0 && (
+
+
+
+ )}
+
+ {isRepliesOpen && (
+ <>
+ {allCommentReply.map(
+ ({
+ commentReplyId,
+ profileImage,
+ writerNickname,
+ createdAt,
+ content,
+ images,
+ writerId,
+ }) => {
+ if (commentReplyId == null) {
+ return null;
+ }
+
+ return (
+
{
+ if (!onCommentReplyDeleteClick) {
+ return;
+ }
+ onCommentReplyDeleteClick(commentId, commentReplyId);
+ }}
+ parentCommentId={commentId}
+ commentReplyId={commentReplyId}
+ />
+ );
+ },
+ )}
+
+ >
+ )}
+
);
};
diff --git a/apps/client/src/widgets/community/components/user-detail-meta/user-detail-meta.tsx b/apps/client/src/widgets/community/components/user-detail-meta/user-detail-meta.tsx
index 9b2b094b2..9f4cb7bbe 100644
--- a/apps/client/src/widgets/community/components/user-detail-meta/user-detail-meta.tsx
+++ b/apps/client/src/widgets/community/components/user-detail-meta/user-detail-meta.tsx
@@ -11,11 +11,6 @@ interface UserDetailMetaProps {
onDeleteClick: () => void;
}
-const BUTTON_TEXT = {
- EDIT: '์์ ',
- DELETE: '์ญ์ ',
-};
-
const UserDetailMeta = ({
nickName,
createdAt,
@@ -36,18 +31,20 @@ const UserDetailMeta = ({
{isOwner ? (
- {BUTTON_TEXT.EDIT}
+ ์์
- {BUTTON_TEXT.DELETE}
+ ์ญ์
) : (
diff --git a/apps/client/src/widgets/community/configs/.gitkeep b/apps/client/src/widgets/community/configs/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/apps/client/src/widgets/community/configs/category-config.ts b/apps/client/src/widgets/community/configs/category-config.ts
new file mode 100644
index 000000000..ea94b70ee
--- /dev/null
+++ b/apps/client/src/widgets/community/configs/category-config.ts
@@ -0,0 +1,7 @@
+import { CategoryType } from '../types/category-type';
+
+export const categoryOptions: CategoryType[] = [
+ { label: '๋ณดํ QnA', value: 'QNA' },
+ { label: '์ ๋ณด๊ณต์ ', value: 'INFORMATION' },
+ { label: '์ฌ๋ด', value: 'CONVERSATION' },
+];
diff --git a/apps/client/src/widgets/community/constant/category.ts b/apps/client/src/widgets/community/constant/category.ts
new file mode 100644
index 000000000..47cb41139
--- /dev/null
+++ b/apps/client/src/widgets/community/constant/category.ts
@@ -0,0 +1,6 @@
+export const CATEGORIES = [
+ { value: 'ALL', label: '์ ์ฒด' },
+ { value: 'QNA', label: '๋ณดํ QnA' },
+ { value: 'INFORMATION', label: '์ ๋ณด๊ณต์ ' },
+ { value: 'CONVERSATION', label: '์ฌ๋ด' },
+] as const;
diff --git a/apps/client/src/widgets/community/constant/list-sort.ts b/apps/client/src/widgets/community/constant/list-sort.ts
new file mode 100644
index 000000000..3058e7dd3
--- /dev/null
+++ b/apps/client/src/widgets/community/constant/list-sort.ts
@@ -0,0 +1,4 @@
+export const SORTS = [
+ { label: '์ต์ ์', value: 'LATEST' },
+ { label: '์ธ๊ธฐ์', value: 'POPULAR' },
+];
diff --git a/apps/client/src/widgets/community/constant/mock-popular-feed.ts b/apps/client/src/widgets/community/constant/mock-popular-feed.ts
new file mode 100644
index 000000000..24983e947
--- /dev/null
+++ b/apps/client/src/widgets/community/constant/mock-popular-feed.ts
@@ -0,0 +1,26 @@
+export const MOCK_FEED_CARD = [
+ {
+ id: 1,
+ title: '์ ๋ชฉ',
+ content:
+ '์ ๋ง ์ผ์ฑ์๋ช
๊ฑด๊ฐ๋ณดํ ๊ด์ฐฎ๋์?? ๋ค๋ฅธ ํ๊ธฐ๋ค์ ๋ณด๋๊น ๋ค ๋ณ๋ก๋ผ ํ๋๋ฐ ์ ๊ทธ๋ฅ ์ด์ฉ๊ตฌ ์ ์ฉ๊ตฌ~',
+ commentCount: 88,
+ likeCount: 21,
+ },
+ {
+ id: 2,
+ title: '์ ๋ชฉ2',
+ content:
+ '์ ๋ง ์ผ์ฑ์๋ช
๊ฑด๊ฐ๋ณดํ ์ ๊ด์ฐฎ๋์?? ๋ค๋ฅธ ํ๊ธฐ๋ค์ ์ ๋ณด๋๊น ๋ค ์ข๋ค๊ณ ํ๋๋ฐ ์ ๊ทธ๋ฅ ์ด์ฉ๊ตฌ ์ ์ฉ๊ตฌ~',
+ commentCount: 32,
+ likeCount: 41,
+ },
+ {
+ id: 3,
+ title: '์ ๋ชฉ3',
+ content:
+ 'Cursor๋ฅผ ๋ฌด๋ฃ ์ฒดํํ์ด ๋๋จ. ๋ค์ ์์ฝ๋ฉ ํ๋ ค๊ณ ํ๋ ํ์ ์น๋ ๋ง์ด ์๋ค์. ๋๋ฌด ์ฌ๋ฐ์ด์',
+ commentCount: 1,
+ likeCount: 21,
+ },
+];
diff --git a/apps/client/src/widgets/community/constant/modal-content.ts b/apps/client/src/widgets/community/constant/modal-content.ts
new file mode 100644
index 000000000..bbad0a225
--- /dev/null
+++ b/apps/client/src/widgets/community/constant/modal-content.ts
@@ -0,0 +1,36 @@
+interface ModalContent {
+ TITLE: string;
+ CONTENT: string;
+ CANCEL?: string;
+ CONFIRM?: string;
+}
+
+export const COMMENT_MODAL: Record<
+ 'FEED' | 'COMMENT' | 'COMMENT_REPLY' | 'CREATE' | 'EDIT',
+ ModalContent
+> = {
+ FEED: {
+ TITLE: '์ด ๊ธ์ ์ญ์ ํ ๊น์?',
+ CONTENT: '์ญ์ ํ ๊ธ์ ๋ณต์๋์ง ์์ต๋๋ค.',
+ },
+ COMMENT: {
+ TITLE: '์ด ๋๊ธ์ ์ญ์ ํ ๊น์?',
+ CONTENT: '์ญ์ ํ ๋๊ธ์ ๋ณต์๋์ง ์์ต๋๋ค.',
+ },
+ COMMENT_REPLY: {
+ TITLE: '์ด ๋๋๊ธ์ ์ญ์ ํ ๊น์?',
+ CONTENT: '์ญ์ ํ ๋๋๊ธ์ ๋ณต์๋์ง ์์ต๋๋ค.',
+ },
+ CREATE: {
+ TITLE: '์์ฑ ๋ด์ฉ์ ์ญ์ ํ ๊น์?',
+ CONTENT: '๋ด์ฉ์ด ์ ์ฅ๋์ง ์์ต๋๋ค.',
+ CANCEL: '๊ณ์ ์์ฑํ๊ธฐ',
+ CONFIRM: '์ญ์ ',
+ },
+ EDIT: {
+ TITLE: '์์ ๋ด์ฉ์ ์ญ์ ํ ๊น์?',
+ CONTENT: '๋ด์ฉ์ด ์ ์ฅ๋์ง ์์ต๋๋ค.',
+ CANCEL: '๊ณ์ ์์ ํ๊ธฐ',
+ CONFIRM: '์ญ์ ',
+ },
+};
diff --git a/apps/client/src/widgets/community/constant/modal-delete-content.ts b/apps/client/src/widgets/community/constant/modal-delete-content.ts
deleted file mode 100644
index ce9e36d2e..000000000
--- a/apps/client/src/widgets/community/constant/modal-delete-content.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export const DELETE_MODAL = {
- FEED: {
- title: '์ด ๊ธ์ ์ญ์ ํ ๊น์?',
- content: '์ญ์ ํ ๊ธ/๋๊ธ์ ๋ณต์๋์ง ์์ต๋๋ค.',
- },
- COMMENT: {
- title: '์ด ๋๊ธ์ ์ญ์ ํ ๊น์?',
- content: '์ญ์ ํ ๋๊ธ์ ๋ณต์๋์ง ์์ต๋๋ค.',
- },
-};
diff --git a/apps/client/src/widgets/community/context/input-mode-context.tsx b/apps/client/src/widgets/community/context/input-mode-context.tsx
new file mode 100644
index 000000000..42767261e
--- /dev/null
+++ b/apps/client/src/widgets/community/context/input-mode-context.tsx
@@ -0,0 +1,61 @@
+import {
+ createContext,
+ Dispatch,
+ ReactNode,
+ useContext,
+ useEffect,
+ useMemo,
+ useReducer,
+} from 'react';
+
+import { createInputModeReducer } from '@widgets/community/hooks/create-input-mode-reducer';
+import {
+ InputBoxMode,
+ ReducerAction,
+} from '@widgets/community/types/input-box-type';
+
+interface InputModeContextValue {
+ mode: InputBoxMode;
+ dispatch: Dispatch;
+}
+
+const InputModeContext = createContext(null);
+
+export const InputModeContextProvider = ({
+ postId,
+ children,
+}: {
+ postId: string;
+ children: ReactNode;
+}) => {
+ const reducer: React.Reducer = (
+ prev,
+ action,
+ ) => {
+ return createInputModeReducer({ postId, _prev: prev, action });
+ };
+
+ const [mode, dispatch] = useReducer(reducer, {
+ type: 'comment',
+ action: 'create',
+ postId,
+ } as const);
+
+ useEffect(() => {
+ dispatch({ type: 'RESET' });
+ }, [postId]);
+ const value = useMemo(() => ({ mode, dispatch }), [mode]);
+ return (
+
+ {children}
+
+ );
+};
+
+export const useChangeInputMode = () => {
+ const mode = useContext(InputModeContext);
+ if (!mode) {
+ throw new Error('InputModeContextProvider ์์์ ์ฌ์ฉํ์ธ์.');
+ }
+ return mode;
+};
diff --git a/apps/client/src/widgets/community/hooks/.gitkeep b/apps/client/src/widgets/community/hooks/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/apps/client/src/widgets/community/hooks/create-input-mode-reducer.ts b/apps/client/src/widgets/community/hooks/create-input-mode-reducer.ts
new file mode 100644
index 000000000..7c2634498
--- /dev/null
+++ b/apps/client/src/widgets/community/hooks/create-input-mode-reducer.ts
@@ -0,0 +1,73 @@
+import type {
+ InputBoxMode,
+ ReducerAction,
+} from '@widgets/community/types/input-box-type';
+
+interface ComposeModeReducerType {
+ postId: string;
+ _prev: InputBoxMode;
+ action: ReducerAction;
+}
+
+export const createInputModeReducer = ({
+ postId,
+ _prev,
+ action,
+}: ComposeModeReducerType): InputBoxMode => {
+ switch (action.type) {
+ case 'COMMENT_EDIT':
+ return {
+ type: 'comment',
+ action: 'edit',
+ postId,
+ commentId: action.commentId,
+ initialContent: action.initialContent,
+ images: action.images ?? [],
+ deleteImageIds: [],
+ } as const;
+ case 'REPLY_CREATE':
+ return {
+ type: 'reply',
+ action: 'create',
+ postId,
+ parentCommentId: action.parentCommentId,
+ } as const;
+ case 'REPLY_EDIT':
+ return {
+ type: 'reply',
+ action: 'edit',
+ postId,
+ commentId: action.commentId,
+ commentReplyId: action.commentReplyId,
+ initialContent: action.initialContent,
+ images: action.images ?? [],
+ deleteImageIds: [],
+ } as const;
+
+ case 'COMMENT_EDIT_DELETE_IMAGE': {
+ if (!(_prev.type === 'comment' && _prev.action === 'edit')) {
+ return _prev;
+ }
+ const nextImages = (_prev.images ?? []).filter(
+ (img) => img.imageId !== action.imageId,
+ );
+ const nextDelete = [...(_prev.deleteImageIds ?? []), action.imageId];
+ return { ..._prev, images: nextImages, deleteImageIds: nextDelete };
+ }
+
+ case 'REPLY_EDIT_DELETE_IMAGE': {
+ if (!(_prev.type === 'reply' && _prev.action === 'edit')) {
+ return _prev;
+ }
+ const nextImages = (_prev.images ?? []).filter(
+ (img) => img.imageId !== action.imageId,
+ );
+ const nextDelete = [...(_prev.deleteImageIds ?? []), action.imageId];
+ return { ..._prev, images: nextImages, deleteImageIds: nextDelete };
+ }
+
+ case 'RESET':
+ default:
+ return { type: 'comment', action: 'create', postId } as const;
+ }
+};
diff --git a/apps/client/src/widgets/community/hooks/use-controll-input-box.ts b/apps/client/src/widgets/community/hooks/use-controll-input-box.ts
new file mode 100644
index 000000000..973324f84
--- /dev/null
+++ b/apps/client/src/widgets/community/hooks/use-controll-input-box.ts
@@ -0,0 +1,28 @@
+import { useEffect, useState } from 'react';
+
+import { InputBoxMode } from '@widgets/community/types/input-box-type';
+
+import { LIMIT_SHORT_TEXT } from '@shared/constants/text-limits';
+
+export const useControlledInputBox = (mode: InputBoxMode) => {
+ const [content, setContent] = useState(
+ 'initialContent' in mode ? mode.initialContent : '',
+ );
+
+ useEffect(() => {
+ setContent('initialContent' in mode ? mode.initialContent : '');
+ }, [mode]);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const next = e.target.value;
+ if (next.length <= LIMIT_SHORT_TEXT) {
+ setContent(next);
+ }
+ };
+
+ const reset = () => {
+ setContent('initialContent' in mode ? mode.initialContent : '');
+ };
+
+ return { content, handleChange, reset };
+};
diff --git a/apps/client/src/widgets/community/types/category-type.ts b/apps/client/src/widgets/community/types/category-type.ts
new file mode 100644
index 000000000..5dddbf522
--- /dev/null
+++ b/apps/client/src/widgets/community/types/category-type.ts
@@ -0,0 +1,6 @@
+export type CategoryValue = 'ALL' | 'QNA' | 'INFORMATION' | 'CONVERSATION';
+
+export interface CategoryType {
+ label: string;
+ value: CategoryValue;
+}
diff --git a/apps/client/src/widgets/community/types/community-comment.type.ts b/apps/client/src/widgets/community/types/community-comment.type.ts
new file mode 100644
index 000000000..65a4c97b2
--- /dev/null
+++ b/apps/client/src/widgets/community/types/community-comment.type.ts
@@ -0,0 +1,8 @@
+export interface CommentType {
+ content?: string;
+ writerNickName?: string;
+ createdAt?: string;
+ onDeleteClick?: VoidFunction;
+ profileImage?: string;
+ isCommentOwner: boolean;
+}
diff --git a/apps/client/src/widgets/community/types/community-modal.type.ts b/apps/client/src/widgets/community/types/community-modal.type.ts
index 246765a80..c0d699800 100644
--- a/apps/client/src/widgets/community/types/community-modal.type.ts
+++ b/apps/client/src/widgets/community/types/community-modal.type.ts
@@ -1 +1 @@
-export type ModalType = 'feed' | 'comment';
+export type ModalType = 'feed' | 'comment' | 'commentReply';
diff --git a/apps/client/src/widgets/community/types/input-box-type.ts b/apps/client/src/widgets/community/types/input-box-type.ts
new file mode 100644
index 000000000..3714f415d
--- /dev/null
+++ b/apps/client/src/widgets/community/types/input-box-type.ts
@@ -0,0 +1,43 @@
+import { Image } from '@shared/types/type.ts';
+
+export type InputBoxMode =
+ | { type: 'comment'; action: 'create'; postId: string; images?: Image[] }
+ | {
+ type: 'comment';
+ action: 'edit';
+ postId: string;
+ commentId: number;
+ initialContent: string;
+ images?: Image[];
+ deleteImageIds?: number[];
+ }
+ | { type: 'reply'; action: 'create'; postId: string; parentCommentId: number }
+ | {
+ type: 'reply';
+ action: 'edit';
+ postId: string;
+ commentId: number;
+ commentReplyId: number;
+ initialContent: string;
+ images?: Image[];
+ deleteImageIds?: number[];
+ };
+
+export type ReducerAction =
+ | {
+ type: 'COMMENT_EDIT';
+ commentId: number;
+ initialContent: string;
+ images?: Image[];
+ }
+ | { type: 'REPLY_CREATE'; parentCommentId: number }
+ | {
+ type: 'REPLY_EDIT';
+ commentId: number;
+ commentReplyId: number;
+ initialContent: string;
+ images?: Image[];
+ }
+ | { type: 'COMMENT_EDIT_DELETE_IMAGE'; imageId: number }
+ | { type: 'REPLY_EDIT_DELETE_IMAGE'; imageId: number }
+ | { type: 'RESET' };
diff --git a/apps/client/src/widgets/community/types/reply-image.type.ts b/apps/client/src/widgets/community/types/reply-image.type.ts
new file mode 100644
index 000000000..9d32b720a
--- /dev/null
+++ b/apps/client/src/widgets/community/types/reply-image.type.ts
@@ -0,0 +1,5 @@
+export type ReplyImage = {
+ imageId?: number;
+ commentReplyImageId?: number;
+ imageUrl: string;
+};
diff --git a/apps/client/src/widgets/community/utils/.gitkeep b/apps/client/src/widgets/community/utils/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/apps/client/src/widgets/community/utils/local-storage.ts b/apps/client/src/widgets/community/utils/local-storage.ts
new file mode 100644
index 000000000..74795967e
--- /dev/null
+++ b/apps/client/src/widgets/community/utils/local-storage.ts
@@ -0,0 +1,30 @@
+export const LocalStorage = (localStorageKey: string) => {
+ const getLocalStorage = (): string[] => {
+ const data = localStorage.getItem(localStorageKey);
+ return data ? JSON.parse(data) : [];
+ };
+
+ const addLocalStorage = (value: string) => {
+ const addValue = value.trim();
+ if (!addValue) {
+ return;
+ }
+ const data = getLocalStorage();
+ if (data[0] === addValue) {
+ return;
+ }
+ const duplicateData = [
+ addValue,
+ ...data.filter((item: string) => item !== addValue),
+ ];
+ localStorage.setItem(localStorageKey, JSON.stringify(duplicateData));
+ };
+
+ const deleteLocalStorage = (value: string) => {
+ const data = getLocalStorage();
+ const newData = data.filter((item: string) => item !== value);
+ localStorage.setItem(localStorageKey, JSON.stringify(newData));
+ };
+
+ return { getLocalStorage, addLocalStorage, deleteLocalStorage };
+};
diff --git a/apps/client/src/widgets/community/utils/type-guard.ts b/apps/client/src/widgets/community/utils/type-guard.ts
new file mode 100644
index 000000000..b8b95c58a
--- /dev/null
+++ b/apps/client/src/widgets/community/utils/type-guard.ts
@@ -0,0 +1,24 @@
+import { components } from '@shared/types/schema';
+
+/**
+ * ์ด๋ฏธ์ง ๊ฐ์ฒด์ ์ ํจ์ฑ์ ๊ฒ์ฌํ๋ ํ์
๊ฐ๋ ํจ์์
๋๋ค.
+ *
+ * API ์คํค๋ง์์ `imageId`์ `imageUrl`์ `undefined`์ผ ์ ์๋๋ก ์ ์๋์ด ์์ต๋๋ค.
+ * ์ด๋ก ์ธํด ์ค์ ๋ก ์ด๋ฏธ์ง๊ฐ ์กด์ฌํ์ง ์๋ ๊ฒฝ์ฐ์๋ `|| []` ๊ฐ์ ๋ถํ์ํ ๋ฐฉ์ด ๋ก์ง์ด ๋ค์ด๊ฐ๊ฑฐ๋,
+ * ์ด๋ฏธ์ง๊ฐ ์กด์ฌํ์ง ์์์ผ ํ ์ํฉ์์ ์๋ชป๋ ๊ฐ์ด ๋ ๋๋ง๋๋ ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ต๋๋ค.
+ *
+ * ์ด ํ์
๊ฐ๋๋ `imageId`์ `imageUrl`์ด ๋ชจ๋ ์ฌ๋ฐ๋ฅด๊ฒ ์กด์ฌํ๋ ๊ฒฝ์ฐ๋ง์ ํต๊ณผ์์ผ,
+ * `undefined` ๊ฐ๋ฅ์ฑ์ ์ ๊ฑฐํ๊ณ ์ฝ๋ ์ ๋ฐ์์ ๋ณด๋ค ๋ช
ํํ๊ณ ์์ ํ๊ฒ ์ด๋ฏธ์ง ๋ฐ์ดํฐ๋ฅผ ๋ค๋ฃฐ ์ ์๋๋ก ํฉ๋๋ค.
+ *
+ * @param image - ๊ฒ์ฌํ ์ด๋ฏธ์ง ๊ฐ์ฒด
+ * @returns `image`๊ฐ ์ ํจํ ์ด๋ฏธ์ง(`imageId`์ `imageUrl`์ ๋ชจ๋ ๊ฐ์ง ๊ฒฝ์ฐ)๋ผ๋ฉด `true`
+ */
+export function isValidImage(
+ image: components['schemas']['PostDetailImageResponse'],
+): image is { imageId: number; imageUrl: string } {
+ return (
+ typeof image.imageId === 'number' &&
+ typeof image.imageUrl === 'string' &&
+ image.imageUrl.trim().length > 0
+ );
+}
diff --git a/apps/client/src/widgets/home/components/features-section/features-section.css.ts b/apps/client/src/widgets/home/components/features-section/features-section.css.ts
index 81973c2dd..abd4cdc72 100644
--- a/apps/client/src/widgets/home/components/features-section/features-section.css.ts
+++ b/apps/client/src/widgets/home/components/features-section/features-section.css.ts
@@ -20,7 +20,7 @@ export const featureSection = recipe({
minHeight: 'calc(100vh - 474px)',
},
lg: {
- minHeight: 'calc(100vh - 422px)',
+ minHeight: 'calc(100vh - 413px)',
},
},
},
@@ -57,10 +57,11 @@ export const indicatorContainer = style({
});
export const slideItem = style({
- width: '200px !important',
+ width: '20rem',
+ height: '11.8rem',
});
export const tipList = style({
padding: '0 1.6rem !important',
- height: '118px !important',
+ height: '11.8rem !important',
});
diff --git a/apps/client/src/widgets/home/components/features-section/features-section.tsx b/apps/client/src/widgets/home/components/features-section/features-section.tsx
index 0c65ca4ba..e32446e58 100644
--- a/apps/client/src/widgets/home/components/features-section/features-section.tsx
+++ b/apps/client/src/widgets/home/components/features-section/features-section.tsx
@@ -1,9 +1,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { Pagination } from 'swiper/modules';
-import { Swiper, SwiperSlide } from 'swiper/react';
-import { Indicator } from '@bds/ui';
+import { Carousel, Indicator } from '@bds/ui';
import { Icon } from '@bds/ui/icons';
import CommunityLink from '@widgets/home/components/community-link/community-link.tsx';
@@ -38,42 +36,33 @@ export const FeaturesSection = ({ height = 'md' }: featureSectionProps) => {
๋ณดํ Tip
- setCurrentPage(swiper.realIndex)}
- onReachEnd={() => setCurrentPage(2)}
+ setCurrentPage(index)}
+ onSlideEnd={() => setCurrentPage(2)}
className={styles.tipList}
>
-
+
-
-
+
+
-
-
+
+
-
-
+
+
diff --git a/apps/client/src/widgets/home/components/home-card/home-card.tsx b/apps/client/src/widgets/home/components/home-card/home-card.tsx
index 08585e262..351909e46 100644
--- a/apps/client/src/widgets/home/components/home-card/home-card.tsx
+++ b/apps/client/src/widgets/home/components/home-card/home-card.tsx
@@ -10,13 +10,13 @@ const statusMap = {
๊ฐ๋ ฅ: 'strong',
} as const;
-interface ChipProps {
+interface HomeCardProps {
icon: ReactNode;
title: string;
status?: StatusType;
}
-const HomeCard = ({ icon, title, status }: ChipProps) => {
+const HomeCard = ({ icon, title, status }: HomeCardProps) => {
const internalStatus = status ? statusMap[status] : undefined;
return (
diff --git a/apps/client/src/widgets/home/components/info-section/info-section.css.ts b/apps/client/src/widgets/home/components/info-section/info-section.css.ts
index dc73ce7b5..436088936 100644
--- a/apps/client/src/widgets/home/components/info-section/info-section.css.ts
+++ b/apps/client/src/widgets/home/components/info-section/info-section.css.ts
@@ -1,4 +1,4 @@
-import { globalStyle, style } from '@vanilla-extract/css';
+import { style } from '@vanilla-extract/css';
import { themeVars } from '@bds/ui/styles';
@@ -30,19 +30,11 @@ export const title = style({
color: themeVars.color.white,
});
-export const homeChipList = style({
- display: 'flex',
- flexDirection: 'row',
- gap: '0.8rem',
- overflowX: 'auto',
-});
-
-globalStyle(`${homeChipList} .swiper-wrapper`, {
- transitionTimingFunction: 'linear',
- padding: '1.8rem 0 2.2rem 0',
+export const homeCardList = style({
+ padding: '1rem 0 1.6rem 0',
});
-export const homeChipIcon = style({
+export const homeCardIcon = style({
height: '5rem',
width: '5rem',
});
diff --git a/apps/client/src/widgets/home/components/info-section/info-section.tsx b/apps/client/src/widgets/home/components/info-section/info-section.tsx
index 77e58fb8f..7a6f68487 100644
--- a/apps/client/src/widgets/home/components/info-section/info-section.tsx
+++ b/apps/client/src/widgets/home/components/info-section/info-section.tsx
@@ -1,13 +1,9 @@
-import { IconName } from 'node_modules/@bds/ui/src/icons/icon-list.ts';
import { useNavigate } from 'react-router-dom';
-import { Autoplay } from 'swiper/modules';
-import { Swiper, SwiperSlide } from 'swiper/react';
-import { Button } from '@bds/ui';
-import { Icon } from '@bds/ui/icons';
+import { Button, Carousel } from '@bds/ui';
import HomeCard from '@widgets/home/components/home-card/home-card.tsx';
-import { homeChipConfig } from '@widgets/home/configs/home-chip-config.ts';
+import { homeCardConfig } from '@widgets/home/configs/home-card-config.ts';
import InsuranceTitle from '@shared/components/insurance-title/insurance-title.tsx';
import { routePath } from '@shared/router/path.ts';
@@ -36,37 +32,22 @@ export const InfoSection = () => {
๋ฑ ๋ง๋ ๋ณดํ, ์ด๋ ต์ง ์๊ฒ ์ฐพ์ ์ ์์ด์!
-