├── .firebase
└── hosting.YnVpbGQ.cache
├── .firebaserc
├── .gitignore
├── README.md
├── firebase.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── App.tsx
├── components
│ ├── Carousel.tsx
│ ├── Comments.tsx
│ ├── Footer.tsx
│ ├── Header.tsx
│ ├── Loader.tsx
│ ├── LoginForm.tsx
│ ├── PostDetail.tsx
│ ├── PostForm.tsx
│ ├── PostList.tsx
│ ├── Profile.tsx
│ ├── Router.tsx
│ └── SignupForm.tsx
├── context
│ ├── AuthContext.tsx
│ └── ThemeContext.tsx
├── firebaseApp.ts
├── index.css
├── index.tsx
├── pages
│ ├── home
│ │ └── index.tsx
│ ├── login
│ │ └── index.tsx
│ ├── posts
│ │ ├── detail.tsx
│ │ ├── edit.tsx
│ │ ├── index.tsx
│ │ └── new.tsx
│ ├── profile
│ │ └── index.tsx
│ └── signup
│ │ └── index.tsx
└── react-app-env.d.ts
├── tsconfig.json
└── yarn.lock
/.firebase/hosting.YnVpbGQ.cache:
--------------------------------------------------------------------------------
1 | asset-manifest.json,1689260176814,e069c21667a431b1115c5ea1fb7bd6214ab4640421e757341d8eb98c592b9022
2 | index.html,1689260176814,ac8a3c33c708130faf62b2b1c6e9e2ea226531c3a61956fec66ae622b4324987
3 | manifest.json,1689260164151,341d52628782f8ac9290bbfc43298afccb47b7cbfcee146ae30cf0f46bc30900
4 | favicon.ico,1689260164150,b72f7455f00e4e58792d2bca892abb068e2213838c0316d6b7a0d6d16acd1955
5 | robots.txt,1689260164151,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2
6 | logo512.png,1689260164151,191fc21360b4ccfb1cda11a1efb97f489ed22672ca83f4064316802bbfdd750e
7 | logo192.png,1689260164150,caff018b7f1e8fd481eb1c50d75b0ef236bcd5078b1d15c8bb348453fee30293
8 | static/js/main.71252914.js.LICENSE.txt,1689260176833,6d25ad6a84c5fd9e32fa79c34139b0dbd57f244290e1449f78c48ee970cdb7cc
9 | static/css/main.8f46c29c.css,1689260176832,b3bd6951b4ef7cf4748bddae23221fa9f6951274c8f0554971a1ba0fecb0639e
10 | static/css/main.8f46c29c.css.map,1689260176833,d26cb3f6ae443511bc0d72da21b38c51f1bb9117c272bcb01f0dbf2952551299
11 | static/js/main.71252914.js,1689260176832,baa91832b8d57d01180044efcaf8837b8274f6f8216a91c27b641cb29c014f1b
12 | static/js/main.71252914.js.map,1689260176833,a1f74f8ac9489e6633bf391a1c3457a172bd28c85fabf8c07ab152ac114f8f8c
13 |
--------------------------------------------------------------------------------
/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": "fastcampus-react-blog"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
25 | .env
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Blog
2 |
3 | - React, Firebase를 이용한 리액트 블로그 프로젝트 입니다.
4 | - URL: [https://fastcampus-react-blog.web.app/](https://fastcampus-react-blog.web.app/)
5 | - [Pull Request](https://github.com/jen-frontend/fastcampus-react-blog/pulls?q=is%3Apr+is%3Aclosed) 탭에서 각 기능별 코드를 확인하실 수 있습니다.
6 |
7 |
8 |
9 | # 프로젝트 설명
10 |
11 | ## 주요 기능
12 |
13 | - CRUD 기능 구현
14 | - 사용자 인증 및 권한 관리
15 | - 라우팅과 페이지 구성
16 |
17 | ## 앱 구조
18 |
19 | - (create-react-app) SPA
20 |
21 | ## 상태관리
22 |
23 | - Context API
24 | - 권한관리
25 | - 테마관리(다크모드 기능)
26 |
27 | ## 애니메이션 & 스타일링
28 |
29 | - CSS 사용 (BEM 구조)
30 | - 캐러셀 transition
31 |
32 | ## 배포
33 |
34 | - Firebase
35 |
36 | ## 컴포넌트
37 |
38 | - 헤더, 푸터, 리스트, 폼, 캐러셀
39 |
40 | ## API
41 |
42 | - firebase의 firestore를 이용한 실시간 데이터 생성
43 | - firebase auth를 이용한 사용자 인증 개념
44 |
45 | ## 사용 스택
46 |
47 | - React
48 | - Firebase(로그인, 보안, 통신)
49 | - CSS
50 | - Vercel
51 |
52 | ## 기타 학습 개념
53 |
54 | - 폴더 구조
55 | - CRA 이용 프로젝트 세팅
56 | - React hooks(useEffect, useState, useContext, useCallback)
57 | - React-router-dom 라우터
58 |
59 |
60 |
61 | # 구현 기능
62 |
63 | ## 공통 페이지
64 |
65 | 1. 로그인 페이지
66 | - Firebase Auth 사용자 인증 기본 로그인
67 |
68 | 2. 메인페이지
69 | - 최신 글 목록, 특징 콘텐츠 보여주기
70 |
71 | 3. 글 목록 페이지
72 | - 블로그에 작성된 모든 글의 목록 보여주기
73 | - 해당 글 선택시 상세페이지 이동
74 |
75 | 4. 글 상세 페이지
76 | - 글 제목, 내용, 작성자, 작성일 등 표시
77 |
78 | 5. 글 수정 페이지(CRUD)
79 |
80 | 6. 카테고리 메뉴
81 |
82 | 7. 사용자 프로필 페이지(Velog,Medium st)
83 |
84 | ## 그 외 기능
85 |
86 | 1. 다크모드
87 | 2. 내가 쓴 글
88 | 3. 댓글 CRUD
89 |
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "hosting": {
3 | "public": "build",
4 | "ignore": [
5 | "firebase.json",
6 | "**/.*",
7 | "**/node_modules/**"
8 | ],
9 | "rewrites": [
10 | {
11 | "source": "**",
12 | "destination": "/index.html"
13 | }
14 | ]
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fastcampus-blog-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/jest-dom": "^5.14.1",
7 | "@testing-library/react": "^13.0.0",
8 | "@testing-library/user-event": "^13.2.1",
9 | "@types/jest": "^27.0.1",
10 | "@types/node": "^16.7.13",
11 | "@types/react": "^18.0.0",
12 | "@types/react-dom": "^18.0.0",
13 | "firebase": "^10.0.0",
14 | "react": "^18.2.0",
15 | "react-dom": "^18.2.0",
16 | "react-icons": "^4.10.1",
17 | "react-router-dom": "^6.14.1",
18 | "react-scripts": "5.0.1",
19 | "react-toastify": "^9.1.3",
20 | "typescript": "^4.4.2",
21 | "web-vitals": "^2.1.0"
22 | },
23 | "scripts": {
24 | "start": "react-scripts start",
25 | "build": "react-scripts build",
26 | "test": "react-scripts test",
27 | "eject": "react-scripts eject"
28 | },
29 | "eslintConfig": {
30 | "extends": [
31 | "react-app",
32 | "react-app/jest"
33 | ]
34 | },
35 | "browserslist": {
36 | "production": [
37 | ">0.2%",
38 | "not dead",
39 | "not op_mini all"
40 | ],
41 | "development": [
42 | "last 1 chrome version",
43 | "last 1 firefox version",
44 | "last 1 safari version"
45 | ]
46 | },
47 | "devDependencies": {
48 | "@types/react-icons": "^3.0.0",
49 | "@types/react-router-dom": "^5.3.3"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jen-frontend/fastcampus-react-blog/633684cc1f322582820a3811e40cec5bdd3f41e2/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
24 | Fastcampus React Blog
25 |
26 |
27 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jen-frontend/fastcampus-react-blog/633684cc1f322582820a3811e40cec5bdd3f41e2/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jen-frontend/fastcampus-react-blog/633684cc1f322582820a3811e40cec5bdd3f41e2/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useContext } from "react";
2 | import { app } from "firebaseApp";
3 | import { getAuth, onAuthStateChanged } from "firebase/auth";
4 | import { ToastContainer } from "react-toastify";
5 | import "react-toastify/dist/ReactToastify.css";
6 | import ThemeContext from "context/ThemeContext";
7 |
8 | import Router from "./components/Router";
9 | import Loader from "components/Loader";
10 |
11 | function App() {
12 | const context = useContext(ThemeContext);
13 | const auth = getAuth(app);
14 | // auth를 체크하기 전에 (initialize 전)에는 loader를 띄워주는 용도
15 | const [init, setInit] = useState(false);
16 | // auth의 currentUser가 있으면 authenticated로 변경
17 | const [isAuthenticated, setIsAuthenticated] = useState(
18 | !!auth?.currentUser
19 | );
20 |
21 | useEffect(() => {
22 | onAuthStateChanged(auth, (user) => {
23 | if (user) {
24 | setIsAuthenticated(true);
25 | } else {
26 | setIsAuthenticated(false);
27 | }
28 | setInit(true);
29 | });
30 | }, [auth]);
31 |
32 | return (
33 |
34 |
35 | {init ? : }
36 |
37 | );
38 | }
39 |
40 | export default App;
41 |
--------------------------------------------------------------------------------
/src/components/Carousel.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | const IMAGE_1_URL =
4 | "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1470&q=80";
5 | const IMAGE_2_URL =
6 | "https://images.unsplash.com/photo-1606117331085-5760e3b58520?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1470&q=80";
7 | const IMAGE_3_URL =
8 | "https://images.unsplash.com/photo-1667971286579-63a5222780ff?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1074&q=80";
9 |
10 | export default function Carousel() {
11 | const [activeImage, setActiveImage] = useState(1);
12 |
13 | return (
14 |
15 |
16 |
17 |
24 | -
25 |
26 |

27 |
28 |
29 |
35 |
41 |
42 |
43 |
50 | -
51 |
52 |

53 |
54 |
55 |
61 |
67 |
68 |
69 |
76 | -
77 |
78 |

79 |
80 |
81 |
87 |
93 |
94 |
95 |
96 |
101 |
106 |
111 |
112 |
113 |
114 |
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/src/components/Comments.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useState } from "react";
2 | import { CommentsInterface, PostProps } from "./PostList";
3 | import { doc, updateDoc, arrayUnion, arrayRemove } from "firebase/firestore";
4 | import { db } from "firebaseApp";
5 | import AuthContext from "context/AuthContext";
6 | import { toast } from "react-toastify";
7 |
8 | interface CommentsProps {
9 | post: PostProps;
10 | getPost: (id: string) => Promise;
11 | }
12 |
13 | export default function Comments({ post, getPost }: CommentsProps) {
14 | const [comment, setComment] = useState("");
15 | const { user } = useContext(AuthContext);
16 |
17 | const onChange = (e: React.ChangeEvent) => {
18 | const {
19 | target: { name, value },
20 | } = e;
21 |
22 | if (name === "comment") {
23 | setComment(value);
24 | }
25 | };
26 |
27 | const onSubmit = async (e: React.FormEvent) => {
28 | e.preventDefault();
29 |
30 | try {
31 | if (post && post?.id) {
32 | const postRef = doc(db, "posts", post.id);
33 | if (user?.uid) {
34 | const commentObj = {
35 | content: comment,
36 | uid: user.uid,
37 | email: user.email,
38 | createdAt: new Date()?.toLocaleDateString("ko", {
39 | hour: "2-digit",
40 | minute: "2-digit",
41 | second: "2-digit",
42 | }),
43 | };
44 |
45 | await updateDoc(postRef, {
46 | comments: arrayUnion(commentObj),
47 | updateDated: new Date()?.toLocaleDateString("ko", {
48 | hour: "2-digit",
49 | minute: "2-digit",
50 | second: "2-digit",
51 | }),
52 | });
53 | // 문서 업데이트
54 | await getPost(post.id);
55 | }
56 | }
57 | toast.success("댓글을 생성했습니다.");
58 | setComment("");
59 | } catch (e: any) {
60 | console.log(e);
61 | toast.error(e?.code);
62 | }
63 | };
64 |
65 | const handleDeleteComment = async (data: CommentsInterface) => {
66 | const confirm = window.confirm("해당 댓글을 삭제하시겠습니까?");
67 | if (confirm && post.id) {
68 | const postRef = doc(db, "posts", post.id);
69 | await updateDoc(postRef, {
70 | comments: arrayRemove(data),
71 | });
72 |
73 | toast.success("댓글을 삭제했습니다.");
74 | // 문서 업데이트
75 | await getPost(post.id);
76 | }
77 | };
78 |
79 | return (
80 |
81 |
96 |
97 | {post?.comments
98 | ?.slice(0)
99 | ?.reverse()
100 | .map((comment) => (
101 |
102 |
103 |
{comment?.email}
104 |
{comment?.createdAt}
105 | {comment.uid === user?.uid && (
106 |
handleDeleteComment(comment)}
109 | >
110 | 삭제
111 |
112 | )}
113 |
114 |
{comment?.content}
115 |
116 | ))}
117 |
118 |
119 | );
120 | }
121 |
--------------------------------------------------------------------------------
/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { BsSun, BsMoonFill } from "react-icons/bs";
3 | import { useContext } from "react";
4 | import ThemeContext from "context/ThemeContext";
5 |
6 | export default function Footer() {
7 | const context = useContext(ThemeContext);
8 |
9 | return (
10 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 |
3 | export default function Header() {
4 | return (
5 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/Loader.tsx:
--------------------------------------------------------------------------------
1 | export default function Loader() {
2 | return ;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | import { Link, useNavigate } from "react-router-dom";
4 | import { toast } from "react-toastify";
5 | import { app } from "firebaseApp";
6 | import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
7 |
8 | export default function LoginForm() {
9 | const [error, setError] = useState("");
10 | const [email, setEmail] = useState("");
11 | const [password, setPassword] = useState("");
12 | const navigate = useNavigate();
13 |
14 | const onSubmit = async (e: React.FormEvent) => {
15 | e.preventDefault();
16 |
17 | try {
18 | const auth = getAuth(app);
19 | await signInWithEmailAndPassword(auth, email, password);
20 |
21 | toast.success("로그인에 성공했습니다.");
22 | navigate("/");
23 | } catch (error: any) {
24 | toast.error(error?.code);
25 | console.log(error);
26 | }
27 | };
28 |
29 | const onChange = (e: React.ChangeEvent) => {
30 | const {
31 | target: { name, value },
32 | } = e;
33 |
34 | if (name === "email") {
35 | setEmail(value);
36 |
37 | const validRegex =
38 | /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
39 |
40 | if (!value?.match(validRegex)) {
41 | setError("이메일 형식이 올바르지 않습니다.");
42 | } else {
43 | setError("");
44 | }
45 | }
46 |
47 | if (name === "password") {
48 | setPassword(value);
49 |
50 | if (value?.length < 8) {
51 | setError("비밀번호는 8자리 이상 입력해주세요");
52 | } else {
53 | setError("");
54 | }
55 | }
56 | };
57 |
58 | return (
59 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/src/components/PostDetail.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useContext } from "react";
2 | import { Link, useParams, useNavigate } from "react-router-dom";
3 | import { PostProps } from "./PostList";
4 | import { deleteDoc, doc, getDoc } from "firebase/firestore";
5 | import { db } from "firebaseApp";
6 | import Loader from "./Loader";
7 | import { toast } from "react-toastify";
8 | import AuthContext from "context/AuthContext";
9 | import Comments from "./Comments";
10 |
11 | export default function PostDetail() {
12 | const [post, setPost] = useState(null);
13 | const params = useParams();
14 | const navigate = useNavigate();
15 | const { user } = useContext(AuthContext);
16 |
17 | const getPost = async (id: string) => {
18 | if (id) {
19 | const docRef = doc(db, "posts", id);
20 | const docSnap = await getDoc(docRef);
21 |
22 | setPost({ id: docSnap.id, ...(docSnap.data() as PostProps) });
23 | }
24 | };
25 |
26 | const handleDelete = async () => {
27 | const confirm = window.confirm("해당 게시글을 삭제하시겠습니까?");
28 | if (confirm && post && post.id) {
29 | await deleteDoc(doc(db, "posts", post.id));
30 | toast.success("게시글을 삭제했습니다.");
31 | navigate("/");
32 | }
33 | };
34 |
35 | useEffect(() => {
36 | if (params?.id) getPost(params?.id);
37 | }, [params?.id]);
38 |
39 | return (
40 | <>
41 |
42 | {post ? (
43 | <>
44 |
45 |
{post?.title}
46 |
47 |
48 |
{post?.email}
49 |
{post?.createdAt}
50 |
51 |
52 |
53 | {post?.category || "자유주제"}
54 |
55 | {post?.uid === user?.uid && (
56 | <>
57 |
62 | 삭제
63 |
64 |
65 | 수정
66 |
67 | >
68 | )}
69 |
70 |
71 | {post?.content}
72 |
73 |
74 |
75 | >
76 | ) : (
77 |
78 | )}
79 |
80 | >
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/PostForm.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect, useState } from "react";
2 | import { collection, addDoc, doc, getDoc, updateDoc } from "firebase/firestore";
3 | import { db } from "firebaseApp";
4 | import AuthContext from "context/AuthContext";
5 |
6 | import { useNavigate, useParams } from "react-router-dom";
7 | import { toast } from "react-toastify";
8 | import { CATEGORIES, CategoryType, PostProps } from "./PostList";
9 |
10 | export default function PostForm() {
11 | const params = useParams();
12 | const [post, setPost] = useState(null);
13 | const [title, setTitle] = useState("");
14 | const [summary, setSummary] = useState("");
15 | const [content, setContent] = useState("");
16 | const [category, setCategory] = useState("Frontend");
17 | const { user } = useContext(AuthContext);
18 | const navigate = useNavigate();
19 |
20 | const onSubmit = async (e: React.FormEvent) => {
21 | e.preventDefault();
22 |
23 | try {
24 | if (post && post.id) {
25 | // 만약 post 데이터가 있다면, firestore로 데이터 수정
26 | const postRef = doc(db, "posts", post?.id);
27 | await updateDoc(postRef, {
28 | title: title,
29 | summary: summary,
30 | content: content,
31 | updatedAt: new Date()?.toLocaleDateString("ko", {
32 | hour: "2-digit",
33 | minute: "2-digit",
34 | second: "2-digit",
35 | }),
36 | category: category,
37 | });
38 |
39 | toast?.success("게시글을 수정했습니다.");
40 | navigate(`/posts/${post.id}`);
41 | } else {
42 | // firestore로 데이터 생성
43 | await addDoc(collection(db, "posts"), {
44 | title: title,
45 | summary: summary,
46 | content: content,
47 | createdAt: new Date()?.toLocaleDateString("ko", {
48 | hour: "2-digit",
49 | minute: "2-digit",
50 | second: "2-digit",
51 | }),
52 | email: user?.email,
53 | uid: user?.uid,
54 | category: category,
55 | });
56 |
57 | toast?.success("게시글을 생성했습니다.");
58 | navigate("/");
59 | }
60 | } catch (e: any) {
61 | console.log(e);
62 | toast?.error(e?.code);
63 | }
64 | };
65 |
66 | const onChange = (
67 | e: React.ChangeEvent<
68 | HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
69 | >
70 | ) => {
71 | const {
72 | target: { name, value },
73 | } = e;
74 |
75 | if (name === "title") {
76 | setTitle(value);
77 | }
78 |
79 | if (name === "summary") {
80 | setSummary(value);
81 | }
82 |
83 | if (name === "content") {
84 | setContent(value);
85 | }
86 |
87 | if (name === "category") {
88 | setCategory(value as CategoryType);
89 | }
90 | };
91 |
92 | const getPost = async (id: string) => {
93 | if (id) {
94 | const docRef = doc(db, "posts", id);
95 | const docSnap = await getDoc(docRef);
96 |
97 | setPost({ id: docSnap.id, ...(docSnap.data() as PostProps) });
98 | }
99 | };
100 |
101 | useEffect(() => {
102 | if (params?.id) getPost(params?.id);
103 | }, [params?.id]);
104 |
105 | useEffect(() => {
106 | if (post && user)
107 | if (post.uid === user.uid) {
108 | setTitle(post?.title);
109 | setSummary(post?.summary);
110 | setContent(post?.content);
111 | setCategory(post?.category as CategoryType);
112 | } else {
113 | navigate("/");
114 | toast.error("접근 불가능한 페이지 입니다.");
115 | }
116 | }, [navigate, post, user]);
117 |
118 | return (
119 |
176 | );
177 | }
178 |
--------------------------------------------------------------------------------
/src/components/PostList.tsx:
--------------------------------------------------------------------------------
1 | import AuthContext from "context/AuthContext";
2 | import {
3 | collection,
4 | deleteDoc,
5 | doc,
6 | getDocs,
7 | orderBy,
8 | query,
9 | where,
10 | } from "firebase/firestore";
11 | import { db } from "firebaseApp";
12 | import { useContext, useEffect, useState } from "react";
13 | import { Link } from "react-router-dom";
14 | import { toast } from "react-toastify";
15 |
16 | interface PostListProps {
17 | hasNavigation?: boolean;
18 | defaultTab?: TabType | CategoryType;
19 | }
20 |
21 | export interface CommentsInterface {
22 | content: string;
23 | uid: string;
24 | email: string;
25 | createdAt: string;
26 | }
27 |
28 | export interface PostProps {
29 | id?: string;
30 | title: string;
31 | email: string;
32 | summary: string;
33 | content: string;
34 | createdAt: string;
35 | updatedAt?: string;
36 | uid: string;
37 | category?: CategoryType;
38 | comments?: CommentsInterface[];
39 | }
40 |
41 | type TabType = "all" | "my";
42 |
43 | export type CategoryType = "Frontend" | "Backend" | "Web" | "Native";
44 | export const CATEGORIES: CategoryType[] = [
45 | "Frontend",
46 | "Backend",
47 | "Web",
48 | "Native",
49 | ];
50 |
51 | export default function PostList({
52 | hasNavigation = true,
53 | defaultTab = "all",
54 | }: PostListProps) {
55 | const [activeTab, setActiveTab] = useState(
56 | defaultTab
57 | );
58 | const [posts, setPosts] = useState([]);
59 | const { user } = useContext(AuthContext);
60 |
61 | const getPosts = async () => {
62 | // posts 초기화
63 | setPosts([]);
64 | let postsRef = collection(db, "posts");
65 | let postsQuery;
66 |
67 | if (activeTab === "my" && user) {
68 | // 나의 글만 필터링
69 | postsQuery = query(
70 | postsRef,
71 | where("uid", "==", user.uid),
72 | orderBy("createdAt", "asc")
73 | );
74 | } else if (activeTab === "all") {
75 | // 모든 글 보여주기
76 | postsQuery = query(postsRef, orderBy("createdAt", "asc"));
77 | } else {
78 | // 카테고리 글 보여주기
79 | postsQuery = query(
80 | postsRef,
81 | where("category", "==", activeTab),
82 | orderBy("createdAt", "asc")
83 | );
84 | }
85 | const datas = await getDocs(postsQuery);
86 | datas?.forEach((doc) => {
87 | const dataObj = { ...doc.data(), id: doc.id };
88 | setPosts((prev) => [...prev, dataObj as PostProps]);
89 | });
90 | };
91 |
92 | const handleDelete = async (id: string) => {
93 | const confirm = window.confirm("해당 게시글을 삭제하시겠습니까?");
94 | if (confirm && id) {
95 | await deleteDoc(doc(db, "posts", id));
96 |
97 | toast.success("게시글을 삭제했습니다.");
98 | getPosts(); // 변경된 post 리스트를 다시 가져옴
99 | }
100 | };
101 |
102 | useEffect(() => {
103 | getPosts();
104 | // eslint-disable-next-line react-hooks/exhaustive-deps
105 | }, [activeTab]);
106 |
107 | return (
108 | <>
109 | {hasNavigation && (
110 |
111 |
setActiveTab("all")}
114 | className={activeTab === "all" ? "post__navigation--active" : ""}
115 | >
116 | 전체
117 |
118 |
setActiveTab("my")}
121 | className={activeTab === "my" ? "post__navigation--active" : ""}
122 | >
123 | 나의 글
124 |
125 | {CATEGORIES?.map((category) => (
126 |
setActiveTab(category)}
130 | className={
131 | activeTab === category ? "post__navigation--active" : ""
132 | }
133 | >
134 | {category}
135 |
136 | ))}
137 |
138 | )}
139 |
140 | {posts?.length > 0 ? (
141 | posts?.map((post) => (
142 |
143 |
144 |
145 |
146 |
{post?.email}
147 |
{post?.createdAt}
148 |
149 |
{post?.title}
150 |
{post?.summary}
151 |
152 | {post?.uid === user?.uid && (
153 |
154 |
handleDelete(post.id as string)}
158 | >
159 | 삭제
160 |
161 |
162 | 수정
163 |
164 |
165 | )}
166 |
167 | ))
168 | ) : (
169 |
게시글이 없습니다.
170 | )}
171 |
172 | >
173 | );
174 | }
175 |
--------------------------------------------------------------------------------
/src/components/Profile.tsx:
--------------------------------------------------------------------------------
1 | import AuthContext from "context/AuthContext";
2 | import { getAuth, signOut } from "firebase/auth";
3 | import { app } from "firebaseApp";
4 | import { useContext } from "react";
5 |
6 | import { toast } from "react-toastify";
7 |
8 | const onSignOut = async () => {
9 | try {
10 | const auth = getAuth(app);
11 | await signOut(auth);
12 | toast.success("로그아웃 되었습니다.");
13 | } catch (error: any) {
14 | console.log(error);
15 | toast.error(error?.code);
16 | }
17 | };
18 |
19 | export default function Profile() {
20 | const { user } = useContext(AuthContext);
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
{user?.email}
28 |
{user?.displayName || "사용자"}
29 |
30 |
31 |
32 | 로그아웃
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/Router.tsx:
--------------------------------------------------------------------------------
1 | import { Route, Routes, Navigate } from "react-router-dom";
2 | import Home from "pages/home";
3 | import PostList from "pages/posts";
4 | import PostDetail from "pages/posts/detail";
5 | import PostNew from "pages/posts/new";
6 | import PostEdit from "pages/posts/edit";
7 | import ProfilePage from "pages/profile";
8 | import LoginPage from "pages/login";
9 | import SignupPage from "pages/signup";
10 |
11 | interface RouterProps {
12 | isAuthenticated: boolean;
13 | }
14 |
15 | export default function Router({ isAuthenticated }: RouterProps) {
16 | return (
17 | <>
18 |
19 | {isAuthenticated ? (
20 | <>
21 | } />
22 | } />
23 | } />
24 | } />
25 | } />
26 | } />
27 | } />
28 | >
29 | ) : (
30 | <>
31 | } />
32 | } />
33 | } />
34 | >
35 | )}
36 |
37 | >
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/SignupForm.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | import { Link, useNavigate } from "react-router-dom";
4 | import { app } from "firebaseApp";
5 | import { getAuth, createUserWithEmailAndPassword } from "firebase/auth";
6 | import { toast } from "react-toastify";
7 |
8 | export default function SignupForm() {
9 | const [error, setError] = useState("");
10 | const [email, setEmail] = useState("");
11 | const [password, setPassword] = useState("");
12 | const [passwordConfirm, setPasswordConfirm] = useState("");
13 | const navigate = useNavigate();
14 |
15 | const onSubmit = async (e: React.FormEvent) => {
16 | e.preventDefault();
17 | try {
18 | const auth = getAuth(app);
19 | await createUserWithEmailAndPassword(auth, email, password);
20 |
21 | toast.success("회원가입에 성공했습니다.");
22 | navigate("/");
23 | } catch (error: any) {
24 | console.log(error);
25 | toast.error(error?.code);
26 | }
27 | };
28 |
29 | const onChange = (e: React.ChangeEvent) => {
30 | const {
31 | target: { name, value },
32 | } = e;
33 |
34 | if (name === "email") {
35 | setEmail(value);
36 | const validRegex =
37 | /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/;
38 |
39 | if (!value?.match(validRegex)) {
40 | setError("이메일 형식이 올바르지 않습니다.");
41 | } else {
42 | setError("");
43 | }
44 | }
45 |
46 | if (name === "password") {
47 | setPassword(value);
48 |
49 | if (value?.length < 8) {
50 | setError("비밀번호는 8자리 이상으로 입력해주세요");
51 | } else if (passwordConfirm?.length > 0 && value !== passwordConfirm) {
52 | setError("비밀번호와 비밀번호 확인 값이 다릅니다. 다시 확인해주세요.");
53 | } else {
54 | setError("");
55 | }
56 | }
57 |
58 | if (name === "password_confirm") {
59 | setPasswordConfirm(value);
60 |
61 | if (value?.length < 8) {
62 | setError("비밀번호는 8자리 이상으로 입력해주세요");
63 | } else if (value !== password) {
64 | setError("비밀번호와 비밀번호 확인 값이 다릅니다. 다시 확인해주세요.");
65 | } else {
66 | setError("");
67 | }
68 | }
69 | };
70 | return (
71 |
123 | );
124 | }
125 |
--------------------------------------------------------------------------------
/src/context/AuthContext.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, createContext, useEffect, useState } from "react";
2 | import { User, getAuth, onAuthStateChanged } from "firebase/auth";
3 | import { app } from "firebaseApp";
4 |
5 | interface AuthProps {
6 | children: ReactNode;
7 | }
8 |
9 | const AuthContext = createContext({
10 | user: null as User | null,
11 | });
12 |
13 | export const AuthContextProvider = ({ children }: AuthProps) => {
14 | const auth = getAuth(app);
15 | const [currentUser, setCurrentUser] = useState(null);
16 |
17 | useEffect(() => {
18 | onAuthStateChanged(auth, (user) => {
19 | if (user) {
20 | setCurrentUser(user);
21 | } else {
22 | setCurrentUser(user);
23 | }
24 | });
25 | }, [auth]);
26 |
27 | return (
28 |
29 | {children}
30 |
31 | );
32 | };
33 |
34 | export default AuthContext;
35 |
--------------------------------------------------------------------------------
/src/context/ThemeContext.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, createContext, useState } from "react";
2 |
3 | const ThemeContext = createContext({
4 | theme: "light",
5 | toggleMode: () => {},
6 | });
7 |
8 | interface ThemeProps {
9 | children: ReactNode;
10 | }
11 |
12 | export const ThemeContextProvider = ({ children }: ThemeProps) => {
13 | const [theme, setTheme] = useState(
14 | window.localStorage.getItem("theme") || "light"
15 | );
16 |
17 | const toggleMode = () => {
18 | setTheme((prev) => (prev === "light" ? "dark" : "light"));
19 | window.localStorage.setItem("theme", theme === "light" ? "dark" : "light");
20 | };
21 |
22 | return (
23 |
24 | {children}
25 |
26 | );
27 | };
28 |
29 | export default ThemeContext;
30 |
--------------------------------------------------------------------------------
/src/firebaseApp.ts:
--------------------------------------------------------------------------------
1 | import { initializeApp, FirebaseApp, getApp } from "firebase/app";
2 | import "firebase/auth";
3 | import { getFirestore } from "firebase/firestore";
4 |
5 | export let app: FirebaseApp;
6 |
7 | // Your web app's Firebase configuration
8 | const firebaseConfig = {
9 | apiKey: process.env.REACT_APP_API_KEY,
10 | authDomain: process.env.REACT_APP_AUTH_DOMAIN,
11 | projectId: process.env.REACT_APP_PROJECT_ID,
12 | storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
13 | messagingSenderId: process.env.REACT_APP_MESSAGING_SENTER_ID,
14 | appId: process.env.REACT_APP_ID,
15 | };
16 |
17 | try {
18 | app = getApp("app");
19 | } catch (e) {
20 | app = initializeApp(firebaseConfig, "app");
21 | }
22 |
23 | // Initialize Firebase
24 | const firebase = initializeApp(firebaseConfig);
25 |
26 | // Initialize Cloud Firestore and get a reference to the service
27 | export const db = getFirestore(app);
28 |
29 | export default firebase;
30 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
12 | monospace;
13 | }
14 |
15 | /* header */
16 |
17 | header {
18 | display: flex;
19 | justify-content: space-between;
20 | /* flex-direction: row-reverse; */
21 | border-bottom: 1px solid #f2f2f2;
22 | padding: 10px 40px;
23 | min-height: 40px;
24 | align-items: center;
25 | }
26 |
27 | header .header__logo {
28 | color: black;
29 | font-weight: 600;
30 | font-size: 18px;
31 | }
32 |
33 | header a {
34 | margin: 0px 10px;
35 | text-decoration: none;
36 | color: gray;
37 | }
38 |
39 | header a:focus,
40 | header a:hover {
41 | color: black;
42 | }
43 |
44 | /* footer */
45 | footer {
46 | background-color: #f2f2f2;
47 | min-height: 40px;
48 | padding: 20px 40px;
49 | font-size: 14px;
50 | text-align: center;
51 | display: flex;
52 | align-items: center;
53 | justify-content: center;
54 | gap: 20px;
55 | }
56 |
57 | footer a {
58 | text-decoration: none;
59 | color: gray;
60 | }
61 |
62 | footer a:focus,
63 | footer a:hover {
64 | color: black;
65 | }
66 |
67 | .footer__theme-btn {
68 | cursor: pointer;
69 | width: 18px;
70 | height: 18px;
71 | }
72 |
73 | /* post navigation */
74 | .post__navigation {
75 | display: flex;
76 | gap: 12px;
77 | margin: 0 auto;
78 | max-width: 680px;
79 | font-size: 16px;
80 | color: gray;
81 | cursor: pointer;
82 | padding: 48px 20px 0px 20px;
83 | }
84 |
85 | .post__navigation--active {
86 | color: black;
87 | font-weight: 600;
88 | }
89 |
90 | /* post list, post detail */
91 | .post__list,
92 | .post__detail {
93 | min-height: 90vh;
94 | padding: 20px;
95 | text-align: center;
96 | max-width: 680px;
97 | margin: 0 auto;
98 | text-align: left;
99 | line-height: 24px;
100 | }
101 |
102 | .post__box {
103 | padding-top: 24px;
104 | padding-bottom: 24px;
105 | border-top: 1px solid #f2f2f2;
106 | }
107 |
108 | .post__profile-box {
109 | display: flex;
110 | gap: 8px;
111 | font-size: 14px;
112 | align-items: center;
113 | }
114 |
115 | .post__profile {
116 | width: 36px;
117 | height: 36px;
118 | background: #f2f2f2;
119 | border-radius: 50%;
120 | }
121 |
122 | .post__date,
123 | .post__author-name {
124 | color: gray;
125 | }
126 |
127 | .post__title {
128 | font-size: 20px;
129 | font-weight: 600;
130 | margin: 14px 0px;
131 | }
132 |
133 | .post__text {
134 | color: dimgray;
135 | font-size: 16px;
136 | }
137 |
138 | .post__list a {
139 | text-decoration: none;
140 | color: black;
141 | }
142 |
143 | .post__utils-box {
144 | display: flex;
145 | gap: 8px;
146 | flex-direction: row-reverse;
147 | font-size: 14px;
148 | color: gray;
149 | }
150 |
151 | .post__delete {
152 | cursor: pointer;
153 | }
154 |
155 | .post__delete:hover,
156 | .post__delete:focus {
157 | color: black;
158 | }
159 |
160 | .post__edit:hover,
161 | .post__edit:focus,
162 | .post__edit a:hover,
163 | .post__edit a:focus {
164 | color: black;
165 | }
166 |
167 | .post__edit a {
168 | text-decoration: none;
169 | color: gray;
170 | }
171 |
172 | .post__no-post {
173 | padding: 24px;
174 | text-align: center;
175 | color: gray;
176 | border: 1px solid #f2f2f2;
177 | border-radius: 20px;
178 | }
179 |
180 | /* post detail */
181 |
182 | .post__detail .post__title {
183 | font-size: 36px;
184 | line-height: 40px;
185 | }
186 |
187 | .post__detail .post__utils-box {
188 | padding: 10px 0px;
189 | border-top: 1px solid #f2f2f2;
190 | border-bottom: 1px solid #f2f2f2;
191 | flex-direction: row;
192 | }
193 |
194 | .post__detail .post__profile-box {
195 | padding: 10px 0px;
196 | }
197 |
198 | .post__detail .post__text {
199 | padding: 20px 0px;
200 | }
201 |
202 | .post__text--pre-wrap {
203 | white-space: pre-wrap;
204 | }
205 |
206 | .post__category {
207 | color: gray;
208 | border: 1px solid lightgray;
209 | backgroud: #f2f2f2;
210 | padding: 0px 4px;
211 | border-radius: 10px;
212 | font-size: 12px;
213 | }
214 |
215 | /* profile */
216 |
217 | .profile__box {
218 | display: flex;
219 | gap: 18px;
220 | align-items: center;
221 | font-size: 18px;
222 | margin: 0 auto;
223 | max-width: 680px;
224 | text-align: left;
225 | line-height: 24px;
226 | justify-content: space-between;
227 | padding: 20px;
228 | }
229 |
230 | .flex__box-lg {
231 | display: flex;
232 | gap: 18px;
233 | align-items: center;
234 | }
235 |
236 | .profile__image {
237 | width: 72px;
238 | height: 72px;
239 | background-color: #f2f2f2;
240 | border-radius: 50%;
241 | }
242 |
243 | .profile__name {
244 | font-size: 16px;
245 | padding-top: 4px;
246 | }
247 |
248 | .profile__email {
249 | font-weight: 500;
250 | }
251 |
252 | .profile__logout {
253 | color: gray;
254 | font-size: 14px;
255 | cursor: pointer;
256 | text-decoration: underline;
257 | }
258 |
259 | .profile__logout:hover,
260 | .profile__logout:focus {
261 | color: black;
262 | }
263 |
264 | /* carousel */
265 |
266 | .carousel {
267 | margin: 0 auto;
268 | max-width: 980px;
269 | margin-top: 36px;
270 | }
271 |
272 | ul.carousel__slides {
273 | display: block;
274 | position: relative;
275 | height: 400px;
276 | margin: 0;
277 | padding: 0;
278 | overflow: hidden;
279 | list-style: none;
280 | }
281 |
282 | .carousel__slides * {
283 | user-select: none;
284 | -ms-user-select: none;
285 | -moz-user-select: none;
286 | -khtml-user-select: none;
287 | -webkit-user-select: none;
288 | -webkit-touch-callout: none;
289 | }
290 |
291 | ul.carousel__slides input {
292 | display: none;
293 | }
294 |
295 | .carousel__slide-container {
296 | display: block;
297 | }
298 |
299 | .carousel__slide-img {
300 | display: block;
301 | position: absolute;
302 | width: 100%;
303 | height: 100%;
304 | top: 0;
305 | opacity: 0;
306 | transition: all 0.7s ease-in-out;
307 | }
308 |
309 | .carousel__slide-img img {
310 | width: auto;
311 | min-width: 100%;
312 | height: 100%;
313 | }
314 |
315 | .carousel__controls {
316 | position: absolute;
317 | top: 0;
318 | left: 0;
319 | right: 0;
320 | z-index: 999;
321 | font-size: 100px;
322 | line-height: 400px;
323 | color: #fff;
324 | }
325 |
326 | .carousel__controls label {
327 | display: none;
328 | position: absolute;
329 | padding: 0 20px;
330 | opacity: 0;
331 | transition: opacity 0.2s;
332 | cursor: pointer;
333 | }
334 |
335 | .carousel__slide-img:hover + .carousel__controls label {
336 | opacity: 0.5;
337 | }
338 |
339 | .carousel__controls label:hover {
340 | opacity: 1;
341 | }
342 |
343 | .carousel__controls .carousel__slide-prev {
344 | width: 49%;
345 | text-align: left;
346 | left: 0;
347 | }
348 |
349 | .carousel__controls .carousel__slide-next {
350 | width: 49%;
351 | text-align: right;
352 | right: 0;
353 | }
354 |
355 | .carousel__dots {
356 | position: absolute;
357 | left: 0;
358 | right: 0;
359 | bottom: 20px;
360 | z-index: 999;
361 | text-align: center;
362 | }
363 |
364 | .carousel__dots .carousel__dot {
365 | display: inline-block;
366 | width: 10px;
367 | height: 10px;
368 | border-radius: 50%;
369 | background-color: #fff;
370 | opacity: 0.5;
371 | margin: 10px;
372 | }
373 |
374 | input:checked + .carousel__slide-container .carousel__slide-img {
375 | opacity: 1;
376 | transform: scale(1);
377 | transition: opacity 1s ease-in-out;
378 | }
379 |
380 | input:checked + .carousel__slide-container .carousel__controls label {
381 | display: block;
382 | }
383 |
384 | input#img-1:checked ~ .carousel__dots label#img-dot-1,
385 | input#img-2:checked ~ .carousel__dots label#img-dot-2,
386 | input#img-3:checked ~ .carousel__dots label#img-dot-3 {
387 | opacity: 1;
388 | }
389 |
390 | input:checked + .carousel__slide-container .nav label {
391 | display: block;
392 | }
393 |
394 | /* form */
395 | .form {
396 | margin: 0 auto;
397 | max-width: 680px;
398 | padding: 20px;
399 | margin-top: 20px;
400 | }
401 |
402 | .form input {
403 | height: 20px;
404 | padding: 10px 10px;
405 | font-size: 16px;
406 | border-radius: 0.3rem;
407 | border: 1px solid lightgray;
408 | width: 96%;
409 | max-width: 680px;
410 | }
411 |
412 | .form textarea {
413 | min-height: 400px;
414 | padding: 10px 10px;
415 | font-size: 16px;
416 | line-height: 1.5;
417 | border-radius: 0.3rem;
418 | border: 1px solid lightgray;
419 | width: 96%;
420 | max-width: 680px;
421 | }
422 |
423 | .form .form__block {
424 | margin-top: 20px;
425 | width: 100%;
426 | }
427 |
428 | .form label {
429 | display: block;
430 | font-weight: 500;
431 | margin-bottom: 10px;
432 | margin-top: 20px;
433 | }
434 |
435 | .form .form__btn--submit {
436 | width: 100%;
437 | height: 48px;
438 | font-weight: 600;
439 | padding: 10px 10px;
440 | float: right;
441 | cursor: pointer;
442 | margin: 0 auto;
443 | font-size: 16px;
444 | background-color: #2563eb;
445 | color: white;
446 | }
447 |
448 | .form .form__btn--submit:hover,
449 | .form .form__btn--submit:focus {
450 | background-color: #1945a4;
451 | }
452 |
453 | /* login form */
454 |
455 | .form--lg {
456 | min-height: 70vh;
457 | margin-top: 10vh;
458 | }
459 |
460 | .form__title {
461 | text-align: center;
462 | margin-bottom: 4px;
463 | }
464 |
465 | .form__link {
466 | margin-left: 10px;
467 | text-decoration: none;
468 | color: gray;
469 | }
470 |
471 | .form__link:hover,
472 | .form__link:focus {
473 | color: black;
474 | }
475 |
476 | .form__error {
477 | color: red;
478 | }
479 |
480 | .form select {
481 | border: 1px solid lightgray;
482 | max-width: 680px;
483 | height: 40px;
484 | padding: 0px 20px;
485 | font-size: 16px;
486 | border-radius: 0.3rem;
487 | }
488 |
489 | /* loader */
490 |
491 | .loader {
492 | width: 48px;
493 | height: 48px;
494 | position: absolute;
495 | left: 0;
496 | right: 0;
497 | top: 0;
498 | bottom: 0;
499 | margin: auto;
500 | border: 5px solid #2563eb;
501 | border-radius: 50%;
502 | z-index: 9999;
503 | animation: rotation 1s linear infinite;
504 | }
505 |
506 | /* comments */
507 |
508 | .comments {
509 | width: 100%;
510 | }
511 |
512 | .comments__form label {
513 | font-weight: 600;
514 | display: block;
515 | margin-bottom: 10px;
516 | margin-top: 20px;
517 | }
518 |
519 | .comments__form textarea {
520 | min-height: 100px;
521 | padding: 10px;
522 | font-size: 16px;
523 | line-height: 1.5;
524 | border-radius: 0.3rem;
525 | border: 1px solid lightgray;
526 | display: block;
527 | width: 100%;
528 | max-width: 680px;
529 | -webkit-box-sizing: border-box;
530 | -moz-box-sizing: border-box;
531 | box-sizing: border-box;
532 | }
533 |
534 | .comments__form .form__block {
535 | margin-top: 10px;
536 | width: 100%;
537 | height: 100%;
538 | }
539 |
540 | .comments__form .form__btn-submit {
541 | width: 100px;
542 | height: 36px;
543 | cursor: pointer;
544 | font-weight: 500;
545 | background-color: #2563eb;
546 | color: white;
547 | border: none;
548 | border-radius: 5px;
549 | }
550 |
551 | .form__block-reverse {
552 | display: flex;
553 | flex-direction: row-reverse;
554 | }
555 |
556 | .comments__form .form__btn-submit:hover,
557 | .comments__form .form__btn-submit:focus {
558 | background-color: #1945a4;
559 | }
560 |
561 | .comments__list {
562 | margin-top: 40px;
563 | margin-bottom: 100px;
564 | }
565 |
566 | .comment__box {
567 | padding: 12px 0px;
568 | border-bottom: 1px solid #f2f2f2;
569 | }
570 |
571 | .comment__profile-box {
572 | display: flex;
573 | gap: 10px;
574 | align-items: center;
575 | font-size: 12px;
576 | }
577 |
578 | .comment__email {
579 | font-weight: 500;
580 | }
581 |
582 | .comment__date {
583 | color: gray;
584 | }
585 |
586 | .comment__delete {
587 | color: gray;
588 | font-size: 12px;
589 | cursor: pointer;
590 | text-decoration: underline;
591 | }
592 |
593 | .comment__delete:hover,
594 | .comment__delete:focus {
595 | color: black;
596 | }
597 |
598 | .comment__text {
599 | font-size: 14px;
600 | padding-top: 4px;
601 | }
602 |
603 | /* dark mode */
604 |
605 | .white {
606 | transition: all 0.25s linear;
607 | background-color: white;
608 | }
609 |
610 | .dark {
611 | transition: all 0.25s linear;
612 | background-color: #1e2937;
613 | min-height: 100vh;
614 | }
615 |
616 | .dark a,
617 | .dark .form label,
618 | .dark .post__delete,
619 | .dark .profile__logout,
620 | .dark .form__title,
621 | .dark .post__navigation--active,
622 | .dark .post__title,
623 | .dark .post__author-name,
624 | .dark .footer__theme-btn,
625 | .dark .profile__name,
626 | .dark .comments__list {
627 | color: white;
628 | }
629 |
630 | .dark a:hover,
631 | .dark a:focus,
632 | .dark .post__text,
633 | .dark .post__text--pre-wrap,
634 | .dark .profile__email,
635 | .dark .post__date,
636 | .dark .form__block,
637 | .dark .comment__date,
638 | .dark .comment__delete {
639 | color: lightgray;
640 | }
641 |
642 | .dark .comment__delete:hover,
643 | .dark .comment__delete:focus {
644 | color: white;
645 | }
646 |
647 | .dark .form__error {
648 | color: #ff6a71;
649 | }
650 |
651 | .dark footer,
652 | .dark header {
653 | background-color: #111827;
654 | }
655 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import "./index.css";
4 | import App from "./App";
5 | import { BrowserRouter as Router } from "react-router-dom";
6 | import { AuthContextProvider } from "context/AuthContext";
7 | import { ThemeContextProvider } from "context/ThemeContext";
8 |
9 | const root = ReactDOM.createRoot(
10 | document.getElementById("root") as HTMLElement
11 | );
12 | root.render(
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | );
21 |
--------------------------------------------------------------------------------
/src/pages/home/index.tsx:
--------------------------------------------------------------------------------
1 | import Header from "components/Header";
2 | import Footer from "components/Footer";
3 | import PostList from "components/PostList";
4 | import Carousel from "components/Carousel";
5 |
6 | export default function Home() {
7 | return (
8 | <>
9 |
10 |
11 |
12 |
13 | >
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/pages/login/index.tsx:
--------------------------------------------------------------------------------
1 | import Header from "components/Header";
2 | import LoginForm from "components/LoginForm";
3 |
4 | export default function LoginPage() {
5 | return (
6 | <>
7 |
8 |
9 | >
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/posts/detail.tsx:
--------------------------------------------------------------------------------
1 | import Footer from "components/Footer";
2 | import Header from "components/Header";
3 | import PostDetail from "components/PostDetail";
4 |
5 | export default function PostPage() {
6 | return (
7 | <>
8 |
9 |
10 |
11 | >
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/posts/edit.tsx:
--------------------------------------------------------------------------------
1 | import Header from "components/Header";
2 | import PostForm from "components/PostForm";
3 |
4 | export default function PostEdit() {
5 | return (
6 | <>
7 |
8 |
9 | >
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/posts/index.tsx:
--------------------------------------------------------------------------------
1 | import Footer from "components/Footer";
2 | import Header from "components/Header";
3 | import PostList from "components/PostList";
4 |
5 | export default function PostsPage() {
6 | return (
7 | <>
8 |
9 |
10 |
11 | >
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/posts/new.tsx:
--------------------------------------------------------------------------------
1 | import Header from "components/Header";
2 | import PostForm from "components/PostForm";
3 |
4 | export default function PostNew() {
5 | return (
6 | <>
7 |
8 |
9 | >
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/pages/profile/index.tsx:
--------------------------------------------------------------------------------
1 | import Footer from "components/Footer";
2 | import Header from "components/Header";
3 | import PostList from "components/PostList";
4 | import Profile from "components/Profile";
5 |
6 | export default function ProfilePage() {
7 | return (
8 | <>
9 |
10 |
11 |
12 |
13 | >
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/pages/signup/index.tsx:
--------------------------------------------------------------------------------
1 | import Header from "components/Header";
2 | import SignupForm from "components/SignupForm";
3 |
4 | export default function SignupPage() {
5 | return (
6 | <>
7 |
8 |
9 | >
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src",
4 | "target": "es5",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "esModuleInterop": true,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "noFallthroughCasesInSwitch": true,
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "jsx": "react-jsx"
19 | },
20 | "include": ["src"],
21 | "paths": {
22 | "pages/*": ["pages/*"],
23 | "components/*": ["components/*"],
24 | "firebaseApp/*": ["firebaseApp/*"],
25 | "context/*": ["context/*"]
26 | }
27 | }
28 |
--------------------------------------------------------------------------------