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 (
+
+ );
+};
+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 (
-
- );
-};
-
-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;
+ },
+ }),
+});