diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 326bf0cc..31b68e75 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -14,7 +14,7 @@ model User { password String @db.VarChar(100) nickname String @unique @db.VarChar(20) description String @db.VarChar(100) - profile_image String @default("https://kr.object.ncloudstorage.com/j027/522da4f3-c9d7-403d-a98f-2b09cabefc47.png") @db.VarChar(255) + profile_image String @default("https://kr.object.ncloudstorage.com/j027/20e92b3e-af66-4eab-8272-a40ea9212930.png") @db.VarChar(255) provider String @db.VarChar(20) created_at DateTime @default(now()) deleted_at DateTime? diff --git a/backend/src/apis/articles/articles.controller.ts b/backend/src/apis/articles/articles.controller.ts index c213bd21..62ad9a0d 100644 --- a/backend/src/apis/articles/articles.controller.ts +++ b/backend/src/apis/articles/articles.controller.ts @@ -7,9 +7,19 @@ import scrapsService from '@apis/scraps/scraps.service'; import { Forbidden, Message } from '@errors'; const searchArticles = async (req: Request, res: Response) => { - const { query, page, take, userId } = req.query as unknown as SearchArticles; + const { query, page, take, isUsers } = req.query as unknown as SearchArticles; - const searchResult = await articlesService.searchArticles({ query, page, take: +take, userId }); + let userId = res.locals.user?.id; + + if (!userId) userId = 0; + + const searchResult = await articlesService.searchArticles({ + query, + page, + take: +take, + isUsers, + userId, + }); return res.status(200).send(searchResult); }; @@ -78,9 +88,9 @@ const updateArticle = async (req: Request, res: Response) => { const deleteArticle = async (req: Request, res: Response) => { const articleId = Number(req.params.articleId); - await articlesService.deleteArticle(articleId); + const article = await articlesService.deleteArticle(articleId); - return res.status(204).send(); + return res.status(200).send(article); }; const getTemporaryArticle = async (req: Request, res: Response) => { diff --git a/backend/src/apis/articles/articles.interface.ts b/backend/src/apis/articles/articles.interface.ts index 5abd0f1b..e8195302 100644 --- a/backend/src/apis/articles/articles.interface.ts +++ b/backend/src/apis/articles/articles.interface.ts @@ -2,7 +2,8 @@ export interface SearchArticles { query: string; page: number; take: number; - userId: number; + userId?: number; + isUsers?: string; } export interface CreateArticle { diff --git a/backend/src/apis/articles/articles.service.ts b/backend/src/apis/articles/articles.service.ts index 89899603..5d331649 100644 --- a/backend/src/apis/articles/articles.service.ts +++ b/backend/src/apis/articles/articles.service.ts @@ -6,19 +6,20 @@ import { import { prisma } from '@config/orm.config'; const searchArticles = async (searchArticles: SearchArticles) => { - const { query, page, take, userId } = searchArticles; + const { query, page, take, userId, isUsers } = searchArticles; const skip = (page - 1) * take; - const matchUserCondition = Number(userId) - ? { - book: { - user: { - id: Number(userId), + const matchUserCondition = + isUsers === 'true' + ? { + book: { + user: { + id: Number(userId), + }, }, - }, - } - : {}; + } + : {}; const articles = await prisma.article.findMany({ select: { @@ -112,7 +113,7 @@ const createArticle = async (dto: CreateArticle) => { }; const deleteArticle = async (articleId: number) => { - await prisma.article.update({ + const article = await prisma.article.update({ where: { id: articleId, }, @@ -120,6 +121,8 @@ const deleteArticle = async (articleId: number) => { deleted_at: new Date(), }, }); + + return article; }; const getTemporaryArticle = async (userId: number) => { diff --git a/backend/src/apis/books/books.controller.ts b/backend/src/apis/books/books.controller.ts index dc94588a..3b28a0f1 100644 --- a/backend/src/apis/books/books.controller.ts +++ b/backend/src/apis/books/books.controller.ts @@ -31,9 +31,19 @@ const getBooks = async (req: Request, res: Response) => { }; const searchBooks = async (req: Request, res: Response) => { - const { query, page, take, userId } = req.query as unknown as SearchBooks; + const { query, page, take, isUsers } = req.query as unknown as SearchBooks; - const searchResult = await booksService.searchBooks({ query, userId, take: +take, page }); + let userId = res.locals.user?.id; + + if (!userId) userId = 0; + + const searchResult = await booksService.searchBooks({ + query, + isUsers, + userId, + take: +take, + page, + }); return res.status(200).send(searchResult); }; diff --git a/backend/src/apis/books/books.interface.ts b/backend/src/apis/books/books.interface.ts index 6d58090a..979b6daf 100644 --- a/backend/src/apis/books/books.interface.ts +++ b/backend/src/apis/books/books.interface.ts @@ -3,6 +3,7 @@ export interface SearchBooks { page: number; take: number; userId?: number; + isUsers?: string; } export interface FindBooks { diff --git a/backend/src/apis/books/books.service.ts b/backend/src/apis/books/books.service.ts index 15965734..8652ce5c 100644 --- a/backend/src/apis/books/books.service.ts +++ b/backend/src/apis/books/books.service.ts @@ -2,7 +2,7 @@ import { FindBooks, SearchBooks, CreateBook } from '@apis/books/books.interface' import { prisma } from '@config/orm.config'; import { Message, NotFound } from '@errors'; -const searchBooks = async ({ query, userId, take, page }: SearchBooks) => { +const searchBooks = async ({ query, userId, isUsers, take, page }: SearchBooks) => { const skip = (page - 1) * take; const books = await prisma.book.findMany({ @@ -40,7 +40,7 @@ const searchBooks = async ({ query, userId, take, page }: SearchBooks) => { }, where: { deleted_at: null, - user_id: Number(userId) ? Number(userId) : undefined, + user_id: isUsers === 'true' ? Number(userId) : undefined, title: { search: `${query}*`, }, diff --git a/backend/src/apis/index.ts b/backend/src/apis/index.ts index 172ca29a..3f179cbd 100644 --- a/backend/src/apis/index.ts +++ b/backend/src/apis/index.ts @@ -10,6 +10,7 @@ import imagesController from '@apis/images/images.controller'; import scrapsController from '@apis/scraps/scraps.controller'; import usersController from '@apis/users/users.controller'; import decoder from '@middlewares/tokenDecoder'; +import { tokenErrorHandler } from '@middlewares/tokenErrorHandler'; import guard from '@middlewares/tokenValidator'; import { catchAsync } from '@utils/catch-async'; @@ -19,19 +20,19 @@ router.post('/auth/signin/local', catchAsync(authController.signIn)); router.post('/auth/signin/github', catchAsync(authController.signInGithub)); router.post('/auth/signup', catchAsync(authController.signUp)); router.get('/auth/signout', catchAsync(authController.signOut)); -router.get('/auth', decoder, catchAsync(authController.checkSignInStatus)); +router.get('/auth', guard, tokenErrorHandler, catchAsync(authController.checkSignInStatus)); router.get('/articles/temporary', guard, catchAsync(articlesController.getTemporaryArticle)); router.post('/articles/temporary', guard, catchAsync(articlesController.createTemporaryArticle)); -router.get('/articles/search', catchAsync(articlesController.searchArticles)); +router.get('/articles/search', decoder, catchAsync(articlesController.searchArticles)); router.get('/articles/:articleId', catchAsync(articlesController.getArticle)); -router.post('/articles', catchAsync(articlesController.createArticle)); -router.patch('/articles/:articleId', catchAsync(articlesController.updateArticle)); -router.delete('/articles/:articleId', catchAsync(articlesController.deleteArticle)); +router.post('/articles', guard, catchAsync(articlesController.createArticle)); +router.patch('/articles/:articleId', guard, catchAsync(articlesController.updateArticle)); +router.delete('/articles/:articleId', guard, catchAsync(articlesController.deleteArticle)); -router.post('/image', multer().single('image'), catchAsync(imagesController.createImage)); +router.post('/image', guard, multer().single('image'), catchAsync(imagesController.createImage)); -router.get('/books/search', catchAsync(booksController.searchBooks)); +router.get('/books/search', decoder, catchAsync(booksController.searchBooks)); router.get('/books/:bookId', decoder, catchAsync(booksController.getBook)); router.get('/books', decoder, catchAsync(booksController.getBooks)); router.post('/books', guard, catchAsync(booksController.createBook)); @@ -39,14 +40,14 @@ router.patch('/books', guard, catchAsync(booksController.updateBook)); router.delete('/books/:bookId', guard, catchAsync(booksController.deleteBook)); router.post('/bookmarks', guard, catchAsync(bookmarksController.createBookmark)); -router.delete('/bookmarks/:bookmarkId', catchAsync(bookmarksController.deleteBookmark)); +router.delete('/bookmarks/:bookmarkId', guard, catchAsync(bookmarksController.deleteBookmark)); router.get('/scraps', catchAsync(scrapsController.getScraps)); -router.patch('/scraps', catchAsync(guard), catchAsync(scrapsController.updateScrapsOrder)); -router.post('/scraps', catchAsync(scrapsController.createScrap)); +router.patch('/scraps', guard, catchAsync(scrapsController.updateScrapsOrder)); +router.post('/scraps', guard, catchAsync(scrapsController.createScrap)); router.delete('/scraps/:scrapId', guard, catchAsync(scrapsController.deleteScrap)); router.get('/users', catchAsync(usersController.getUserProfile)); -router.patch('/users/:userId', catchAsync(usersController.editUserProfile)); +router.patch('/users/:userId', guard, catchAsync(usersController.editUserProfile)); export default router; diff --git a/backend/src/apis/scraps/scraps.controller.ts b/backend/src/apis/scraps/scraps.controller.ts index 99307850..076de041 100644 --- a/backend/src/apis/scraps/scraps.controller.ts +++ b/backend/src/apis/scraps/scraps.controller.ts @@ -26,10 +26,11 @@ const createScrap = async (req: Request, res: Response) => { const deleteScrap = async (req: Request, res: Response) => { const scrapId = Number(req.params.scrapId); - await scrapsService.deleteScrap(scrapId); + const scrap = await scrapsService.deleteScrap(scrapId); - return res.status(200).send(); + return res.status(200).send(scrap); }; + const getScraps = async (req: Request, res: Response) => { const scraps = await scrapsService.getScraps(); diff --git a/backend/src/apis/scraps/scraps.service.ts b/backend/src/apis/scraps/scraps.service.ts index 14cbfb6b..3d6e54a3 100644 --- a/backend/src/apis/scraps/scraps.service.ts +++ b/backend/src/apis/scraps/scraps.service.ts @@ -53,11 +53,13 @@ const updateScrapOrder = async (scraps: IScrap) => { }; const deleteScrap = async (scrapId: number) => { - await prisma.scrap.delete({ + const scrap = await prisma.scrap.delete({ where: { id: scrapId, }, }); + + return scrap; }; const getScraps = async () => { diff --git a/backend/src/middlewares/tokenErrorHandler.ts b/backend/src/middlewares/tokenErrorHandler.ts new file mode 100644 index 00000000..1e21c5dc --- /dev/null +++ b/backend/src/middlewares/tokenErrorHandler.ts @@ -0,0 +1,7 @@ +import { ErrorRequestHandler } from 'express'; + +export const tokenErrorHandler: ErrorRequestHandler = (err, req, res, next) => { + if (!err) return next(); + + return res.status(200).send({ id: 0 }); +}; diff --git a/frontend/apis/articleApi.ts b/frontend/apis/articleApi.ts index d817a6ba..866f1c38 100644 --- a/frontend/apis/articleApi.ts +++ b/frontend/apis/articleApi.ts @@ -2,7 +2,7 @@ import api from '@utils/api'; interface SearchArticlesApi { query: string; - userId: number; + isUsers: boolean; page: number; take: number; } @@ -11,7 +11,7 @@ export const searchArticlesApi = async (data: SearchArticlesApi) => { const url = `/api/articles/search`; const params = { query: data.query, - userId: data.userId, + isUsers: data.isUsers, page: data.page, take: data.take, }; @@ -52,7 +52,7 @@ export const modifyArticleApi = async (articleId: number, data: CreateArticleApi return response.data; }; -export const deleteArticleApi = async (articleId: string) => { +export const deleteArticleApi = async (articleId: number) => { const url = `/api/articles/${articleId}`; const response = await api({ url, method: 'DELETE' }); diff --git a/frontend/apis/bookApi.ts b/frontend/apis/bookApi.ts index 1ccf0e09..988fd8b3 100644 --- a/frontend/apis/bookApi.ts +++ b/frontend/apis/bookApi.ts @@ -3,7 +3,7 @@ import api from '@utils/api'; interface SearchBooksApi { query: string; - userId: number; + isUsers: boolean; page: number; take: number; } @@ -12,7 +12,7 @@ export const searchBooksApi = async (data: SearchBooksApi) => { const url = `/api/books/search`; const params = { query: data.query, - userId: data.userId, + isUsers: data.isUsers, page: data.page, take: data.take, }; diff --git a/frontend/components/common/Book/styled.ts b/frontend/components/common/Book/styled.ts index e82fa156..27a88bff 100644 --- a/frontend/components/common/Book/styled.ts +++ b/frontend/components/common/Book/styled.ts @@ -18,24 +18,24 @@ export const BookWrapper = styled(FlexColumn)` overflow: hidden; color: var(--grey-01-color); - aspect-ratio: 280/480; - @media ${(props) => props.theme.tablet} { - width: 100%; - height: auto; - overflow: none; - } + // aspect-ratio: 280/480; + // @media ${(props) => props.theme.tablet} { + // width: 100%; + // height: auto; + // overflow: none; + // } `; export const BookThumbnail = styled(Image)` width: 280px; min-height: 200px; object-fit: cover; - aspect-ratio: 280/200; + // aspect-ratio: 280/200; - @media ${(props) => props.theme.tablet} { - width: 100%; - min-height: auto; - } + // @media ${(props) => props.theme.tablet} { + // width: 100%; + // min-height: auto; + // } `; export const BookInfoContainer = styled(FlexColumn)` diff --git a/frontend/components/common/Content/index.tsx b/frontend/components/common/Content/index.tsx index c62932a2..f1569cee 100644 --- a/frontend/components/common/Content/index.tsx +++ b/frontend/components/common/Content/index.tsx @@ -1,3 +1,5 @@ +import { markdown2html } from '@utils/parser'; + import { ContentBody, ContentTitle, ContentWrapper } from './styled'; import 'highlight.js/styles/github.css'; @@ -13,7 +15,7 @@ export default function Content({ title, content }: ContentProps) { {title && {title}} diff --git a/frontend/components/common/Content/styled.ts b/frontend/components/common/Content/styled.ts index 14e8e97d..e9f55c85 100644 --- a/frontend/components/common/Content/styled.ts +++ b/frontend/components/common/Content/styled.ts @@ -56,7 +56,11 @@ export const ContentBody = styled.div` p { img { - width: 100%; + max-width: 720px; + + @media ${(props) => props.theme.mobile} { + width: 100%; + } } } diff --git a/frontend/components/common/DragDrop/Container/index.tsx b/frontend/components/common/DragDrop/Container/index.tsx index e1c50ef8..11849e36 100644 --- a/frontend/components/common/DragDrop/Container/index.tsx +++ b/frontend/components/common/DragDrop/Container/index.tsx @@ -1,11 +1,10 @@ -import { useEffect, memo, useCallback } from 'react'; +import { memo, useCallback } from 'react'; import { useDrop } from 'react-dnd'; import update from 'immutability-helper'; import { useRecoilState } from 'recoil'; import scrapState from '@atoms/scrap'; -import { IScrap } from '@interfaces'; import { ListItem } from '../ListItem'; import ContainerWapper from './styled'; @@ -15,22 +14,15 @@ const ItemTypes = { }; export interface ContainerState { - data: IScrap[]; isContentsShown: boolean; isDeleteBtnShown: boolean; } const DragContainer = memo(function Container({ - data, isContentsShown, isDeleteBtnShown, }: ContainerState) { const [scraps, setScraps] = useRecoilState(scrapState); - useEffect(() => { - if (!data) return; - setScraps(data); - }, []); - const findScrap = useCallback( (id: number) => { const scrap = scraps.filter((c) => c.article.id === id)[0]; diff --git a/frontend/components/common/DragDrop/ListItem/index.tsx b/frontend/components/common/DragDrop/ListItem/index.tsx index 08d0e279..143c7ecd 100644 --- a/frontend/components/common/DragDrop/ListItem/index.tsx +++ b/frontend/components/common/DragDrop/ListItem/index.tsx @@ -82,8 +82,6 @@ export const ListItem = memo(function Scrap({ ); const handleMinusBtnClick = () => { - // 원본글이 아니면 스크랩에서만 삭제 - // 원본글이면 실제로 삭제 if (isOriginal) { if (window.confirm('이 글은 원본 글입니다. 정말로 삭제하시겠습니까?')) { setEditInfo({ diff --git a/frontend/components/common/DragDrop/ListItem/styled.ts b/frontend/components/common/DragDrop/ListItem/styled.ts index d6be6f76..dbee668e 100644 --- a/frontend/components/common/DragDrop/ListItem/styled.ts +++ b/frontend/components/common/DragDrop/ListItem/styled.ts @@ -16,6 +16,7 @@ export const Article = styled.div<{ isShown: true | false }>` align-items: center; border-bottom: 1px solid var(--grey-02-color); padding: 5px; + cursor: pointer; `; export const MinusButton = styled.div` diff --git a/frontend/components/common/DragDrop/index.tsx b/frontend/components/common/DragDrop/index.tsx index ed4d57d7..73be9ce4 100644 --- a/frontend/components/common/DragDrop/index.tsx +++ b/frontend/components/common/DragDrop/index.tsx @@ -13,19 +13,14 @@ export interface EditScrap { }; } export interface ContainerState { - data: EditScrap[]; isContentsShown: boolean; isDeleteBtnShown: boolean; } -export default function DragArticle({ data, isContentsShown, isDeleteBtnShown }: ContainerState) { +export default function DragArticle({ isContentsShown, isDeleteBtnShown }: ContainerState) { return ( - + ); } diff --git a/frontend/components/common/LabeledInput/styled.ts b/frontend/components/common/LabeledInput/styled.ts index 4cc7f700..7c297653 100644 --- a/frontend/components/common/LabeledInput/styled.ts +++ b/frontend/components/common/LabeledInput/styled.ts @@ -13,4 +13,14 @@ export const Input = styled.input` font-size: 16px; border: 1px solid var(--grey-02-color); border-radius: 10px; + + ::placeholder { + font-size: 14px; + } + + @media ${(props) => props.theme.mobile} { + ::placeholder { + font-size: 12px; + } + } `; diff --git a/frontend/components/edit/EditBar/index.tsx b/frontend/components/edit/EditBar/index.tsx index 18127792..04fa80c2 100644 --- a/frontend/components/edit/EditBar/index.tsx +++ b/frontend/components/edit/EditBar/index.tsx @@ -1,3 +1,4 @@ +import Image from 'next/image'; import { useRouter } from 'next/router'; import { useEffect } from 'react'; @@ -5,9 +6,11 @@ import { useEffect } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { createTemporaryArticleApi, getTemporaryArticleApi } from '@apis/articleApi'; +import ExitIcon from '@assets/ico_exit.svg'; import articleState from '@atoms/article'; import articleBuffer from '@atoms/articleBuffer'; import useFetch from '@hooks/useFetch'; +import { toastSuccess } from '@utils/toast'; import { Bar, ButtonGroup, ExitButton, PublishButton, TemporaryButton } from './styled'; @@ -25,11 +28,19 @@ export default function EditBar({ handleModalOpen, isModifyMode }: EditBarProps) const { execute: createTemporaryArticle } = useFetch(createTemporaryArticleApi); const handleLoadButton = () => { - getTemporaryArticle(); + const confirm = window.confirm('현재 작성하신 글이 사라집니다.\n정말 불러오시겠습니까?'); + + if (confirm) getTemporaryArticle(); }; const handleSaveButton = () => { - createTemporaryArticle({ title: article.title, content: article.content }); + const confirm = window.confirm('기존에 임시 저장한 글이 사라집니다.\n정말 저장하시겠습니까?'); + + if (confirm) { + createTemporaryArticle({ title: article.title, content: article.content }); + + toastSuccess('글을 임시 저장했습니다.'); + } }; const handleExitButton = () => { @@ -45,20 +56,22 @@ export default function EditBar({ handleModalOpen, isModifyMode }: EditBarProps) title: temporaryArticle.title, content: temporaryArticle.content, }); + + toastSuccess('임시 저장된 글을 불러왔습니다.'); }, [temporaryArticle]); return ( handleExitButton()}> - 나가기 + Exit Icon handleLoadButton()}>불러오기 - handleSaveButton()}>저장 + handleSaveButton()}>저장하기 - {isModifyMode ? '수정하기' : '발행'} + {isModifyMode ? '수정하기' : '발행하기'} diff --git a/frontend/components/edit/EditBar/styled.ts b/frontend/components/edit/EditBar/styled.ts index d4d5dc70..6e90e5c6 100644 --- a/frontend/components/edit/EditBar/styled.ts +++ b/frontend/components/edit/EditBar/styled.ts @@ -20,15 +20,17 @@ export const ButtonGroup = styled.div` `; const Button = styled.button` - padding: 8px 16px; - border-radius: 10px; + padding: 4px; + border-radius: 8px; + line-height: 16px; font-family: 'Noto Sans KR'; `; -export const ExitButton = styled(Button)` - color: var(--title-active-color); - background-color: var(--light-orange-color); - border: 1px solid var(--grey-01-color); +export const ExitButton = styled.button` + > img { + width: 32px; + height: 32px; + } `; export const TemporaryButton = styled(Button)` diff --git a/frontend/components/edit/Editor/index.tsx b/frontend/components/edit/Editor/index.tsx index 90b3d8cd..99fab747 100644 --- a/frontend/components/edit/Editor/index.tsx +++ b/frontend/components/edit/Editor/index.tsx @@ -20,7 +20,6 @@ import EditBar from '@components/edit/EditBar'; import useCodeMirror from '@components/edit/Editor/core/useCodeMirror'; import useInput from '@hooks/useInput'; import { IArticle } from '@interfaces'; -import { html2markdown, markdown2html } from '@utils/parser'; import { EditorButtonWrapper, @@ -68,7 +67,7 @@ export default function Editor({ handleModalOpen, originalArticle }: EditorProps if (!buffer.title && !buffer.content) return; title.setValue(buffer.title); - replaceDocument(html2markdown(buffer.content)); + replaceDocument(buffer.content); setBuffer({ title: '', content: '' }); }, [buffer]); @@ -77,7 +76,7 @@ export default function Editor({ handleModalOpen, originalArticle }: EditorProps setArticle({ ...article, title: title.value, - content: markdown2html(document), + content: document, }); }, [title.value, document]); diff --git a/frontend/components/edit/ModifyModal/index.tsx b/frontend/components/edit/ModifyModal/index.tsx index 8c56b093..0ae32357 100644 --- a/frontend/components/edit/ModifyModal/index.tsx +++ b/frontend/components/edit/ModifyModal/index.tsx @@ -12,6 +12,7 @@ import Dropdown from '@components/common/Dropdown'; import ModalButton from '@components/common/Modal/ModalButton'; import useFetch from '@hooks/useFetch'; import { IArticle, IBook, IBookScraps, IScrap } from '@interfaces'; +import { toastSuccess } from '@utils/toast'; import { ArticleWrapper, Label, ModifyModalWrapper, WarningLabel } from './styled'; @@ -95,7 +96,11 @@ export default function ModifyModal({ books, originalArticle }: ModifyModalProps }; useEffect(() => { - if (modifiedArticle) router.push('/'); + if (modifiedArticle) { + const { id, title } = modifiedArticle.modifiedArticle; + router.push(`/viewer/${selectedBookIndex}/${id}`); + toastSuccess(`${title}글이 수정되었습니다.`); + } }, [modifiedArticle]); return ( @@ -113,11 +118,7 @@ export default function ModifyModal({ books, originalArticle }: ModifyModalProps {filteredScraps.length !== 0 && ( - + )} diff --git a/frontend/components/edit/PublishModal/index.tsx b/frontend/components/edit/PublishModal/index.tsx index bb7de33c..95460e5d 100644 --- a/frontend/components/edit/PublishModal/index.tsx +++ b/frontend/components/edit/PublishModal/index.tsx @@ -12,6 +12,7 @@ import Dropdown from '@components/common/Dropdown'; import ModalButton from '@components/common/Modal/ModalButton'; import useFetch from '@hooks/useFetch'; import { IBook, IBookScraps, IScrap } from '@interfaces'; +import { toastSuccess } from '@utils/toast'; import { ArticleWrapper, Label, PublishModalWrapper } from './styled'; @@ -73,7 +74,11 @@ export default function PublishModal({ books }: PublishModalProps) { }; useEffect(() => { - if (createdArticle) router.push('/'); + if (createdArticle) { + const { id, title } = createdArticle.createdArticle; + router.push(`/viewer/${selectedBookIndex}/${id}`); + toastSuccess(`${title}글이 발행되었습니다.`); + } }, [createdArticle]); return ( @@ -89,11 +94,7 @@ export default function PublishModal({ books }: PublishModalProps) { {filteredScraps.length !== 0 && ( - + )} diff --git a/frontend/components/home/Slider/index.tsx b/frontend/components/home/Slider/index.tsx index 8f17cd51..f5dc184e 100644 --- a/frontend/components/home/Slider/index.tsx +++ b/frontend/components/home/Slider/index.tsx @@ -1,13 +1,15 @@ import Image from 'next/image'; -import { useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import LeftArrowIcon from '@assets/ico_arrow_left.svg'; import RightArrowIcon from '@assets/ico_arrow_right.svg'; import ListIcon from '@assets/ico_flower.svg'; import Book from '@components/common/Book'; import SkeletonBook from '@components/common/SkeletonBook'; +import useSessionStorage from '@hooks/useSessionStorage'; import { IBookScraps } from '@interfaces'; +import { Flex } from '@styles/layout'; import { SliderContent, @@ -37,8 +39,19 @@ const setNumBetween = (val: number, min: number, max: number) => { }; function Slider({ bookList, title, isLoading, numberPerPage }: SliderProps) { - const [curBookIndex, setCurBookIndex] = useState(0); - const [sliderNumber, setSliderNumber] = useState(1); + const { + value: curBookIndex, + isValueSet: isCurBookIndexSet, + setValue: setCurBookIndex, + } = useSessionStorage(`${title}_curBookIndex`, 0); + + const { + value: sliderNumber, + isValueSet: isSliderNumberSet, + setValue: setSliderNumber, + } = useSessionStorage(`${title}_sliderNumber`, 1); + + const [touchPositionX, setTouchPositionX] = useState(0); const SkeletonList = Array.from({ length: numberPerPage }, (_, i) => i + 1); @@ -55,6 +68,20 @@ function Slider({ bookList, title, isLoading, numberPerPage }: SliderProps) { setSliderNumber(sliderNumber + 1); }; + const handleSliderTrackTouchStart = (e: React.TouchEvent) => { + setTouchPositionX(e.changedTouches[0].pageX); + }; + + const handleSliderTrackTouchEnd = (e: React.TouchEvent) => { + const distanceX = touchPositionX - e.changedTouches[0].pageX; + if (distanceX > 30 && sliderNumber !== sliderIndicatorCount) { + handleRightArrowClick(); + } + if (distanceX < -30 && sliderNumber !== 1) { + handleLeftArrowClick(); + } + }; + useEffect(() => { if (!bookList) return; @@ -93,15 +120,25 @@ function Slider({ bookList, title, isLoading, numberPerPage }: SliderProps) { - - {isLoading - ? SkeletonList.map((key) => ) - : bookList.map((book) => ( - - - - ))} - + {!isLoading && isCurBookIndexSet && isSliderNumberSet ? ( + + {bookList.map((book) => ( + + + + ))} + + ) : ( + + {SkeletonList.map((key) => ( + + ))} + + )} diff --git a/frontend/components/home/Slider/styled.ts b/frontend/components/home/Slider/styled.ts index eee8827c..40fa5bf7 100644 --- a/frontend/components/home/Slider/styled.ts +++ b/frontend/components/home/Slider/styled.ts @@ -51,7 +51,7 @@ export const SliderBookContainer = styled.div` export const SliderTrack = styled.div<{ curBookIndex: number }>` display: flex; ${(props) => `transform: translateX(-${300 * props.curBookIndex}px);`} - transition: transform 700ms ease 0ms; + transition: transform 500ms ease 0ms; `; export const SliderIndicatorContainer = styled.div` diff --git a/frontend/components/search/ArticleList/index.tsx b/frontend/components/search/ArticleList/index.tsx index e59f4919..55779c4b 100644 --- a/frontend/components/search/ArticleList/index.tsx +++ b/frontend/components/search/ArticleList/index.tsx @@ -1,5 +1,6 @@ import ArticleItem from '@components/search/ArticleItem'; import { IArticleBook } from '@interfaces'; +import { markdown2text } from '@utils/parser'; interface ArticleListProps { articles: IArticleBook[]; @@ -24,7 +25,7 @@ export default function ArticleList({ articles, keywords }: ArticleListProps) { let paddingIndex = 0; if (isFirst) { - const regex = /(<([^>]+)>)/g; + const regex = /\n/g; while (regex.test(text.slice(0, startIndex))) paddingIndex = regex.lastIndex; } @@ -33,7 +34,7 @@ export default function ArticleList({ articles, keywords }: ArticleListProps) { <> {text.slice(paddingIndex, startIndex)} {text.slice(startIndex, endIndex)} - {highlightWord(text.slice(endIndex).replace(/(<([^>]+)>)/gi, ''), words)} + {highlightWord(text.slice(endIndex), words)} ); }; @@ -44,7 +45,7 @@ export default function ArticleList({ articles, keywords }: ArticleListProps) { void; + handleFilter: (value: { [value: string]: string | boolean }) => void; filter: Filter; } @@ -43,8 +43,8 @@ export default function SearchFilter({ handleFilter, filter }: SearchFilterProps handleFilter({ userId: e.target.checked ? signInStatus.id : 0 })} - checked={filter.userId !== 0} + onChange={() => handleFilter({ isUsers: !filter.isUsers })} + checked={filter.isUsers} /> 내 책에서 검색 diff --git a/frontend/components/study/BookListTab/index.tsx b/frontend/components/study/BookListTab/index.tsx index 76cd6fd4..ba16c4df 100644 --- a/frontend/components/study/BookListTab/index.tsx +++ b/frontend/components/study/BookListTab/index.tsx @@ -1,16 +1,17 @@ -import dynamic from 'next/dynamic'; - import React, { useState } from 'react'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useSetRecoilState } from 'recoil'; import MinusWhite from '@assets/ico_minus_white.svg'; import curKnottedBookListState from '@atoms/curKnottedBookList'; import editInfoState from '@atoms/editInfo'; +import scrapState from '@atoms/scrap'; import Book from '@components/common/Book'; +import Modal from '@components/common/Modal'; import FAB from '@components/study/FAB'; import { IBookScraps } from '@interfaces'; +import EditBookModal from '../EditBookModal'; import { BookGrid, BookListTabWrapper, @@ -34,11 +35,9 @@ export default function BookListTab({ bookmarkedBookList, isUserMatched, }: BookListTabProps) { - const Modal = dynamic(() => import('@components/common/Modal')); - const EditBookModal = dynamic(() => import('@components/study/EditBookModal')); - const [curKnottedBookList, setCurKnottedBookList] = useRecoilState(curKnottedBookListState); const [editInfo, setEditInfo] = useRecoilState(editInfoState); + const setScraps = useSetRecoilState(scrapState); const [isModalShown, setModalShown] = useState(false); const [curEditBook, setCurEditBook] = useState(null); @@ -51,10 +50,12 @@ export default function BookListTab({ setModalShown(true); setCurEditBook(curBook); + setScraps(curBook.scraps); }; const handleModalClose = () => { setModalShown(false); + setScraps([]); }; const handleMinusBtnClick = (e: React.MouseEvent, id: number) => { diff --git a/frontend/components/study/EditBookModal/index.tsx b/frontend/components/study/EditBookModal/index.tsx index e3033ccd..803e208d 100644 --- a/frontend/components/study/EditBookModal/index.tsx +++ b/frontend/components/study/EditBookModal/index.tsx @@ -41,7 +41,7 @@ interface BookProps { } export default function EditBookModal({ book, handleModalClose }: BookProps) { - const { id, title, user, scraps } = book; + const { id, title, user } = book; const { data: imgFile, execute: createImage } = useFetch(createImageApi); const { value: titleData, onChange: onTitleChange } = useInput(title); @@ -106,7 +106,6 @@ export default function EditBookModal({ book, handleModalClose }: BookProps) { }, ], }); - handleModalClose(); }; @@ -151,11 +150,11 @@ export default function EditBookModal({ book, handleModalClose }: BookProps) { Contents - {isContentsShown ? '완료' : '수정'} + {isContentsShown ? '순서저장' : '순서수정'} - + {isContentsShown && ( 드래그앤드롭으로 글의 순서를 변경할 수 있습니다. diff --git a/frontend/components/study/FAB/index.tsx b/frontend/components/study/FAB/index.tsx index 65563d63..cc3f1bf0 100644 --- a/frontend/components/study/FAB/index.tsx +++ b/frontend/components/study/FAB/index.tsx @@ -1,6 +1,6 @@ import Image from 'next/image'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useRecoilState } from 'recoil'; @@ -24,10 +24,15 @@ interface FabProps { } export default function FAB({ isEditing, setIsEditing }: FabProps) { - const { data: deletedBook, execute: deleteBook } = useFetch(deleteBookApi); - const { data: editBookData, execute: editBook } = useFetch(editBookApi); - const { data: deleteArticleData, execute: deleteArticle } = useFetch(deleteArticleApi); - const { data: deleteScrapData, execute: deleteScrap } = useFetch(deleteScrapApi); + const { execute: deleteBook } = useFetch(deleteBookApi); + const { execute: editBook } = useFetch(editBookApi); + const { execute: deleteArticle } = useFetch(deleteArticleApi); + const { execute: deleteScrap } = useFetch(deleteScrapApi); + + // const { data: deletedBook, execute: deleteBook } = useFetch(deleteBookApi); + // const { data: editBookData, execute: editBook } = useFetch(editBookApi); + // const { data: deleteArticleData, execute: deleteArticle } = useFetch(deleteArticleApi); + // const { data: deleteScrapData, execute: deleteScrap } = useFetch(deleteScrapApi); const [editInfo, setEditInfo] = useRecoilState(editInfoState); @@ -48,43 +53,74 @@ export default function FAB({ isEditing, setIsEditing }: FabProps) { editInfo.editted.forEach((edit) => { editBook(edit); }); - // 원본글 삭제 editInfo.deletedArticle.forEach((articleId) => { deleteArticle(articleId); }); - // 스크랩 삭제 editInfo.deletedScraps.forEach((scrapId) => { deleteScrap(scrapId); }); - }; - - useEffect(() => { - if (!deletedBook) return; - setEditInfo({ - ...editInfo, - deleted: editInfo.deleted.filter((id) => id !== deletedBook.id), + deleted: [], + editted: [], + deletedArticle: [], + deletedScraps: [], }); - }, [deletedBook]); - - useEffect(() => { - if (!editBookData) return; + toastSuccess(`수정 완료되었습니다`); + }; - setEditInfo({ - ...editInfo, - editted: editInfo.editted.filter((edit) => edit.id !== editBookData.id), - }); - }, [editBookData]); - - useEffect(() => { - if ( - (deletedBook || editBookData) && - editInfo.deleted.length === 0 && - editInfo.editted.length === 0 - ) { - toastSuccess(`수정 완료되었습니다`); - } - }, [editInfo]); + // useEffect(() => { + // if (!deletedBook) return; + + // setEditInfo({ + // ...editInfo, + // deleted: editInfo.deleted.filter((id) => id !== deletedBook.id), + // }); + // }, [deletedBook]); + + // useEffect(() => { + // console.log('editBookData', editBookData); + // if (!editBookData) return; + + // setEditInfo({ + // ...editInfo, + // editted: editInfo.editted.filter((edit) => edit.id !== editBookData.id), + // deletedScraps: editInfo.deletedScraps.filter((scrapId) => scrapId !== deleteScrapData.id), + // }); + // }, [editBookData, deleteScrapData]); + + // useEffect(() => { + // if (!deleteArticleData) return; + // console.log('deleteArticleData', deleteArticleData); + + // setEditInfo({ + // ...editInfo, + // deletedArticle: editInfo.deletedArticle.filter( + // (articleId) => articleId !== deleteArticleData.id + // ), + // }); + // }, [deleteArticleData]); + + // useEffect(() => { + // if (!deleteScrapData) return; + // console.log('deleteScrapData', deleteScrapData); + + // setEditInfo({ + // ...editInfo, + // deletedScraps: editInfo.deletedScraps.filter((scrapId) => scrapId !== deleteScrapData.id), + // }); + // }, [deleteScrapData]); + + // useEffect(() => { + // console.log(deletedBook, editBookData, deleteArticleData, deleteScrapData, editInfo); + // if ( + // (deletedBook || editBookData || deleteArticleData || deleteScrapData) && + // editInfo.deleted.length === 0 && + // editInfo.editted.length === 0 && + // editInfo.deletedArticle.length === 0 && + // editInfo.deletedScraps.length === 0 + // ) { + // } + // }, [editInfo]); return ( diff --git a/frontend/components/study/UserProfile/styled.ts b/frontend/components/study/UserProfile/styled.ts index 2b89c6bf..6cb558a4 100644 --- a/frontend/components/study/UserProfile/styled.ts +++ b/frontend/components/study/UserProfile/styled.ts @@ -40,7 +40,7 @@ export const Username = styled(TextLarge)` export const UserDescription = styled(TextSmall)` width: 400px; @media ${(props) => props.theme.tablet} { - width: 300px; + width: 250px; } `; diff --git a/frontend/components/viewer/ArticleContent/index.tsx b/frontend/components/viewer/ArticleContent/index.tsx index c33e9602..7cb51f11 100644 --- a/frontend/components/viewer/ArticleContent/index.tsx +++ b/frontend/components/viewer/ArticleContent/index.tsx @@ -16,6 +16,7 @@ import Content from '@components/common/Content'; import useFetch from '@hooks/useFetch'; import useScrollDetector from '@hooks/useScrollDetector'; import { IArticleBook, IScrap } from '@interfaces'; +import { toastSuccess } from '@utils/toast'; import ArticleButton from './Button'; import { @@ -117,6 +118,19 @@ export default function Article({ setIsScrollDown(isScrollDown ? 'true' : 'false'); }, [isScrollDown]); + useEffect(() => { + if (scrollTarget.current) { + scrollTarget.current.scrollTop = 0; + } + }, [router.query.data]); + + useEffect(() => { + if (!deleteArticleData) return; + + toastSuccess(`<${article.title}> 글이 삭제되었습니다`); + router.push('/'); + }, [deleteArticleData]); + return ( diff --git a/frontend/components/viewer/ScrapModal/index.tsx b/frontend/components/viewer/ScrapModal/index.tsx index 3d307bc9..1c7a4c54 100644 --- a/frontend/components/viewer/ScrapModal/index.tsx +++ b/frontend/components/viewer/ScrapModal/index.tsx @@ -13,6 +13,7 @@ import Dropdown from '@components/common/Dropdown'; import ModalButton from '@components/common/Modal/ModalButton'; import useFetch from '@hooks/useFetch'; import { IBook, IArticle, IScrap, IBookScraps } from '@interfaces'; +import { toastSuccess } from '@utils/toast'; import { ArticleWrapper, Label, ScrapModalWrapper, WarningLabel } from './styled'; @@ -97,6 +98,7 @@ export default function ScrapModal({ bookId, handleModalClose, article }: ScrapM useEffect(() => { if (createScrapData === undefined) return; router.push(`/viewer/${bookId}/${article.id}`); + toastSuccess(`${article.title}글이 스크랩되었습니다.`); handleModalClose(); }, [createScrapData]); @@ -117,11 +119,7 @@ export default function ScrapModal({ bookId, handleModalClose, article }: ScrapM {filteredScraps.length !== 0 && ( - + )} diff --git a/frontend/components/viewer/TOC/index.tsx b/frontend/components/viewer/TOC/index.tsx index 8390b2dc..f0f3844c 100644 --- a/frontend/components/viewer/TOC/index.tsx +++ b/frontend/components/viewer/TOC/index.tsx @@ -10,6 +10,7 @@ import useBookmark from '@hooks/useBookmark'; import { IBookScraps } from '@interfaces'; import { TextMedium, TextSmall } from '@styles/common'; import { FlexCenter, FlexSpaceBetween } from '@styles/layout'; +import { text2link } from '@utils/toc'; import { TocWrapper, @@ -25,6 +26,7 @@ import { TocArticleTitle, TocCurrentArticle, TocOpenButton, + TocCurrentText, } from './styled'; interface TocProps { @@ -86,13 +88,13 @@ export default function TOC({ ) : ( - + {v.order}.{v.article.title} - + {isArticleShown && articleToc.map((article) => ( diff --git a/frontend/components/viewer/TOC/styled.ts b/frontend/components/viewer/TOC/styled.ts index 6bdc3846..f91c4635 100644 --- a/frontend/components/viewer/TOC/styled.ts +++ b/frontend/components/viewer/TOC/styled.ts @@ -3,6 +3,7 @@ import Link from 'next/link'; import styled from 'styled-components'; +import { TextSmall } from '@styles/common'; import { Flex, FlexColumn, FlexColumnSpaceBetween } from '@styles/layout'; interface TocWrapperProps { @@ -63,11 +64,13 @@ export const TocContainer = styled.div` color: var(--grey-01-color); background-color: var(--white-color); border-radius: 16px; - overflow: auto; + overflow-x: hidden; + overflow-y: scroll; ::-webkit-scrollbar { width: 10px; } + ::-webkit-scrollbar-thumb { background-color: var(--grey-02-color); border-radius: 10px; @@ -89,23 +92,30 @@ export const TocArticle = styled(Link)` color: inherit; display: block; margin-bottom: 5px; + font-weight: 600; `; export const TocCurrentArticle = styled.div` font-size: 14px; line-height: 20px; display: block; margin-bottom: 5px; - - color: #ca7647; + color: var(--primary-color); +`; +export const TocCurrentText = styled(TextSmall)` + cursor: 'pointer'; + font-weight: 600; `; export const TocArticleTitle = styled(Link)<{ count: number | undefined }>` font-size: 14px; line-height: 20px; text-decoration: none; - color: #c29880; display: block; margin-bottom: 5px; padding-left: ${(props) => `${props.count}px`}; + + &:hover { + color: var(--primary-color); + } `; export const TocProfile = styled(Link)` diff --git a/frontend/hooks/useSessionStorage.ts b/frontend/hooks/useSessionStorage.ts index 2151e819..855d9ceb 100644 --- a/frontend/hooks/useSessionStorage.ts +++ b/frontend/hooks/useSessionStorage.ts @@ -6,9 +6,9 @@ const useSessionStorage = (key: string, initialValue: T) => { useEffect(() => { if (typeof window !== 'undefined') { - setIsValueSet(true); const savedValue = sessionStorage.getItem(key); if (savedValue) setStateValue(JSON.parse(savedValue)); + setIsValueSet(true); } }, [typeof window]); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 08e0cf5b..eec9f602 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -35,14 +35,18 @@ "react-dom": "18.2.0", "react-toastify": "^9.1.1", "recoil": "^0.7.6", + "rehype": "^12.0.1", "rehype-highlight": "^6.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", + "rehype-slug": "^5.1.0", "rehype-stringify": "^9.0.3", + "remark": "^14.0.2", "remark-breaks": "^3.0.2", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", "remark-stringify": "^10.0.2", + "strip-markdown": "^5.0.0", "styled-components": "^5.3.6", "styled-reset": "^4.4.2", "typescript": "4.8.4", @@ -3218,6 +3222,11 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" + }, "node_modules/glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", @@ -3417,6 +3426,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-heading-rank": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-2.1.0.tgz", + "integrity": "sha512-w+Rw20Q/iWp2Bcnr6uTrYU6/ftZLbHKhvc8nM26VIWpDqDMlku2iXUVTeOlsdoih/UKQhY7PHQ+vZ0Aqq8bxtQ==", + "dependencies": { + "@types/hast": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-is-body-ok-link": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-2.0.0.tgz", @@ -3518,6 +3539,18 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-2.0.0.tgz", + "integrity": "sha512-02AQ3vLhuH3FisaMM+i/9sm4OXGSq1UhOOCpTLLQtHdL3tZt7qil69r8M8iDkZYyC0HCFylcYoP+8IO7ddta1A==", + "dependencies": { + "@types/hast": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-text": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-3.1.1.tgz", @@ -5319,6 +5352,21 @@ "url": "https://github.com/sponsors/mysticatea" } }, + "node_modules/rehype": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-12.0.1.tgz", + "integrity": "sha512-ey6kAqwLM3X6QnMDILJthGvG1m1ULROS9NT4uG9IDCuv08SFyLlreSuvOa//DgEvbXx62DS6elGVqusWhRUbgw==", + "dependencies": { + "@types/hast": "^2.0.0", + "rehype-parse": "^8.0.0", + "rehype-stringify": "^9.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-highlight": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-6.0.0.tgz", @@ -5382,6 +5430,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-slug": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-5.1.0.tgz", + "integrity": "sha512-Gf91dJoXneiorNEnn+Phx97CO7oRMrpi+6r155tTxzGuLtm+QrI4cTwCa9e1rtePdL4i9tSO58PeSS6HWfgsiw==", + "dependencies": { + "@types/hast": "^2.0.0", + "github-slugger": "^2.0.0", + "hast-util-has-property": "^2.0.0", + "hast-util-heading-rank": "^2.0.0", + "hast-util-to-string": "^2.0.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-stringify": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-9.0.3.tgz", @@ -5396,6 +5462,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/remark/-/remark-14.0.2.tgz", + "integrity": "sha512-A3ARm2V4BgiRXaUo5K0dRvJ1lbogrbXnhkJRmD0yw092/Yl0kOCZt1k9ZeElEwkZsWGsMumz6qL5MfNJH9nOBA==", + "dependencies": { + "@types/mdast": "^3.0.0", + "remark-parse": "^10.0.0", + "remark-stringify": "^10.0.0", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-breaks": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-3.0.2.tgz", @@ -5783,6 +5864,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-markdown": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/strip-markdown/-/strip-markdown-5.0.0.tgz", + "integrity": "sha512-PXSts6Ta9A/TwGxVVSRlQs1ukJTAwwtbip2OheJEjPyfykaQ4sJSTnQWjLTI2vYWNts/R/91/csagp15W8n9gA==", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.6", + "unified": "^10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/style-mod": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz", @@ -8891,6 +8986,11 @@ "integrity": "sha512-X8u8fREiYOE6S8hLbq99PeykTDoLVnxvF4DjWKJmz9xy2nNRdUcV8ZN9tniJFeKyTU3qnC9lL8n4Chd6LmVKHg==", "dev": true }, + "github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" + }, "glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", @@ -9033,6 +9133,14 @@ "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-2.0.0.tgz", "integrity": "sha512-4Qf++8o5v14us4Muv3HRj+Er6wTNGA/N9uCaZMty4JWvyFKLdhULrv4KE1b65AthsSO9TXSZnjuxS8ecIyhb0w==" }, + "hast-util-heading-rank": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-2.1.0.tgz", + "integrity": "sha512-w+Rw20Q/iWp2Bcnr6uTrYU6/ftZLbHKhvc8nM26VIWpDqDMlku2iXUVTeOlsdoih/UKQhY7PHQ+vZ0Aqq8bxtQ==", + "requires": { + "@types/hast": "^2.0.0" + } + }, "hast-util-is-body-ok-link": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-2.0.0.tgz", @@ -9110,6 +9218,14 @@ "unist-util-visit": "^4.0.0" } }, + "hast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-2.0.0.tgz", + "integrity": "sha512-02AQ3vLhuH3FisaMM+i/9sm4OXGSq1UhOOCpTLLQtHdL3tZt7qil69r8M8iDkZYyC0HCFylcYoP+8IO7ddta1A==", + "requires": { + "@types/hast": "^2.0.0" + } + }, "hast-util-to-text": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-3.1.1.tgz", @@ -10242,6 +10358,17 @@ "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==" }, + "rehype": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-12.0.1.tgz", + "integrity": "sha512-ey6kAqwLM3X6QnMDILJthGvG1m1ULROS9NT4uG9IDCuv08SFyLlreSuvOa//DgEvbXx62DS6elGVqusWhRUbgw==", + "requires": { + "@types/hast": "^2.0.0", + "rehype-parse": "^8.0.0", + "rehype-stringify": "^9.0.0", + "unified": "^10.0.0" + } + }, "rehype-highlight": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-6.0.0.tgz", @@ -10289,6 +10416,20 @@ "unified": "^10.0.0" } }, + "rehype-slug": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-5.1.0.tgz", + "integrity": "sha512-Gf91dJoXneiorNEnn+Phx97CO7oRMrpi+6r155tTxzGuLtm+QrI4cTwCa9e1rtePdL4i9tSO58PeSS6HWfgsiw==", + "requires": { + "@types/hast": "^2.0.0", + "github-slugger": "^2.0.0", + "hast-util-has-property": "^2.0.0", + "hast-util-heading-rank": "^2.0.0", + "hast-util-to-string": "^2.0.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0" + } + }, "rehype-stringify": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-9.0.3.tgz", @@ -10299,6 +10440,17 @@ "unified": "^10.0.0" } }, + "remark": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/remark/-/remark-14.0.2.tgz", + "integrity": "sha512-A3ARm2V4BgiRXaUo5K0dRvJ1lbogrbXnhkJRmD0yw092/Yl0kOCZt1k9ZeElEwkZsWGsMumz6qL5MfNJH9nOBA==", + "requires": { + "@types/mdast": "^3.0.0", + "remark-parse": "^10.0.0", + "remark-stringify": "^10.0.0", + "unified": "^10.0.0" + } + }, "remark-breaks": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-3.0.2.tgz", @@ -10557,6 +10709,16 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, + "strip-markdown": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/strip-markdown/-/strip-markdown-5.0.0.tgz", + "integrity": "sha512-PXSts6Ta9A/TwGxVVSRlQs1ukJTAwwtbip2OheJEjPyfykaQ4sJSTnQWjLTI2vYWNts/R/91/csagp15W8n9gA==", + "requires": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.6", + "unified": "^10.0.0" + } + }, "style-mod": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7622954b..cdcfaf54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,14 +36,18 @@ "react-dom": "18.2.0", "react-toastify": "^9.1.1", "recoil": "^0.7.6", + "rehype": "^12.0.1", "rehype-highlight": "^6.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", + "rehype-slug": "^5.1.0", "rehype-stringify": "^9.0.3", + "remark": "^14.0.2", "remark-breaks": "^3.0.2", "remark-parse": "^10.0.1", "remark-rehype": "^10.1.0", "remark-stringify": "^10.0.2", + "strip-markdown": "^5.0.0", "styled-components": "^5.3.6", "styled-reset": "^4.4.2", "typescript": "4.8.4", diff --git a/frontend/pages/search.tsx b/frontend/pages/search.tsx index 037b92b2..05981657 100644 --- a/frontend/pages/search.tsx +++ b/frontend/pages/search.tsx @@ -31,20 +31,12 @@ export default function Search() { pageNumber: 2, }); - const { - value: filter, - isValueSet: isFilterSet, - setValue: setFilter, - } = useSessionStorage('filter', { + const { value: filter, setValue: setFilter } = useSessionStorage('filter', { type: 'article', - userId: 0, + isUsers: false, }); - const { - value: keyword, - isValueSet: isKeywordSet, - setValue: setKeyword, - } = useSessionStorage('keyword', ''); + const { value: keyword, setValue: setKeyword } = useSessionStorage('keyword', ''); const debouncedKeyword = useDebounce(keyword, 300); const [keywords, setKeywords] = useState([]); @@ -70,7 +62,7 @@ export default function Search() { }, [debouncedKeyword]); useEffect(() => { - if (!isKeywordSet || !isFilterSet || isInitialRendering) return; + if (isInitialRendering) return; if (debouncedKeyword === '') { setArticles([]); @@ -90,7 +82,7 @@ export default function Search() { searchArticles({ query: debouncedKeyword, - userId: filter.userId, + isUsers: filter.isUsers, page: 1, take: 12, }); @@ -101,7 +93,7 @@ export default function Search() { searchBooks({ query: debouncedKeyword, - userId: filter.userId, + isUsers: filter.isUsers, page: 1, take: 12, }); @@ -109,7 +101,7 @@ export default function Search() { hasNextPage: true, pageNumber: 2, }); - }, [debouncedKeyword, filter.userId]); + }, [debouncedKeyword, filter.isUsers]); useEffect(() => { if (!isIntersecting || !debouncedKeyword) return; @@ -118,7 +110,7 @@ export default function Search() { if (!articlePage.hasNextPage) return; searchArticles({ query: debouncedKeyword, - userId: filter.userId, + isUsers: filter.isUsers, page: articlePage.pageNumber, take: 12, }); @@ -130,7 +122,7 @@ export default function Search() { if (!bookPage.hasNextPage) return; searchBooks({ query: debouncedKeyword, - userId: filter.userId, + isUsers: filter.isUsers, page: bookPage.pageNumber, take: 12, }); @@ -200,7 +192,8 @@ export default function Search() { }; }, []); - const handleFilter = (value: { [value: string]: string | number }) => { + const handleFilter = (value: { [value: string]: string | boolean }) => { + setIsInitialRendering(false); setFilter({ ...filter, ...value, diff --git a/frontend/pages/viewer/[...data].tsx b/frontend/pages/viewer/[...data].tsx index c6a7e488..b5097ed5 100644 --- a/frontend/pages/viewer/[...data].tsx +++ b/frontend/pages/viewer/[...data].tsx @@ -13,7 +13,7 @@ import ViewerHead from '@components/viewer/ViewerHead'; import useFetch from '@hooks/useFetch'; import { IArticleBook, IBookScraps } from '@interfaces'; import { Flex, PageGNBHide, PageNoScrollWrapper } from '@styles/layout'; -import { articleToc, articleConversion } from '@utils/articleConversion'; +import { parseHeadings } from '@utils/toc'; interface ViewerProps { article: IArticleBook; @@ -38,6 +38,7 @@ export default function Viewer({ article }: ViewerProps) { }; const checkArticleAuthority = (targetBook: IBookScraps, id: number) => { + if (!targetBook) return false; if (targetBook.scraps.find((scrap) => scrap.article.id === id)) return true; return false; }; @@ -64,7 +65,7 @@ export default function Viewer({ article }: ViewerProps) { }, [router.query.data]); useEffect(() => { - if (!book) return; + if (book === undefined) return; if (!checkArticleAuthority(book, article.id)) router.push('/404'); }, [book]); @@ -81,7 +82,7 @@ export default function Viewer({ article }: ViewerProps) { diff --git a/frontend/public/assets/ico_exit.svg b/frontend/public/assets/ico_exit.svg new file mode 100644 index 00000000..e7efdf8e --- /dev/null +++ b/frontend/public/assets/ico_exit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 718d6fea..8923f4ab 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/utils/articleConversion.ts b/frontend/utils/articleConversion.ts index 94e01841..27dfc7f7 100644 --- a/frontend/utils/articleConversion.ts +++ b/frontend/utils/articleConversion.ts @@ -29,7 +29,7 @@ export const articleConversion = (content: string) => { if (v.includes('h1') || v.includes('h2') || v.includes('h3')) { const title = v.replace(/<[^>]*>?/g, ''); const result = v.split(''); - result.splice(3, 0, ' ', `id=${title}`); + result.splice(3, 0, ' ', `id="${title}"`); return result.join(''); } return v; diff --git a/frontend/utils/parser.ts b/frontend/utils/parser.ts index 42fea91b..6803d3a0 100644 --- a/frontend/utils/parser.ts +++ b/frontend/utils/parser.ts @@ -1,11 +1,15 @@ +import { rehype } from 'rehype'; import rehypeHighlight from 'rehype-highlight'; import rehypeParse from 'rehype-parse'; import rehypeRemark from 'rehype-remark'; +import rehypeSlug from 'rehype-slug'; import rehypeStringify from 'rehype-stringify'; +import { remark } from 'remark'; import remarkBreaks from 'remark-breaks'; import remarkParse from 'remark-parse'; import remarkRehype from 'remark-rehype'; import remarkStringify from 'remark-stringify'; +import stripMarkdown from 'strip-markdown'; import { unified } from 'unified'; export const markdown2html = (markdown: string) => { @@ -17,19 +21,28 @@ export const markdown2html = (markdown: string) => { .processSync(markdown) .toString(); - return unified() - .use(rehypeParse) + const htmlWithSyntaxHighlight = rehype() + .use(rehypeSlug) .use(rehypeHighlight, { ignoreMissing: true }) - .use(rehypeStringify) .processSync(html) .toString(); + + return htmlWithSyntaxHighlight; }; export const html2markdown = (html: string) => { - return unified() + const markdown = unified() .use(rehypeParse) .use(rehypeRemark) .use(remarkStringify) .processSync(html) .toString(); + + return markdown; +}; + +export const markdown2text = (markdown: string) => { + const text = remark().use(stripMarkdown).processSync(markdown).toString(); + + return text; }; diff --git a/frontend/utils/toast.ts b/frontend/utils/toast.ts index 9b2b20b2..d1172285 100644 --- a/frontend/utils/toast.ts +++ b/frontend/utils/toast.ts @@ -3,7 +3,7 @@ import { toast } from 'react-toastify'; export const toastError = (message: string) => { toast.error(message, { position: 'top-right', - autoClose: 3000, + autoClose: 1000, hideProgressBar: false, closeOnClick: true, pauseOnHover: true, @@ -16,7 +16,7 @@ export const toastError = (message: string) => { export const toastSuccess = (message: string) => { toast.success(message, { position: 'top-right', - autoClose: 3000, + autoClose: 1000, hideProgressBar: false, closeOnClick: true, pauseOnHover: true, diff --git a/frontend/utils/toc.ts b/frontend/utils/toc.ts new file mode 100644 index 00000000..505550f8 --- /dev/null +++ b/frontend/utils/toc.ts @@ -0,0 +1,27 @@ +export const parseHeadings = (content: string) => { + // 게시물 본문을 줄바꿈 기준으로 나누고, 제목 요소인 것만 저장 + const headings = content.split('\n').filter((line) => line.includes('# ')); + + // 예외처리 - 제목은 문자열 시작부터 #을 써야함 + const parsedHeadings = headings + .filter((heading) => heading.startsWith('#')) + .map((heading) => { + // #의 갯수에 따라 제목의 크기가 달라지므로 갯수를 센다. + let count = heading.match(/#/g)?.length; + + // 갯수에 따라 목차에 그릴때 들여쓰기 하기위해 *10을 함. + if (count) count *= 16; + + // 제목의 내용물만 꺼내기 위해 '# '을 기준으로 나누고, 백틱과 공백을 없애주고 count와 묶어서 리턴 + return { + title: heading.split('# ')[1].trim(), + count, + }; + }); + + return parsedHeadings; +}; + +export const text2link = (text: string) => { + return `#${text.replace(/ /g, '-').replace(/[^\uAC00-\uD7A30-9a-zA-Z_-]/g, '')}`; +};