diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..6db3279 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,68 @@ +{ + "plugins": ["import"], + "extends": ["next/core-web-vitals", "plugin:import/recommended"], + "rules": { + "no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], + "import/named": 0, + "import/order": [ + "error", + { + "newlines-between": "always", + "groups": [ + "type", + "builtin", + "external", + "internal", + "parent", + "sibling", + "index", + "unknown" + ], + "pathGroups": [ + { + "pattern": "next*", + "group": "external", + "position": "before" + }, + { + "pattern": "react*", + "group": "external", + "position": "before" + }, + { + "pattern": "src/@hooks/**", + "group": "internal", + "position": "after" + }, + { + "pattern": "src/@Component/**", + "group": "internal", + "position": "after" + }, + { + "pattern": "src/@utils/**", + "group": "internal", + "position": "after" + } + ], + "pathGroupsExcludedImportTypes": [ + "next*", + "react*", + "src/@hooks/**", + "src/@Component/**", + "src/@utils/**" + ], + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + } + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b848d1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# lighthouseci +.lighthouseci +/lhci_reports/ + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +.env + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +.lighthouserc.js +.commitlint.config.js + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry Auth Token +.sentryclirc +.sentry.client.config.ts +.sentry.edge.config.ts +.sentry.server.config.ts + +.tsconfig.json + +public + +posts +.next +.vercel +.lighthouseci +.husky + +lhci_reports +.vscode +# package.json +package-lock.json + +public diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 0000000..2579c96 --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,108 @@ +import fs from 'fs'; +import { join, basename } from 'path'; + +import matter from 'gray-matter'; + +const PostDirectory = join(process.cwd(), 'posts'); +const ImageDirectory = join(process.cwd(), 'public/images'); + +export function getPostSlugs() { + return fs.readdirSync(PostDirectory); +} + +export function getPostBySlug(slug: string, fields: string[] = []) { + const realSlug = decodeURIComponent(slug.replace(/\.md/, '')); + const fullPath = join(PostDirectory, `${realSlug}.md`); + const fileContent = fs.readFileSync(fullPath, 'utf-8'); + const { data, content } = matter(fileContent); + + type Item = { + [key: string]: string; + }; + const items: Item = {}; + + fields.forEach((field) => { + if (field === 'slug') { + items[field] = realSlug; + } + if (field === 'content') { + items[field] = content; + } + if (typeof data[field] !== 'undefined') { + items[field] = data[field]; + } + if (field === 'image') { + const imagePath = join(ImageDirectory, data[field]); + } + }); + + return items; +} + +export function getAllPosts(fields: string[] = []) { + const slugs = getPostSlugs(); + const posts = slugs + .map((slug) => getPostBySlug(slug, fields)) + // sort posts by date in descending order + .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)); + + return posts; +} + +export function getInitPosts(fields: string[] = []) { + const slugs = getPostSlugs(); + const posts = slugs + .map((slug) => getPostBySlug(slug, fields)) + .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)) + .slice(0, 5); + + return posts; +} + +export function getAllPostRequest(fields: string[] = []) { + const slugs = getPostSlugs(); + const posts = slugs + .map((slug) => getPostBySlug(slug, fields)) + // sort posts by date in descending order + .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)); + + return {}; +} + +//리팩토링 필요한 부분 +export function getAllCategories() { + const allPosts = getAllPosts(['category']); + + const allCategory = new Map(); + + allPosts.map((post) => { + const getCategory = allCategory.get(post.category); + if (getCategory) { + allCategory.set(post.category, getCategory + 1); + return; + } + + allCategory.set(post.category, 1); + }); + + return Array.from(allCategory).map(([category, categoryCount]) => { + return { + category: category, + categoryCount: categoryCount + '', + }; + }); +} + +export function getCategoryFilteredPosts( + fields: string[] = [], + category: string +) { + const slugs = getPostSlugs(); + const posts = slugs + .map((slug) => getPostBySlug(slug, fields)) + // sort posts by date in descending order + .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)) + .filter((post) => post.category === category); + + return posts; +} diff --git a/lib/makeToc.tsx b/lib/makeToc.tsx new file mode 100644 index 0000000..7ac8c43 --- /dev/null +++ b/lib/makeToc.tsx @@ -0,0 +1,3 @@ +export default function makeToc({ children }: { children: string }) { + return children.match(/(?:##|###)(.*)/g); +} diff --git a/lib/markdowntoHTML.ts b/lib/markdowntoHTML.ts new file mode 100644 index 0000000..6730420 --- /dev/null +++ b/lib/markdowntoHTML.ts @@ -0,0 +1,13 @@ +import { remark } from 'remark'; +import html from 'remark-html'; + +export default async function markdownToHtml(markdown: string) { + const result = await remark() + .use(html, { + sanitize: false, + }) + + .process(markdown); + + return result.toString(); +} diff --git a/lib/replaceStr.ts b/lib/replaceStr.ts new file mode 100644 index 0000000..839d748 --- /dev/null +++ b/lib/replaceStr.ts @@ -0,0 +1,13 @@ +export default function replaceStrWithBlank([input, target]: [ + string, + string | string[] +]) { + if (target instanceof Array) { + const result = target.map((eachTarget) => { + return input.replaceAll(eachTarget, ''); + }); + return result[0].trim(); + } + + return target.replaceAll(input, ''); +} diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..25e8879 --- /dev/null +++ b/next.config.js @@ -0,0 +1,29 @@ +const withBundleAnalyzer = require('@next/bundle-analyzer')({ + enabled: process.env.ANALYZE === 'true', +}); + +const nextConfig = { + experimental: { + optimizePackageImports: ['styled-components,sentry,lodash,react-markdown'], + }, + reactStrictMode: true, + compiler: { + styledComponents: true, + }, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: '**', + }, + ], + formats: ['image/avif', 'image/webp'], + }, +}; + +const bundleAnalyzerConfig = withBundleAnalyzer({ + ...nextConfig, + compress: true, +}); + +module.exports = bundleAnalyzerConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..3d18104 --- /dev/null +++ b/package.json @@ -0,0 +1,73 @@ +{ + "name": "hj-devlog", + "version": "0.1.0", + "private": true, + "homepage": "https://github.com/khj0426/HJ_Devlog", + "scripts": { + "lhci": "next build && lhci autorun", + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "lint:fix": "next lint --fix", + "deploy": "next build && npx gh-pages -d out", + "analyze": "cross-env ANALYZE=true next build", + "analyze:server": "cross-env BUNDLE_ANALYZE=server next build", + "analyze:browser": "cross-env BUNDLE_ANALYZE=browser next build" + }, + "dependencies": { + "@giscus/react": "^2.2.8", + "@next/bundle-analyzer": "^13.4.7", + "@sentry/nextjs": "^7.64.0", + "@tanstack/react-query": "^4.33.0", + "@types/node": "20.2.5", + "@types/react": "18.2.8", + "@types/react-dom": "18.2.4", + "@types/react-syntax-highlighter": "^15.5.7", + "@types/recoil": "^0.0.9", + "@types/styled-components": "^5.1.26", + "@types/uuid": "^9.0.1", + "autoprefixer": "10.4.14", + "axios": "^1.6.2", + "cross-env": "^7.0.3", + "date-fns": "^2.30.0", + "eslint": "8.42.0", + "eslint-config-next": "13.4.4", + "firebase": "^10.7.1", + "github-label-sync": "^2.3.1", + "gray-matter": "^4.0.3", + "js-cookie": "^3.0.5", + "next": "^13.5.0", + "notion-client": "^6.16.0", + "postcss": "8.4.24", + "prism-react-renderer": "^2.0.5", + "react": "18.2.0", + "react-cookie": "^6.1.1", + "react-dom": "18.2.0", + "react-markdown": "^8.0.7", + "react-notion-x": "^6.16.0", + "react-secure-storage": "^1.3.0", + "react-syntax-highlighter": "^15.5.0", + "recoil": "^0.7.7", + "rehype-raw": "^6.1.1", + "remark": "^14.0.3", + "remark-html": "^15.0.2", + "styled-components": "^6.0.0-rc.3", + "tailwindcss": "3.3.2", + "typescript": "5.1.3", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@commitlint/cli": "^17.7.2", + "@commitlint/config-conventional": "^17.7.0", + "@lhci/cli": "^0.12.0", + "@sentry/utils": "^7.64.0", + "@types/glob": "^8.1.0", + "@typescript-eslint/parser": "^6.7.5", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.28.1", + "gh-pages": "^5.0.0", + "glob": "^10.2.7", + "husky": "^8.0.3" + } +} diff --git a/src/@types/AboutProps.ts b/src/@types/AboutProps.ts new file mode 100644 index 0000000..9ae002c --- /dev/null +++ b/src/@types/AboutProps.ts @@ -0,0 +1,6 @@ +type AboutProps = { + title: string; + imgurl: string; + content: string | string[]; +}; +export default AboutProps; diff --git a/src/@types/ButtonType.ts b/src/@types/ButtonType.ts new file mode 100644 index 0000000..a3dc9a7 --- /dev/null +++ b/src/@types/ButtonType.ts @@ -0,0 +1,10 @@ +import { CSSProperties } from 'react'; + +export type ButtonProps = { + style?: CSSProperties; + disabled?: boolean; + variant?: 'default' | 'outlined'; + label?: string; + icon?: React.ReactNode; + onClick?: () => void; +}; diff --git a/src/@types/CategoryType.ts b/src/@types/CategoryType.ts new file mode 100644 index 0000000..18cac1d --- /dev/null +++ b/src/@types/CategoryType.ts @@ -0,0 +1,4 @@ +export type CategoryItem = { + category: string; + categoryCount: string; +}; diff --git a/src/@types/GuestBookType.ts b/src/@types/GuestBookType.ts new file mode 100644 index 0000000..9be0aeb --- /dev/null +++ b/src/@types/GuestBookType.ts @@ -0,0 +1,8 @@ +export type GuestBook = { + guestbook: { + [key: string]: { + comment: string; + commentTime: string; + }; + }; +}; diff --git a/src/@types/PostType.ts b/src/@types/PostType.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/@types/ThemeType.ts b/src/@types/ThemeType.ts new file mode 100644 index 0000000..d2c2a51 --- /dev/null +++ b/src/@types/ThemeType.ts @@ -0,0 +1 @@ +export type ThemeType = 'light' | 'dark'; diff --git a/src/Component/About/Content.tsx b/src/Component/About/Content.tsx new file mode 100644 index 0000000..8f05252 --- /dev/null +++ b/src/Component/About/Content.tsx @@ -0,0 +1,22 @@ +import styled from 'styled-components'; +import { v4 as uuidv4 } from 'uuid'; + +const StyledContent = styled.section` + display: flex; + min-width: 300px; + min-height: 400px; + max-width: 400px; + max-height: 500px; + display: flex; + font-size: 18px; + flex-direction: column; +`; +export default function Content({ content }: { content: string | string[] }) { + return ( + + {Array.from(content).map((eachContent) => ( +

{eachContent}

+ ))} +
+ ); +} diff --git a/src/Component/About/ProfileImgWrapper.tsx b/src/Component/About/ProfileImgWrapper.tsx new file mode 100644 index 0000000..405c361 --- /dev/null +++ b/src/Component/About/ProfileImgWrapper.tsx @@ -0,0 +1,18 @@ +'use client'; + +import Image from 'next/image'; +export default function ProfileImageWrapper({ imgurl }: { imgurl: string }) { + return ( +
+ About 페이지 프로필 이미지 +
+ ); +} diff --git a/src/Component/About/Title.tsx b/src/Component/About/Title.tsx new file mode 100644 index 0000000..f0d00e8 --- /dev/null +++ b/src/Component/About/Title.tsx @@ -0,0 +1,20 @@ +'use client'; + +import styled from 'styled-components'; + +const StyledTitle = styled.h3` + font-weight: 700; + display: flex; + flex-wrap: wrap; + + @media ${({ theme }) => theme.device.tablet} { + font-size: 24px; + } + @media ${({ theme }) => theme.device.mobile} { + font-size: 20px; + } +`; + +export default function Title({ title }: { title: string }) { + return {title}; +} diff --git a/src/Component/Blog/CodeBlock.tsx b/src/Component/Blog/CodeBlock.tsx new file mode 100644 index 0000000..6c47113 --- /dev/null +++ b/src/Component/Blog/CodeBlock.tsx @@ -0,0 +1,35 @@ +'use client'; +import dynamic from 'next/dynamic'; +const PrismLight = dynamic( + () => import('react-syntax-highlighter/dist/cjs/prism-light') +); +import { materialDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +export default function CodeBlock({ + children, +}: { + children: string | string[]; +}) { + return ( + + {children} + + ); +} diff --git a/src/Component/Blog/Exterct.tsx b/src/Component/Blog/Exterct.tsx new file mode 100644 index 0000000..d7e1b5a --- /dev/null +++ b/src/Component/Blog/Exterct.tsx @@ -0,0 +1,20 @@ +'use client'; +import styled from 'styled-components'; + +const StyledPostExterct = styled.h5` + color: #808080; + @media ${({ theme }) => theme.device.laptop} { + font-size: 14px; + } + + @media ${({ theme }) => theme.device.tablet} { + font-size: 13px; + } + @media ${({ theme }) => theme.device.mobile} { + font-size: 11px; + } +`; + +export default function PostExterct({ exterct }: { exterct: string }) { + return {exterct}; +} diff --git a/src/Component/Blog/LayOut.tsx b/src/Component/Blog/LayOut.tsx new file mode 100644 index 0000000..2190666 --- /dev/null +++ b/src/Component/Blog/LayOut.tsx @@ -0,0 +1,42 @@ +'use client'; +import { useEffect } from 'react'; + +import styled, { css } from 'styled-components'; + +const PostLayOutPC = css` + min-width: 60%; + max-width: 60%; +`; +const PostLayOutMobile = css` + min-width: 80%; + max-width: 80%; +`; + +const StyledPostLayOut = styled.article` + ${PostLayOutPC} + display: flex; + margin: 20px auto; + flex-direction: column; + margin-bottom: 50px; + font-size: 20px; + + @media ${({ theme }) => theme.device.laptop} { + ${PostLayOutPC} + } + + @media ${({ theme }) => theme.device.tablet} { + ${PostLayOutPC} + } + + @media ${({ theme }) => theme.device.mobile} { + ${PostLayOutMobile} + } +`; + +export default function BlogLayOut({ + children, +}: { + children: React.ReactNode[]; +}) { + return {children}; +} diff --git a/src/Component/Blog/PostSearchModal.tsx b/src/Component/Blog/PostSearchModal.tsx new file mode 100644 index 0000000..e4bae32 --- /dev/null +++ b/src/Component/Blog/PostSearchModal.tsx @@ -0,0 +1,94 @@ +import { ChangeEvent, useState } from 'react'; + +import Link from 'next/link'; +import styled from 'styled-components'; + +import useSearchPost from '@/hooks/useSearchPost'; + +const StyledPostSearchModalWrapper = styled.div` + position: fixed; + top: 0; + left: 0; + backdrop-filter: blur(1px); + width: 100%; + height: 100%; +`; + +const StyledPostSearchInput = styled.input` + padding: 14px 24px; + width: 100%; + height: 100px; + box-sizing: border-box; + outline: none; + border: none; + background-color: inherit; + color: rgb(255, 255, 255); + font-family: inherit; +`; + +const StyledPostSearchModal = styled.div` + width: 350px; + min-height: 450px; + display: flex; + flex-direction: column; + align-items: center; + height: auto; + position: absolute; + background-color: rgb(38, 41, 43); + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(1); + color: rgb(236, 237, 238); + box-shadow: 0 15px 30px 0 rgba(#000, 0.25); + border-radius: 15px; + overflow: auto; +`; + +export default function PostSearchModal({ + onCloseModal, +}: { + onCloseModal: () => void; +}) { + const [querySearch, setQuerySearch] = useState(null); + const { posts } = useSearchPost(querySearch as string); + + const handleChangSearchQuery = (e: ChangeEvent) => { + if (e.target.value.length === 0) { + return; + } + + setQuerySearch(e.target.value); + }; + + return ( + onCloseModal()}> + +

onCloseModal()} + style={{ + cursor: 'pointer', + }} + > + X +

+ + {posts.map((post) => ( + onCloseModal()} + style={{ + color: 'inherit', + }} + > + {post.title} + + ))} +
+
+ ); +} diff --git a/src/Component/Blog/SearchPost.tsx b/src/Component/Blog/SearchPost.tsx new file mode 100644 index 0000000..b2837d9 --- /dev/null +++ b/src/Component/Blog/SearchPost.tsx @@ -0,0 +1,35 @@ +import Image from 'next/image'; +import { useRecoilState } from 'recoil'; + +import { postSearchModalState } from '@/app/globalAtom'; + +import SearchImage from '../../.././public/images/search.webp'; + +import PostSearchModal from './PostSearchModal'; + +export default function SearchPostButton() { + const [modalState, setPostSearchModal] = useRecoilState(postSearchModalState); + + return ( + <> + 블로그 글 검색 이미지 { + setPostSearchModal(!modalState); + }} + > + {modalState && ( + setPostSearchModal(!modalState)} /> + )} + + ); +} diff --git a/src/Component/CategoryList/CategoryList.tsx b/src/Component/CategoryList/CategoryList.tsx new file mode 100644 index 0000000..56f1a6d --- /dev/null +++ b/src/Component/CategoryList/CategoryList.tsx @@ -0,0 +1,43 @@ +'use client'; + +import type { CategoryItem } from '@/@types/CategoryType'; + +import Link from 'next/link'; +import styled from 'styled-components'; + +const CategoryListStyle = styled.span` + color: ${({ theme }) => theme.text}; + font-size: 20px; + cursor: pointer; + &:hover { + text-decoration: underline ${({ theme }) => theme.text}; + } +`; + +const CategoryListWrapper = styled.div` + gap: 15px; + display: flex; + width: 100%; + flex-wrap: wrap; + flex-direction: row; + justify-content: center; +`; +export default function CategoryList({ + category, +}: { + category: CategoryItem[]; +}) { + return ( + + {category.map(({ category, categoryCount }) => { + return ( + + + # {category + categoryCount} + + + ); + })} + + ); +} diff --git a/src/Component/Common/Avatar.tsx b/src/Component/Common/Avatar.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/Component/Common/Button.tsx b/src/Component/Common/Button.tsx new file mode 100644 index 0000000..afe9a2f --- /dev/null +++ b/src/Component/Common/Button.tsx @@ -0,0 +1,43 @@ +import type { ButtonProps } from '@/@types/ButtonType'; + +import styled from 'styled-components'; + +const StyledBaseButton = styled.button` + background-color: ${({ theme }) => theme.backgroundPost}; + font-weight: bold; + border-radius: 0.25rem; + cursor: pointer; + &:hover { + background-color: rgba(66, 153, 225, 0.7); + } + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + color: ${({ theme }) => theme.text}; + transition: icon 0.3s ease-in-out; + border: ${(props) => + props.variant === 'outlined' ? '1px rgba(255,255,255,1)' : 'none'}; +`; + +export default function Button({ + variant, + style, + disabled, + icon, + onClick, + label, +}: ButtonProps) { + return ( + + {icon} + {label} + + ); +} diff --git a/src/Component/Common/Footer.tsx b/src/Component/Common/Footer.tsx new file mode 100644 index 0000000..6e55496 --- /dev/null +++ b/src/Component/Common/Footer.tsx @@ -0,0 +1,51 @@ +'use client'; + +import Image from 'next/image'; +import Link from 'next/link'; +import styled from 'styled-components'; + +import InstagramImage from '../../../public/images/Instagramgram.webp'; + +const StyledFooter = styled.footer` + height: 30px; + display: flex; + flex-direction: column; + align-items: center; + margin: 60px auto; +`; + +const StyledLinkIconArea = styled.div` + display: flex; + justify-content: center; + gap: 10px; + margin: 0 auto; +`; + +export default function Footer() { + return ( + + + + + + + + + 인스타그램 주소 + + +
HJ DevLog
+
©{new Date().getFullYear()} 효중킴의 블로그, Powered By Next.js
+
+ ); +} diff --git a/src/Component/Common/Hydrate.tsx b/src/Component/Common/Hydrate.tsx new file mode 100644 index 0000000..abd64a0 --- /dev/null +++ b/src/Component/Common/Hydrate.tsx @@ -0,0 +1,10 @@ +'use client'; +import React from 'react'; + +import { Hydrate as HydrationBoundary } from '@tanstack/react-query'; + +function Hydrate(props: any) { + return ; +} + +export default Hydrate; diff --git a/src/Component/Common/Navbar.tsx b/src/Component/Common/Navbar.tsx new file mode 100644 index 0000000..07b0808 --- /dev/null +++ b/src/Component/Common/Navbar.tsx @@ -0,0 +1,61 @@ +'use client'; +import Link from 'next/link'; +import styled, { css } from 'styled-components'; + +import SearchPostButton from '../Blog/SearchPost'; +import ToggleDarkModeButton from '../DarkMode/ToggoeButton'; + +const StyledNavBarLayout = styled.nav` + position: sticky; + top: 0; + left: 0; + width: 100%; + display: flex; + font-size: 20px; + color: ${({ theme }) => theme.text}; + gap: 15px; + margin: 0 auto; + background-color: ${({ theme }) => theme.body}; + align-items: center; + justify-content: space-around; + z-index: 1; +`; + +const StyledNavBarTitle = styled(Link)` + font-weight: 600; + text-decoration: none; + color: inherit; + ${({ href }) => + href === '/notion/resume' && + css` + @media (max-width: 1024px) { + opacity: 0; + } + `} +`; + +export default function Navbar() { + return ( + +
+ Blog + About + Resume +
+
+ + +
+
+ ); +} diff --git a/src/Component/Common/PostLayout.tsx b/src/Component/Common/PostLayout.tsx new file mode 100644 index 0000000..559ef01 --- /dev/null +++ b/src/Component/Common/PostLayout.tsx @@ -0,0 +1,32 @@ +'use client'; +import styled from 'styled-components'; +const StyledPostLayOut = styled.section` + display: flex; + justify-content: space-between; + padding-left: 5px; + border-radius: 25px; + margin: 25px auto; + background: ${({ theme }) => theme.backgroundPost}; + max-width: 800px; + width: 60%; + + @media ${({ theme }) => theme.device.laptop} { + width: 80%; + } + + @media ${({ theme }) => theme.device.tablet} { + width: 90%; + } + + @media ${({ theme }) => theme.device.mobile} { + width: 90%; + } +`; + +export default function PostLayout({ + children, +}: { + children: React.ReactNode | React.ReactNode[]; +}) { + return {children}; +} diff --git a/src/Component/DarkMode/ToggoeButton.tsx b/src/Component/DarkMode/ToggoeButton.tsx new file mode 100644 index 0000000..99b2357 --- /dev/null +++ b/src/Component/DarkMode/ToggoeButton.tsx @@ -0,0 +1,34 @@ +import Image from 'next/image'; +import { useRecoilState } from 'recoil'; + +import { themeState } from '@/app/globalAtom'; + +import darkModeImage from '../../../public/images/darkmode.webp'; +import lightModeImage from '../../../public/images/lightmode.webp'; + +export default function ToggleDarkModeButton() { + const [currentTheme, setCurrentTheme] = useRecoilState(themeState); + const modeImageSrc = + currentTheme === 'light' ? darkModeImage : lightModeImage; + + const handleClickToggleImage = () => { + if (currentTheme === 'light') { + setCurrentTheme('dark'); + return; + } + setCurrentTheme('light'); + }; + return ( + 모드를 바꾸는 이미지 + ); +} diff --git a/src/Component/GA/GA.tsx b/src/Component/GA/GA.tsx new file mode 100644 index 0000000..f9b261d --- /dev/null +++ b/src/Component/GA/GA.tsx @@ -0,0 +1,37 @@ +'use client'; +import Script from 'next/script'; + +const GoogleAnalytics = ({ + GA_MEASUREMENT_ID, +}: { + GA_MEASUREMENT_ID: string; +}) => { + return ( + <> +