diff --git a/public/assets/sprites/common.svg b/public/assets/sprites/common.svg index 2f44bdb..f81dfb3 100644 --- a/public/assets/sprites/common.svg +++ b/public/assets/sprites/common.svg @@ -113,4 +113,68 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/styles/_reset.scss b/src/app/styles/_reset.scss index c5a8d38..dcde9a0 100644 --- a/src/app/styles/_reset.scss +++ b/src/app/styles/_reset.scss @@ -78,7 +78,8 @@ summary, time, mark, audio, -video { +video, +textarea { margin: 0; padding: 0; border: 0; diff --git a/src/features/feed-reports/consts/index.ts b/src/features/feed-reports/consts/index.ts new file mode 100644 index 0000000..dc23413 --- /dev/null +++ b/src/features/feed-reports/consts/index.ts @@ -0,0 +1 @@ +export * from './reports'; diff --git a/src/features/feed-reports/consts/reports.ts b/src/features/feed-reports/consts/reports.ts new file mode 100644 index 0000000..e8a55ef --- /dev/null +++ b/src/features/feed-reports/consts/reports.ts @@ -0,0 +1,18 @@ +export type ReportCategoryId = 1 | 2 | 3 | 4 | 5 | 6 | 7; + +interface ReportCategory { + id: ReportCategoryId; + name: string; +} + +export const REPORT_CATEOGRIES: ReportCategory[] = [ + { id: 1, name: '상업적/홍보성' }, + { id: 2, name: '음란/선정성' }, + { id: 3, name: '저작권 침해' }, + { id: 4, name: '개인정보 노출' }, + { id: 5, name: '욕설/인신공격' }, + { id: 6, name: '반복적인 내용' }, + { id: 7, name: '기타' }, +]; + +export const MAX_REPORT_CONTENT_LENGTH = 100; diff --git a/src/features/feed-reports/index.ts b/src/features/feed-reports/index.ts new file mode 100644 index 0000000..0fe27b8 --- /dev/null +++ b/src/features/feed-reports/index.ts @@ -0,0 +1 @@ +export { FeedReportsForm } from './ui'; diff --git a/src/features/feed-reports/model/index.ts b/src/features/feed-reports/model/index.ts new file mode 100644 index 0000000..33ebb3d --- /dev/null +++ b/src/features/feed-reports/model/index.ts @@ -0,0 +1 @@ +export * from './useReportCategories'; diff --git a/src/features/feed-reports/model/useReportCategories.tsx b/src/features/feed-reports/model/useReportCategories.tsx new file mode 100644 index 0000000..94e1b92 --- /dev/null +++ b/src/features/feed-reports/model/useReportCategories.tsx @@ -0,0 +1,26 @@ +import { useState } from 'react'; + +import { REPORT_CATEOGRIES, ReportCategoryId } from '../consts'; + +export const useReportCategories = () => { + const [categories, setCategories] = useState( + new Map( + REPORT_CATEOGRIES.map((item) => [item.id, false]), + ), + ); + + const handleClickCategory = (id: ReportCategoryId) => { + setCategories((prev) => { + const newCheckedItem = new Map(prev); + newCheckedItem.set(id, !newCheckedItem.get(id)); + return newCheckedItem; + }); + }; + + return { categories, handleClickCategory }; +}; + +export function getCategoryName(id: ReportCategoryId) { + const category = REPORT_CATEOGRIES.find((item) => item.id === id); + return category?.name ?? ''; +} diff --git a/src/features/feed-reports/ui/ConfirmReportModal.scss b/src/features/feed-reports/ui/ConfirmReportModal.scss new file mode 100644 index 0000000..75534f5 --- /dev/null +++ b/src/features/feed-reports/ui/ConfirmReportModal.scss @@ -0,0 +1,21 @@ +.confirm-report-modal { + display: flex; + flex-direction: column; + + border-radius: 8px; + border: none; + background-color: white; + + width: 244px; + padding: 18px; + + .title { + align-self: start; + } + + .modal-btn-container { + display: flex; + justify-content: center; + gap: 8px; + } +} diff --git a/src/features/feed-reports/ui/ConfirmReportModal.tsx b/src/features/feed-reports/ui/ConfirmReportModal.tsx new file mode 100644 index 0000000..4defdf9 --- /dev/null +++ b/src/features/feed-reports/ui/ConfirmReportModal.tsx @@ -0,0 +1,39 @@ +import { ActiveButton, BasicButton } from '@/shared/ui'; +import { ModalOverlay } from '@/shared/ui/modal/ModalOverlay'; + +import './ConfirmReportModal.scss'; + +interface ConfirmReportModalProps { + onExecute: () => void; + onExecuteIsDisabled: boolean; + onClose: () => void; + children: JSX.Element[]; +} + +export const ConfirmReportModal: React.FC = ({ + onExecute, + onExecuteIsDisabled, + onClose, + children, +}) => { + return ( + + + 신고하기 + {children} + + + 취소 + + + 신고하기 + + + + + ); +}; diff --git a/src/features/feed-reports/ui/FeedReportsForm.scss b/src/features/feed-reports/ui/FeedReportsForm.scss new file mode 100644 index 0000000..af20af6 --- /dev/null +++ b/src/features/feed-reports/ui/FeedReportsForm.scss @@ -0,0 +1,68 @@ +.reports-list { + margin-top: 16px; + + display: grid; + grid-template-columns: repeat(2, 1fr); + + row-gap: 10px; + + .report-item { + display: flex; + align-items: center; + + gap: 4px; + + .checkbox-btn { + width: 20px; + height: 20px; + } + + .item-name { + color: $gray5; + } + } +} + +.report-textarea-container { + margin-top: 16px; + position: relative; + + width: 244px; + height: 72px; + + .report-textarea { + padding: 10px; + + width: calc(100% - 20px); + height: calc(100% - 20px); + + background-color: $gray1; + + resize: none; + + &:focus { + outline: none; + } + } + + .textarea-text-count { + position: absolute; + + right: 10px; + bottom: 8px; + + color: $gray3; + } +} + +.hide-checkbox-container { + margin: 12px 0 20px; + + display: flex; + align-items: center; + gap: 4px; + + .hide-checkbox-text { + color: $gray5; + } +} diff --git a/src/features/feed-reports/ui/FeedReportsForm.tsx b/src/features/feed-reports/ui/FeedReportsForm.tsx new file mode 100644 index 0000000..0c8d3a4 --- /dev/null +++ b/src/features/feed-reports/ui/FeedReportsForm.tsx @@ -0,0 +1,74 @@ +import { useInput, useToggle } from '@/shared/hooks'; +import { Icon } from '@/shared/ui'; + +import { MAX_REPORT_CONTENT_LENGTH } from '../consts'; +import { useReportCategories, getCategoryName } from '../model'; + +import { ConfirmReportModal } from './ConfirmReportModal'; +import './FeedReportsForm.scss'; + +interface FeedReportsFormProps { + onClose: () => void; +} + +export const FeedReportsForm: React.FC = ({ + onClose, +}) => { + const { categories, handleClickCategory } = useReportCategories(); + const [content, handleInputContent] = useInput(); + const [isBlind, toggleBlind] = useToggle(false); + + return ( + {}} // API 연동 후 수정 + onExecuteIsDisabled={false} // API 연동 후 수정 + onClose={onClose} + > + {/* 신고 카테고리 */} + + {[...categories].map(([id, checked]) => ( + + handleClickCategory(id)} + > + + + {getCategoryName(id)} + + ))} + + + {/* 신고 사유 */} + + + + {content.length}/{MAX_REPORT_CONTENT_LENGTH} + + + + {/* 숨김 처리 체크박스 */} + + + + + 해당 게시물 숨기기 + + + ); +}; diff --git a/src/features/feed-reports/ui/index.ts b/src/features/feed-reports/ui/index.ts new file mode 100644 index 0000000..df284f0 --- /dev/null +++ b/src/features/feed-reports/ui/index.ts @@ -0,0 +1 @@ +export { FeedReportsForm } from './FeedReportsForm'; diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts new file mode 100644 index 0000000..82a3582 --- /dev/null +++ b/src/shared/hooks/index.ts @@ -0,0 +1,2 @@ +export { useToggle } from './useToggle'; +export { useInput } from './useInput'; diff --git a/src/shared/hooks/useInput.tsx b/src/shared/hooks/useInput.tsx new file mode 100644 index 0000000..705eb82 --- /dev/null +++ b/src/shared/hooks/useInput.tsx @@ -0,0 +1,19 @@ +import { useCallback, useState } from 'react'; + +/** + * useInput 훅은 input 태그의 value와 onChange 이벤트를 처리하는 함수를 반환하는 커스텀 훅입니다. + * @param defaultValue 초기값 + * @returns [상태값, 이벤트 핸들러, 상태 설정 함수] + */ +export const useInput = (defaultValue: string = '') => { + const [value, setValue] = useState(defaultValue); + + const handleInputContent = useCallback( + (e: React.ChangeEvent) => { + setValue(e.target.value); + }, + [], + ); + + return [value, handleInputContent, setValue] as const; // 자리 고정 +}; diff --git a/src/shared/hooks/useToggle.tsx b/src/shared/hooks/useToggle.tsx new file mode 100644 index 0000000..534c0d9 --- /dev/null +++ b/src/shared/hooks/useToggle.tsx @@ -0,0 +1,14 @@ +import { useState } from 'react'; + +/** + * reference: https://usehooks-ts.com/react-hook/use-toggle + * useToggle 훅은 boolean 값과 값을 토글할 수 있는 함수를 반환하는 커스텀 훅입니다. + * @param defaultValue 초기값 + * @returns [상태값, 토글 함수, 상태 설정 함수] + */ +export function useToggle(defaultValue?: boolean) { + const [value, setValue] = useState(!!defaultValue); + const toggle = () => setValue((x) => !x); + + return [value, toggle, setValue] as const; +} diff --git a/src/shared/ui/icon/consts/sprite.ts b/src/shared/ui/icon/consts/sprite.ts index 15e905e..2b85fc8 100644 --- a/src/shared/ui/icon/consts/sprite.ts +++ b/src/shared/ui/icon/consts/sprite.ts @@ -10,4 +10,8 @@ export type IconName = | 'chat' | 'search' | 'caution' - | 'no-profile'; + | 'no-profile' + | 'checkbox-circle_off' + | 'checkbox-circle_on' + | 'checkbox-square_on' + | 'checkbox-square_off'; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 37d0277..0e97d90 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1,6 +1,6 @@ export { ActiveButton, BasicButton } from './button'; export { PageHeader } from './header'; -export { ConfirmModal, ConfirmReportModal, BottomSheetModal } from './modal'; +export { ConfirmModal, BottomSheetModal } from './modal'; export { Profile, SkeletonProfile } from './profile'; export { Icon } from './icon'; export { NetworkError } from './network-error'; diff --git a/src/shared/ui/modal/ConfirmModal.scss b/src/shared/ui/modal/ConfirmModal.scss index 71b7503..c96404e 100644 --- a/src/shared/ui/modal/ConfirmModal.scss +++ b/src/shared/ui/modal/ConfirmModal.scss @@ -32,10 +32,3 @@ .confirm-modal { @include confirmModal(false); } - -.confirm-report-modal { - @include confirmModal(true); - .title { - align-self: start; - } -} diff --git a/src/shared/ui/modal/ConfirmReportModal.tsx b/src/shared/ui/modal/ConfirmReportModal.tsx deleted file mode 100644 index 29b87e0..0000000 --- a/src/shared/ui/modal/ConfirmReportModal.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { ActiveButton, BasicButton } from '../button/index'; - -import { ModalOverlay } from './ModalOverlay'; -import { BaseModalProps as ConfirmReportModalProps } from './types'; - -export const ConfirmReportModal = ({ - title, - onExecute, - onExecuteMsg, - onExecuteIsDisabled, - onClose, - onCloseMsg, -}: ConfirmReportModalProps) => { - return ( - - - {title} - - - - {onCloseMsg} - - - {onExecuteMsg} - - - - - ); -}; diff --git a/src/shared/ui/modal/index.ts b/src/shared/ui/modal/index.ts index 79a663b..ad89259 100644 --- a/src/shared/ui/modal/index.ts +++ b/src/shared/ui/modal/index.ts @@ -1,3 +1,2 @@ export { ConfirmModal } from './ConfirmModal'; -export { ConfirmReportModal } from './ConfirmReportModal'; export { BottomSheetModal } from './BottomSheetModal'; diff --git a/src/widgets/feed-kebab/index.ts b/src/widgets/feed-kebab/index.ts new file mode 100644 index 0000000..bdff833 --- /dev/null +++ b/src/widgets/feed-kebab/index.ts @@ -0,0 +1 @@ +export { FeedKebabButton } from './ui'; diff --git a/src/widgets/feed-kebab/ui/FeedKebabButton.tsx b/src/widgets/feed-kebab/ui/FeedKebabButton.tsx new file mode 100644 index 0000000..e4ad669 --- /dev/null +++ b/src/widgets/feed-kebab/ui/FeedKebabButton.tsx @@ -0,0 +1,17 @@ +import { useToggle } from '@/shared/hooks'; +import { Icon } from '@/shared/ui'; + +import { KebabMenu } from './KebabMenu'; + +export const FeedKebabButton = () => { + const [isVisibilityKebabMenu, toggleVisibility] = useToggle(false); + + return ( + <> + + + + {isVisibilityKebabMenu && } + > + ); +}; diff --git a/src/widgets/feed-kebab/ui/KebabMenu.scss b/src/widgets/feed-kebab/ui/KebabMenu.scss new file mode 100644 index 0000000..38885ca --- /dev/null +++ b/src/widgets/feed-kebab/ui/KebabMenu.scss @@ -0,0 +1,42 @@ +.kebab-menu-list { + z-index: 99; + + position: absolute; + top: 32px; + right: 20px; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + padding: 4px; + + border-radius: 4px; + + background-color: white; + box-shadow: 0px 0px 12px 0px #0000000f; + + .kebab-menu-item { + display: flex; + + width: 110px; + height: 28px; + + .item-btn { + padding: 7px; + + width: 100%; + height: 100%; + + text-align: left; + + color: $gray4; + + &:hover { + background-color: $gray2; + color: $gray5; + } + } + } +} diff --git a/src/widgets/feed-kebab/ui/KebabMenu.tsx b/src/widgets/feed-kebab/ui/KebabMenu.tsx new file mode 100644 index 0000000..e6ccbbe --- /dev/null +++ b/src/widgets/feed-kebab/ui/KebabMenu.tsx @@ -0,0 +1,31 @@ +import { FeedReportsForm } from '@/features/feed-reports'; +import { useToggle } from '@/shared/hooks'; +import './KebabMenu.scss'; + +interface KebabMenuProps { + onClose: () => void; +} + +export const KebabMenu: React.FC = ({ onClose }) => { + const [isVisibilityReportsForm, toggleVisibilityReportsForm] = + useToggle(false); + + return ( + <> + + + 게시물 숨기기 + + + + 신고하기 + + + + {isVisibilityReportsForm && } + > + ); +}; diff --git a/src/widgets/feed-kebab/ui/index.ts b/src/widgets/feed-kebab/ui/index.ts new file mode 100644 index 0000000..c2fba5e --- /dev/null +++ b/src/widgets/feed-kebab/ui/index.ts @@ -0,0 +1 @@ +export { FeedKebabButton } from './FeedKebabButton'; diff --git a/src/widgets/feed-main-list/ui/Feed.scss b/src/widgets/feed-main-list/ui/Feed.scss index 6ed811f..fff20b2 100644 --- a/src/widgets/feed-main-list/ui/Feed.scss +++ b/src/widgets/feed-main-list/ui/Feed.scss @@ -1,6 +1,7 @@ .feed-wrapper { .feed-article { .feed-header { + position: relative; padding: 0 3px 0 20px; display: flex; diff --git a/src/widgets/feed-main-list/ui/Feed.tsx b/src/widgets/feed-main-list/ui/Feed.tsx index 22818a5..06ee298 100644 --- a/src/widgets/feed-main-list/ui/Feed.tsx +++ b/src/widgets/feed-main-list/ui/Feed.tsx @@ -3,6 +3,7 @@ import { LikeButton } from '@/features/feed-main-like'; import { Feed as FeedProps } from '@/shared/consts'; import { Carousel, Icon, Profile } from '@/shared/ui'; import { calculateElapsedTime } from '@/shared/utils'; +import { FeedKebabButton } from '@/widgets/feed-kebab'; import './Feed.scss'; @@ -29,9 +30,7 @@ export const Feed: React.FC<{ feed: FeedProps }> = ({ feed }) => { content={calculateElapsedTime(updatedAt)} /> - - - + {content}
{getCategoryName(id)}
해당 게시물 숨기기
{content}