diff --git a/backend/src/apis/books/books.service.ts b/backend/src/apis/books/books.service.ts index 1a2f0145..15965734 100644 --- a/backend/src/apis/books/books.service.ts +++ b/backend/src/apis/books/books.service.ts @@ -20,6 +20,7 @@ const searchBooks = async ({ query, userId, take, page }: SearchBooks) => { select: { id: true, order: true, + is_original: true, article: { select: { id: true, @@ -76,7 +77,9 @@ const getBook = async (bookId: number, userId: number) => { scraps: { orderBy: { order: 'asc' }, select: { + id: true, order: true, + is_original: true, article: { select: { id: true, diff --git a/backend/src/apis/index.ts b/backend/src/apis/index.ts index 312219cf..172ca29a 100644 --- a/backend/src/apis/index.ts +++ b/backend/src/apis/index.ts @@ -42,6 +42,7 @@ router.post('/bookmarks', guard, catchAsync(bookmarksController.createBookmark)) router.delete('/bookmarks/:bookmarkId', catchAsync(bookmarksController.deleteBookmark)); router.get('/scraps', catchAsync(scrapsController.getScraps)); +router.patch('/scraps', catchAsync(guard), catchAsync(scrapsController.updateScrapsOrder)); router.post('/scraps', catchAsync(scrapsController.createScrap)); router.delete('/scraps/:scrapId', guard, catchAsync(scrapsController.deleteScrap)); diff --git a/backend/src/apis/scraps/scraps.controller.ts b/backend/src/apis/scraps/scraps.controller.ts index e834fd91..99307850 100644 --- a/backend/src/apis/scraps/scraps.controller.ts +++ b/backend/src/apis/scraps/scraps.controller.ts @@ -36,8 +36,19 @@ const getScraps = async (req: Request, res: Response) => { return res.status(200).send(scraps); }; +const updateScrapsOrder = async (req: Request, res: Response) => { + const scraps = req.body; + + scraps.forEach(async (scrap: IScrap) => { + await scrapsService.updateScrapOrder(scrap); + }); + + res.status(200).send(scraps); +}; + export default { createScrap, deleteScrap, getScraps, + updateScrapsOrder, }; diff --git a/frontend/apis/scrapApi.ts b/frontend/apis/scrapApi.ts index 2ac50ba2..47ece7ff 100644 --- a/frontend/apis/scrapApi.ts +++ b/frontend/apis/scrapApi.ts @@ -1,3 +1,4 @@ +import { IScrap } from '@interfaces'; import api from '@utils/api'; export const getScrapsApi = async () => { @@ -30,3 +31,9 @@ export const deleteScrapApi = async (scrapId: string) => { return response.data; }; +export const updateScrapsOrderApi = async (data: IScrap[]) => { + const url = `/api/scraps`; + const response = await api({ url, method: 'PATCH', data }); + + return response.data; +}; diff --git a/frontend/components/common/Book/index.tsx b/frontend/components/common/Book/index.tsx index 9e4fc0fb..7cd28b13 100644 --- a/frontend/components/common/Book/index.tsx +++ b/frontend/components/common/Book/index.tsx @@ -1,13 +1,10 @@ -import Image from 'next/image'; - import InactiveBookmarkIcon from '@assets/ico_bookmark_black.svg'; import ActiveBookmarkIcon from '@assets/ico_bookmark_grey_filled.svg'; -import MoreContentsIcon from '@assets/ico_more_contents.svg'; import sampleImage from '@assets/img_sample_thumbnail.jpg'; import useBookmark from '@hooks/useBookmark'; import { IBookScraps } from '@interfaces'; import { TextLarge, TextXSmall, TextSmall } from '@styles/common'; -import { FlexCenter, FlexSpaceBetween } from '@styles/layout'; +import { FlexSpaceBetween } from '@styles/layout'; import { BookWrapper, diff --git a/frontend/components/common/Content/index.tsx b/frontend/components/common/Content/index.tsx index 3fb1f599..c62932a2 100644 --- a/frontend/components/common/Content/index.tsx +++ b/frontend/components/common/Content/index.tsx @@ -1,5 +1,7 @@ import { ContentBody, ContentTitle, ContentWrapper } from './styled'; +import 'highlight.js/styles/github.css'; + interface ContentProps { title?: string; content: string; diff --git a/frontend/components/common/Content/styled.ts b/frontend/components/common/Content/styled.ts index af2efe59..1fd5f4cf 100644 --- a/frontend/components/common/Content/styled.ts +++ b/frontend/components/common/Content/styled.ts @@ -68,8 +68,6 @@ export const ContentBody = styled.div` blockquote { margin: 24px 0; padding: 24px 16px; - /* background-color: var(--light-orange-color); */ - /* border-radius: 4px; */ border-left: 8px solid var(--light-orange-color); } @@ -90,6 +88,7 @@ export const ContentBody = styled.div` code { padding: 0; + white-space: pre-wrap; } } `; diff --git a/frontend/components/study/BookListTab/index.tsx b/frontend/components/study/BookListTab/index.tsx index f659d3a6..98187a7d 100644 --- a/frontend/components/study/BookListTab/index.tsx +++ b/frontend/components/study/BookListTab/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useState } from 'react'; import { useRecoilState } from 'recoil'; diff --git a/frontend/components/viewer/ArticleContent/index.tsx b/frontend/components/viewer/ArticleContent/index.tsx index 7536c14d..67c2bf61 100644 --- a/frontend/components/viewer/ArticleContent/index.tsx +++ b/frontend/components/viewer/ArticleContent/index.tsx @@ -6,6 +6,7 @@ import { useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { deleteArticleApi } from '@apis/articleApi'; +import { deleteScrapApi, updateScrapsOrderApi } from '@apis/scrapApi'; import LeftBtnIcon from '@assets/ico_leftBtn.svg'; import Original from '@assets/ico_original.svg'; import RightBtnIcon from '@assets/ico_rightBtn.svg'; @@ -45,6 +46,9 @@ export default function Article({ const user = useRecoilValue(signInStatusState); const { data: deleteArticleData, execute: deleteArticle } = useFetch(deleteArticleApi); + const { execute: deleteScrap } = useFetch(deleteScrapApi); + const { data: updateScrapsData, execute: updateScrapsOrder } = useFetch(updateScrapsOrderApi); + const router = useRouter(); const handleOriginalBtnOnClick = () => { @@ -65,13 +69,21 @@ export default function Article({ const handleDeleteBtnOnClick = () => { if (window.confirm('해당 글을 삭제하시겠습니까?')) { + const curScrap = scraps.find((scrap) => scrap.article.id === article.id); + deleteScrap(curScrap?.id); deleteArticle(article.id); } }; const handleScrapDeleteBtnOnClick = () => { if (window.confirm('해당 글을 책에서 삭제하시겠습니까?')) { - // + const curScrap = scraps.find((scrap) => scrap.article.id === article.id); + if (!curScrap) return; + const newScraps = scraps + .filter((scrap) => scrap.id !== curScrap.id) + .map((v, i) => ({ ...v, order: i + 1 })); + updateScrapsOrder(newScraps); + deleteScrap(curScrap.id); } }; @@ -83,6 +95,16 @@ export default function Article({ if (deleteArticleData !== undefined) router.push('/'); }, [deleteArticleData]); + useEffect(() => { + if (updateScrapsData === undefined) return; + + if (updateScrapsData.length !== 0) { + router.push(`/viewer/${bookId}/${updateScrapsData[0].article.id}`); + return; + } + router.push('/'); + }, [updateScrapsData]); + return ( {article.id === scraps.at(0)?.article.id ? null : ( @@ -112,9 +134,9 @@ export default function Article({ 글 수정 )} - {/* {article.book_id !== bookId && bookAuthor === user.nickname && ( + {article.book_id !== bookId && bookAuthor === user.nickname && ( 스크랩 삭제 - )} */} + )} {user.id !== 0 && ( Scrap Icon diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 58dad363..92adead7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "dotenv-webpack": "^8.0.1", "eslint": "8.27.0", "eslint-config-next": "13.0.3", + "highlight.js": "^11.7.0", "immutability-helper": "^3.1.1", "next": "13.0.3", "next-sitemap": "^3.1.32", @@ -33,6 +34,7 @@ "react-dom": "18.2.0", "react-toastify": "^9.1.1", "recoil": "^0.7.6", + "rehype-highlight": "^6.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", @@ -3036,6 +3038,18 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3122,6 +3136,14 @@ "node": ">= 6" } }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3533,6 +3555,14 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.7.0.tgz", + "integrity": "sha512-1rRqesRFhMO/PRF+G86evnyJkCgaZFOI+Z6kdj15TA18funfoqJXvgPCLSf0SWq3SRfg1j3HlDs8o4s3EGq1oQ==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -4051,6 +4081,20 @@ "loose-envify": "cli.js" } }, + "node_modules/lowlight": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.8.0.tgz", + "integrity": "sha512-WeExw1IKEkel9ZcYwzpvcFzORIB0IlleTcxJYoEuUgHASuYe/OBYbV6ym/AetG7unNVCBU/SXpgTgs2nT93mhg==", + "dependencies": { + "@types/hast": "^2.0.0", + "fault": "^2.0.0", + "highlight.js": "~11.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -5273,6 +5317,22 @@ "url": "https://github.com/sponsors/mysticatea" } }, + "node_modules/rehype-highlight": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-6.0.0.tgz", + "integrity": "sha512-q7UtlFicLhetp7K48ZgZiJgchYscMma7XjzX7t23bqEJF8m6/s+viXQEe4oHjrATTIZpX7RG8CKD7BlNZoh9gw==", + "dependencies": { + "@types/hast": "^2.0.0", + "hast-util-to-text": "^3.0.0", + "lowlight": "^2.0.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-minify-whitespace": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/rehype-minify-whitespace/-/rehype-minify-whitespace-5.0.1.tgz", @@ -8697,6 +8757,14 @@ "reusify": "^1.0.4" } }, + "fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "requires": { + "format": "^0.2.0" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -8751,6 +8819,11 @@ "mime-types": "^2.1.12" } }, + "format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==" + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -9048,6 +9121,11 @@ "space-separated-tokens": "^2.0.0" } }, + "highlight.js": { + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.7.0.tgz", + "integrity": "sha512-1rRqesRFhMO/PRF+G86evnyJkCgaZFOI+Z6kdj15TA18funfoqJXvgPCLSf0SWq3SRfg1j3HlDs8o4s3EGq1oQ==" + }, "hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -9396,6 +9474,16 @@ "js-tokens": "^3.0.0 || ^4.0.0" } }, + "lowlight": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.8.0.tgz", + "integrity": "sha512-WeExw1IKEkel9ZcYwzpvcFzORIB0IlleTcxJYoEuUgHASuYe/OBYbV6ym/AetG7unNVCBU/SXpgTgs2nT93mhg==", + "requires": { + "@types/hast": "^2.0.0", + "fault": "^2.0.0", + "highlight.js": "~11.7.0" + } + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -10138,6 +10226,18 @@ "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==" }, + "rehype-highlight": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-6.0.0.tgz", + "integrity": "sha512-q7UtlFicLhetp7K48ZgZiJgchYscMma7XjzX7t23bqEJF8m6/s+viXQEe4oHjrATTIZpX7RG8CKD7BlNZoh9gw==", + "requires": { + "@types/hast": "^2.0.0", + "hast-util-to-text": "^3.0.0", + "lowlight": "^2.0.0", + "unified": "^10.0.0", + "unist-util-visit": "^4.0.0" + } + }, "rehype-minify-whitespace": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/rehype-minify-whitespace/-/rehype-minify-whitespace-5.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index e3450499..0b7c13c2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "dotenv-webpack": "^8.0.1", "eslint": "8.27.0", "eslint-config-next": "13.0.3", + "highlight.js": "^11.7.0", "immutability-helper": "^3.1.1", "next": "13.0.3", "next-sitemap": "^3.1.32", @@ -34,6 +35,7 @@ "react-dom": "18.2.0", "react-toastify": "^9.1.1", "recoil": "^0.7.6", + "rehype-highlight": "^6.0.0", "rehype-parse": "^8.0.4", "rehype-remark": "^9.1.2", "rehype-stringify": "^9.0.3", diff --git a/frontend/pages/search.tsx b/frontend/pages/search.tsx index 6ed1eb17..b9907d14 100644 --- a/frontend/pages/search.tsx +++ b/frontend/pages/search.tsx @@ -139,7 +139,10 @@ export default function Search() { return { ...article, title: highlightWord(article.title, keywords), - content: highlightWord(article.content, keywords), + content: highlightWord( + article.content.slice(0, 400).replace(/(<([^>]+)>)/gi, ''), + keywords + ), }; }); diff --git a/frontend/pages/viewer/[...data].tsx b/frontend/pages/viewer/[...data].tsx index 13b02cf8..655ea426 100644 --- a/frontend/pages/viewer/[...data].tsx +++ b/frontend/pages/viewer/[...data].tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import { getArticleApi } from '@apis/articleApi'; -import { getBookApi } from '@apis/bookApi'; +import { getBookApi, getUserKnottedBooksApi } from '@apis/bookApi'; import GNB from '@components/common/GNB'; import Modal from '@components/common/Modal'; import ArticleContainer from '@components/viewer/ArticleContent'; @@ -12,15 +12,16 @@ import ClosedSideBar from '@components/viewer/ClosedSideBar'; import ScrapModal from '@components/viewer/ScrapModal'; import TOC from '@components/viewer/TOC'; import ViewerHead from '@components/viewer/ViewerHead'; +import useFetch from '@hooks/useFetch'; import { IArticleBook, IBookScraps } from '@interfaces'; import { Flex } from '@styles/layout'; interface ViewerProps { - book: IBookScraps; article: IArticleBook; } -export default function Viewer({ book, article }: ViewerProps) { +export default function Viewer({ article }: ViewerProps) { + const { data: book, execute: getBook } = useFetch(getBookApi); const router = useRouter(); const [isOpened, setIsOpened] = useState(false); @@ -34,23 +35,32 @@ export default function Viewer({ book, article }: ViewerProps) { setIsOpened((prev) => !prev); }; - const checkArticleAuthority = (id: number) => { - if (book.scraps.find((scrap) => scrap.article.id === id)) { + const checkArticleAuthority = (targetBook: IBookScraps, id: number) => { + if (targetBook.scraps.find((scrap) => scrap.article.id === id)) { return true; } return false; }; useEffect(() => { - if (!checkArticleAuthority(article.id)) router.push('/404'); - }); + if (Array.isArray(router.query.data) && router.query.data?.length === 2) { + const bookId = router.query.data[0]; + getBook(bookId); + } + }, [router.query.data]); + + useEffect(() => { + if (!book) return; + if (!checkArticleAuthority(book, article.id)) router.push('/404'); + }, [book]); + useEffect(() => { if (window.innerWidth > 576) setIsOpened(true); }, []); return ( <> - + {article && } {book && article ? ( @@ -74,7 +84,7 @@ export default function Viewer({ book, article }: ViewerProps) { ) : (
loading
)} - {isModalShown && ( + {isModalShown && book && ( @@ -85,8 +95,7 @@ export default function Viewer({ book, article }: ViewerProps) { export const getServerSideProps: GetServerSideProps = async (context) => { const [bookId, articleId] = context.query.data as string[]; - const book = await getBookApi(bookId); const article = await getArticleApi(articleId); - return { props: { book, article } }; + return { props: { article } }; }; diff --git a/frontend/utils/parser.ts b/frontend/utils/parser.ts index 10f4790e..93de0109 100644 --- a/frontend/utils/parser.ts +++ b/frontend/utils/parser.ts @@ -1,3 +1,4 @@ +import rehypeHighlight from 'rehype-highlight'; import rehypeParse from 'rehype-parse'; import rehypeRemark from 'rehype-remark'; import rehypeStringify from 'rehype-stringify'; @@ -7,12 +8,19 @@ import remarkStringify from 'remark-stringify'; import { unified } from 'unified'; export const markdown2html = (markdown: string) => { - return unified() + const html = unified() .use(remarkParse) .use(remarkRehype) .use(rehypeStringify) .processSync(markdown) .toString(); + + return unified() + .use(rehypeParse) + .use(rehypeHighlight, { ignoreMissing: true }) + .use(rehypeStringify) + .processSync(html) + .toString(); }; export const html2markdown = (html: string) => {