diff --git a/package-lock.json b/package-lock.json index b76aefb..060d74b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "@lukemorales/query-key-factory": "^1.3.4", "@supabase/supabase-js": "^2.40.0", "@tanstack/react-query": "^5.28.14", + "@types/dompurify": "^3.0.5", "dayjs": "^1.11.10", + "dompurify": "^3.1.0", "github-label-sync": "^2.3.1", "jotai": "^2.7.0", "quill-delta-to-html": "^0.12.1", @@ -1447,6 +1449,14 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -1523,6 +1533,11 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, "node_modules/@types/ws": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", @@ -2403,6 +2418,11 @@ "node": ">=6.0.0" } }, + "node_modules/dompurify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.0.tgz", + "integrity": "sha512-yoU4rhgPKCo+p5UrWWWNKiIq+ToGqmVVhk0PmMYBK4kRsR3/qhemNFL8f6CFmBd4gMwm3F4T7HBoydP5uY07fA==" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", diff --git a/package.json b/package.json index 949f617..6e48197 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "@lukemorales/query-key-factory": "^1.3.4", "@supabase/supabase-js": "^2.40.0", "@tanstack/react-query": "^5.28.14", + "@types/dompurify": "^3.0.5", "dayjs": "^1.11.10", + "dompurify": "^3.1.0", "github-label-sync": "^2.3.1", "jotai": "^2.7.0", "quill-delta-to-html": "^0.12.1", diff --git a/src/App.tsx b/src/App.tsx index e7320c7..58ab922 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,8 @@ import Header from "./components/Header"; import Login from "./pages/Login"; import Home from "./pages/Home"; import Footer from "./components/Footer"; -import WriteNotice from "./pages/Notice/WriteNotice"; +import WriteNoticeForm from "./components/WriteNoticeForm"; +import EditNotice from "./pages/Notice/EditNotice"; import WriteRecord from "./pages/Record/WriteRecord"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import DetailNotice from "./pages/Notice/DetailNotice"; @@ -28,7 +29,11 @@ function App() { } /> } /> } /> - } /> + } + /> + } /> } /> } /> } /> diff --git a/src/components/Components.css b/src/components/Components.css index 2da0085..14f67e7 100644 --- a/src/components/Components.css +++ b/src/components/Components.css @@ -7,7 +7,12 @@ } .line { - height: 2px; border-bottom: 1px solid #e8e8e8; - margin: 3px 3px 23px 3px; + margin: 10px 0 20px 0; +} + +.errorMessage { + color: #be2e22; + font-size: 15px; + font-weight: 500; } diff --git a/src/components/EditorModules.tsx b/src/components/EditorModules.tsx new file mode 100644 index 0000000..81e94af --- /dev/null +++ b/src/components/EditorModules.tsx @@ -0,0 +1,56 @@ +export const modules = { + toolbar: { + container: [ + [{ header: [1, 2, 3, 4, 5, 6, false] }], + [{ font: [] }], + [{ align: [] }], + ["bold", "italic", "underline", "strike", "blockquote"], + [{ list: "ordered" }, { list: "bullet" }, "link"], + [ + { + color: [ + "#000000", + "#e60000", + "#ff9900", + "#ffff00", + "#008a00", + "#0066cc", + "#9933ff", + "#ffffff", + "#facccc", + "#ffebcc", + "#ffffcc", + "#cce8cc", + "#cce0f5", + "#ebd6ff", + "#bbbbbb", + "#f06666", + "#ffc266", + "#ffff66", + "#66b966", + "#66a3e0", + "#c285ff", + "#888888", + "#a10000", + "#b26b00", + "#b2b200", + "#006100", + "#0047b2", + "#6b24b2", + "#444444", + "#5c0000", + "#663d00", + "#666600", + "#003700", + "#002966", + "#3d1466", + "custom-color", + ], + }, + { background: [] }, + ], + ["image", "video"], + ["clean"], + ], + }, +}; diff --git a/src/components/Line.css b/src/components/Line.css new file mode 100644 index 0000000..826c7ae --- /dev/null +++ b/src/components/Line.css @@ -0,0 +1,5 @@ +.line { + height: 2px; + border-bottom: 1px solid #e8e8e8; + margin: 3px 3px 23px 3px; +} diff --git a/src/components/NoticeItem.css b/src/components/NoticeItem.css new file mode 100644 index 0000000..993fef0 --- /dev/null +++ b/src/components/NoticeItem.css @@ -0,0 +1,20 @@ +.notice_article { + display: flex; + cursor: pointer; + margin-bottom: 35px; +} + +.notice_article_title { + font-size: 23px; + font-weight: bold; + margin-bottom: 5px; +} + +.notice_article_date, +.notice_article_content_preview { + margin-left: 5px; +} + +.notice_article_content_preview { + width: 100vh; +} diff --git a/src/components/NoticeItem.tsx b/src/components/NoticeItem.tsx new file mode 100644 index 0000000..cada1d3 --- /dev/null +++ b/src/components/NoticeItem.tsx @@ -0,0 +1,40 @@ +import "./NoticeItem.css"; +import { Database } from "../api/supabase/supabase"; +import DOMPurify from "dompurify"; +import { useNavigate } from "react-router-dom"; +import Line from "./Line"; +import dayjs from "dayjs"; + +type Notice = Database["public"]["Tables"]["notice"]["Row"]; + +const NoticeItem = ({ id, title, createdDate, content }: Notice) => { + const navigate = useNavigate(); + + const onClickMoveToDetail = (id: number) => { + navigate(`/notice/${id}`); + }; + + return ( +
+
onClickMoveToDetail(id)} + > +
+
{title}
+ + {dayjs(createdDate).format("YYYY.MM.DD. HH:mm")} + + +
+
+
+
+ ); +}; +export default NoticeItem; diff --git a/src/components/WriteNoticeForm.css b/src/components/WriteNoticeForm.css new file mode 100644 index 0000000..d8d72e3 --- /dev/null +++ b/src/components/WriteNoticeForm.css @@ -0,0 +1,32 @@ +.write_wrapper { + padding: 50px 300px; +} + +.write_btn_wrapper { + display: flex; + flex-direction: row; +} + +.write_btn_submit { + margin-left: 20px; +} + +.write_title { + width: 100%; + height: 50px; + border: none; + border-bottom: 2px solid gray; + margin-bottom: 10px; + padding-bottom: 7px; + outline: none; + font-size: 30px; + font-weight: bold; +} + +.write_title:focus { + border-bottom: 3px solid black; +} + +.write_content_reactQuill { + height: 500px; +} diff --git a/src/components/WriteNoticeForm.tsx b/src/components/WriteNoticeForm.tsx new file mode 100644 index 0000000..1402104 --- /dev/null +++ b/src/components/WriteNoticeForm.tsx @@ -0,0 +1,135 @@ +// TODO: 임시저장 기능 + +import "react-quill/dist/quill.snow.css"; +import { SubmitHandler, useForm, useController } from "react-hook-form"; +import "./WriteNoticeForm.css"; +import Line from "./Line"; +import dayjs from "dayjs"; +import ReactQuill from "react-quill"; +import { modules } from "./EditorModules"; +import { Database } from "../api/supabase/supabase"; +import { useNavigate, useParams } from "react-router-dom"; +import { useEffect, useRef } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { PostgrestSingleResponse } from "@supabase/supabase-js"; +import { handleSupabaseResponse } from "../utils/handleSupabaseResponse"; +import { supabase } from "../api/supabase/supabaseClient"; + +type Notice = Database["public"]["Tables"]["notice"]["Row"]; + +interface WriteProps { + isEdit: boolean; + data?: Notice[]; +} + +const WriteNoticeForm = (props: WriteProps) => { + const params = useParams(); + const noticeId = Number(params.id); + const navigate = useNavigate(); + const quillRef = useRef(null); + + const saveNotice = useMutation({ + mutationFn: async (noticeData: Notice): Promise => { + const { title, content, writer, createdDate } = noticeData; + const savedNotice: PostgrestSingleResponse = props.isEdit + ? await supabase + .from("notice") + .update({ title, content, createdDate }) + .eq("id", noticeId) + .select() + : await supabase + .from("notice") + .insert([{ title, content, writer, createdDate }]) + .select(); + + return (await handleSupabaseResponse(savedNotice))[0]; + }, + onSuccess: async (savedNotice: Notice): Promise => { + const savedNoticeId: number = savedNotice.id; + if (savedNoticeId) { + navigate(`/notice/${savedNoticeId}`); + } + }, + onError: (error) => { + if (error instanceof Error) { + console.log("공지사항 등록/수정 시 오류 >> ", error.message); + } + }, + }); + + const { + register, + formState: { errors }, + handleSubmit, + control, + } = useForm(); + + const { + field: { value, onChange }, + } = useController({ name: "content", control, rules: { required: true } }); + + useEffect(() => { + onChange(props.data?.[0].content ?? ""); + }, [props.data, onChange]); + + const onChangeContents = (content: string) => { + onChange(content === "


