diff --git a/public/icons/plus_gray.svg b/public/icons/plus_gray.svg new file mode 100644 index 00000000..8b8fcb81 --- /dev/null +++ b/public/icons/plus_gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/_api/adminTopics/editAdminTopic.ts b/src/app/_api/adminTopics/editAdminTopic.ts new file mode 100644 index 00000000..172bee3e --- /dev/null +++ b/src/app/_api/adminTopics/editAdminTopic.ts @@ -0,0 +1,14 @@ +import { editAdminTopicType } from '@/lib/types/requestedTopicType'; +//PUT "/admin/topics/{topicId}" + +import axiosInstance from '@/lib/axios/axiosInstance'; + +const editAdminTopic = async ({ topicId, isExposed, categoryCode, title }: editAdminTopicType) => { + await axiosInstance.put(`/admin/topics/${topicId}`, { + isExposed, + categoryCode, + title, + }); +}; + +export default editAdminTopic; diff --git a/src/app/_api/adminTopics/getAdminTopics.ts b/src/app/_api/adminTopics/getAdminTopics.ts new file mode 100644 index 00000000..9f86027c --- /dev/null +++ b/src/app/_api/adminTopics/getAdminTopics.ts @@ -0,0 +1,23 @@ +// GET "/admin/topics?cursorId={}&size={}" + +import axiosInstance from '@/lib/axios/axiosInstance'; + +interface GetTopicsType { + cursorId?: number | null; +} + +const getAdminTopics = async ({ cursorId }: GetTopicsType) => { + const params = new URLSearchParams({ + size: '5', + }); + + if (cursorId) { + params.append('cursorId', cursorId.toString()); + } + + const response = await axiosInstance.get(`/admin/topics?${params.toString()}`); + + return response.data; +}; + +export default getAdminTopics; diff --git a/src/app/temp-admin-topic/_components/AdminTopicBox.css.ts b/src/app/temp-admin-topic/_components/AdminTopicBox.css.ts new file mode 100644 index 00000000..6301de84 --- /dev/null +++ b/src/app/temp-admin-topic/_components/AdminTopicBox.css.ts @@ -0,0 +1,139 @@ +import { style } from '@vanilla-extract/css'; +import { vars } from '@/styles/theme.css'; +import * as fonts from '@/styles/font.css'; + +export const container = style({ + width: '100%', + padding: '12px', + + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: '16px', + + position: 'relative', + + backgroundColor: vars.color.white, + borderRadius: '20px', + cursor: 'pointer', +}); + +export const wrapper = style({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + rowGap: '10px', +}); + +export const topicWrapper = style({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + columnGap: '8px', +}); + +export const buttonWrapper = style({ + display: 'flex', + flexDirection: 'column', + gap: '10px', +}); + +export const button = style({ + padding: '6px 12px', + + width: '80px', + + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + flexShrink: 0, + + borderRadius: '14px', +}); + +export const exposeToggleButton = style([ + button, + { + backgroundColor: vars.color.blue, + color: vars.color.white, + }, +]); + +export const editButton = style([ + button, + { + border: '1px solid #3D95FF80', + backgroundColor: vars.color.white, + color: vars.color.blue, + }, +]); + +export const category = style([ + fonts.Label, + { + padding: '6px 12px', + + border: `0.5px solid ${vars.color.lightgray}`, + borderRadius: '20px', + + color: vars.color.lightgray, + }, +]); + +export const topic = style([fonts.BodyBold, { color: vars.color.black }]); + +export const contentWrapper = style([ + fonts.Label, + { + width: '100%', + paddingLeft: '4px', + + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + + color: vars.color.black, + }, +]); + +export const bottomWrapper = style({ + width: '100%', + paddingLeft: '4px', + + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: '8px', +}); + +export const author = style({ + fontSize: '1.3rem', + color: vars.color.bluegray10, +}); + +export const anonymous = style({ + fontSize: '1.3rem', + color: vars.color.blue, +}); + +export const click = style({ + fontSize: '1.3rem', + color: vars.color.blue, +}); + +export const addBtn = style({ + width: '56px', + height: '56px', + + position: 'absolute', + bottom: '12px', + right: '12px', + + borderRadius: '50%', + boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', + + backgroundColor: vars.color.white, +}); diff --git a/src/app/temp-admin-topic/_components/AdminTopicBox.tsx b/src/app/temp-admin-topic/_components/AdminTopicBox.tsx new file mode 100644 index 00000000..8182412d --- /dev/null +++ b/src/app/temp-admin-topic/_components/AdminTopicBox.tsx @@ -0,0 +1,72 @@ +'use client'; +import { useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; + +import BottomSheet from './BottomSheet'; + +import { RequestedTopicType } from '@/lib/types/requestedTopicType'; +import editAdminTopic from '@/app/_api/adminTopics/editAdminTopic'; +import * as styles from './AdminTopicBox.css'; + +interface TopicBoxProps { + topic: RequestedTopicType; + onClick: () => void; +} + +function TopicBox({ topic, onClick }: TopicBoxProps) { + const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false); + + const editTopicMutation = useMutation({ + // mutationFn: () => + // editAdminTopic({ + // isExposed : !topic.isExposed, + // title, + // categoryCode, + // }), + }); + + const clickToggleExposeButton = () => { + setIsBottomSheetOpen(true); + editTopicMutation.mutate(); + }; + + return ( +
+
+
+
{topic.categoryKorName}
+
{topic.title}
+
+
+
{topic.description}
+
+
+
{topic.ownerNickname}
+
{topic.createdDate}
+
{topic.isAnonymous && '익명'}
+
+ {/* */} +
+
+ + +
+ {isBottomSheetOpen && ( + { + setIsBottomSheetOpen(false); + }} + topicTitle={topic.title} + category={topic.categoryKorName} + isExposed={topic.isExposed} + /> + )} +
+ ); +} + +export default TopicBox; diff --git a/src/app/temp-admin-topic/_components/AdminTopicMock.ts b/src/app/temp-admin-topic/_components/AdminTopicMock.ts new file mode 100644 index 00000000..b67e1541 --- /dev/null +++ b/src/app/temp-admin-topic/_components/AdminTopicMock.ts @@ -0,0 +1,92 @@ +import { RequestedTopicType } from '@/lib/types/requestedTopicType'; + +export const requestedTopicData: RequestedTopicType[] = [ + { + categoryEngName: 'culture', + categoryKorName: '문화', + title: '지역 문화 행사 안내', + description: '이번 주말에 진행되는 지역 문화 행사를 안내드립니다.', + createdDate: '2024-10-26', + ownerId: 2, + ownerNickname: '문화팀', + isAnonymous: false, + isExposed: true, + }, + { + categoryEngName: 'life', + categoryKorName: '일상생활', + title: '일상생활 팁 공유', + description: '일상생활을 더 편리하게 만들어줄 유용한 팁을 공유합니다.', + createdDate: '2024-10-27', + ownerId: 3, + ownerNickname: '일상고수', + isAnonymous: true, + isExposed: false, + }, + { + categoryEngName: 'place', + categoryKorName: '장소', + title: '서울의 숨겨진 명소 추천', + description: '잘 알려지지 않은 서울의 멋진 장소를 소개합니다.', + createdDate: '2024-10-20', + ownerId: 4, + ownerNickname: '여행자', + isAnonymous: false, + isExposed: true, + }, + { + categoryEngName: 'music', + categoryKorName: '음악', + title: '이번 주 추천 음악', + description: '가을에 어울리는 음악 리스트를 공유합니다.', + createdDate: '2024-10-21', + ownerId: 5, + ownerNickname: '음악사랑', + isAnonymous: false, + isExposed: true, + }, + { + categoryEngName: 'movie_drama', + categoryKorName: '영화/드라마', + title: '최근 본 영화/드라마 리뷰', + description: '최근에 감상한 영화와 드라마에 대한 리뷰를 남깁니다.', + createdDate: '2024-10-22', + ownerId: 6, + ownerNickname: '영화광', + isAnonymous: false, + isExposed: true, + }, + { + categoryEngName: 'book', + categoryKorName: '도서', + title: '이번 달 추천 도서', + description: '이달의 추천 도서를 소개합니다. 책을 좋아하는 분들께 추천!', + createdDate: '2024-10-23', + ownerId: 7, + ownerNickname: '책사랑', + isAnonymous: false, + isExposed: true, + }, + { + categoryEngName: 'animal_plant', + categoryKorName: '동식물', + title: '우리 집 반려동물 소개', + description: '우리 집 귀여운 반려동물을 소개합니다. 사진 포함!', + createdDate: '2024-10-24', + ownerId: 8, + ownerNickname: '동물사랑', + isAnonymous: true, + isExposed: false, + }, + { + categoryEngName: 'etc', + categoryKorName: '기타', + title: '기타 자유 주제', + description: '어떤 주제든 자유롭게 이야기 나누는 공간입니다.', + createdDate: '2024-10-25', + ownerId: 9, + ownerNickname: '자유인', + isAnonymous: false, + isExposed: true, + }, +]; diff --git a/src/app/temp-admin-topic/_components/BottomSheet.css.ts b/src/app/temp-admin-topic/_components/BottomSheet.css.ts new file mode 100644 index 00000000..0ebea0b3 --- /dev/null +++ b/src/app/temp-admin-topic/_components/BottomSheet.css.ts @@ -0,0 +1,216 @@ +import { keyframes, style } from '@vanilla-extract/css'; +import * as fonts from '@/styles/font.css'; +import { vars } from '@/styles/theme.css'; + +export const backGround = style({ + zIndex: 99, + position: 'fixed', + margin: 'auto', + top: 0, + left: 0, + bottom: 0, + right: 0, + background: 'rgba(0,0,0,0.3)', +}); + +const slideIn = keyframes({ + from: { transform: 'translateY(100%)' }, + to: { transform: 'translateY(0)' }, +}); + +export const bottomsheet = style({ + height: 'fit-content', + padding: '35px 20px 49px', + margin: 'auto', + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + backgroundColor: vars.color.bggray, + borderTopLeftRadius: '25px', + borderTopRightRadius: '25px', + transition: 'all 0.2s ease-in-out', + animation: `${slideIn} 0.2s ease-in-out`, +}); + +export const header = style([ + fonts.Header, + { + marginBottom: '10px', + }, +]); + +export const subText = style([ + fonts.Label, + { + color: vars.color.bluegray8, + marginBottom: '30px', + }, +]); + +export const upperWrapper = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); + +export const selectWrapper = style({ + position: 'relative', + display: 'flex', + alignItems: 'center', +}); + +export const categoryButton = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100px', + padding: '10px', + borderRadius: '8px', + cursor: 'pointer', +}); + +export const categoryText = style({ + color: vars.color.bluegray10, +}); + +export const arrow = style({ + color: vars.color.bluegray6, + fontSize: '14px', +}); + +export const dropdown = style({ + position: 'absolute', + top: '100%', + left: 0, + width: '110px', + backgroundColor: 'white', + border: `1px solid ${vars.color.bluegray6}`, + borderRadius: '8px', + marginTop: '5px', + zIndex: 4, +}); + +export const dropdownItem = style([ + fonts.LabelSmall, + { + padding: '7px 20px', + color: vars.color.bluegray10, + + cursor: 'pointer', + ':hover': { + backgroundColor: vars.color.bluegray8, + }, + }, +]); + +export const checkbox = style({ + appearance: 'none', + width: '19px', + height: '19px', + marginRight: '8px', + color: 'white', + backgroundColor: 'white', + border: `1px solid ${vars.color.bluegray6}`, + borderRadius: '0px', + position: 'relative', + + selectors: { + '&:checked': { + backgroundColor: vars.color.blue, + borderColor: vars.color.blue, + }, + '&:checked::before': { + content: '"✓"', + color: 'white', + fontSize: '18px', + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }, + }, +}); + +export const checkboxLabel = style([ + fonts.Body, + { + color: vars.color.bluegray10, + }, +]); + +export const inputWrapper = style({ + margin: '25px auto', + display: 'flex', + flexDirection: 'column', + rowGap: '15px;', +}); + +export const input = style([ + fonts.Body, + { + width: '100%', + padding: '15px', + color: vars.color.bluegray10, + backgroundColor: 'white', + borderRadius: '8px', + fontSize: '14px', + }, +]); + +export const anonymousWrapper = style({ + display: 'flex', + alignItems: 'center', +}); + +export const submitButton = style([ + fonts.BodyBold, + { + width: '100%', + padding: '16px 10px', + marginTop: '80px', + color: 'white', + backgroundColor: vars.color.blue, + borderRadius: '18px', + cursor: 'pointer', + textAlign: 'center', + ':disabled': { + backgroundColor: vars.color.lightgray, + cursor: 'not-allowed', + }, + }, +]); + +export const errorMessage = style([ + fonts.Label, + { + color: vars.color.red, + marginLeft: '12px', + }, +]); + +export const modalContainer = style({ + position: 'relative', +}); + +export const modalText = style([fonts.BodyBold]); + +export const buttonContainer = style({ + width: '100%', + + display: 'flex', + justifyContent: 'flex-end', + gap: '16px', +}); + +export const modalButton = style([ + fonts.BodyBold, + { + zIndex: '900', + width: '30%', + padding: '10px 30px', + color: 'white', + backgroundColor: vars.color.blue, + borderRadius: '13px', + }, +]); diff --git a/src/app/temp-admin-topic/_components/BottomSheet.tsx b/src/app/temp-admin-topic/_components/BottomSheet.tsx new file mode 100644 index 00000000..88a50c7e --- /dev/null +++ b/src/app/temp-admin-topic/_components/BottomSheet.tsx @@ -0,0 +1,156 @@ +'use client'; + +import * as styles from './BottomSheet.css'; +import { MouseEventHandler, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useUser } from '@/store/useUser'; +import { QUERY_KEYS } from '@/lib/constants/queryKeys'; +import useOnClickOutside from '@/hooks/useOnClickOutside'; +import getCategories from '@/app/_api/category/getCategories'; +import editAdminTopic from '@/app/_api/adminTopics/editAdminTopic'; + +import { CategoryType } from '@/lib/types/categoriesType'; +import ArrowDown from '/public/icons/down_chevron.svg'; +import useBooleanOutput from '@/hooks/useBooleanOutput'; +import Modal from '@/components/Modal/Modal'; + +interface BottomSheetProps { + onClose: MouseEventHandler; + topicTitle: string; + category: string; + isExposed: boolean; +} +// TODO: 컴포넌트 공통화 작업 +function BottomSheet({ onClose, topicTitle, category, isExposed }: BottomSheetProps) { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const { isOn: isModalOn, handleSetOn: openModal, handleSetOff: closeModal } = useBooleanOutput(false); + + const [title, setTitle] = useState(topicTitle); + const [selectedCategory, setSelectedCategory] = useState(category); + const [errorMessage, setErrorMessage] = useState(null); + + //카테고리 불러오기 + const { data: categories } = useQuery({ + queryKey: [QUERY_KEYS.getCategories], + queryFn: getCategories, + }); + + const editTopicMutation = useMutation({ + // mutationFn: () => + // editAdminTopic({ + // isExposed, + // title, + // categoryCode, + // }), + onSuccess: () => { + setTitle(''); + setSelectedCategory(selectedCategory); + openModal(); + }, + onError: (error) => { + setErrorMessage('요청 중 오류가 발생했습니다. 다시 시도해 주세요. :('); + }, + }); + + //드롭다운 바깥쪽 클릭하면 닫히게 + const { ref } = useOnClickOutside(() => { + setIsDropdownOpen(false); + }); + const stopPropagation: MouseEventHandler = (event) => { + event.stopPropagation(); + }; + + const toggleDropdown = () => { + setIsDropdownOpen(!isDropdownOpen); + }; + + const selectCategory = (category: string) => { + setSelectedCategory(category); + setIsDropdownOpen(false); + }; + + // 리스트 주제(제목) 글자 수 제한 정책 + const handleTitleChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value; + if (inputValue.length > 30) { + setErrorMessage('리스트 주제를 30자 이내로 작성해주세요.'); + } else { + setErrorMessage(null); + } + setTitle(inputValue); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (title.length > 30) { + return; + } + + setIsDropdownOpen(false); + editTopicMutation.mutate(); + }; + + return ( +
+
+
수정하기
+
+
+
+ + {isDropdownOpen && ( +
    + {categories?.map((category) => ( +
  • selectCategory(category.korName)} + > + {category.korName} +
  • + ))} +
+ )} +
+
+ +
+ + {errorMessage &&
{errorMessage}
} +
+ + +
+
+ {isModalOn && ( + +
{`요청 주제 수정이 완료되었어요.`}
+ +
+ )} +
+ ); +} + +export default BottomSheet; diff --git a/src/app/temp-admin-topic/page.css.ts b/src/app/temp-admin-topic/page.css.ts new file mode 100644 index 00000000..8dd51d2d --- /dev/null +++ b/src/app/temp-admin-topic/page.css.ts @@ -0,0 +1,89 @@ +import { style } from '@vanilla-extract/css'; +import { vars } from '@/styles/theme.css'; +import * as fonts from '@/styles/font.css'; + +export const body = style({ + width: '100vw', + minHeight: '100vh', + padding: '16px 16px 120px', + + position: 'relative', + overflowY: 'auto', + + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + rowGap: '12px', +}); + +export const goBackButton = style([ + fonts.Body, + { + marginBottom: '6.5px', + textAlign: 'left', + color: vars.color.deepblue10, + }, +]); + +export const title = style([ + fonts.Subtitle, + { + color: vars.color.black, + }, +]); + +export const subtitle = style([ + fonts.Body, + { + paddingBottom: '15px', + color: vars.color.bluegray8, + }, +]); + +export const gradientOverlay = style({ + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + height: '113px', + pointerEvents: 'none', + background: 'linear-gradient(to top, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))', + zIndex: 2, +}); + +export const floatingBox = style([ + fonts.BodyBold, + { + position: 'fixed', + bottom: '49px', + zIndex: 3, + + width: 'calc(100vw - 32px)', + maxWidth: '398px', + padding: '16px 10px', + + textAlign: 'center', + + borderRadius: '18px', + color: 'white', + backgroundColor: vars.color.blue, + cursor: 'pointer', + }, +]); + +export const pagesList = style({ + marginTop: '20px', + + display: 'flex', + alignItems: 'center', + gap: '8px', + + alignSelf: 'center', +}); + +export const page = style([ + fonts.BodyRegular, + { + color: vars.color.bluegray6, + }, +]); diff --git a/src/app/temp-admin-topic/page.tsx b/src/app/temp-admin-topic/page.tsx new file mode 100644 index 00000000..03a7e06c --- /dev/null +++ b/src/app/temp-admin-topic/page.tsx @@ -0,0 +1,74 @@ +'use client'; +import { useMemo } from 'react'; + +import AdminTopicBox from './_components/AdminTopicBox'; +import * as styles from './page.css'; +import { useRouter } from 'next/navigation'; +import BottomSheet from './_components/BottomSheet'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { QUERY_KEYS } from '@/lib/constants/queryKeys'; +import getAdminTopics from '../_api/adminTopics/getAdminTopics'; +import { RequestedTopicType } from '@/lib/types/requestedTopicType'; +import { useUser } from '@/store/useUser'; +import LoginModal from '@/components/login/LoginModal'; +import Modal from '@/components/Modal/Modal'; +import { requestedTopicData } from './_components/AdminTopicMock'; + +export default function TopicPage() { + const router = useRouter(); + + const { user } = useUser(); + + const pages = useMemo(() => Array.from({ length: 5 }, (_, idx) => idx + 1), []); + + //요청 주제목록 무한스크롤 리액트 쿼리 함수 + // const { + // data: topicsData, + // hasNextPage, + // fetchNextPage, + // isFetching, + // } = useInfiniteQuery({ + // queryKey: [QUERY_KEYS.getAdminTopics], + // queryFn: ({ pageParam: cursorId }) => { + // return getTopics({ cursorId: cursorId }); + // }, + // initialPageParam: null, + // getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.cursorId : null), + // }); + + const handleTopicClick = (topic: RequestedTopicType) => { + router.push(`/list/create?title=${topic.title}&category=${topic.categoryKorName}`); + }; + + return ( +
+ +
요청 주제 관리
+ {requestedTopicData?.map((topic, index: number) => { + return ( + { + handleTopicClick(topic); + }} + /> + ); + })} +
    + {pages.map((page) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/src/lib/constants/queryKeys.ts b/src/lib/constants/queryKeys.ts index 63046abc..976982d5 100644 --- a/src/lib/constants/queryKeys.ts +++ b/src/lib/constants/queryKeys.ts @@ -30,5 +30,6 @@ export const QUERY_KEYS = { getCollection: 'getCollection', getFolders: 'getFolders', getCollectionCategories: 'getCollectionCategories', // ver2.0 + getAdminTopics: 'getAdminTopics', getTopics: 'getTopics', }; diff --git a/src/lib/types/requestedTopicType.ts b/src/lib/types/requestedTopicType.ts new file mode 100644 index 00000000..3d8f1264 --- /dev/null +++ b/src/lib/types/requestedTopicType.ts @@ -0,0 +1,26 @@ +/* @TODO +1. 유진님 파일 머지 받아서 타입 공통화 작업. +*/ + +export interface RequestedTopicType { + categoryEngName: string; + categoryKorName: string; + title: string; + description: string; + createdDate: string; + ownerId: number; + ownerNickname: string; + isAnonymous: boolean; + isExposed: boolean; +} + +export interface RequestedTopicsListType { + data: RequestedTopicType[]; +} + +export interface editAdminTopicType { + topicId: number; + isExposed: boolean; + categoryCode: string; + title: string; +}