diff --git a/.github/ISSUE_TEMPLATE/pull_request_template.md b/.github/ISSUE_TEMPLATE/pull_request_template.md deleted file mode 100644 index 44eb70f9..00000000 --- a/.github/ISSUE_TEMPLATE/pull_request_template.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Pull Request -about: Pull Request 작업 사항을 알려주세요! -title: '' -labels: '' -assignees: '' - ---- - -## 개요 - - - -## 변경 사항 - -- - -## 참고 사항 - -- - -## 스크린샷 - - - -## 관련 이슈 - -- diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml new file mode 100644 index 00000000..86c6a4a5 --- /dev/null +++ b/.github/workflows/backend-ci.yml @@ -0,0 +1,68 @@ +name: Backend CI + +on: + pull_request: + branches: [main, develop] + paths: + - 'backend/**' + +jobs: + lint: + name: Lint CI + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Cache Dependencies + uses: actions/cache@v3 + id: backend-cache + with: + path: backend/node_modules + key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.OS }}-build- + ${{ runner.OS }}- + + - if: ${{ steps.backend-cache.outputs.cache-hit != 'true' }} + name: Install Dependencies + run: npm install + working-directory: backend + + - name: Check Lint + run: npm run lint + env: + CI: true + working-directory: backend + + build: + name: Build CI + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Cache Dependencies + uses: actions/cache@v3 + id: backend-cache + with: + path: backend/node_modules + key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.OS }}-build- + ${{ runner.OS }}- + + - if: ${{ steps.backend-cache.outputs.cache-hit != 'true' }} + name: Install Dependencies + run: npm install + working-directory: backend + + - name: Build + run: npm run build + env: + CI: true + working-directory: backend diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index 3ba8c273..4af6a157 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -19,7 +19,7 @@ module.exports = { node: true, jest: true, }, - ignorePatterns: ['.eslintrc.js'], + ignorePatterns: ['.eslintrc.js', 'dist/**'], rules: { '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', diff --git a/backend/package.json b/backend/package.json index 96a89bf9..daac1602 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,7 +2,8 @@ "scripts": { "dev": "cross-env NODE_ENV=dev tsc-watch --onSuccess \"ts-node -r tsconfig-paths/register dist/index.js\"", "build": "tsc", - "start": "NODE_ENV=prod node -r ./tsconfig-paths.js dist/index.js" + "start": "NODE_ENV=prod node -r ./tsconfig-paths.js dist/index.js", + "lint": "eslint ." }, "dependencies": { "@prisma/client": "^4.7.0", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 70d9fdf0..4ad8e7e0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -52,6 +52,8 @@ model Article { book_id Int scraps Scrap[] + @@fulltext([content]) + @@fulltext([title]) @@fulltext([content, title]) } diff --git a/backend/src/apis/articles/articles.controller.ts b/backend/src/apis/articles/articles.controller.ts index b94c35c4..b6033450 100644 --- a/backend/src/apis/articles/articles.controller.ts +++ b/backend/src/apis/articles/articles.controller.ts @@ -2,14 +2,15 @@ import { Request, Response } from 'express'; import { SearchArticles } from '@apis/articles/articles.interface'; import articlesService from '@apis/articles/articles.service'; +import { IScrap } from '@apis/scraps/scraps.interface'; import scrapsService from '@apis/scraps/scraps.service'; const searchArticles = async (req: Request, res: Response) => { const { query, page, take, userId } = req.query as unknown as SearchArticles; - const articles = await articlesService.searchArticles({ query, page, take, userId }); + const searchResult = await articlesService.searchArticles({ query, page, take: +take, userId }); - res.status(200).send(articles); + res.status(200).send(searchResult); }; const getArticle = async (req: Request, res: Response) => { @@ -20,22 +21,30 @@ const getArticle = async (req: Request, res: Response) => { }; const createArticle = async (req: Request, res: Response) => { - const { title, content, book_id, order } = req.body; + const { article, scraps } = req.body; - const article = await articlesService.createArticle({ - title, - content, - book_id, + const createdArticle = await articlesService.createArticle({ + title: article.title, + content: article.content, + book_id: article.book_id, }); - - const scrap = await scrapsService.createScrap({ - order, - is_original: true, - book_id, - article_id: article.id, + // forEach와 async,await을 같이사용하는 것이 맞나? 다른방법은 없나? + const result: any[] = []; + scraps.forEach(async (scrap: IScrap) => { + if (scrap.id === 0) { + result.push( + await scrapsService.createScrap({ + order: scrap.order, + is_original: true, + book_id: article.book_id, + article_id: createdArticle.id, + }) + ); + } else { + result.push(await scrapsService.updateScraps(scrap)); + } }); - - res.status(201).send({ article, scrap }); + res.status(201).send({ createdArticle, result }); }; const deleteArticle = async (req: Request, res: Response) => { diff --git a/backend/src/apis/articles/articles.service.ts b/backend/src/apis/articles/articles.service.ts index 73f0e954..ffca3951 100644 --- a/backend/src/apis/articles/articles.service.ts +++ b/backend/src/apis/articles/articles.service.ts @@ -10,7 +10,7 @@ const searchArticles = async (searchArticles: SearchArticles) => { const skip = (page - 1) * take; - const matchUserCondition = userId + const matchUserCondition = Number(userId) ? { book: { user: { @@ -28,6 +28,7 @@ const searchArticles = async (searchArticles: SearchArticles) => { created_at: true, book: { select: { + id: true, user: { select: { id: true, @@ -52,7 +53,10 @@ const searchArticles = async (searchArticles: SearchArticles) => { skip, }); - return articles; + return { + data: articles, + hasNextPage: articles.length === take, + }; }; const getArticle = async (articleId: number) => { @@ -85,7 +89,6 @@ const getArticle = async (articleId: number) => { const createArticle = async (dto: CreateArticle) => { const { title, content, book_id } = dto; - const article = await prisma.article.create({ data: { title, @@ -97,7 +100,6 @@ const createArticle = async (dto: CreateArticle) => { }, }, }); - return article; }; diff --git a/backend/src/apis/books/books.controller.ts b/backend/src/apis/books/books.controller.ts index d7e2eba4..da536dec 100644 --- a/backend/src/apis/books/books.controller.ts +++ b/backend/src/apis/books/books.controller.ts @@ -2,6 +2,8 @@ import { Request, Response } from 'express'; import { FindBooks, SearchBooks } from '@apis/books/books.interface'; import booksService from '@apis/books/books.service'; +import { IScrap } from '@apis/scraps/scraps.interface'; +import scrapsService from '@apis/scraps/scraps.service'; const getBook = async (req: Request, res: Response) => { const { bookId } = req.params; @@ -16,13 +18,13 @@ const getBook = async (req: Request, res: Response) => { }; const getBooks = async (req: Request, res: Response) => { - const { order, take, editor } = req.query as unknown as FindBooks; + const { order, take, editor, type } = req.query as unknown as FindBooks; let userId = res.locals.user?.id; if (!userId) userId = 0; - const books = await booksService.findBooks({ order, take: +take, userId, editor }); + const books = await booksService.findBooks({ order, take: +take, userId, editor, type }); res.status(200).send(books); }; @@ -30,9 +32,9 @@ 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 books = await booksService.searchBooks({ query, userId, take, page }); + const searchResult = await booksService.searchBooks({ query, userId, take: +take, page }); - res.status(200).send(books); + res.status(200).send(searchResult); }; const createBook = async (req: Request, res: Response) => { @@ -42,7 +44,36 @@ const createBook = async (req: Request, res: Response) => { const book = await booksService.createBook({ title, userId }); - res.status(201).send(book); + const bookData = await booksService.findBook(book.id, userId); + + res.status(201).send(bookData); +}; + +const editBook = async (req: Request, res: Response) => { + const { id, title, thumbnail_image, scraps } = req.body; + + const userId = res.locals.user.id; + + const book = await booksService.editBook({ id, title, thumbnail_image }); + + const result: any[] = []; + scraps.forEach(async (scrap: IScrap) => { + result.push(await scrapsService.updateScraps(scrap)); + }); + + const bookData = await booksService.findBook(book.id, userId); + + res.status(200).send(bookData); +}; + +const deleteBook = async (req: Request, res: Response) => { + const bookId = Number(req.params.bookId); + + const userId = res.locals.user.id; + + const book = await booksService.deleteBook(bookId, userId); + + res.status(200).send(book); }; export default { @@ -50,4 +81,6 @@ export default { getBooks, searchBooks, createBook, + editBook, + deleteBook, }; diff --git a/backend/src/apis/books/books.interface.ts b/backend/src/apis/books/books.interface.ts index adbeee2c..6d58090a 100644 --- a/backend/src/apis/books/books.interface.ts +++ b/backend/src/apis/books/books.interface.ts @@ -10,6 +10,7 @@ export interface FindBooks { take: number; userId?: number; editor?: string; + type?: 'bookmark'; } export interface CreateBook { diff --git a/backend/src/apis/books/books.service.ts b/backend/src/apis/books/books.service.ts index 7d2ea597..1ca235a0 100644 --- a/backend/src/apis/books/books.service.ts +++ b/backend/src/apis/books/books.service.ts @@ -1,11 +1,13 @@ import { FindBooks, SearchBooks, CreateBook } from '@apis/books/books.interface'; import { prisma } from '@config/orm.config'; +import { Message, NotFound } from '@errors'; const findBook = async (bookId: number, userId: number) => { const book = await prisma.book.findFirst({ select: { id: true, title: true, + thumbnail_image: true, user: { select: { nickname: true, @@ -43,9 +45,8 @@ const findBook = async (bookId: number, userId: number) => { return book; }; -const findBooks = async ({ order, take, userId, editor }: FindBooks) => { +const findBooks = async ({ order, take, userId, editor, type }: FindBooks) => { const sortOptions = []; - if (order === 'bookmark') sortOptions.push({ bookmarks: { _count: 'desc' as const } }); if (order === 'newest') sortOptions.push({ created_at: 'desc' as const }); @@ -61,7 +62,9 @@ const findBooks = async ({ order, take, userId, editor }: FindBooks) => { }, }, scraps: { + orderBy: { order: 'asc' }, select: { + id: true, order: true, article: { select: { @@ -82,11 +85,26 @@ const findBooks = async ({ order, take, userId, editor }: FindBooks) => { }, where: { deleted_at: null, - user: { - is: { - nickname: editor ? editor : undefined, - }, - }, + user: + type === 'bookmark' + ? {} + : { + is: { + nickname: editor ? editor : undefined, + }, + }, + bookmarks: + type === 'bookmark' + ? { + some: { + user: { + is: { + nickname: editor ? editor : undefined, + }, + }, + }, + } + : {}, }, orderBy: sortOptions, take, @@ -111,6 +129,7 @@ const searchBooks = async ({ query, userId, take, page }: SearchBooks) => { }, scraps: { select: { + id: true, order: true, article: { select: { @@ -122,7 +141,7 @@ const searchBooks = async ({ query, userId, take, page }: SearchBooks) => { }, bookmarks: { where: { - user_id: userId ? Number(userId) : 0, + user_id: Number(userId) ? Number(userId) : 0, }, }, _count: { @@ -131,16 +150,19 @@ const searchBooks = async ({ query, userId, take, page }: SearchBooks) => { }, where: { deleted_at: null, - user_id: userId ? Number(userId) : undefined, + user_id: Number(userId) ? Number(userId) : undefined, title: { search: `${query}*`, }, }, skip, - take: 10, + take, }); - return books; + return { + data: books, + hasNextPage: books.length === take, + }; }; const createBook = async ({ title, userId }: CreateBook) => { @@ -156,6 +178,48 @@ const createBook = async ({ title, userId }: CreateBook) => { }, }, }); + + return book; +}; + +const editBook = async (dto: any) => { + const { id, title, thumbnail_image } = dto; + const book = await prisma.book.update({ + where: { + id, + }, + data: { + title, + thumbnail_image: thumbnail_image, + }, + }); + + return book; +}; + +const deleteBook = async (id: number, userId: number) => { + if (!(await checkBookOwnerCorrect(id, userId))) throw new NotFound(Message.BOOK_NOTFOUND); + + const book = await prisma.book.update({ + where: { + id, + }, + data: { + deleted_at: new Date(), + }, + }); + + return book; +}; + +const checkBookOwnerCorrect = async (id: number, userId: number) => { + const book = await prisma.book.findFirst({ + where: { + id, + user_id: userId, + }, + }); + return book; }; @@ -164,4 +228,6 @@ export default { findBooks, searchBooks, createBook, + editBook, + deleteBook, }; diff --git a/backend/src/apis/index.ts b/backend/src/apis/index.ts index bfaa50f6..47cfaa2e 100644 --- a/backend/src/apis/index.ts +++ b/backend/src/apis/index.ts @@ -32,8 +32,10 @@ router.post('/image', multer().single('image'), catchAsync(imagesController.crea router.get('/books/search', catchAsync(booksController.searchBooks)); router.get('/books/:bookId', decoder, catchAsync(booksController.getBook)); +router.delete('/books/:bookId', catchAsync(guard), catchAsync(booksController.deleteBook)); router.get('/books', decoder, catchAsync(booksController.getBooks)); router.post('/books', catchAsync(guard), catchAsync(booksController.createBook)); +router.patch('/books', catchAsync(guard), catchAsync(booksController.editBook)); router.post('/bookmarks', catchAsync(guard), catchAsync(bookmarksController.createBookmark)); router.delete('/bookmarks/:bookmarkId', catchAsync(bookmarksController.deleteBookmark)); diff --git a/backend/src/apis/scraps/scraps.controller.ts b/backend/src/apis/scraps/scraps.controller.ts index 8898e229..f1dfe3e5 100644 --- a/backend/src/apis/scraps/scraps.controller.ts +++ b/backend/src/apis/scraps/scraps.controller.ts @@ -1,13 +1,29 @@ import { Request, Response } from 'express'; +import { IScrap } from '@apis/scraps/scraps.interface'; + import scrapsService from './scraps.service'; const createScrap = async (req: Request, res: Response) => { - await scrapsService.checkScrapExists(req.body); + const { book_id, article_id, scraps } = req.body; - const scrap = await scrapsService.createScrap(req.body); + const result: any[] = []; + scraps.forEach(async (scrap: IScrap) => { + if (scrap.id === 0) { + result.push( + await scrapsService.createScrap({ + order: scrap.order, + is_original: true, + book_id, + article_id: article_id, + }) + ); + } else { + result.push(await scrapsService.updateScraps(scrap)); + } + }); - res.status(201).send({ scrap }); + res.status(201).send(); }; export default { diff --git a/backend/src/apis/scraps/scraps.interface.ts b/backend/src/apis/scraps/scraps.interface.ts index 8055e1a9..624a33c2 100644 --- a/backend/src/apis/scraps/scraps.interface.ts +++ b/backend/src/apis/scraps/scraps.interface.ts @@ -4,3 +4,11 @@ export interface CreateScrap { book_id: number; article_id: number; } +export interface IScrap { + id: number; + order: number; + article: { + id: number; + title: string; + }; +} diff --git a/backend/src/apis/scraps/scraps.service.ts b/backend/src/apis/scraps/scraps.service.ts index 52acd97d..7751e7fc 100644 --- a/backend/src/apis/scraps/scraps.service.ts +++ b/backend/src/apis/scraps/scraps.service.ts @@ -1,4 +1,4 @@ -import { CreateScrap } from '@apis/scraps/scraps.interface'; +import { CreateScrap, IScrap } from '@apis/scraps/scraps.interface'; import { prisma } from '@config/orm.config'; import { ResourceConflict } from '@errors/error'; import Message from '@errors/message'; @@ -39,7 +39,20 @@ const checkScrapExists = async (dto: CreateScrap) => { if (scrap) throw new ResourceConflict(Message.SCRAP_OVERLAP); }; +const updateScraps = async (scraps: IScrap) => { + const scrap = await prisma.scrap.update({ + where: { + id: scraps.id, + }, + data: { + order: scraps.order, + }, + }); + return scrap; +}; + export default { createScrap, checkScrapExists, + updateScraps, }; diff --git a/backend/src/loaders/express.loader.ts b/backend/src/loaders/express.loader.ts index f5fd42b1..0f99cbda 100644 --- a/backend/src/loaders/express.loader.ts +++ b/backend/src/loaders/express.loader.ts @@ -1,10 +1,11 @@ import { Application, ErrorRequestHandler, json, urlencoded } from 'express'; -import router from '@apis'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import logger from 'morgan'; +import router from '@apis'; + const errorHandler: ErrorRequestHandler = (err, req, res, next) => { const { status, message } = err; diff --git a/frontend/apis/articleApi.ts b/frontend/apis/articleApi.ts index 4f641b24..fe5f71eb 100644 --- a/frontend/apis/articleApi.ts +++ b/frontend/apis/articleApi.ts @@ -2,16 +2,18 @@ import api from '@utils/api'; interface SearchArticlesApi { query: string; - page: number; userId: number; + page: number; + take: number; } export const searchArticlesApi = async (data: SearchArticlesApi) => { const url = `/api/articles/search`; const params = { query: data.query, - page: data.page, userId: data.userId, + page: data.page, + take: data.take, }; const response = await api({ url, method: 'GET', params }); diff --git a/frontend/apis/bookApi.ts b/frontend/apis/bookApi.ts index 58465a99..c2563212 100644 --- a/frontend/apis/bookApi.ts +++ b/frontend/apis/bookApi.ts @@ -1,17 +1,20 @@ +import { IScrap } from '@interfaces'; import api from '@utils/api'; interface SearchBooksApi { query: string; - page: number; userId: number; + page: number; + take: number; } export const searchBooksApi = async (data: SearchBooksApi) => { const url = `/api/books/search`; const params = { query: data.query, - page: data.page, userId: data.userId, + page: data.page, + take: data.take, }; const response = await api({ url, method: 'GET', params }); @@ -24,6 +27,13 @@ interface GetBooksApi { take: number; } +interface EditBookApi { + id: number; + title: string; + thumbnail_image: any; + scraps: IScrap[]; +} + // NOTE: 서버에서 take가 없을 때 최대로 export const getBooksApi = async (data: GetBooksApi) => { @@ -57,6 +67,15 @@ export const getUserKnottedBooksApi = async (nickname: string) => { return response.data; }; + +export const getUserBookmarkedBooksApi = async (nickname: string) => { + const url = `/api/books?editor=${nickname}&type=bookmark&take=12`; + + const response = await api({ url, method: 'GET' }); + + return response.data; +}; + export const addBookApi = async (data: { title: string }) => { const url = `/api/books`; @@ -64,3 +83,19 @@ export const addBookApi = async (data: { title: string }) => { return response.data; }; + +export const editBookApi = async (data: EditBookApi) => { + const url = `/api/books`; + + const response = await api({ url, method: 'PATCH', data }); + + return response.data; +}; + +export const deleteBookApi = async (bookId: number) => { + const url = `/api/books/${bookId}`; + + const response = await api({ url, method: 'DELETE' }); + + return response.data; +}; diff --git a/frontend/atoms/article.ts b/frontend/atoms/article.ts index 694ed4b1..7487a1c8 100644 --- a/frontend/atoms/article.ts +++ b/frontend/atoms/article.ts @@ -6,7 +6,6 @@ const articleState = atom({ title: '', content: '', book_id: -1, - order: -1, }, }); diff --git a/frontend/atoms/curKnottedBookList.ts b/frontend/atoms/curKnottedBookList.ts new file mode 100644 index 00000000..7f82dd3e --- /dev/null +++ b/frontend/atoms/curKnottedBookList.ts @@ -0,0 +1,10 @@ +import { atom } from 'recoil'; + +import { IBookScraps } from '@interfaces'; + +const curKnottedBookListState = atom({ + key: 'curKnottedBookListState', + default: [], +}); + +export default curKnottedBookListState; diff --git a/frontend/atoms/editInfo.ts b/frontend/atoms/editInfo.ts new file mode 100644 index 00000000..de56e0df --- /dev/null +++ b/frontend/atoms/editInfo.ts @@ -0,0 +1,23 @@ +import { atom } from 'recoil'; + +import { IScrap } from '@interfaces'; + +interface EditInfoState { + deleted: number[]; + editted: { + id: number; + title: string; + thumbnail_image: string; + scraps: IScrap[]; + }[]; +} + +const editInfoState = atom({ + key: 'editInfoState', + default: { + deleted: [], + editted: [], + }, +}); + +export default editInfoState; diff --git a/frontend/atoms/scrap.ts b/frontend/atoms/scrap.ts new file mode 100644 index 00000000..4b0ea75c --- /dev/null +++ b/frontend/atoms/scrap.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +const scrapState = atom({ + key: 'scrapState', + default: [], +}); + +export default scrapState; diff --git a/frontend/components/common/Book/index.tsx b/frontend/components/common/Book/index.tsx index b1847e8e..75aa183b 100644 --- a/frontend/components/common/Book/index.tsx +++ b/frontend/components/common/Book/index.tsx @@ -3,7 +3,7 @@ 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 SampleThumbnail from '@assets/img_sample_thumbnail.jpg'; +import sampleImage from '@assets/img_sample_thumbnail.jpg'; import useBookmark from '@hooks/useBookmark'; import { IBookScraps } from '@interfaces'; import { TextLarge, TextXSmall, TextSmall } from '@styles/common'; @@ -24,21 +24,24 @@ import { interface BookProps { book: IBookScraps; - handleEditBookModalOpen?: () => void; } -export default function Book({ book, handleEditBookModalOpen }: BookProps) { +export default function Book({ book }: BookProps) { const { id, title, user, scraps, _count, bookmarks } = book; const { handleBookmarkClick, curBookmarkCnt, curBookmarkId } = useBookmark( bookmarks.length ? bookmarks[0].id : null, _count.bookmarks, id ); - return ( // 수정모드일때만 아래 onclick이 실행되도록 수정해야함 -> 민형님 작업 후 - - + + @@ -63,7 +66,9 @@ export default function Book({ book, handleEditBookModalOpen }: BookProps) { (scrap, idx) => idx < 4 && ( - {idx + 1}. {scrap.article.title} + + {idx + 1}. {scrap.article.title} + ) )} @@ -78,6 +83,3 @@ export default function Book({ book, handleEditBookModalOpen }: BookProps) { ); } -Book.defaultProps = { - handleEditBookModalOpen: null, -}; diff --git a/frontend/components/common/Book/styled.ts b/frontend/components/common/Book/styled.ts index 23692b0b..9b462b2a 100644 --- a/frontend/components/common/Book/styled.ts +++ b/frontend/components/common/Book/styled.ts @@ -8,7 +8,8 @@ import { FlexColumn } from '@styles/layout'; export const BookWrapper = styled(FlexColumn)` min-width: 280px; - min-height: 480px; + max-width: 280px; + height: 480px; margin: 0 10px; box-sizing: border-box; @@ -27,7 +28,7 @@ export const BookThumbnail = styled(Image)` `; export const BookInfoContainer = styled(FlexColumn)` - padding: 24px; + padding: 15px 24px; gap: 18px; `; @@ -35,6 +36,11 @@ export const BookTitle = styled.div` div:nth-child(1) { font-weight: 700; } + + b { + color: var(--primary-color); + font-weight: 700; + } `; export const Bookmark = styled(FlexColumn)` @@ -50,6 +56,7 @@ export const BookContentsInfo = styled(FlexColumn)` gap: 8px; div:nth-child(1) { + box-sizing: border-box; padding-bottom: 10px; border-bottom: 1px solid var(--primary-color); } @@ -58,7 +65,6 @@ export const BookContentsInfo = styled(FlexColumn)` export const BookContents = styled(TextXSmall)` display: flex; flex-direction: column; - a { border-bottom: 1px solid var(--grey-02-color); height: 28px; @@ -69,10 +75,21 @@ export const BookContents = styled(TextXSmall)` export const ArticleLink = styled(Link)` font-size: 14px; - line-height: 20px; text-decoration: none; color: inherit; - display: block; + display: flex; + align-items: center; + + border-bottom: 1px solid var(--grey-02-color); + height: 28px; + + span { + line-height: 30px; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } `; export const AuthorLink = styled(Link)` diff --git a/frontend/components/common/Content/styled.ts b/frontend/components/common/Content/styled.ts index f3987096..08cc4d77 100644 --- a/frontend/components/common/Content/styled.ts +++ b/frontend/components/common/Content/styled.ts @@ -11,6 +11,8 @@ export const ContentTitle = styled.h1` `; export const ContentBody = styled.div` + padding-top: 10px; + > * { line-height: 1.4; } diff --git a/frontend/components/common/DragDrop/Container/index.tsx b/frontend/components/common/DragDrop/Container/index.tsx new file mode 100644 index 00000000..f741ef02 --- /dev/null +++ b/frontend/components/common/DragDrop/Container/index.tsx @@ -0,0 +1,86 @@ +import { useEffect, memo, useCallback } from 'react'; +import { useDrop } from 'react-dnd'; + +import update from 'immutability-helper'; +import { useRecoilState } from 'recoil'; + +import scrapState from '@atoms/scrap'; + +import { ListItem } from '../ListItem'; +import ContainerWapper from './styled'; + +const ItemTypes = { + Scrap: 'scrap', +}; + +export interface EditScrap { + id: number; + order: number; + article: { + id: number; + title: string; + }; +} +export interface ContainerState { + data: EditScrap[]; + isContentsShown: boolean; +} + +export const Container = memo(function Container({ data, isContentsShown }: ContainerState) { + const [scraps, setScraps] = useRecoilState(scrapState); + + useEffect(() => { + if (!data) return; + setScraps(data); + }, []); + + const findScrap = useCallback( + (id: string) => { + const scrap = scraps.filter((c) => `${c.article.id}` === id)[0] as { + id: number; + order: number; + article: { + id: number; + title: string; + }; + }; + return { + scrap, + index: scraps.indexOf(scrap), + }; + }, + [scraps] + ); + + const moveScrap = useCallback( + (id: string, atIndex: number) => { + const { scrap, index } = findScrap(id); + setScraps( + update(scraps, { + $splice: [ + [index, 1], + [atIndex, 0, scrap], + ], + }) + ); + }, + [findScrap, scraps, setScraps] + ); + + const [, drop] = useDrop(() => ({ accept: ItemTypes.Scrap })); + return ( + + {scraps.map((scrap, index) => ( + + ))} + + ); +}); diff --git a/frontend/components/common/DragDrop/Container/styled.ts b/frontend/components/common/DragDrop/Container/styled.ts new file mode 100644 index 00000000..7dbec1b6 --- /dev/null +++ b/frontend/components/common/DragDrop/Container/styled.ts @@ -0,0 +1,14 @@ +import styled from 'styled-components'; + +const ContainerWapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + + div { + border-bottom: 1px solid var(--grey-02-color); + height: 28px; + } +`; + +export default ContainerWapper; diff --git a/frontend/components/common/DragDrop/ListItem/index.tsx b/frontend/components/common/DragDrop/ListItem/index.tsx new file mode 100644 index 00000000..bdfba4ce --- /dev/null +++ b/frontend/components/common/DragDrop/ListItem/index.tsx @@ -0,0 +1,75 @@ +import { memo } from 'react'; +import { useDrag, useDrop } from 'react-dnd'; + +import Article from './styled'; + +const ItemTypes = { + Scrap: 'scrap', +}; + +export interface ScrapProps { + id: string; + text: string; + moveScrap: (id: string, to: number) => void; + findScrap: (id: string) => { index: number }; + isShown: boolean; + isContentsShown: boolean; +} + +interface Item { + id: string; + originalIndex: number; +} + +export const ListItem = memo(function Scrap({ + id, + text, + moveScrap, + findScrap, + isShown, + isContentsShown, +}: ScrapProps) { + const originalIndex = findScrap(id).index; + + // Drag + const [{ isDragging }, drag] = useDrag( + () => ({ + // 타입설정 useDrop의 accept와 일치시켜야함 + type: ItemTypes.Scrap, + item: { id, originalIndex }, + // Return array의 첫번째 값에 들어갈 객체를 정의한다. + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + // 드래그가 끝났을때 실행한다. + end: (item, monitor) => { + const { id: droppedId } = item; + const didDrop = monitor.didDrop(); + if (!didDrop) { + moveScrap(droppedId, originalIndex); + } + }, + }), + [id, originalIndex, moveScrap] + ); + // Drop + const [, drop] = useDrop( + () => ({ + accept: ItemTypes.Scrap, + + hover({ id: draggedId }: Item) { + if (draggedId !== id) { + const { index: overIndex } = findScrap(id); + moveScrap(draggedId, overIndex); + } + }, + }), + [findScrap, moveScrap] + ); + + return ( +
drag(drop(node))} isShown={isContentsShown ? true : isShown}> + {text} +
+ ); +}); diff --git a/frontend/components/common/DragDrop/ListItem/styled.ts b/frontend/components/common/DragDrop/ListItem/styled.ts new file mode 100644 index 00000000..746f128a --- /dev/null +++ b/frontend/components/common/DragDrop/ListItem/styled.ts @@ -0,0 +1,13 @@ +import styled from 'styled-components'; + +const Article = styled.div<{ isShown: true | false }>` + font-size: 14px; + line-height: 20px; + text-decoration: none; + color: inherit; + display: ${(props) => (props.isShown ? 'block' : 'none')}; + padding: 2px 0; + border-bottom: 1px solid var(--grey-02-color); +`; + +export default Article; diff --git a/frontend/components/common/DragDrop/dndInterface.ts b/frontend/components/common/DragDrop/dndInterface.ts new file mode 100644 index 00000000..5cb406a1 --- /dev/null +++ b/frontend/components/common/DragDrop/dndInterface.ts @@ -0,0 +1,12 @@ +export interface EditScrap { + id: number; + order: number; + article: { + id: number; + title: string; + }; +} +export interface ContainerState { + data: EditScrap[]; + isContentsShown: boolean; +} diff --git a/frontend/components/common/DragDrop/index.tsx b/frontend/components/common/DragDrop/index.tsx new file mode 100644 index 00000000..fa1ed574 --- /dev/null +++ b/frontend/components/common/DragDrop/index.tsx @@ -0,0 +1,25 @@ +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; + +import { Container } from '@components/common/DragDrop/Container'; + +export interface EditScrap { + id: number; + order: number; + article: { + id: number; + title: string; + }; +} +export interface ContainerState { + data: EditScrap[]; + isContentsShown: boolean; +} + +export default function DragArticle({ data, isContentsShown }: any) { + return ( + + + + ); +} diff --git a/frontend/components/common/Dropdown/index.tsx b/frontend/components/common/Dropdown/index.tsx index fed53b8d..dab4025b 100644 --- a/frontend/components/common/Dropdown/index.tsx +++ b/frontend/components/common/Dropdown/index.tsx @@ -36,7 +36,11 @@ export default function Dropdown({ label, items, selectedId, handleItemSelect }: return ( - setDropdownSpread(!isDropdownSpread)}> + { + setDropdownSpread(!isDropdownSpread); + }} + > Caret Down Icon diff --git a/frontend/components/common/GNB/styled.ts b/frontend/components/common/GNB/styled.ts index d024c05a..cd272f53 100644 --- a/frontend/components/common/GNB/styled.ts +++ b/frontend/components/common/GNB/styled.ts @@ -11,7 +11,7 @@ export const GNBbar = styled(TopBar)` display: flex; justify-content: space-between; align-items: center; - padding: 4px 50px; + padding: 4px 30px; box-sizing: border-box; background-color: var(--light-yellow-color); `; @@ -20,14 +20,14 @@ export const Logo = styled(Link)` font-family: 'Sofia'; font-style: normal; font-weight: 500; - font-size: 44px; + font-size: 32px; line-height: 57px; color: var(--title-active-color); text-decoration: none; `; export const IconsContainer = styled.div` - width: 112px; + width: 96px; display: flex; justify-content: space-between; align-items: center; diff --git a/frontend/components/common/Modal/styled.ts b/frontend/components/common/Modal/styled.ts index 6503f7ab..8025d329 100644 --- a/frontend/components/common/Modal/styled.ts +++ b/frontend/components/common/Modal/styled.ts @@ -14,14 +14,15 @@ export const ModalWrapper = styled.div` top: 0px; left: 0px; width: 100%; + height: 100%; display: flex; justify-content: center; + align-items: center; z-index: 99; `; export const ModalInner = styled.div` width: 360px; - margin-top: 150px; padding: 32px; background: var(--white-color); border-radius: 30px; diff --git a/frontend/components/edit/EditBar/styled.ts b/frontend/components/edit/EditBar/styled.ts index 70fd3c39..d4d5dc70 100644 --- a/frontend/components/edit/EditBar/styled.ts +++ b/frontend/components/edit/EditBar/styled.ts @@ -3,12 +3,15 @@ import styled from 'styled-components'; import { TopBar } from '@styles/layout'; export const Bar = styled(TopBar)` - padding: 16px 50px; - background-color: var(--light-yellow-color); - border-bottom: 1px solid var(--title-active-color); + width: 100%; + padding: 16px 32px; display: flex; justify-content: space-between; box-sizing: border-box; + position: absolute; + left: 0; + bottom: 0; + box-shadow: rgb(0 0 0 / 10%) 0px 0px 8px; `; export const ButtonGroup = styled.div` @@ -19,6 +22,7 @@ export const ButtonGroup = styled.div` const Button = styled.button` padding: 8px 16px; border-radius: 10px; + font-family: 'Noto Sans KR'; `; export const ExitButton = styled(Button)` diff --git a/frontend/components/edit/Editor/index.tsx b/frontend/components/edit/Editor/index.tsx index 037bdf8b..6d1297a8 100644 --- a/frontend/components/edit/Editor/index.tsx +++ b/frontend/components/edit/Editor/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useRecoilState } from 'recoil'; import rehypeStringify from 'rehype-stringify'; @@ -8,22 +8,22 @@ import { unified } from 'unified'; import articleState from '@atoms/article'; import Content from '@components/common/Content'; +import EditBar from '@components/edit/EditBar'; import useCodeMirror from '@components/edit/Editor/core/useCodeMirror'; import useInput from '@hooks/useInput'; import { CodeMirrorWrapper, EditorInner, EditorWrapper, TitleInput } from './styled'; -export default function Editor() { +interface EditorProps { + handleModalOpen: () => void; +} + +export default function Editor({ handleModalOpen }: EditorProps) { const { ref, value } = useCodeMirror(); const [article, setArticle] = useRecoilState(articleState); - const [height, setHeight] = useState(0); const title = useInput(); - useEffect(() => { - setHeight(window.innerHeight - 68); - }, []); - useEffect(() => { setArticle({ ...article, @@ -44,12 +44,13 @@ export default function Editor() { }, [value]); return ( - +
+ diff --git a/frontend/components/edit/Editor/styled.ts b/frontend/components/edit/Editor/styled.ts index 57fbae00..117dfb5f 100644 --- a/frontend/components/edit/Editor/styled.ts +++ b/frontend/components/edit/Editor/styled.ts @@ -2,7 +2,7 @@ import styled from 'styled-components'; export const EditorWrapper = styled.div` width: 100%; - height: calc(100vh - 67px); + height: 100vh; display: flex; > div:nth-child(2) { @@ -13,11 +13,14 @@ export const EditorWrapper = styled.div` export const EditorInner = styled.div` flex: 1; overflow: auto; - padding: 8px; + padding: 32px; + position: relative; `; export const CodeMirrorWrapper = styled.div` font-size: 16px; + height: calc(100vh - 160px); + overflow: auto; .cm-editor.cm-focused { outline: none; @@ -25,6 +28,7 @@ export const CodeMirrorWrapper = styled.div` `; export const TitleInput = styled.input` + padding-left: 6px; width: 100%; border: none; outline: none; diff --git a/frontend/components/edit/PublishModal/index.tsx b/frontend/components/edit/PublishModal/index.tsx index bc62005c..f0504cb3 100644 --- a/frontend/components/edit/PublishModal/index.tsx +++ b/frontend/components/edit/PublishModal/index.tsx @@ -6,12 +6,15 @@ import { useRecoilState } from 'recoil'; import { createArticleApi } from '@apis/articleApi'; import articleState from '@atoms/article'; +import scrapState from '@atoms/scrap'; +import DragArticle from '@components/common/DragDrop'; 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 { IEditScrap } from 'interfaces/scrap.interface'; -import { Label, PublishModalWrapper } from './styled'; +import { ArticleWrapper, Label, PublishModalWrapper } from './styled'; interface PublishModalProps { books: IBookScraps[]; @@ -22,11 +25,12 @@ export default function PublishModal({ books }: PublishModalProps) { const { execute: createArticle } = useFetch(createArticleApi); + // 전역으로 관리해야할까? const [article, setArticle] = useRecoilState(articleState); const [selectedBookIndex, setSelectedBookIndex] = useState(-1); - const [selectedScrapIndex, setSelectedScrapIndex] = useState(-1); const [filteredScraps, setFilteredScraps] = useState([]); + const [scrapList, setScrapList] = useRecoilState(scrapState); const createBookDropdownItems = (items: IBook[]) => items.map((item) => { @@ -36,18 +40,17 @@ export default function PublishModal({ books }: PublishModalProps) { }; }); - const createScrapDropdownItems = (items: IScrap[]) => - items.map((item) => { - return { - id: item.id, - name: item.article.title, - }; - }); + const createScrapDropdownItems = (items: IEditScrap[]) => { + // 깔끔하게 리팩토릭 필요 + const itemList = [...items]; + + itemList.push({ id: 0, order: items.length + 1, article: { id: 0, title: article.title } }); + return itemList; + }; useEffect(() => { const selectedBook = books.find((book) => book.id === selectedBookIndex); - setSelectedScrapIndex(-1); setFilteredScraps(selectedBook ? selectedBook.scraps : []); setArticle({ @@ -55,21 +58,16 @@ export default function PublishModal({ books }: PublishModalProps) { book_id: selectedBookIndex, }); }, [selectedBookIndex]); - useEffect(() => { - setArticle((prev) => { - // NOTE: 책의 가장 마지막으로 글이 들어감 (임시) + setScrapList(createScrapDropdownItems(filteredScraps)); + }, [filteredScraps]); - const prevState = { ...prev }; - // const selectedScrap = filteredScraps.find((scrap) => scrap.id === selectedScrapIndex); + const handlePublishBtnClick = () => { + const scraps = scrapList.map((v: IEditScrap, i: number) => ({ ...v, order: i + 1 })); - // prevState.order = selectedScrap ? selectedScrap.order : 0; - - prevState.order = filteredScraps.length; - - return { ...prevState }; - }); - }, [selectedScrapIndex]); + createArticle({ article, scraps }); + router.push('/'); + }; return ( @@ -80,20 +78,14 @@ export default function PublishModal({ books }: PublishModalProps) { selectedId={selectedBookIndex} handleItemSelect={(id) => setSelectedBookIndex(id)} /> - - setSelectedScrapIndex(id)} - /> - { - createArticle(article); - router.push('/'); - }} - > + + {filteredScraps.length !== 0 && ( + + + + + )} + 발행하기 diff --git a/frontend/components/edit/PublishModal/styled.ts b/frontend/components/edit/PublishModal/styled.ts index c1c325fb..a31bd882 100644 --- a/frontend/components/edit/PublishModal/styled.ts +++ b/frontend/components/edit/PublishModal/styled.ts @@ -11,3 +11,8 @@ export const PublishModalWrapper = styled.div` `; export const Label = styled(TextLarge)``; +export const ArticleWrapper = styled.div` + width: 100%; + height: 300px; + overflow: auto; +`; diff --git a/frontend/components/home/Slider/styled.ts b/frontend/components/home/Slider/styled.ts index ab032d73..f6d5cf98 100644 --- a/frontend/components/home/Slider/styled.ts +++ b/frontend/components/home/Slider/styled.ts @@ -17,7 +17,9 @@ export const SliderContent = styled(FlexColumn)` margin-top: 30px; `; -export const SliderInfoContainer = styled(FlexSpaceBetween)``; +export const SliderInfoContainer = styled(FlexSpaceBetween)` + padding: 0px 10px; +`; export const SliderInfo = styled.div` display: flex; diff --git a/frontend/components/search/ArticleItem/index.tsx b/frontend/components/search/ArticleItem/index.tsx new file mode 100644 index 00000000..ce0de834 --- /dev/null +++ b/frontend/components/search/ArticleItem/index.tsx @@ -0,0 +1,53 @@ +import Image from 'next/image'; +import Link from 'next/link'; + +import TemporaryImage from '@assets/img_profile.png'; +import { TextSmall, TextXSmall } from '@styles/common'; + +import { + ItemContent, + ItemGroup, + ItemTitle, + ItemWrapper, + ProfileDescription, + ProfileImage, + UserProfile, +} from './styled'; + +interface ArticleItemProps { + title: string; + content: string; + nickname: string; + articleUrl: string; + studyUrl: string; +} + +export default function ArticleItem({ + title, + content, + nickname, + articleUrl, + studyUrl, +}: ArticleItemProps) { + return ( + + + + {title} + {content} + + + + + + Written By + {nickname} + + + profile + + + + + ); +} diff --git a/frontend/components/search/SearchListItem/styled.ts b/frontend/components/search/ArticleItem/styled.ts similarity index 93% rename from frontend/components/search/SearchListItem/styled.ts rename to frontend/components/search/ArticleItem/styled.ts index 56cf25f6..b3eb30da 100644 --- a/frontend/components/search/SearchListItem/styled.ts +++ b/frontend/components/search/ArticleItem/styled.ts @@ -11,6 +11,11 @@ export const ItemWrapper = styled.div` export const ItemGroup = styled.div` flex: 1; + + b { + color: var(--primary-color); + font-weight: 700; + } `; export const ItemTitle = styled(TextMedium)` diff --git a/frontend/components/search/ArticleList/index.tsx b/frontend/components/search/ArticleList/index.tsx index 10473d78..a66835a8 100644 --- a/frontend/components/search/ArticleList/index.tsx +++ b/frontend/components/search/ArticleList/index.tsx @@ -1,12 +1,22 @@ -import SearchListItem from '@components/search/SearchListItem'; +import ArticleItem from '@components/search/ArticleItem'; +import { IArticleBook } from '@interfaces'; -export default function ArticleList() { - const items = Array.from({ length: 50 }, (_, i) => i); +interface ArticleListProps { + articles: IArticleBook[]; +} +export default function ArticleList({ articles }: ArticleListProps) { return ( <> - {items.map((item) => ( - + {articles.map((article) => ( + ))} ); diff --git a/frontend/components/search/BookList/index.tsx b/frontend/components/search/BookList/index.tsx index c640766c..e8e03578 100644 --- a/frontend/components/search/BookList/index.tsx +++ b/frontend/components/search/BookList/index.tsx @@ -1,3 +1,20 @@ -export default function BookList() { - return <>Book Result; +import Book from '@components/common/Book'; +import { IBookScraps } from '@interfaces'; + +import { BookContainer, BookListWrapper } from './styled'; + +interface BookListProps { + books: IBookScraps[]; +} + +export default function BookList({ books }: BookListProps) { + return ( + + {books.map((book) => ( + + + + ))} + + ); } diff --git a/frontend/components/search/BookList/styled.ts b/frontend/components/search/BookList/styled.ts new file mode 100644 index 00000000..391cf947 --- /dev/null +++ b/frontend/components/search/BookList/styled.ts @@ -0,0 +1,15 @@ +import styled from 'styled-components'; + +export const BookContainer = styled.div` + width: 280px; +`; + +export const BookListWrapper = styled.div` + width: 100%; + + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-row-gap: 30px; + + margin-bottom: 30px; +`; diff --git a/frontend/components/search/SearchBar/index.tsx b/frontend/components/search/SearchBar/index.tsx index 7793b5ed..17f8f646 100644 --- a/frontend/components/search/SearchBar/index.tsx +++ b/frontend/components/search/SearchBar/index.tsx @@ -12,7 +12,7 @@ interface SearchBarProps { export default function SearchBar({ value, onChange }: SearchBarProps) { return ( - + Search Icon ); diff --git a/frontend/components/search/SearchBar/styled.ts b/frontend/components/search/SearchBar/styled.ts index 4ca1cd94..860acffb 100644 --- a/frontend/components/search/SearchBar/styled.ts +++ b/frontend/components/search/SearchBar/styled.ts @@ -15,4 +15,5 @@ export const SearchBarInput = styled.input` border: none; outline: none; font-size: 32px; + font-family: Noto Sans KR; `; diff --git a/frontend/components/search/SearchFilter/index.tsx b/frontend/components/search/SearchFilter/index.tsx index 95174ccf..1a581b73 100644 --- a/frontend/components/search/SearchFilter/index.tsx +++ b/frontend/components/search/SearchFilter/index.tsx @@ -1,3 +1,7 @@ +import { useRecoilValue } from 'recoil'; + +import signInStatusState from '@atoms/signInStatus'; + import { FilterButton, FilterGroup, FilterLabel, FilterWrapper } from './styled'; interface SearchFilterProps { @@ -5,6 +9,8 @@ interface SearchFilterProps { } export default function SearchFilter({ handleFilter }: SearchFilterProps) { + const signInStatus = useRecoilValue(signInStatusState); + return ( @@ -25,7 +31,7 @@ export default function SearchFilter({ handleFilter }: SearchFilterProps) { handleFilter({ userId: e.target.checked ? 1 : 0 })} + onChange={(e) => handleFilter({ userId: e.target.checked ? signInStatus.id : 0 })} /> 내 책에서 검색 diff --git a/frontend/components/search/SearchListItem/index.tsx b/frontend/components/search/SearchListItem/index.tsx deleted file mode 100644 index fc063428..00000000 --- a/frontend/components/search/SearchListItem/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import Image from 'next/image'; - -import TemporaryImage from '@assets/img_profile.png'; -import { TextSmall, TextXSmall } from '@styles/common'; - -import { - ItemContent, - ItemGroup, - ItemTitle, - ItemWrapper, - ProfileDescription, - ProfileImage, - UserProfile, -} from './styled'; - -export default function SearchListItem() { - return ( - - - 리액트 개발 환경 세팅하기 - - 그럼 리액트를 사용하기 위한 개발 환경을 세팅해보자. 대부분의 블로그에서 리액트와 함께 - Webpack, Babel을 함께 소개하는 경우가 많다. 하지만 입문자 입장에서는 리액트만으로도 - 공부하기 벅차기 때문에 본 글에서는 내용을 공부하기 벅차기 때문에 - 가나다라마바사가나다라마바사가나다라마바사가나다라마바사가나다라마바사 - - - - - Written By - Web01 - - - profile - - - - ); -} diff --git a/frontend/components/search/SearchNoResult/index.tsx b/frontend/components/search/SearchNoResult/index.tsx new file mode 100644 index 00000000..8d8ba16e --- /dev/null +++ b/frontend/components/search/SearchNoResult/index.tsx @@ -0,0 +1,5 @@ +import NoResult from './styled'; + +export default function SearchNoResult() { + return 검색 결과가 없습니다.; +} diff --git a/frontend/components/search/SearchNoResult/styled.ts b/frontend/components/search/SearchNoResult/styled.ts new file mode 100644 index 00000000..a4bf67a0 --- /dev/null +++ b/frontend/components/search/SearchNoResult/styled.ts @@ -0,0 +1,10 @@ +import styled from 'styled-components'; + +const NoResult = styled.div` + font-size: 28px; + color: var(--grey-01-color); + font-weight: 400; + padding-top: 50px; +`; + +export default NoResult; diff --git a/frontend/components/study/AddBook/index.tsx b/frontend/components/study/AddBook/index.tsx index 1d7a20e1..6f2c88d2 100644 --- a/frontend/components/study/AddBook/index.tsx +++ b/frontend/components/study/AddBook/index.tsx @@ -1,15 +1,15 @@ -import { useRouter } from 'next/router'; - import { useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { addBookApi } from '@apis/bookApi'; import SampleThumbnail from '@assets/img_sample_thumbnail.jpg'; +import curKnottedBookListState from '@atoms/curKnottedBookList'; import signInStatusState from '@atoms/signInStatus'; import Button from '@components/common/Modal/ModalButton'; import useFetch from '@hooks/useFetch'; import useInput from '@hooks/useInput'; +import { IBookScraps } from '@interfaces'; import { FlexSpaceBetween } from '@styles/layout'; import { toastSuccess } from '@utils/toast'; @@ -31,18 +31,17 @@ interface AddBookProps { } export default function AddBook({ handleModalClose }: AddBookProps) { + const [curKnottedBookList, setCurKnottedBookList] = + useRecoilState(curKnottedBookListState); const user = useRecoilValue(signInStatusState); const title = useInput(''); const { data: addBookData, execute: addBook } = useFetch(addBookApi); - const router = useRouter(); - useEffect(() => { if (!addBookData) return; + setCurKnottedBookList([...curKnottedBookList, addBookData]); handleModalClose(); - // 토스트 메세지, reload 중 어떤 방식으로 처리해야할까? -> 우선 민형님 작업이 끝나고 합치면서 확정예정 toastSuccess(`${addBookData.title}책이 추가되었습니다!`); - router.reload(); }, [addBookData]); const handleAddBookBtnClick = () => { diff --git a/frontend/components/study/BookListTab/index.tsx b/frontend/components/study/BookListTab/index.tsx index e676015d..281a1571 100644 --- a/frontend/components/study/BookListTab/index.tsx +++ b/frontend/components/study/BookListTab/index.tsx @@ -1,57 +1,134 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; -import { getOrderedBookListApi } from '@apis/bookApi'; +import { useRecoilState } from 'recoil'; + +import MinusWhite from '@assets/ico_minus_white.svg'; +import curKnottedBookListState from '@atoms/curKnottedBookList'; +import editInfoState from '@atoms/editInfo'; import Book from '@components/common/Book'; import Modal from '@components/common/Modal'; -import useFetch from '@hooks/useFetch'; +import EditBook from '@components/study/EditBook'; +import FAB from '@components/study/FAB'; import { IBookScraps } from '@interfaces'; -import EditBook from '../EditBook'; -import { BookGrid, BookListTabWrapper, TabTitle, TabTitleContent } from './styled'; +import { + BookGrid, + BookListTabWrapper, + EditBookWrapper, + EditModalOpener, + EditModeIndicator, + MinusButton, + MinusIcon, + TabTitle, + TabTitleContent, +} from './styled'; -export default function BookListTab() { - // 일단 에러 안 뜨게 새로 엮은 책 보여주기 - const [isModalShown, setModalShown] = useState(false); - const [curEditBook, setCurEditBook] = useState(null); +interface BookListTabProps { + knottedBookList: IBookScraps[]; + bookmarkedBookList: IBookScraps[]; + isUserMatched: boolean; +} - const { data: newestBookList, execute: getNewestBookList } = - useFetch(getOrderedBookListApi); +export default function BookListTab({ + knottedBookList, + bookmarkedBookList, + isUserMatched, +}: BookListTabProps) { + const [curKnottedBookList, setCurKnottedBookList] = useRecoilState(curKnottedBookListState); + const [editInfo, setEditInfo] = useRecoilState(editInfoState); - useEffect(() => { - getNewestBookList('newest'); - }, []); + const [isModalShown, setModalShown] = useState(false); + const [curEditBook, setCurEditBook] = useState(null); + const [tabStatus, setTabStatus] = useState<'knotted' | 'bookmarked'>('knotted'); + const [isEditing, setIsEditing] = useState(false); const handleEditBookModalOpen = (id: number) => { - const curbook = newestBookList?.find((v) => v.id === id); + const curbook = knottedBookList?.find((v) => v.id === id); if (!curbook) return; + setModalShown(true); setCurEditBook(curbook); }; + const handleModalClose = () => { setModalShown(false); }; + const handleMinusBtnClick = (e: React.MouseEvent, id: number) => { + e.stopPropagation(); + setCurKnottedBookList([...curKnottedBookList.filter((book) => id !== book.id)]); + setEditInfo({ + ...editInfo, + deleted: [...editInfo.deleted, id], + }); + }; + + const handleEditModalOpenerClick = (e: React.MouseEvent, bookId: number) => { + e.stopPropagation(); + handleEditBookModalOpen(bookId); + }; + return ( + {isEditing && 수정 모드} - 엮은 책 - 북마크한 책 + { + setTabStatus('knotted'); + }} + isActive={tabStatus === 'knotted'} + > + 엮은 책 + + { + setTabStatus('bookmarked'); + setIsEditing(false); + }} + isActive={tabStatus === 'bookmarked'} + > + 북마크한 책 + - - {newestBookList && - newestBookList.map((book) => ( - { - handleEditBookModalOpen(book.id); - }} - /> - ))} - + {tabStatus === 'knotted' ? ( + + {knottedBookList && + knottedBookList.map((book) => + isEditing ? ( + + { + handleMinusBtnClick(e, book.id); + }} + > + + + { + handleEditModalOpenerClick(e, book.id); + }} + /> + + + ) : ( + + ) + )} + + ) : ( + + {bookmarkedBookList && + bookmarkedBookList.map((book) => )} + + )} + + {isUserMatched && tabStatus === 'knotted' && ( + + )} + {isModalShown && ( - {curEditBook && } + {curEditBook && } )} diff --git a/frontend/components/study/BookListTab/styled.ts b/frontend/components/study/BookListTab/styled.ts index e09dd1fb..725c48f9 100644 --- a/frontend/components/study/BookListTab/styled.ts +++ b/frontend/components/study/BookListTab/styled.ts @@ -1,8 +1,11 @@ +import Image from 'next/image'; + import styled from 'styled-components'; import { TextLinkMedium } from '@styles/common'; export const BookListTabWrapper = styled.div` + position: relative; margin-top: 20px; `; export const TabTitle = styled.div` @@ -11,12 +14,55 @@ export const TabTitle = styled.div` padding: 10px; gap: 30px; `; -export const TabTitleContent = styled(TextLinkMedium)` +export const TabTitleContent = styled(TextLinkMedium)<{ isActive: boolean }>` cursor: pointer; + font-size: 18px; + line-height: 24px; + ${(props) => (props.isActive ? 'color: var(--primary-color); text-decoration:underline' : '')} `; + export const BookGrid = styled.div` display: grid; grid-template-columns: repeat(4, 1fr); gap: 30px; padding: 20px; `; + +export const EditModeIndicator = styled(TextLinkMedium)` + position: absolute; + background-color: var(--red-color); + padding: 5px 10px; + color: var(--white-color); + width: auto; + border-radius: 10px; + right: 0; +`; + +export const EditBookWrapper = styled.div` + position: relative; +`; + +export const EditModalOpener = styled.div` + position: absolute; + z-index: 4; + cursor: pointer; + width: 100%; + height: 100%; + background-color: transparent; +`; + +export const MinusButton = styled.button` + z-index: 5; + position: absolute; + display: center; + justify-content: center; + align-items: center; + width: 40px; + height: 40px; + border-radius: 100%; + background-color: var(--red-color); + right: -10px; + top: -10px; +`; + +export const MinusIcon = styled(Image)``; diff --git a/frontend/components/study/EditBook/index.tsx b/frontend/components/study/EditBook/index.tsx index ff89dbbc..35b6a5d6 100644 --- a/frontend/components/study/EditBook/index.tsx +++ b/frontend/components/study/EditBook/index.tsx @@ -1,69 +1,160 @@ import Image from 'next/image'; +import { useRef, useState } from 'react'; + +import { useRecoilState } from 'recoil'; + +import { createImageApi } from '@apis/imageApi'; +import Edit from '@assets/ico_edit.svg'; import MoreContentsIcon from '@assets/ico_more_contents.svg'; -import SampleThumbnail from '@assets/img_sample_thumbnail.jpg'; +import curKnottedBookListState from '@atoms/curKnottedBookList'; +import editInfoState from '@atoms/editInfo'; +import scrapState from '@atoms/scrap'; +import DragArticle from '@components/common/DragDrop'; import Button from '@components/common/Modal/ModalButton'; +import useFetch from '@hooks/useFetch'; +import useInput from '@hooks/useInput'; import { IBookScraps } from '@interfaces'; -import { FlexCenter, FlexSpaceBetween } from '@styles/layout'; +import { FlexSpaceBetween } from '@styles/layout'; +import { IEditScrap } from 'interfaces/scrap.interface'; import { BookWrapper, BookInfoContainer, BookTitle, BookContentsInfo, - BookContents, BookThumbnail, - Article, Author, Input, BookContent, + EditBookWapper, + EditBookThumbnailWrapper, + EditBookThumbnailIcon, + MoreContentsIconWrapper, + DragArticleWrapper, } from './styled'; interface BookProps { book: IBookScraps; + handleModalClose: () => void; } -export default function EditBook({ book }: BookProps) { +export default function EditBook({ book, handleModalClose }: BookProps) { const { id, title, user, scraps } = book; + const { data: imgFile, execute: createImage } = useFetch(createImageApi); + const { value: titleData, onChange: onTitleChange } = useInput(title); + + const [editInfo, setEditInfo] = useRecoilState(editInfoState); + const [curKnottedBookList, setCurKnottedBookList] = useRecoilState(curKnottedBookListState); + const [scrapList] = useRecoilState(scrapState); + + const [isContentsShown, setIsContentsShown] = useState(false); + + const inputFile = useRef(null); + + const handleEditBookImgClick = () => { + if (!inputFile.current) return; + inputFile.current.click(); + }; + + const handleFileUpload = (event: React.ChangeEvent) => { + event.stopPropagation(); + event.preventDefault(); + + if (!event.target.files) return; + + const formData = new FormData(); + formData.append('image', event.target.files[0]); + createImage(formData); + }; + + const handleCompletedBtnClick = () => { + const editScraps = scrapList.map((v: IEditScrap, i: number) => ({ ...v, order: i + 1 })); + + // 해당하는 책을 찾아서 전역에서 관리하고 있는 애를 변경해서 업데이트 + setCurKnottedBookList([ + ...curKnottedBookList.map((curBook) => { + if (id === curBook.id) { + return { + ...curBook, + title: titleData, + thumbnail_image: imgFile?.imagePath || book.thumbnail_image, + scraps: editScraps, + }; + } + return curBook; + }), + ]); + + // editInfo에 정보 담아놓기 + setEditInfo({ + ...editInfo, + editted: [ + ...editInfo.editted, + { + id, + title: titleData, + thumbnail_image: imgFile?.imagePath || book.thumbnail_image, + scraps: editScraps, + }, + ], + }); + + handleModalClose(); + }; + + const handleContentsOnClick = () => { + setIsContentsShown((prev) => !prev); + }; + return ( - <> + - - {/* 수정 버튼 추가 예정 */} - - - - console.log(e.target.value)} + {isContentsShown ? null : ( + + + + profile_edit + - by {user.nickname} - - + + + )} + + {isContentsShown ? null : ( + + + + by {user.nickname} + + + )} Contents - - {scraps.map( - (scrap, idx) => - idx < 4 && ( -
- {idx + 1}. {scrap.article.title} -
- ) - )} -
+ + +
- - More Contents Icon -
+ + More Contents Icon +
- - +
); } diff --git a/frontend/components/study/EditBook/styled.ts b/frontend/components/study/EditBook/styled.ts index 8a678f8e..dff242f5 100644 --- a/frontend/components/study/EditBook/styled.ts +++ b/frontend/components/study/EditBook/styled.ts @@ -3,7 +3,12 @@ import Image from 'next/image'; import styled from 'styled-components'; import { TextXSmall, TextSmall } from '@styles/common'; -import { FlexColumn } from '@styles/layout'; +import { FlexCenter, FlexColumn, FlexColumnCenter } from '@styles/layout'; + +export const EditBookWapper = styled(FlexColumnCenter)` + width: 320px; + margin: auto; +`; export const BookWrapper = styled(FlexColumn)` width: 100%; @@ -86,3 +91,26 @@ export const Author = styled.div` display: block; margin-top: 2px; `; + +export const EditBookThumbnailWrapper = styled.div``; +export const EditBookThumbnailIcon = styled.div` + position: absolute; + display: flex; + justify-content: center; + align-items: center; + border-radius: 100%; + padding: 10px; + box-sizing: content-box; + border: 1px solid var(--grey-01-color); + transform: translate(650%, -120%); + background-color: var(--white-color); + cursor: pointer; +`; + +export const MoreContentsIconWrapper = styled(FlexCenter)``; +export const DragArticleWrapper = styled.div<{ isContentsShown: true | false }>` + ${(props) => + props.isContentsShown + ? { overflow: 'auto', height: '400px' } + : { overflow: 'none', height: '120px' }}; +`; diff --git a/frontend/components/study/EditUserProfile/index.tsx b/frontend/components/study/EditUserProfile/index.tsx index 5b6f5475..533984e5 100644 --- a/frontend/components/study/EditUserProfile/index.tsx +++ b/frontend/components/study/EditUserProfile/index.tsx @@ -33,10 +33,12 @@ export default function EditUserProfile({ handleEditFinishBtnClick, }: EditUserProfileProps) { const { data: imgFile, execute: createImage } = useFetch(createImageApi); + const { value: nicknameValue, onChange: onNicknameChange } = useInput(curUserProfile.nickname); const { value: descriptionValue, onChange: onDescriptionChange } = useInput( curUserProfile.description ); + const inputFile = useRef(null); const handleEditThumbnailClick = () => { diff --git a/frontend/components/study/EditUserProfile/styled.ts b/frontend/components/study/EditUserProfile/styled.ts index f59527ea..c10293c3 100644 --- a/frontend/components/study/EditUserProfile/styled.ts +++ b/frontend/components/study/EditUserProfile/styled.ts @@ -73,11 +73,9 @@ export const ProfileEditButton = styled(Button)` align-items: center; border-radius: 10px; - border: 1px solid var(--grey-01-color); -`; - -export const LogoutButton = styled(Button)` - border: 1px solid var(--grey-01-color); + border: 1px solid rgba(148, 173, 46, 1); + background-color: var(--green-color); + color: var(--white-color); `; export const EditThumbnailIcon = styled.div` diff --git a/frontend/components/study/FAB/index.tsx b/frontend/components/study/FAB/index.tsx index b9e122af..4e29a301 100644 --- a/frontend/components/study/FAB/index.tsx +++ b/frontend/components/study/FAB/index.tsx @@ -1,15 +1,32 @@ import Image from 'next/image'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useRecoilState } from 'recoil'; + +import { deleteBookApi, editBookApi } from '@apis/bookApi'; import Add from '@assets/ico_add.svg'; +import CheckWhite from '@assets/ico_check_white.svg'; import EditWhite from '@assets/ico_edit_white.svg'; +import editInfoState from '@atoms/editInfo'; import Modal from '@components/common/Modal'; +import useFetch from '@hooks/useFetch'; +import { toastSuccess } from '@utils/toast'; import AddBook from '../AddBook'; import { FabButton, FabWrapper } from './styled'; -export default function FAB() { +interface FabProps { + isEditing: boolean; + setIsEditing: (value: boolean) => void; +} + +export default function FAB({ isEditing, setIsEditing }: FabProps) { + const { data: deletedBook, execute: deleteBook } = useFetch(deleteBookApi); + const { data: editBookData, execute: editBook } = useFetch(editBookApi); + + const [editInfo, setEditInfo] = useRecoilState(editInfoState); + const [isModalShown, setModalShown] = useState(false); const handleModalOpen = () => { @@ -19,14 +36,63 @@ export default function FAB() { setModalShown(false); }; + const handleEditFinishBtnClick = () => { + setIsEditing(false); + editInfo.deleted.forEach((bookId) => { + deleteBook(bookId); + }); + editInfo.editted.forEach((edit) => { + editBook(edit); + }); + }; + + useEffect(() => { + if (!deletedBook) return; + + setEditInfo({ + ...editInfo, + deleted: editInfo.deleted.filter((id) => id !== deletedBook.id), + }); + }, [deletedBook]); + + useEffect(() => { + if (!editBookData) return; + + setEditInfo({ + ...editInfo, + editted: editInfo.editted.filter((edit) => edit.id !== editBookData.id), + }); + }, [editBookData]); + + // useEffect(() => { + // if ( + // deletedBook && + // editInfo.deleted.length === 0 && + // editBookData && + // editInfo.editted.length === 0 + // ) + // toastSuccess(`수정 완료되었습니다`); + // }, [deletedBook, editBookData, editInfo]); + return ( 책 추가 - - 책 수정 - + + {isEditing ? ( + + 책 수정 완료 + + ) : ( + { + setIsEditing(true); + }} + > + 책 수정 + + )} {isModalShown && ( diff --git a/frontend/components/study/FAB/styled.ts b/frontend/components/study/FAB/styled.ts index ea8d6250..e829f81c 100644 --- a/frontend/components/study/FAB/styled.ts +++ b/frontend/components/study/FAB/styled.ts @@ -4,9 +4,11 @@ export const FabWrapper = styled.div` position: fixed; display: flex; justify-content: space-between; - bottom: 60px; - right: 330px; - width: 110px; + bottom: 0; + right: 0; + margin: 40px; + gap: 10px; + z-index: 14; `; export const FabButton = styled.button<{ isGreen?: boolean }>` diff --git a/frontend/components/study/UserProfile/styled.ts b/frontend/components/study/UserProfile/styled.ts index 37c7d27c..97df613d 100644 --- a/frontend/components/study/UserProfile/styled.ts +++ b/frontend/components/study/UserProfile/styled.ts @@ -55,5 +55,7 @@ export const ProfileEditButton = styled(Button)` `; export const LogoutButton = styled(Button)` - border: 1px solid var(--grey-01-color); + border: 1px solid #8f4c26; + background-color: var(--primary-color); + color: white; `; diff --git a/frontend/components/viewer/ArticleContent/styled.ts b/frontend/components/viewer/ArticleContent/styled.ts index a24a5a17..7ccc2807 100644 --- a/frontend/components/viewer/ArticleContent/styled.ts +++ b/frontend/components/viewer/ArticleContent/styled.ts @@ -9,12 +9,14 @@ export const ArticleContainer = styled(Flex)` export const ArticleLeftBtn = styled.div` position: fixed; top: 50%; - margin-left: 10px; + margin-left: 20px; + cursor: pointer; `; export const ArticleRightBtn = styled.div` position: fixed; top: 50%; right: 25px; + cursor: pointer; `; export const ArticleMain = styled(Flex)` flex-direction: column; diff --git a/frontend/components/viewer/ScrapModal/index.tsx b/frontend/components/viewer/ScrapModal/index.tsx index a6b43100..dcf0125b 100644 --- a/frontend/components/viewer/ScrapModal/index.tsx +++ b/frontend/components/viewer/ScrapModal/index.tsx @@ -1,27 +1,30 @@ -import { useRouter } from 'next/router'; - import { useEffect, useState } from 'react'; +import { useRecoilState } from 'recoil'; + import { createScrapApi } from '@apis/scrapApi'; +import scrapState from '@atoms/scrap'; +import DragArticle from '@components/common/DragDrop'; 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 { IBook, IBookScraps, IScrap, IArticle } from '@interfaces'; +import { IEditScrap } from 'interfaces/scrap.interface'; -import { Label, ScrapModalWrapper } from './styled'; +import { ArticleWrapper, Label, ScrapModalWrapper } from './styled'; interface ScrapModalProps { books: IBookScraps[]; handleModalClose: () => void; + article: IArticle; } -export default function ScrapModal({ books, handleModalClose }: ScrapModalProps) { +export default function ScrapModal({ books, handleModalClose, article }: ScrapModalProps) { const [selectedBookIndex, setSelectedBookIndex] = useState(-1); - const [selectedScrapIndex, setSelectedScrapIndex] = useState(-1); const [filteredScraps, setFilteredScraps] = useState([]); const { execute: createScrap } = useFetch(createScrapApi); - const router = useRouter(); + const [scrapList, setScrapList] = useRecoilState(scrapState); const createBookDropdownItems = (items: IBook[]) => items.map((item) => { @@ -31,31 +34,33 @@ export default function ScrapModal({ books, handleModalClose }: ScrapModalProps) }; }); - const createScrapDropdownItems = (items: IScrap[]) => - items.map((item) => { - return { - id: item.id, - name: item.article.title, - }; + const createScrapDropdownItems = (items: IEditScrap[]) => { + const itemList = [...items]; + + itemList.push({ + id: 0, + order: items.length + 1, + article: { id: article.id, title: article.title }, }); + return itemList; + }; useEffect(() => { const selectedBook = books.find((book) => book.id === selectedBookIndex); - setSelectedScrapIndex(-1); setFilteredScraps(selectedBook ? selectedBook.scraps : []); }, [selectedBookIndex]); + useEffect(() => { + setScrapList(createScrapDropdownItems(filteredScraps)); + }, [filteredScraps]); + const handleScrapBtnClick = () => { - const [bookId, articleId] = router.query.data as string[]; - if (selectedBookIndex === -1 || selectedScrapIndex === -1) return; - // NOTE: 책의 가장 마지막으로 글이 들어감 (임시) - createScrap({ - order: filteredScraps.length, - is_original: false, - book_id: selectedBookIndex, - article_id: +articleId, - }); + if (selectedBookIndex === -1) return; + + const scraps = scrapList.map((v: IEditScrap, i: number) => ({ ...v, order: i + 1 })); + + createScrap({ book_id: selectedBookIndex, article_id: article.id, scraps }); handleModalClose(); }; @@ -68,13 +73,12 @@ export default function ScrapModal({ books, handleModalClose }: ScrapModalProps) selectedId={selectedBookIndex} handleItemSelect={(id) => setSelectedBookIndex(id)} /> - - setSelectedScrapIndex(id)} - /> + {filteredScraps.length !== 0 && ( + + + + + )} 스크랩하기 diff --git a/frontend/components/viewer/ScrapModal/styled.ts b/frontend/components/viewer/ScrapModal/styled.ts index ac14ceed..ac45a476 100644 --- a/frontend/components/viewer/ScrapModal/styled.ts +++ b/frontend/components/viewer/ScrapModal/styled.ts @@ -11,3 +11,8 @@ export const ScrapModalWrapper = styled.div` `; export const Label = styled(TextLarge)``; +export const ArticleWrapper = styled.div` + width: 100%; + height: 300px; + overflow: auto; +`; diff --git a/frontend/components/viewer/TOC/styled.ts b/frontend/components/viewer/TOC/styled.ts index 844d979b..9580c0ce 100644 --- a/frontend/components/viewer/TOC/styled.ts +++ b/frontend/components/viewer/TOC/styled.ts @@ -16,18 +16,18 @@ const slide = keyframes` export const TocWrapper = styled(Flex)` /* 고정크기? %? */ - flex-basis: 250px; + flex-basis: 300px; height: calc(100vh - 67px); + overflow: hidden; background-color: var(--primary-color); color: var(--white-color); flex-direction: column; - position: relative; + justify-content: space-between; // animation: ${slide} 1s ease-in-out; `; export const TocSideBar = styled.div` - padding: 30px; - flex-basis: 90%; + padding: 30px 24px 10px 24px; `; export const TocIcons = styled(Flex)` @@ -38,17 +38,27 @@ export const TocTitle = styled.div` padding: 10px 0; border-bottom: 1px solid var(--white-color); `; + export const TocContainer = styled.div` background-color: var(--white-color); - height: 70%; color: var(--grey-01-color); border-radius: 20px; - padding: 20px; + padding: 24px; margin-top: 10px; overflow: auto; + height: calc(100vh - 357px); + + ::-webkit-scrollbar { + width: 10px; + } + ::-webkit-scrollbar-thumb { + background-color: var(--grey-02-color); + border-radius: 10px; + } `; + export const TocList = styled.div` - margin: 5px; + margin-top: 10px; `; export const TocArticle = styled(Link)` @@ -67,9 +77,9 @@ export const TocArticle = styled(Link)` export const TocProfile = styled(Flex)` justify-content: end; align-items: end; - flex-basis: 10%; padding: 20px; `; + export const TocProfileText = styled(Flex)` flex-direction: column; align-items: end; diff --git a/frontend/hooks/useIntersectionObserver.ts b/frontend/hooks/useIntersectionObserver.ts new file mode 100644 index 00000000..9668f560 --- /dev/null +++ b/frontend/hooks/useIntersectionObserver.ts @@ -0,0 +1,28 @@ +import { RefObject, useEffect, useState } from 'react'; + +const useIntersectionObserver = (elementRef: RefObject) => { + const [isIntersecting, setIntersecting] = useState(false); + + const onIntersect = ([entry]: IntersectionObserverEntry[]) => { + const isElementIntersecting = entry.isIntersecting; + setIntersecting(isElementIntersecting); + }; + + useEffect(() => { + const target = elementRef?.current; + if (!target) return undefined; + + const observer = new IntersectionObserver(onIntersect, { + threshold: 0.1, + rootMargin: '300px', + }); + + observer.observe(target); + + return () => observer.disconnect(); + }, [elementRef?.current]); + + return isIntersecting; +}; + +export default useIntersectionObserver; diff --git a/frontend/interfaces/article.interface.ts b/frontend/interfaces/article.interface.ts index ffbc4198..c2c340c4 100644 --- a/frontend/interfaces/article.interface.ts +++ b/frontend/interfaces/article.interface.ts @@ -1,5 +1,3 @@ -// import { IBook } from './book.interface'; - export interface IArticle { id: number; title: string; @@ -7,5 +5,4 @@ export interface IArticle { created_at: string; deleted_at: string; book_id: number; - // book: IBook; } diff --git a/frontend/interfaces/book.interface.ts b/frontend/interfaces/book.interface.ts index 8d5e33be..1fc138bb 100644 --- a/frontend/interfaces/book.interface.ts +++ b/frontend/interfaces/book.interface.ts @@ -4,6 +4,7 @@ import { IUser } from './user.interface'; export interface IBook { id: number; title: string; + thumbnail_image: string; user: IUser; _count: { bookmarks: number; diff --git a/frontend/interfaces/scrap.interface.ts b/frontend/interfaces/scrap.interface.ts index ea7f2e8f..2975de36 100644 --- a/frontend/interfaces/scrap.interface.ts +++ b/frontend/interfaces/scrap.interface.ts @@ -5,3 +5,11 @@ export interface IScrap { order: number; article: IArticle; } +export interface IEditScrap { + id: number; + order: number; + article: { + id: number; + title: string; + }; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d8cd2325..8eb40a38 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,8 +22,11 @@ "dotenv-webpack": "^8.0.1", "eslint": "8.27.0", "eslint-config-next": "13.0.3", + "immutability-helper": "^3.1.1", "next": "13.0.3", "react": "18.2.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.2.0", "react-toastify": "^9.1.1", "recoil": "^0.7.6", @@ -896,6 +899,21 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" + }, "node_modules/@rushstack/eslint-patch": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", @@ -963,7 +981,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", - "dev": true, + "devOptional": true, "dependencies": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" @@ -2021,6 +2039,16 @@ "node": ">=8" } }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3187,6 +3215,11 @@ "node": ">= 4" } }, + "node_modules/immutability-helper": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz", + "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -4693,6 +4726,43 @@ "node": ">=0.10.0" } }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -4741,6 +4811,14 @@ } } }, + "node_modules/redux": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz", + "integrity": "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/regenerator-runtime": { "version": "0.13.10", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz", @@ -6425,6 +6503,21 @@ "tslib": "^2.4.0" } }, + "@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==" + }, + "@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==" + }, + "@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" + }, "@rushstack/eslint-patch": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", @@ -6492,7 +6585,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", - "dev": true, + "devOptional": true, "requires": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" @@ -7278,6 +7371,16 @@ "path-type": "^4.0.0" } }, + "dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "requires": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -8126,6 +8229,11 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==" }, + "immutability-helper": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz", + "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ==" + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -9064,6 +9172,26 @@ "loose-envify": "^1.1.0" } }, + "react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "requires": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + } + }, + "react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "requires": { + "dnd-core": "^16.0.1" + } + }, "react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -9094,6 +9222,14 @@ "hamt_plus": "1.0.2" } }, + "redux": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz", + "integrity": "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, "regenerator-runtime": { "version": "0.13.10", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz", diff --git a/frontend/package.json b/frontend/package.json index e4b873d5..3e1addc6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,8 +23,11 @@ "dotenv-webpack": "^8.0.1", "eslint": "8.27.0", "eslint-config-next": "13.0.3", + "immutability-helper": "^3.1.1", "next": "13.0.3", "react": "18.2.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.2.0", "react-toastify": "^9.1.1", "recoil": "^0.7.6", diff --git a/frontend/pages/editor.tsx b/frontend/pages/editor.tsx index 67676d06..de8f66ce 100644 --- a/frontend/pages/editor.tsx +++ b/frontend/pages/editor.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; -import { getBooksApi } from '@apis/bookApi'; +import { useRecoilValue } from 'recoil'; + +import { getUserKnottedBooksApi } from '@apis/bookApi'; +import signInStatusState from '@atoms/signInStatus'; import Modal from '@components/common/Modal'; -import EditBar from '@components/edit/EditBar'; import Editor from '@components/edit/Editor'; import PublishModal from '@components/edit/PublishModal'; import useFetch from '@hooks/useFetch'; @@ -13,16 +15,17 @@ export default function EditorPage() { const handleModalOpen = () => setModalShown(true); const handleModalClose = () => setModalShown(false); - const { data: books, execute: getBooks } = useFetch(getBooksApi); + const { data: books, execute: getUserKnottedBooks } = useFetch(getUserKnottedBooksApi); + + const user = useRecoilValue(signInStatusState); useEffect(() => { - getBooks({ userId: 4 }); + getUserKnottedBooks(user.nickname); }, []); return ( <> - handleModalOpen()} /> - + {isModalShown && ( diff --git a/frontend/pages/search.tsx b/frontend/pages/search.tsx index 6c040ef3..7acc5e71 100644 --- a/frontend/pages/search.tsx +++ b/frontend/pages/search.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { RefObject, useEffect, useRef, useState } from 'react'; import { searchArticlesApi } from '@apis/articleApi'; import { searchBooksApi } from '@apis/bookApi'; @@ -7,23 +7,152 @@ import ArticleList from '@components/search/ArticleList'; import BookList from '@components/search/BookList'; import SearchBar from '@components/search/SearchBar'; import SearchFilter from '@components/search/SearchFilter'; +import SearchNoResult from '@components/search/SearchNoResult'; import useDebounce from '@hooks/useDebounce'; import useFetch from '@hooks/useFetch'; import useInput from '@hooks/useInput'; +import useIntersectionObserver from '@hooks/useIntersectionObserver'; +import { IArticle, IBook } from '@interfaces'; import { PageInnerSmall, PageWrapper } from '@styles/layout'; export default function Search() { - const { data: articles, execute: searchArticles } = useFetch(searchArticlesApi); - const { data: books, execute: searchBooks } = useFetch(searchBooksApi); + const [articles, setArticles] = useState([]); + const [books, setBooks] = useState([]); + + const { data: newArticles, execute: searchArticles } = useFetch(searchArticlesApi); + const { data: newBooks, execute: searchBooks } = useFetch(searchBooksApi); const keyword = useInput(); - const debouncedKeyword = useDebounce(keyword.value, 1000); + const debouncedKeyword = useDebounce(keyword.value, 300); + + const target = useRef() as RefObject; + const isIntersecting = useIntersectionObserver(target); + + const [articlePage, setArticlePage] = useState({ hasNextPage: true, pageNumber: 2 }); + const [bookPage, setBookPage] = useState({ hasNextPage: true, pageNumber: 2 }); const [filter, setFilter] = useState({ type: 'article', userId: 0 }); + const highlightWord = (text: string, words: string[]): React.ReactNode => { + let wordIndexList = words.map((word) => text.toLowerCase().indexOf(word.toLowerCase())); + + const filteredWords = words.filter((_, index) => wordIndexList[index] !== -1); + wordIndexList = wordIndexList.filter((wordIndex) => wordIndex !== -1); + + if (wordIndexList.length === 0) return text; + + const startIndex = Math.min(...wordIndexList); + + const targetWord = filteredWords[wordIndexList.indexOf(startIndex)]; + + const endIndex = startIndex + targetWord.length; + + return ( + <> + {text.slice(0, startIndex)} + {text.slice(startIndex, endIndex)} + {highlightWord(text.slice(endIndex), words)} + + ); + }; + useEffect(() => { - // 데이터 받아오기 - }, [debouncedKeyword]); + if (!debouncedKeyword) return; + + setArticles([]); + searchArticles({ + query: debouncedKeyword, + userId: filter.userId, + page: 1, + take: 12, + }); + setArticlePage({ + hasNextPage: true, + pageNumber: 2, + }); + setBooks([]); + searchBooks({ + query: debouncedKeyword, + userId: filter.userId, + page: 1, + take: 12, + }); + setBookPage({ + hasNextPage: true, + pageNumber: 2, + }); + }, [debouncedKeyword, filter.userId]); + + useEffect(() => { + if (!isIntersecting || !debouncedKeyword) return; + + if (filter.type === 'article') { + if (!articlePage.hasNextPage) return; + searchArticles({ + query: debouncedKeyword, + userId: filter.userId, + page: articlePage.pageNumber, + take: 12, + }); + setArticlePage({ + ...articlePage, + pageNumber: articlePage.pageNumber + 1, + }); + } else if (filter.type === 'book') { + if (!bookPage.hasNextPage) return; + searchBooks({ + query: debouncedKeyword, + userId: filter.userId, + page: bookPage.pageNumber, + take: 12, + }); + setBookPage({ + ...bookPage, + pageNumber: bookPage.pageNumber + 1, + }); + } + }, [isIntersecting]); + + useEffect(() => { + if (!newArticles) return; + setArticles( + articles.concat( + newArticles.data.map((article: IArticle) => { + const keywords = debouncedKeyword.trim().split(' '); + + return { + ...article, + title: highlightWord(article.title, keywords), + content: highlightWord(article.content, keywords), + }; + }) + ) + ); + setArticlePage({ + ...articlePage, + hasNextPage: newArticles.hasNextPage, + }); + }, [newArticles]); + + useEffect(() => { + if (!newBooks) return; + setBooks( + books.concat( + newBooks.data.map((book: IBook) => { + const keywords = debouncedKeyword.trim().split(' '); + + return { + ...book, + title: highlightWord(book.title, keywords), + }; + }) + ) + ); + setBookPage({ + ...bookPage, + hasNextPage: newBooks.hasNextPage, + }); + }, [newBooks]); const handleFilter = (value: { [value: string]: string | number }) => { setFilter({ @@ -39,8 +168,12 @@ export default function Search() { - {filter.type === 'article' && } - {filter.type === 'book' && } + {articles?.length > 0 && filter.type === 'article' && } + {books?.length > 0 && filter.type === 'book' && } + {debouncedKeyword !== '' && + ((articles?.length === 0 && filter.type === 'article') || + (books?.length === 0 && filter.type === 'book')) && } +
diff --git a/frontend/pages/study/[...data].tsx b/frontend/pages/study/[...data].tsx index e2dd9f4d..d68149eb 100644 --- a/frontend/pages/study/[...data].tsx +++ b/frontend/pages/study/[...data].tsx @@ -4,12 +4,13 @@ import { useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; +import { getUserBookmarkedBooksApi, getUserKnottedBooksApi } from '@apis/bookApi'; import { getUserProfileApi, updateUserProfileApi } from '@apis/userApi'; +import curKnottedBookListState from '@atoms/curKnottedBookList'; import signInStatusState from '@atoms/signInStatus'; import GNB from '@components/common/GNB'; import BookListTab from '@components/study/BookListTab'; import EditUserProfile from '@components/study/EditUserProfile'; -import FAB from '@components/study/FAB'; import UserProfile from '@components/study/UserProfile'; import useFetch from '@hooks/useFetch'; import { IUser } from '@interfaces'; @@ -17,9 +18,16 @@ import { PageInnerLarge, PageWrapper } from '@styles/layout'; export default function Study() { const router = useRouter(); + const { data: userProfile, execute: getUserProfile } = useFetch(getUserProfileApi); const { data: updatedUserProfile, execute: updateUserProfile } = useFetch(updateUserProfileApi); + const { data: knottedBookList, execute: getKnottedBookList } = useFetch(getUserKnottedBooksApi); + const { data: bookmarkedBookList, execute: getBookmarkedBookList } = + useFetch(getUserBookmarkedBooksApi); + const [signInStatus, setSignInStatus] = useRecoilState(signInStatusState); + const [curKnottedBookList, setCurKnottedBookList] = useRecoilState(curKnottedBookListState); + const [curUserProfile, setCurUserProfile] = useState(null); const [isEditing, setIsEditing] = useState(false); @@ -34,6 +42,8 @@ export default function Study() { const nickname = router.query.data; getUserProfile(nickname); + getKnottedBookList(nickname); + getBookmarkedBookList(nickname); }, [router.query.data]); useEffect(() => { @@ -55,6 +65,12 @@ export default function Study() { window.history.replaceState(null, '', `/study/${curUserProfile.nickname}`); }, [updatedUserProfile]); + useEffect(() => { + if (!knottedBookList) return; + + setCurKnottedBookList(knottedBookList); + }, [knottedBookList]); + return ( <> @@ -75,9 +91,12 @@ export default function Study() { }} /> )} - + - )} diff --git a/frontend/pages/viewer/[...data].tsx b/frontend/pages/viewer/[...data].tsx index 485d989b..daf2a349 100644 --- a/frontend/pages/viewer/[...data].tsx +++ b/frontend/pages/viewer/[...data].tsx @@ -2,8 +2,11 @@ import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; + import { getArticleApi } from '@apis/articleApi'; import { getBookApi, getUserKnottedBooksApi } from '@apis/bookApi'; +import signInStatusState from '@atoms/signInStatus'; import GNB from '@components/common/GNB'; import Modal from '@components/common/Modal'; import ArticleContainer from '@components/viewer/ArticleContent'; @@ -18,6 +21,8 @@ export default function Viewer() { const { data: book, execute: getBook } = useFetch(getBookApi); const { data: userBooks, execute: getUserKnottedBooks } = useFetch(getUserKnottedBooksApi); + const user = useRecoilValue(signInStatusState); + const [isOpened, setIsOpened] = useState(true); const [isModalShown, setModalShown] = useState(false); @@ -37,7 +42,7 @@ export default function Viewer() { getBook(bookId); getArticle(articleId); - getUserKnottedBooks('dahyeon'); + getUserKnottedBooks(user.nickname); } }, [router.query.data]); @@ -63,7 +68,7 @@ export default function Viewer() { )} {isModalShown && ( - + )} diff --git a/frontend/public/assets/ico_check_white.svg b/frontend/public/assets/ico_check_white.svg new file mode 100644 index 00000000..270072d8 --- /dev/null +++ b/frontend/public/assets/ico_check_white.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/ico_minus_white.svg b/frontend/public/assets/ico_minus_white.svg new file mode 100644 index 00000000..5f10ef15 --- /dev/null +++ b/frontend/public/assets/ico_minus_white.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/styles/GlobalStyle.ts b/frontend/styles/GlobalStyle.ts index 3860631c..3c8ce081 100644 --- a/frontend/styles/GlobalStyle.ts +++ b/frontend/styles/GlobalStyle.ts @@ -28,6 +28,18 @@ const GlobalStyle = createGlobalStyle` cursor: pointer; } + a { + color: var(--title-active-color); + text-decoration: none; + outline: none + } + + a:hover, a:active { + color: var(--title-active-color); + text-decoration: none; + background-color: transparent; + } + `; export default GlobalStyle; diff --git a/frontend/styles/layout.ts b/frontend/styles/layout.ts index 9a05b397..1e3eb36e 100644 --- a/frontend/styles/layout.ts +++ b/frontend/styles/layout.ts @@ -27,7 +27,7 @@ export const FlexColumnCenter = styled.div` export const PageWrapper = styled.div` padding-top: 64px; - min-height: calc(100vh - 67px); + min-height: calc(100vh - 131px); background-color: var(--light-yellow-color); `;