" ? "" : content); + }; + + const onClickSubmit: SubmitHandler = (data: Notice) => { + const quillEditor = quillRef.current?.getEditor(); + const content = quillEditor?.getContents(); + + const formattedData = { + ...data, + content: JSON.stringify(content), + createdDate: dayjs().format("YYYY.MM.DD HH:mm:ss"), + writer: "작성자", + }; + + const confirmMessage = props.isEdit + ? "수정하시겠습니까?" + : "등록하시겠습니까?"; + + if (confirm(confirmMessage)) { + saveNotice.mutate(formattedData); + } + }; + + return ( +
+
+
+ + +
+ + + {errors?.title?.type === "required" && ( +
제목을 입력해주세요.
+ )} + {errors?.content?.type === "required" && ( +
본문 내용을 입력해주세요.
+ )} +
+ + +
+ ); +}; +export default WriteNoticeForm; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 7d610c0..dc3ccde 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -8,7 +8,7 @@ import crossfit2 from "../assets/crossfit2.jpg"; import crossfit3 from "../assets/crossfit3.jpg"; import "./Home.css"; import Wod from "./Wod"; -import Notice from "./Notice"; +import Notice from "./Notice/Notice"; const Home = () => { return ( diff --git a/src/pages/Notice.tsx b/src/pages/Notice.tsx deleted file mode 100644 index 62eefef..0000000 --- a/src/pages/Notice.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Link } from "react-router-dom"; -import ActionButton from "../components/ActionButton"; - -const Notice = () => { - return ( -
- Notice - - - + - - -
- ); -}; - -export default Notice; diff --git a/src/pages/Notice/DetailNotice.css b/src/pages/Notice/DetailNotice.css new file mode 100644 index 0000000..65e5d5b --- /dev/null +++ b/src/pages/Notice/DetailNotice.css @@ -0,0 +1,30 @@ +.detail_notice_title { + font-size: 27px; + font-weight: bold; + margin-top: 70px; + margin-bottom: 15px; +} + +.detail_notice_head_detail { + margin-top: 10px; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.detail_notice_head_detail_left { + padding-left: 5px; +} + +.detail_notice_head_detail_left_writer { + font-weight: 600; + margin-right: 20px; +} + +.detail_notice_head_detail_right_editBtn { + margin-right: 15px; +} + +.detail_notice_main_content { + padding: 0 20px; +} diff --git a/src/pages/Notice/DetailNotice.tsx b/src/pages/Notice/DetailNotice.tsx new file mode 100644 index 0000000..1a35dd6 --- /dev/null +++ b/src/pages/Notice/DetailNotice.tsx @@ -0,0 +1,76 @@ +import Line from "../../components/Line"; +import dayjs from "dayjs"; +import DOMPurify from "dompurify"; +import { useNavigate, useParams } from "react-router-dom"; +import "./DetailNotice.css"; +import { noticeQueryKeys } from "../../queries/noticeQueries"; +import { supabase } from "../../api/supabase/supabaseClient"; +import { useQuery } from "@tanstack/react-query"; + +const DetailNotice = () => { + const params = useParams(); + const noticeId = Number(params.id); + const navigate = useNavigate(); + + const { data: detailNoticeData } = useQuery(noticeQueryKeys.detail(noticeId)); + + if (!detailNoticeData) { + return
Loading...
; + } + + const onClickMoveToEdit = (id: number): void => { + navigate(`/notice/${id}/edit`); + }; + + const onClickDeleteNotice = async () => { + if (!confirm("삭제하시겠습니까?")) { + return; + } else { + await supabase.from("notice").delete().eq("id", noticeId); + alert("해당 게시물이 삭제되었습니다."); + navigate("/notice", { replace: true }); + } + }; + + return ( + <> +

Notice

+
+ {detailNoticeData.map((post) => ( +
+

{post.title}

+
+
+ + {post.writer} + + + {dayjs(post.createdDate).format("YYYY.MM.DD. HH:mm")} + +
+
+ + +
+
+ +
+
+
+
+ ))} +
+ + ); +}; + +export default DetailNotice; diff --git a/src/pages/Notice/EditNotice.tsx b/src/pages/Notice/EditNotice.tsx new file mode 100644 index 0000000..e634354 --- /dev/null +++ b/src/pages/Notice/EditNotice.tsx @@ -0,0 +1,17 @@ +import WriteNoticeForm from "../../components/WriteNoticeForm"; +import { useParams } from "react-router-dom"; +import { noticeQueryKeys } from "../../queries/noticeQueries"; +import { useQuery } from "@tanstack/react-query"; + +const EditNotice = () => { + const params = useParams(); + const noticeId = Number(params.id); + + const { data: editNoticeData } = useQuery(noticeQueryKeys.detail(noticeId)); + if (!editNoticeData) { + return
Loading...
; + } + + return ; +}; +export default EditNotice; diff --git a/src/pages/Notice/Notice.css b/src/pages/Notice/Notice.css new file mode 100644 index 0000000..3c24bd0 --- /dev/null +++ b/src/pages/Notice/Notice.css @@ -0,0 +1,19 @@ +.home_notice_wrapper { + padding: 10px 25px; +} + +.notice_wrapper { + padding: 0 320px; +} + +.notice_count { + display: flex; + justify-content: flex-end; + margin-bottom: 50px; + font-size: 20px; +} + +div > div > div > div > .line { + border-bottom: 3px solid #e8e8e8; + margin: 10px 0 20px 0; +} diff --git a/src/pages/Notice/Notice.tsx b/src/pages/Notice/Notice.tsx new file mode 100644 index 0000000..9919074 --- /dev/null +++ b/src/pages/Notice/Notice.tsx @@ -0,0 +1,54 @@ +import "./Notice.css"; +import "../../components/Components.css"; +import { Link, useLocation } from "react-router-dom"; +import ActionButton from "../../components/ActionButton"; +import { QuillDeltaToHtmlConverter } from "quill-delta-to-html"; +import NoticeItem from "../../components/NoticeItem"; +import Line from "../../components/Line"; +import { noticeQueryKeys } from "../../queries/noticeQueries"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Database } from "../../api/supabase/supabase"; + +type Notice = Database["public"]["Tables"]["notice"]["Row"]; + +const Notice = () => { + // 경로에 따라 Notice wrapper CSS를 다르게 설정하기 위한 코드 + const { pathname } = useLocation(); + const noticeWrapperClassName = + pathname === "/" ? "home_notice_wrapper" : "notice_wrapper"; + + const { data: notice } = useSuspenseQuery(noticeQueryKeys.list()); + + // delta로 저장된 contents를 html로 변환 + const deltaToHtmlData = notice.map((post) => { + const postContent = post.content; + const deltaOps = JSON.parse(postContent).ops; + const deltaToHtmlConverter = new QuillDeltaToHtmlConverter(deltaOps, {}); + const html = deltaToHtmlConverter.convert(); + return { ...post, content: html }; + }); + + return ( +
+

Notice

+

총 {notice.length}개의 글이 있습니다🏋🏻‍♀️

+ +
+ {deltaToHtmlData.map((post) => ( +
+ + +
+ ))} +
+ + + + + + + +
+ ); +}; + +export default Notice; diff --git a/src/queries/noticeQueries.ts b/src/queries/noticeQueries.ts new file mode 100644 index 0000000..1a4897a --- /dev/null +++ b/src/queries/noticeQueries.ts @@ -0,0 +1,47 @@ +import { PostgrestSingleResponse } from "@supabase/supabase-js"; +import { supabase } from "../api/supabase/supabaseClient"; +import { createQueryKeys } from "@lukemorales/query-key-factory"; +import { Database } from "../api/supabase/supabase"; +import { handleSupabaseResponse } from "../utils/handleSupabaseResponse"; +import { QuillDeltaToHtmlConverter } from "quill-delta-to-html"; + +type Notice = Database["public"]["Tables"]["notice"]["Row"]; + +export const noticeQueryKeys = createQueryKeys("notice", { + // Notice/Notice + // Notice 목록 + list: () => ({ + queryKey: ["all"], + queryFn: async () => { + const data = await supabase + .from("notice") + .select("*") + .order("id", { ascending: false }); + return await handleSupabaseResponse(data); + }, + }), + // Notice/DetailNotice + // Notice/EditNotice + // Notice 상세 페이지 / Notice 수정시 기존 값 조회 + detail: (noticeId: number) => ({ + queryKey: [noticeId], + queryFn: async () => { + const data: PostgrestSingleResponse = await supabase + .from("notice") + .select("*") + .eq("id", noticeId); + const noticeData = await handleSupabaseResponse(data); + const deltaToHtmlNoticeData = noticeData.map((post) => { + const postContent = post.content; + const deltaOps = JSON.parse(postContent).ops; + const deltaToHtmlConverter = new QuillDeltaToHtmlConverter( + deltaOps, + {} + ); + const html = deltaToHtmlConverter.convert(); + return { ...post, content: html }; + }); + return deltaToHtmlNoticeData; + }, + }), +});