diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 4295c690..216b748f 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -110,7 +110,6 @@ jobs: `| ${score(summaryTotal.accessibility)} Accessibility | ${summaryTotal.accessibility} |`, `| ${score(summaryTotal['best-practices'])} Best Practices | ${summaryTotal['best-practices']} |`, `| ${score(summaryTotal.seo)} SEO | ${summaryTotal.seo} |`, - `| ${score(summaryTotal.pwa)} PWA | ${summaryTotal.pwa} |`, ].join('\n'); core.setOutput('comment', comment) diff --git a/backend/pm2.json b/backend/pm2.json index fa0b0537..144a7d43 100644 --- a/backend/pm2.json +++ b/backend/pm2.json @@ -2,8 +2,17 @@ "apps": [ { "name": "knoticle-backend", - "script": "npm", - "args": "run start" + "script": "./dist/index.js", + "node_args": "-r ./tsconfig-paths.js", + "instance": 1, + "exec_mode": "cluster", + "wait_ready": true, + "listen_timeout": 50000, + "kill_timeout": 5000, + "merge_logs": true, + "env": { + "NODE_ENV": "prod" + } } ] } diff --git a/backend/src/apis/bookmarks/bookmarks.controller.ts b/backend/src/apis/bookmarks/bookmarks.controller.ts index 8191114e..b5381fa8 100644 --- a/backend/src/apis/bookmarks/bookmarks.controller.ts +++ b/backend/src/apis/bookmarks/bookmarks.controller.ts @@ -14,9 +14,9 @@ const createBookmark = async (req: Request, res: Response) => { const deleteBookmark = async (req: Request, res: Response) => { const bookmarkId = Number(req.params.bookmarkId); - await bookmarksService.deleteBookmark(bookmarkId); + const bookamrk = await bookmarksService.deleteBookmark(bookmarkId); - return res.status(200).send(); + return res.status(200).send(bookamrk); }; export default { diff --git a/backend/src/apis/bookmarks/bookmarks.service.ts b/backend/src/apis/bookmarks/bookmarks.service.ts index bb4b8ce5..b33eda36 100644 --- a/backend/src/apis/bookmarks/bookmarks.service.ts +++ b/backend/src/apis/bookmarks/bookmarks.service.ts @@ -1,7 +1,16 @@ import { prisma } from '@config/orm.config'; -import { Message, NotFound } from '@errors'; +import { Message, NotFound, ResourceConflict } from '@errors'; const createBookmark = async (user_id: number, book_id: number) => { + const bookmark = await prisma.bookmark.findFirst({ + where: { + user_id, + book_id, + }, + }); + + if (bookmark) throw new ResourceConflict(Message.BOOKMARK_ALREADY_EXISTS); + const { id } = await prisma.bookmark.create({ data: { user_id, @@ -14,11 +23,13 @@ const createBookmark = async (user_id: number, book_id: number) => { const deleteBookmark = async (id: number) => { try { - await prisma.bookmark.delete({ + const bookmark = await prisma.bookmark.delete({ where: { id, }, }); + + return bookmark; } catch (err) { throw new NotFound(Message.BOOKMARK_NOTFOUND); } diff --git a/backend/src/errors/message.ts b/backend/src/errors/message.ts index 7c3f94f6..e183c929 100644 --- a/backend/src/errors/message.ts +++ b/backend/src/errors/message.ts @@ -7,6 +7,7 @@ export default { TOKEN_MALFORMED: '로그인이 필요합니다.', BOOK_NOTFOUND: '일치하는 책이 없습니다.', BOOK_INVALID_TITLE: '책 제목이 비어있습니다.', + BOOKMARK_ALREADY_EXISTS: '이미 북마크된 책입니다', BOOKMARK_NOTFOUND: '북마크된 책이 아닙니다.', ARTICLE_NOTFOUND: '일치하는 글이 없습니다.', ARTICLE_INVALID_TITLE: '글 제목이 비어있습니다.', diff --git a/backend/src/index.ts b/backend/src/index.ts index 65441e7a..640541c8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,9 +8,15 @@ const startServer = async () => { await loader(app); - app.listen(+process.env.PORT, process.env.HOST, () => { + const server = app.listen(+process.env.PORT, process.env.HOST, () => { + if (process.send) process.send('ready'); + console.log(`Server listening on port: ${process.env.PORT}`); }); + + process.on('SIGINT', () => { + server.close(() => process.exit(0)); + }); }; startServer(); diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 96f0c8e7..534aa081 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -47,6 +47,12 @@ } } ], + "jsx-a11y/label-has-associated-control": [ + "error", + { + "labelAttributes": ["htmlFor"] + } + ], "prettier/prettier": ["error", { "endOfLine": "auto" }], "react/react-in-jsx-scope": "off", "react/jsx-filename-extension": ["error", { "extensions": [".ts", ".tsx"] }], diff --git a/frontend/.lighthouserc.js b/frontend/.lighthouserc.js index d849f919..15afe459 100644 --- a/frontend/.lighthouserc.js +++ b/frontend/.lighthouserc.js @@ -4,6 +4,17 @@ module.exports = { startServerCommand: 'npm run start', url: ['http://localhost:3000'], numberOfRuns: 3, + settings: { + onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'], + }, + }, + assert: { + assertions: { + 'categories:performance': ['warn', { minScore: 0.9, aggregationMethod: 'median-run' }], + 'categories:accessibility': ['warn', { minScore: 1, aggregationMethod: 'pessimistic' }], + 'categories:best-practices': ['warn', { minScore: 1, aggregationMethod: 'pessimistic' }], + 'categories:seo': ['warn', { minScore: 1, aggregationMethod: 'pessimistic' }], + }, }, upload: { target: 'filesystem', diff --git a/frontend/atoms/curBookmarkedBookList.ts b/frontend/atoms/curBookmarkedBookList.ts new file mode 100644 index 00000000..11e13b03 --- /dev/null +++ b/frontend/atoms/curBookmarkedBookList.ts @@ -0,0 +1,10 @@ +import { atom } from 'recoil'; + +import { IBookScraps } from '@interfaces'; + +const curBookmarkedBookListState = atom({ + key: 'curBookmarkedBookListState', + default: [], +}); + +export default curBookmarkedBookListState; diff --git a/frontend/components/common/Book/index.tsx b/frontend/components/common/Book/index.tsx index baabada5..df8c38d4 100644 --- a/frontend/components/common/Book/index.tsx +++ b/frontend/components/common/Book/index.tsx @@ -25,12 +25,9 @@ interface 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 - ); + const { id, title, user, scraps } = book; + const { handleBookmarkClick, curBookmarkCnt, curBookmarkId } = useBookmark(book); + return ( // 수정모드일때만 아래 onclick이 실행되도록 수정해야함 -> 민형님 작업 후 diff --git a/frontend/components/common/Content/styled.ts b/frontend/components/common/Content/styled.ts index 5f4a2a29..14e8e97d 100644 --- a/frontend/components/common/Content/styled.ts +++ b/frontend/components/common/Content/styled.ts @@ -8,11 +8,12 @@ export const ContentTitle = styled.h1` margin-bottom: 16px; font-size: 24px; font-weight: 700; + display: flex; + align-items: center; + height: 35px; `; export const ContentBody = styled.div` - padding-top: 10px; - > * { line-height: 2; font-weight: 400; @@ -68,6 +69,10 @@ export const ContentBody = styled.div` } } + em { + font-style: italic; + } + blockquote { margin: 24px 0; padding: 24px 16px; @@ -86,7 +91,6 @@ export const ContentBody = styled.div` border-radius: 4px; font-family: 'consolas'; font-size: 16px; - font-weight: 700; line-height: 1.4; code { diff --git a/frontend/components/common/DragDrop/ListItem/index.tsx b/frontend/components/common/DragDrop/ListItem/index.tsx index 1175f52b..08d0e279 100644 --- a/frontend/components/common/DragDrop/ListItem/index.tsx +++ b/frontend/components/common/DragDrop/ListItem/index.tsx @@ -84,17 +84,16 @@ export const ListItem = memo(function Scrap({ const handleMinusBtnClick = () => { // 원본글이 아니면 스크랩에서만 삭제 // 원본글이면 실제로 삭제 - if (window.confirm('글을 책에서 삭제하시겠습니까?')) { - if (isOriginal) { + if (isOriginal) { + if (window.confirm('이 글은 원본 글입니다. 정말로 삭제하시겠습니까?')) { setEditInfo({ ...editInfo, deletedArticle: [...editInfo.deletedArticle, id], deletedScraps: [...editInfo.deletedScraps, scrapId], }); setScraps(scraps.filter((v) => v.article.id !== id)); - return; } - + } else if (window.confirm('글을 책에서 삭제하시겠습니까?')) { setEditInfo({ ...editInfo, deletedScraps: [...editInfo.deletedScraps, scrapId], diff --git a/frontend/components/common/DragDrop/ListItem/styled.ts b/frontend/components/common/DragDrop/ListItem/styled.ts index 4e0e27eb..d6be6f76 100644 --- a/frontend/components/common/DragDrop/ListItem/styled.ts +++ b/frontend/components/common/DragDrop/ListItem/styled.ts @@ -22,7 +22,8 @@ export const MinusButton = styled.div` display: flex; justify-content: center; align-items: center; - width: 30px; + width: 25px; + height: 25px; border-radius: 50%; background-color: var(--red-color); `; diff --git a/frontend/components/common/Footer/index.tsx b/frontend/components/common/Footer/index.tsx index 590080f3..165251a8 100644 --- a/frontend/components/common/Footer/index.tsx +++ b/frontend/components/common/Footer/index.tsx @@ -1,11 +1,13 @@ -import FooterContent from './styled'; +import { FooterContent, FooterLink } from './styled'; function Footer() { return (
Copyright © 2022 by Knoticle Team
-
Github
+
+ Github +
); diff --git a/frontend/components/common/Footer/styled.ts b/frontend/components/common/Footer/styled.ts index 45ccfead..e70a1c3e 100644 --- a/frontend/components/common/Footer/styled.ts +++ b/frontend/components/common/Footer/styled.ts @@ -1,8 +1,10 @@ +import Link from 'next/link'; + import styled from 'styled-components'; import { FlexCenter } from '@styles/layout'; -const FooterContent = styled(FlexCenter)` +export const FooterContent = styled(FlexCenter)` flex-direction: column; padding: 50px; gap: 8px; @@ -10,4 +12,14 @@ const FooterContent = styled(FlexCenter)` font-size: 14px; `; +export const FooterLink = styled(Link)` + color: var(--primary-color); + font-size: 14px; + + :hover { + color: var(--primary-color); + text-decoration-line: underline; + } +`; + export default FooterContent; diff --git a/frontend/components/common/GNB/index.tsx b/frontend/components/common/GNB/index.tsx index d017a82d..71b5d656 100644 --- a/frontend/components/common/GNB/index.tsx +++ b/frontend/components/common/GNB/index.tsx @@ -1,3 +1,4 @@ +import dynamic from 'next/dynamic'; import Link from 'next/link'; import { useState } from 'react'; @@ -8,13 +9,14 @@ import ArticleIcon from '@assets/ico_article.svg'; import PersonIcon from '@assets/ico_person.svg'; import SearchIcon from '@assets/ico_search.svg'; import signInStatusState from '@atoms/signInStatus'; -import SignInModal from '@components/auth/SignInModal'; -import SignUpModal from '@components/auth/SignUpModal'; -import Modal from '@components/common/Modal'; import { GNBbar, Icon, IconsContainer, Logo, LogoWrapper } from './styled'; export default function GNB() { + const Modal = dynamic(() => import('@components/common/Modal')); + const SignInModal = dynamic(() => import('@components/auth/SignInModal')); + const SignUpModal = dynamic(() => import('@components/auth/SignUpModal')); + const [isModalShown, setModalShown] = useState(false); const [currentModalState, setCurrentModalState] = useState<'SignIn' | 'SignUp'>('SignIn'); const signInStatus = useRecoilValue(signInStatusState); @@ -33,13 +35,15 @@ export default function GNB() { knoticle - - - + {signInStatus.id !== 0 && ( + + + + )} {signInStatus.id !== 0 ? ( diff --git a/frontend/components/edit/Editor/core/theme.ts b/frontend/components/edit/Editor/core/theme.ts index 78182252..f6e50d48 100644 --- a/frontend/components/edit/Editor/core/theme.ts +++ b/frontend/components/edit/Editor/core/theme.ts @@ -1,25 +1,34 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; -import { tags } from '@lezer/highlight'; +import { tags as t } from '@lezer/highlight'; export default function theme() { const highlightStyle = HighlightStyle.define([ - { tag: tags.heading1, fontSize: '24px', fontWeight: '700' }, - { tag: tags.heading2, fontSize: '20px', fontWeight: '700' }, - { tag: tags.heading3, fontSize: '18px', fontWeight: '700' }, - { tag: tags.link, textDecoration: 'underline' }, - { tag: tags.strikethrough, textDecoration: 'line-through' }, - { tag: tags.invalid, color: '#cb2431' }, - { tag: [tags.string, tags.meta, tags.regexp], color: '#222222', fontWeight: 700 }, - { tag: [tags.heading, tags.strong], color: '#222222', fontWeight: '700' }, - { tag: [tags.emphasis], color: '#24292e', fontStyle: 'italic' }, - { tag: [tags.comment, tags.bracket], color: '#6a737d' }, - { tag: [tags.className, tags.propertyName], color: '#6f42c1' }, - { tag: [tags.variableName, tags.attributeName, tags.number, tags.operator], color: '#005cc5' }, - { tag: [tags.keyword, tags.typeName, tags.typeOperator, tags.typeName], color: '#d73a49' }, - { tag: [tags.name, tags.quote], color: '#22863a' }, - { tag: [tags.deleted], color: '#b31d28', backgroundColor: 'ffeef0' }, - { tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#e36209' }, - { tag: [tags.url, tags.escape, tags.regexp, tags.link], color: '#222222' }, + { tag: t.heading1, fontSize: '24px', fontWeight: '700' }, + { tag: t.heading2, fontSize: '20px', fontWeight: '700' }, + { tag: t.heading3, fontSize: '18px', fontWeight: '700' }, + { tag: t.link, textDecoration: 'underline' }, + { tag: t.strikethrough, textDecoration: 'line-through' }, + { tag: t.invalid, color: '#cb2431' }, + { tag: t.name, color: '#22863a' }, + { tag: t.emphasis, color: '#24292e', fontStyle: 'italic' }, + { tag: t.deleted, color: '#b31d28', backgroundColor: '#ffeef0' }, + { tag: [t.heading, t.strong, t.meta], color: '#222222', fontWeight: '700' }, + { tag: [t.url, t.escape, t.regexp, t.link, t.quote], color: '#222222' }, + { tag: t.comment, color: '#6a737d', fontFamily: 'consolas' }, + { + tag: [t.attributeName, t.className, t.propertyName, t.function(t.definition(t.variableName))], + color: '#6f42c1', + fontFamily: 'consolas', + }, + { tag: [t.operator, t.variableName, t.bracket], color: '#222222', fontFamily: 'consolas' }, + { tag: [t.string], color: '#032f62', fontFamily: 'consolas' }, + { tag: [t.number], color: '#005cc5', fontFamily: 'consolas' }, + { + tag: [t.keyword, t.typeName, t.typeOperator, t.typeName, t.atom, t.moduleKeyword], + color: '#d73a49', + fontFamily: 'consolas', + }, + { tag: [t.bool, t.special(t.variableName)], color: '#005cc5', fontFamily: 'consolas' }, ]); return [syntaxHighlighting(highlightStyle)]; diff --git a/frontend/components/edit/Editor/core/useCodeMirror.ts b/frontend/components/edit/Editor/core/useCodeMirror.ts index b2ab2cb4..a7a517e5 100644 --- a/frontend/components/edit/Editor/core/useCodeMirror.ts +++ b/frontend/components/edit/Editor/core/useCodeMirror.ts @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; +import { indentWithTab } from '@codemirror/commands'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { languages } from '@codemirror/language-data'; import { EditorState } from '@codemirror/state'; -import { placeholder } from '@codemirror/view'; +import { placeholder, keymap } from '@codemirror/view'; import { EditorView } from 'codemirror'; import { createImageApi } from '@apis/imageApi'; @@ -37,13 +38,113 @@ export default function useCodeMirror() { }); }; + const handleImage = (imageFile: File) => { + if (!/image\/[png,jpg,jpeg,gif]/.test(imageFile.type)) return; + + const formData = new FormData(); + + formData.append('image', imageFile); + + createImage(formData); + }; + const onChange = () => { return EditorView.updateListener.of(({ view, docChanged }) => { if (docChanged) setDocument(view.state.doc.toString()); }); }; - const onPaste = () => { + const insertStartToggle = (symbol: string) => { + if (!editorView) return; + + editorView.focus(); + + const { head } = editorView.state.selection.main; + const { from, to, text } = editorView.state.doc.lineAt(head); + + const hasExist = text.startsWith(symbol); + + if (!hasExist) { + editorView.dispatch({ + changes: { + from, + to, + insert: `${symbol}${text}`, + }, + selection: { + anchor: from + text.length + symbol.length, + }, + }); + + return; + } + + editorView.dispatch({ + changes: { + from, + to, + insert: `${text.slice(symbol.length, text.length)}`, + }, + }); + }; + + const insertBetweenToggle = (symbol: string, defaultText = '텍스트') => { + if (!editorView) return; + + editorView.focus(); + + const { from, to } = editorView.state.selection.ranges[0]; + + const text = editorView.state.sliceDoc(from, to); + + const prefixText = editorView.state.sliceDoc(from - symbol.length, from); + const affixText = editorView.state.sliceDoc(to, to + symbol.length); + + const hasExist = symbol === prefixText && symbol === affixText; + + if (!hasExist) { + editorView.dispatch({ + changes: { + from, + to, + insert: `${symbol}${text || defaultText}${symbol}`, + }, + selection: { + head: from + symbol.length, + anchor: text ? to + symbol.length : to + symbol.length + defaultText.length, + }, + }); + + return; + } + + editorView.dispatch({ + changes: { + from: from - symbol.length, + to: to + symbol.length, + insert: text, + }, + selection: { + head: from - symbol.length, + anchor: to - symbol.length, + }, + }); + }; + + const insertCursor = (text: string) => { + if (!editorView) return; + + editorView.focus(); + + const cursor = editorView.state.selection.main.head; + + editorView.dispatch({ + changes: { from: cursor, to: cursor, insert: text }, + selection: { anchor: cursor + text.length }, + }); + }; + + const eventHandler = () => { return EditorView.domEventHandlers({ paste(event) { if (!event.clipboardData) return; @@ -52,16 +153,20 @@ export default function useCodeMirror() { // eslint-disable-next-line no-restricted-syntax for (const item of items) { - if (item.kind === 'file' && /image\/[png,jpg,jpeg,gif]/.test(item.type)) { - const blob = item.getAsFile() as Blob; + if (!(item.kind === 'file')) return; + handleImage(item.getAsFile() as File); + } + }, + drop(event, view) { + if (!event.dataTransfer) return; - const formData = new FormData(); + const cursorPos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (cursorPos) view.dispatch({ selection: { anchor: cursorPos, head: cursorPos } }); - formData.append('image', blob); + const { files } = event.dataTransfer; - createImage(formData); - } - } + // eslint-disable-next-line no-restricted-syntax + for (const file of files) handleImage(file); }, }); }; @@ -69,20 +174,10 @@ export default function useCodeMirror() { useEffect(() => { if (!editorView) return; - const cursor = editorView.state.selection.main.head; - - const markdownImage = (path: string) => `![image](${path})\n`; - - const insert = markdownImage(image?.imagePath); + const markdownImage = (path: string) => `![image](${path})`; + const text = markdownImage(image?.imagePath); - editorView.dispatch({ - changes: { - from: cursor, - to: cursor, - insert, - }, - selection: { anchor: cursor + insert.length }, - }); + insertCursor(text); }, [image]); useEffect(() => { @@ -97,8 +192,19 @@ export default function useCodeMirror() { placeholder('내용을 입력해주세요.'), theme(), onChange(), - onPaste(), + eventHandler(), EditorView.lineWrapping, + EditorView.theme({ + '.cm-content': { + padding: 0, + lineHeight: 2, + fontFamily: 'Noto Sans KR', + }, + '.cm-line': { + padding: 0, + }, + }), + keymap.of([indentWithTab]), ], }); @@ -113,5 +219,13 @@ export default function useCodeMirror() { return () => view?.destroy(); }, [element]); - return { ref, document, replaceDocument }; + return { + ref, + document, + replaceDocument, + insertStartToggle, + insertBetweenToggle, + insertCursor, + handleImage, + }; } diff --git a/frontend/components/edit/Editor/index.tsx b/frontend/components/edit/Editor/index.tsx index 357b92e5..90b3d8cd 100644 --- a/frontend/components/edit/Editor/index.tsx +++ b/frontend/components/edit/Editor/index.tsx @@ -1,7 +1,18 @@ +import Image from 'next/image'; + import { useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; +import BoldIcon from '@assets/ico_bold.svg'; +import CodeIcon from '@assets/ico_code.svg'; +import H1Icon from '@assets/ico_h1.svg'; +import H2Icon from '@assets/ico_h2.svg'; +import H3Icon from '@assets/ico_h3.svg'; +import ImageIcon from '@assets/ico_image.svg'; +import ItalicIcon from '@assets/ico_italic.svg'; +import LinkIcon from '@assets/ico_link.svg'; +import QuoteIcon from '@assets/ico_quote.svg'; import articleState from '@atoms/article'; import articleBuffer from '@atoms/articleBuffer'; import Content from '@components/common/Content'; @@ -11,7 +22,16 @@ import useInput from '@hooks/useInput'; import { IArticle } from '@interfaces'; import { html2markdown, markdown2html } from '@utils/parser'; -import { CodeMirrorWrapper, EditorInner, EditorWrapper, TitleInput } from './styled'; +import { + EditorButtonWrapper, + CodeMirrorWrapper, + EditorInner, + EditorWrapper, + TitleInput, + EditorButton, + EditorButtonSplit, + EditorImageInput, +} from './styled'; interface EditorProps { handleModalOpen: () => void; @@ -19,7 +39,15 @@ interface EditorProps { } export default function Editor({ handleModalOpen, originalArticle }: EditorProps) { - const { ref, document, replaceDocument } = useCodeMirror(); + const { + ref, + document, + replaceDocument, + insertStartToggle, + insertBetweenToggle, + insertCursor, + handleImage, + } = useCodeMirror(); const [buffer, setBuffer] = useRecoilState(articleBuffer); const [isModifyMode, setIsModifyMode] = useState(false); @@ -57,6 +85,48 @@ export default function Editor({ handleModalOpen, originalArticle }: EditorProps + + insertStartToggle('# ')}> + Heading1 Icon + + insertStartToggle('## ')}> + Heading2 Icon + + insertStartToggle('### ')}> + Heading3 Icon + + + insertBetweenToggle('**')}> + Bold Icon + + insertBetweenToggle('_')}> + Italic Icon + + + insertStartToggle('> ')}> + Quote Icon + + insertBetweenToggle('\n```\n', '코드')}> + Code Icon + + + insertCursor('[텍스트](주소)')}> + Link Icon + + + + { + if (event.target.files) handleImage(event.target.files[0]); + }} + /> + +
diff --git a/frontend/components/edit/Editor/styled.ts b/frontend/components/edit/Editor/styled.ts index b6395662..a1780044 100644 --- a/frontend/components/edit/Editor/styled.ts +++ b/frontend/components/edit/Editor/styled.ts @@ -1,5 +1,7 @@ import styled from 'styled-components'; +import { Flex } from '@styles/layout'; + export const EditorWrapper = styled.div` width: 100%; height: calc(var(--window-inner-height)); @@ -23,22 +25,70 @@ export const EditorInner = styled.div` } `; -export const CodeMirrorWrapper = styled.div` - font-size: 16px; - height: calc(var(--window-inner-height) - 160px); - overflow: auto; +export const EditorButtonWrapper = styled(Flex)` + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--grey-02-color); + align-items: center; + gap: 8px; + overflow-x: auto; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } +`; - .cm-editor.cm-focused { - outline: none; +export const EditorButton = styled.button` + padding: 4px; + width: 36px; + height: 36px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + box-sizing: border-box; + + &:hover { + background-color: var(--light-orange-color); + } + + img, + label { + width: 24px; + height: 24px; + } + + > div { + color: var(--title-active-color); } `; +export const EditorImageInput = styled.input` + display: none; +`; + +export const EditorButtonSplit = styled.div` + border-left: 2px solid var(--grey-02-color); + height: 16px; +`; + export const TitleInput = styled.input` - padding-left: 6px; + padding: 0; width: 100%; border: none; outline: none; + font-family: 'Noto Sans KR'; font-size: 24px; font-weight: 700; margin-bottom: 16px; `; + +export const CodeMirrorWrapper = styled.div` + font-size: 16px; + height: calc(var(--window-inner-height) - 220px); + overflow: auto; + + .cm-editor.cm-focused { + outline: none; + } +`; diff --git a/frontend/components/edit/ModifyModal/index.tsx b/frontend/components/edit/ModifyModal/index.tsx index 6c9714dc..8c56b093 100644 --- a/frontend/components/edit/ModifyModal/index.tsx +++ b/frontend/components/edit/ModifyModal/index.tsx @@ -29,7 +29,7 @@ export default function ModifyModal({ books, originalArticle }: ModifyModalProps const [article, setArticle] = useRecoilState(articleState); - const [selectedBookIndex, setSelectedBookIndex] = useState(-1); + const [selectedBookIndex, setSelectedBookIndex] = useState(originalBookId); const [filteredScraps, setFilteredScraps] = useState([]); const [scrapList, setScrapList] = useRecoilState(scrapState); diff --git a/frontend/components/home/Slider/index.tsx b/frontend/components/home/Slider/index.tsx index 8a2b3970..8f17cd51 100644 --- a/frontend/components/home/Slider/index.tsx +++ b/frontend/components/home/Slider/index.tsx @@ -1,6 +1,6 @@ import Image from 'next/image'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import LeftArrowIcon from '@assets/ico_arrow_left.svg'; import RightArrowIcon from '@assets/ico_arrow_right.svg'; @@ -30,6 +30,12 @@ interface SliderProps { numberPerPage: number; } +const setNumBetween = (val: number, min: number, max: number) => { + if (val < min) return min; + if (val > max) return max; + return val; +}; + function Slider({ bookList, title, isLoading, numberPerPage }: SliderProps) { const [curBookIndex, setCurBookIndex] = useState(0); const [sliderNumber, setSliderNumber] = useState(1); @@ -43,11 +49,25 @@ function Slider({ bookList, title, isLoading, numberPerPage }: SliderProps) { setCurBookIndex(curBookIndex - numberPerPage); setSliderNumber(sliderNumber - 1); }; + const handleRightArrowClick = () => { setCurBookIndex(curBookIndex + numberPerPage); setSliderNumber(sliderNumber + 1); }; + useEffect(() => { + if (!bookList) return; + + const newSliderNum = setNumBetween( + Math.round(curBookIndex / numberPerPage) + 1, + 1, + sliderIndicatorCount + ); + + setSliderNumber(newSliderNum); + setCurBookIndex((newSliderNum - 1) * numberPerPage); + }, [numberPerPage]); + return ( {nickname} - profile + profile diff --git a/frontend/components/search/ArticleList/index.tsx b/frontend/components/search/ArticleList/index.tsx index a66835a8..e59f4919 100644 --- a/frontend/components/search/ArticleList/index.tsx +++ b/frontend/components/search/ArticleList/index.tsx @@ -3,17 +3,50 @@ import { IArticleBook } from '@interfaces'; interface ArticleListProps { articles: IArticleBook[]; + keywords: string[]; } -export default function ArticleList({ articles }: ArticleListProps) { +export default function ArticleList({ articles, keywords }: ArticleListProps) { + const highlightWord = (text: string, words: string[], isFirst = false): 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; + + let paddingIndex = 0; + + if (isFirst) { + const regex = /(<([^>]+)>)/g; + + while (regex.test(text.slice(0, startIndex))) paddingIndex = regex.lastIndex; + } + + return ( + <> + {text.slice(paddingIndex, startIndex)} + {text.slice(startIndex, endIndex)} + {highlightWord(text.slice(endIndex).replace(/(<([^>]+)>)/gi, ''), words)} + + ); + }; + return ( <> {articles.map((article) => ( diff --git a/frontend/components/search/BookList/index.tsx b/frontend/components/search/BookList/index.tsx index e8e03578..572733d6 100644 --- a/frontend/components/search/BookList/index.tsx +++ b/frontend/components/search/BookList/index.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from 'react'; + import Book from '@components/common/Book'; import { IBookScraps } from '@interfaces'; @@ -5,12 +7,61 @@ import { BookContainer, BookListWrapper } from './styled'; interface BookListProps { books: IBookScraps[]; + keywords: string[]; +} + +interface HighlightedBooks extends Omit { + title: string | React.ReactNode; } -export default function BookList({ books }: BookListProps) { +export default function BookList({ books, keywords }: BookListProps) { + const [highlightedBooks, setHighlightedBooks] = useState([]); + + const highlightWord = (text: string, words: string[], isFirst = false): 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; + + let paddingIndex = 0; + + if (isFirst) { + const regex = /(<([^>]+)>)/g; + + while (regex.test(text.slice(0, startIndex))) paddingIndex = regex.lastIndex; + } + + return ( + <> + {text.slice(paddingIndex, startIndex)} + {text.slice(startIndex, endIndex)} + {highlightWord(text.slice(endIndex).replace(/(<([^>]+)>)/gi, ''), words)} + + ); + }; + + useEffect(() => { + setHighlightedBooks( + books.map((book) => { + return { + ...book, + title: highlightWord(book.title, keywords), + }; + }) + ); + }, [books, keywords]); + return ( - {books.map((book) => ( + {highlightedBooks.map((book: any) => ( diff --git a/frontend/components/search/SearchFilter/index.tsx b/frontend/components/search/SearchFilter/index.tsx index 28a7cf13..66f60173 100644 --- a/frontend/components/search/SearchFilter/index.tsx +++ b/frontend/components/search/SearchFilter/index.tsx @@ -4,11 +4,17 @@ import signInStatusState from '@atoms/signInStatus'; import { FilterButton, FilterGroup, FilterLabel, FilterWrapper } from './styled'; +interface Filter { + type: string; + userId: number; +} + interface SearchFilterProps { handleFilter: (value: { [value: string]: string | number }) => void; + filter: Filter; } -export default function SearchFilter({ handleFilter }: SearchFilterProps) { +export default function SearchFilter({ handleFilter, filter }: SearchFilterProps) { const signInStatus = useRecoilValue(signInStatusState); return ( @@ -19,12 +25,17 @@ export default function SearchFilter({ handleFilter }: SearchFilterProps) { type="radio" name="type" onChange={() => handleFilter({ type: 'article' })} - defaultChecked + checked={filter.type !== 'book'} /> 글 - handleFilter({ type: 'book' })} /> + handleFilter({ type: 'book' })} + checked={filter.type === 'book'} + /> 책 @@ -33,6 +44,7 @@ export default function SearchFilter({ handleFilter }: SearchFilterProps) { handleFilter({ userId: e.target.checked ? signInStatus.id : 0 })} + checked={filter.userId !== 0} /> 내 책에서 검색 diff --git a/frontend/components/study/AddBook/index.tsx b/frontend/components/study/AddBook/index.tsx index 7602bd88..7b428cb1 100644 --- a/frontend/components/study/AddBook/index.tsx +++ b/frontend/components/study/AddBook/index.tsx @@ -10,7 +10,7 @@ import Button from '@components/common/Modal/ModalButton'; import useFetch from '@hooks/useFetch'; import useInput from '@hooks/useInput'; import { FlexSpaceBetween } from '@styles/layout'; -import { toastSuccess } from '@utils/toast'; +import { toastError, toastSuccess } from '@utils/toast'; import { BookWrapper, @@ -43,6 +43,11 @@ export default function AddBook({ handleModalClose }: AddBookProps) { }, [addBookData]); const handleAddBookBtnClick = () => { + if (title.value === '') { + toastError('책 제목이 비어있습니다.'); + return; + } + addBook({ title: title.value }); }; return ( diff --git a/frontend/components/study/BookListTab/index.tsx b/frontend/components/study/BookListTab/index.tsx index 98187a7d..76cd6fd4 100644 --- a/frontend/components/study/BookListTab/index.tsx +++ b/frontend/components/study/BookListTab/index.tsx @@ -1,3 +1,5 @@ +import dynamic from 'next/dynamic'; + import React, { useState } from 'react'; import { useRecoilState } from 'recoil'; @@ -6,8 +8,6 @@ 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 EditBook from '@components/study/EditBook'; import FAB from '@components/study/FAB'; import { IBookScraps } from '@interfaces'; @@ -34,6 +34,9 @@ export default function BookListTab({ bookmarkedBookList, isUserMatched, }: BookListTabProps) { + const Modal = dynamic(() => import('@components/common/Modal')); + const EditBookModal = dynamic(() => import('@components/study/EditBookModal')); + const [curKnottedBookList, setCurKnottedBookList] = useRecoilState(curKnottedBookListState); const [editInfo, setEditInfo] = useRecoilState(editInfoState); @@ -137,7 +140,7 @@ export default function BookListTab({ {isModalShown && ( - {curEditBook && } + {curEditBook && } )} diff --git a/frontend/components/study/EditBook/index.tsx b/frontend/components/study/EditBookModal/index.tsx similarity index 86% rename from frontend/components/study/EditBook/index.tsx rename to frontend/components/study/EditBookModal/index.tsx index 79cccf24..e3033ccd 100644 --- a/frontend/components/study/EditBook/index.tsx +++ b/frontend/components/study/EditBookModal/index.tsx @@ -6,7 +6,6 @@ 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 curKnottedBookListState from '@atoms/curKnottedBookList'; import editInfoState from '@atoms/editInfo'; import scrapState from '@atoms/scrap'; @@ -16,6 +15,7 @@ import useFetch from '@hooks/useFetch'; import useInput from '@hooks/useInput'; import { IBookScraps } from '@interfaces'; import { FlexSpaceBetween } from '@styles/layout'; +import { toastError } from '@utils/toast'; import { BookWrapper, @@ -29,8 +29,10 @@ import { EditBookWapper, EditBookThumbnailWrapper, EditBookThumbnailIcon, - MoreContentsIconWrapper, + EditArticle, DragArticleWrapper, + ContentsWrapper, + DragArticleText, } from './styled'; interface BookProps { @@ -38,7 +40,7 @@ interface BookProps { handleModalClose: () => void; } -export default function EditBook({ book, handleModalClose }: BookProps) { +export default function EditBookModal({ book, handleModalClose }: BookProps) { const { id, title, user, scraps } = book; const { data: imgFile, execute: createImage } = useFetch(createImageApi); @@ -69,6 +71,11 @@ export default function EditBook({ book, handleModalClose }: BookProps) { }; const handleCompletedBtnClick = () => { + if (titleData === '') { + toastError('책 제목이 비어있습니다.'); + return; + } + const editScraps = scrapList.map((v, i) => ({ ...v, order: i + 1 })); // 해당하는 책을 찾아서 전역에서 관리하고 있는 애를 변경해서 업데이트 @@ -141,15 +148,20 @@ export default function EditBook({ book, handleModalClose }: BookProps) { )} - Contents + + Contents + + {isContentsShown ? '완료' : '수정'} + + + {isContentsShown && ( + 드래그앤드롭으로 글의 순서를 변경할 수 있습니다. + )} - - More Contents Icon -