├── public ├── .nojekyll ├── favicon.ico ├── assets │ ├── author.png │ └── blog │ │ ├── preview │ │ └── cover.jpg │ │ ├── hello-world │ │ └── cover.jpg │ │ └── dynamic-routing │ │ └── cover.jpg └── favicons │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ └── safari-pinned-tab.svg ├── @types ├── remark.d.ts └── rehype-prism.d.ts ├── src ├── config │ ├── pagination.ts │ ├── app.ts │ ├── links.tsx │ └── sns.tsx ├── components │ ├── common │ │ ├── Image │ │ │ ├── index.ts │ │ │ └── Image.tsx │ │ ├── Date │ │ │ ├── index.tsx │ │ │ └── Date.tsx │ │ ├── Link │ │ │ ├── index.tsx │ │ │ ├── Link.tsx │ │ │ └── ConditionalLink.tsx │ │ ├── Motion │ │ │ ├── index.ts │ │ │ └── Motion.tsx │ │ └── DropdownMenu │ │ │ ├── index.tsx │ │ │ └── DropdownMenu.tsx │ ├── features │ │ ├── app │ │ │ ├── Seo │ │ │ │ ├── index.tsx │ │ │ │ └── Seo.tsx │ │ │ ├── Footer │ │ │ │ ├── index.tsx │ │ │ │ └── Footer.tsx │ │ │ ├── Header │ │ │ │ ├── index.tsx │ │ │ │ ├── NavBar.tsx │ │ │ │ └── Header.tsx │ │ │ ├── Profile │ │ │ │ ├── index.tsx │ │ │ │ └── Profile.tsx │ │ │ ├── Hamburger │ │ │ │ ├── index.tsx │ │ │ │ └── Hamburger.tsx │ │ │ └── Layout │ │ │ │ ├── index.tsx │ │ │ │ ├── ContentLayout.tsx │ │ │ │ └── MainLayout.tsx │ │ ├── post │ │ │ ├── Post │ │ │ │ ├── index.tsx │ │ │ │ ├── styles │ │ │ │ │ └── markdown-styles.module.css │ │ │ │ ├── PostBody.tsx │ │ │ │ ├── Post.tsx │ │ │ │ └── PostHeader.tsx │ │ │ ├── Share │ │ │ │ ├── index.tsx │ │ │ │ └── Share.tsx │ │ │ └── Toc │ │ │ │ ├── index.tsx │ │ │ │ └── Toc.tsx │ │ └── story │ │ │ ├── Stories │ │ │ ├── index.tsx │ │ │ ├── Stories.tsx │ │ │ └── Story.tsx │ │ │ └── Pagination │ │ │ ├── Cell │ │ │ ├── index.tsx │ │ │ ├── StyledCell.tsx │ │ │ └── Cell.tsx │ │ │ ├── index.tsx │ │ │ ├── types │ │ │ └── cell.ts │ │ │ ├── Pagination.tsx │ │ │ └── utils │ │ │ └── getCells.ts │ └── pages │ │ ├── about │ │ └── index.tsx │ │ ├── tag │ │ └── index.tsx │ │ ├── home │ │ └── index.tsx │ │ ├── page │ │ └── index.tsx │ │ ├── tags │ │ └── index.tsx │ │ └── posts │ │ └── index.tsx ├── lib │ ├── cn.ts │ ├── joinPath.ts │ ├── date.ts │ ├── markdownToHtml.ts │ └── api.ts ├── pages │ ├── about │ │ └── index.tsx │ ├── _document.tsx │ ├── tags │ │ ├── index.tsx │ │ └── [tag].tsx │ ├── index.tsx │ ├── 404.tsx │ ├── _app.tsx │ └── posts │ │ ├── page │ │ └── [page].tsx │ │ └── [slug].tsx ├── types │ ├── link.ts │ └── post.ts ├── hooks │ ├── useRootPath.ts │ ├── useBreakPoint.ts │ ├── useWindowSize.ts │ └── useDarkMode.ts └── styles │ ├── toc.css │ ├── post.css │ └── index.css ├── .env ├── .husky └── pre-commit ├── postcss.config.js ├── tailwind.config.js ├── next-env.d.ts ├── next.config.js ├── .prettierrc.json ├── .vscode └── settings.json ├── .gitignore ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ └── deploy.yml ├── README.md ├── package.json ├── .eslintrc.js └── _posts └── motion-slot.md /public/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /@types/remark.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'remark' 2 | -------------------------------------------------------------------------------- /src/config/pagination.ts: -------------------------------------------------------------------------------- 1 | export const paginationOffset = 8; -------------------------------------------------------------------------------- /src/components/common/Image/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Image'; -------------------------------------------------------------------------------- /@types/rehype-prism.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@mapbox/rehype-prism' 2 | -------------------------------------------------------------------------------- /src/components/common/Date/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Date'; 2 | -------------------------------------------------------------------------------- /src/components/common/Link/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Link'; 2 | -------------------------------------------------------------------------------- /src/components/common/Motion/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Motion'; 2 | -------------------------------------------------------------------------------- /src/components/features/app/Seo/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Seo'; 2 | -------------------------------------------------------------------------------- /src/components/features/post/Post/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Post'; 2 | -------------------------------------------------------------------------------- /src/components/features/post/Share/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Share'; 2 | -------------------------------------------------------------------------------- /src/components/features/post/Toc/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Toc'; 2 | -------------------------------------------------------------------------------- /src/components/features/app/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Footer'; 2 | -------------------------------------------------------------------------------- /src/components/features/app/Header/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Header'; 2 | -------------------------------------------------------------------------------- /src/components/features/app/Profile/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Profile'; 2 | -------------------------------------------------------------------------------- /src/components/features/story/Stories/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Stories'; 2 | -------------------------------------------------------------------------------- /src/components/common/DropdownMenu/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './DropdownMenu'; 2 | -------------------------------------------------------------------------------- /src/components/features/app/Hamburger/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Hamburger'; 2 | -------------------------------------------------------------------------------- /src/components/features/story/Pagination/Cell/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Cell'; 2 | -------------------------------------------------------------------------------- /src/components/features/story/Pagination/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Pagination'; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sub-t/blog-template/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_ROOT_URL=https://sub-t.github.io/blog-template/ 2 | NEXT_PUBLIC_SITE_NAME=blog -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint:fix 5 | -------------------------------------------------------------------------------- /public/assets/author.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sub-t/blog-template/HEAD/public/assets/author.png -------------------------------------------------------------------------------- /src/components/features/story/Pagination/types/cell.ts: -------------------------------------------------------------------------------- 1 | export type CellType = number | '<' | '>' | '...'; -------------------------------------------------------------------------------- /src/components/common/Link/Link.tsx: -------------------------------------------------------------------------------- 1 | import NextLink from 'next/link'; 2 | 3 | export const Link = NextLink; 4 | -------------------------------------------------------------------------------- /src/components/features/app/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './ContentLayout'; 2 | export * from './MainLayout'; 3 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sub-t/blog-template/HEAD/public/favicons/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sub-t/blog-template/HEAD/public/favicons/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sub-t/blog-template/HEAD/public/favicons/mstile-150x150.png -------------------------------------------------------------------------------- /public/assets/blog/preview/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sub-t/blog-template/HEAD/public/assets/blog/preview/cover.jpg -------------------------------------------------------------------------------- /public/favicons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sub-t/blog-template/HEAD/public/favicons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/assets/blog/hello-world/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sub-t/blog-template/HEAD/public/assets/blog/hello-world/cover.jpg -------------------------------------------------------------------------------- /public/assets/blog/dynamic-routing/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sub-t/blog-template/HEAD/public/assets/blog/dynamic-routing/cover.jpg -------------------------------------------------------------------------------- /public/favicons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sub-t/blog-template/HEAD/public/favicons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sub-t/blog-template/HEAD/public/favicons/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/lib/cn.ts: -------------------------------------------------------------------------------- 1 | export function cn(...classes: (false | null | undefined | string)[]) { 2 | return classes.filter(Boolean).join(' '); 3 | } 4 | -------------------------------------------------------------------------------- /src/config/app.ts: -------------------------------------------------------------------------------- 1 | export const ROOT_URL = process.env.NEXT_PUBLIC_ROOT_URL as string; 2 | export const SITE_NAME = process.env.NEXT_PUBLIC_SITE_NAME as string; 3 | -------------------------------------------------------------------------------- /src/lib/joinPath.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | 3 | export const joinPath = (...paths: string[]) => 4 | join(...paths).replace('https:/', 'https://') 5 | -------------------------------------------------------------------------------- /src/pages/about/index.tsx: -------------------------------------------------------------------------------- 1 | import { About } from '@/components/pages/about'; 2 | 3 | const View: React.VFC = () => ; 4 | 5 | export default View; 6 | -------------------------------------------------------------------------------- /src/types/link.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type LinkType = { 4 | name: string; 5 | href: string; 6 | icon?: React.ReactElement; 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/date.ts: -------------------------------------------------------------------------------- 1 | export const formatDate = (date: string) => 2 | new Date(date).toLocaleDateString('ja-JP', { 3 | year: 'numeric', 4 | month: '2-digit', 5 | day: '2-digit', 6 | }); 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./src/**/*.tsx'], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [require('@tailwindcss/line-clamp')], 7 | darkMode: 'class', 8 | }; 9 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | basePath: process.env.GITHUB_ACTIONS ? "/blog-template" : "", 5 | trailingSlash: true, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /src/components/common/Motion/Motion.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from '@radix-ui/react-slot'; 2 | import { motion } from 'framer-motion'; 3 | 4 | export const Motion = motion(Slot); 5 | 6 | export type AnimationConfig = React.ComponentProps; 7 | -------------------------------------------------------------------------------- /src/types/post.ts: -------------------------------------------------------------------------------- 1 | export type PostType = { 2 | slug: string; 3 | title: string; 4 | date: string; 5 | coverImage: string; 6 | excerpt: string; 7 | ogImage: { 8 | url: string; 9 | }; 10 | content: string; 11 | tags: string[]; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/pages/about/index.tsx: -------------------------------------------------------------------------------- 1 | import { MainLayout } from '@/components/features/app/Layout'; 2 | import { Profile } from '@/components/features/app/Profile'; 3 | 4 | export const About = () => ( 5 | } /> 6 | ); 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "printWidth": 80, 5 | "singleQuote": true, 6 | "jsxSingleQuote": false, 7 | "arrowParens": "always", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "semi": true, 11 | "endOfLine": "lf" 12 | } 13 | -------------------------------------------------------------------------------- /src/hooks/useRootPath.ts: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | 3 | export const useRootPath = () => { 4 | const router = useRouter(); 5 | const depth = router.pathname.split('/').filter(Boolean).length; 6 | const cd = depth ? new Array(depth).fill('..').join('/') : '.'; 7 | 8 | return cd; 9 | }; 10 | -------------------------------------------------------------------------------- /src/config/links.tsx: -------------------------------------------------------------------------------- 1 | import { AiOutlineInfoCircle } from 'react-icons/ai'; 2 | import { FiTag } from 'react-icons/fi'; 3 | import { LinkType } from '@/types/link'; 4 | 5 | export const links: LinkType[] = [ 6 | { name: 'tags', href: '/tags', icon: }, 7 | { name: 'about', href: '/about', icon: }, 8 | ]; 9 | -------------------------------------------------------------------------------- /src/hooks/useBreakPoint.ts: -------------------------------------------------------------------------------- 1 | import { useWindowSize } from './useWindowSize'; 2 | 3 | type Bp = 'sm' | 'md' | 'lg' | 'xl'; 4 | 5 | const bps = { 6 | sm: 640, 7 | md: 768, 8 | lg: 1024, 9 | xl: 1280, 10 | }; 11 | 12 | export const useBreakPoint = (bp: Bp) => { 13 | const { width } = useWindowSize(); 14 | return width >= bps[bp]; 15 | }; 16 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document'; 2 | 3 | export default class MyDocument extends Document { 4 | render() { 5 | return ( 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/common/Date/Date.tsx: -------------------------------------------------------------------------------- 1 | import { RiTimeFill } from 'react-icons/ri'; 2 | import { formatDate } from '@/lib/date'; 3 | 4 | type Props = { 5 | date: string; 6 | }; 7 | 8 | export const Date: React.VFC = ({ date }) => ( 9 |
10 | 11 | {formatDate(date)} 12 |
13 | ); 14 | -------------------------------------------------------------------------------- /src/components/features/post/Post/styles/markdown-styles.module.css: -------------------------------------------------------------------------------- 1 | .markdown { 2 | @apply text-lg leading-relaxed; 3 | } 4 | 5 | .markdown p, 6 | .markdown ul, 7 | .markdown ol, 8 | .markdown blockquote { 9 | @apply my-6; 10 | } 11 | 12 | .markdown h2 { 13 | @apply text-3xl mt-12 mb-4 leading-snug; 14 | } 15 | 16 | .markdown h3 { 17 | @apply text-2xl mt-8 mb-4 leading-snug; 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/node_modules": true 4 | }, 5 | "editor.tabSize": 2, 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | "editor.formatOnSave": true, 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll.eslint": true 10 | }, 11 | "[markdown]": { 12 | "files.trimTrailingWhitespace": false 13 | }, 14 | "git.ignoreLimitWarning": true 15 | } 16 | -------------------------------------------------------------------------------- /src/components/features/app/Layout/ContentLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from '@radix-ui/react-slot'; 2 | 3 | type Props = React.ComponentPropsWithoutRef<'div'>; 4 | 5 | export const ContentLayout: React.VFC = ({ children, ...props }) => { 6 | return ( 7 | 8 |
9 |
{children}
10 |
11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/features/post/Post/PostBody.tsx: -------------------------------------------------------------------------------- 1 | import markdownStyles from './styles/markdown-styles.module.css'; 2 | 3 | type Props = { 4 | content: string; 5 | }; 6 | 7 | export const PostBody = ({ content }: Props) => { 8 | return ( 9 |
10 |
14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/styles/toc.css: -------------------------------------------------------------------------------- 1 | .toc-list { 2 | @apply vstack gap-3; 3 | } 4 | 5 | .toc-list .toc-list { 6 | @apply vstack gap-2 pl-4 mt-1; 7 | } 8 | 9 | .toc-link { 10 | @apply text-sm sm:text-base font-bold text-neutral-400 dark:text-neutral-400 hover:text-teal-800 dark:hover:text-teal-400; 11 | } 12 | 13 | .toc-list .toc-list .toc-link { 14 | @apply text-sm; 15 | } 16 | 17 | .is-active-link { 18 | @apply text-neutral-900 dark:text-teal-400; 19 | } 20 | -------------------------------------------------------------------------------- /src/config/sns.tsx: -------------------------------------------------------------------------------- 1 | import { SiGithub, SiTwitter, SiZenn } from 'react-icons/si'; 2 | 3 | export const sns = [ 4 | { 5 | href: 'https://zenn.dev', 6 | icon: , 7 | label: 'Zenn', 8 | }, 9 | { 10 | href: 'https://github.com', 11 | icon: , 12 | label: 'Github', 13 | }, 14 | { 15 | href: 'https://twitter.com', 16 | icon: , 17 | label: 'Twitter', 18 | }, 19 | ]; 20 | -------------------------------------------------------------------------------- /src/components/features/app/Header/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@/components/common/Link'; 2 | import { links } from '@/config/links'; 3 | 4 | export const NavBar = () => { 5 | return ( 6 | <> 7 | {links.map(({ name, href }) => ( 8 | 9 | 10 | {name} 11 | 12 | 13 | ))} 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/pages/tags/index.tsx: -------------------------------------------------------------------------------- 1 | import { Tags } from '@/components/pages/tags'; 2 | import { getAllPosts } from '@/lib/api'; 3 | 4 | type Props = React.ComponentPropsWithoutRef; 5 | 6 | const View: React.VFC = (props: Props) => ; 7 | 8 | export default View; 9 | 10 | export const getStaticProps = async () => { 11 | let tags = getAllPosts(['tags']).flatMap(({ tags }) => tags); 12 | tags = Array.from(new Set(tags)); 13 | 14 | return { 15 | props: { tags }, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Home } from '@/components/pages/home'; 2 | import { getAllPosts } from '@/lib/api'; 3 | 4 | type Props = React.ComponentPropsWithoutRef; 5 | 6 | const View: React.VFC = (props: Props) => ; 7 | 8 | export default View; 9 | 10 | export const getStaticProps = async () => { 11 | const posts = getAllPosts([ 12 | 'title', 13 | 'date', 14 | 'slug', 15 | 'coverImage', 16 | 'excerpt', 17 | ]).slice(0, 4); 18 | 19 | return { 20 | props: { posts }, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/common/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useRootPath } from '@/hooks/useRootPath'; 3 | 4 | export const Image = React.forwardRef< 5 | React.ElementRef<'img'>, 6 | React.ComponentPropsWithoutRef<'img'> 7 | >(({ children, src, alt, ...props }, forwardedRef) => { 8 | const rootPath = useRootPath(); 9 | const imgPath = rootPath + src; 10 | 11 | return ( 12 | {alt} 13 | {children} 14 | 15 | ); 16 | }); 17 | 18 | Image.displayName = 'Image'; 19 | -------------------------------------------------------------------------------- /src/hooks/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const useWindowSize = () => { 4 | const [windowSize, setWindowSize] = React.useState({ 5 | width: 0, 6 | height: 0, 7 | }); 8 | 9 | React.useEffect(() => { 10 | const handleResize = () => 11 | setWindowSize({ width: window.innerWidth, height: window.innerHeight }); 12 | 13 | window.addEventListener('resize', handleResize); 14 | 15 | handleResize(); 16 | 17 | return () => { 18 | window.removeEventListener('resize', handleResize); 19 | }; 20 | }, []); 21 | 22 | return windowSize; 23 | }; -------------------------------------------------------------------------------- /src/hooks/useDarkMode.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | 3 | type UseDarkMode = () => { 4 | dark: boolean; 5 | toggle: () => void; 6 | }; 7 | 8 | export const useDarkMode: UseDarkMode = () => { 9 | const [dark, setDark] = useState(false); 10 | const toggle = useCallback(() => setDark((state) => !state), []); 11 | 12 | useEffect(() => { 13 | if (dark) { 14 | document.documentElement.classList.add('dark'); 15 | } else { 16 | document.documentElement.classList.remove('dark'); 17 | } 18 | }, [dark]); 19 | 20 | return { dark, toggle }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/features/post/Post/Post.tsx: -------------------------------------------------------------------------------- 1 | import { PostType } from '@/types/post'; 2 | import { PostBody } from './PostBody'; 3 | import { PostHeader } from './PostHeader'; 4 | 5 | type Props = { 6 | post: PostType; 7 | }; 8 | 9 | export const Post: React.VFC = ({ post }) => { 10 | const { title, coverImage, date, tags, content } = post; 11 | 12 | return ( 13 |
14 | 20 | 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/common/Link/ConditionalLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from './Link'; 3 | 4 | type Props = React.ComponentPropsWithoutRef & { 5 | condition: boolean; 6 | }; 7 | 8 | export const ConditionalLink = React.forwardRef< 9 | React.ElementRef, 10 | Props 11 | >(({ children, href, condition, ...props }, forwardedRef) => ( 12 | <> 13 | {condition ? ( 14 | 15 | {children} 16 | 17 | ) : ( 18 | <>{children} 19 | )} 20 | 21 | )); 22 | 23 | ConditionalLink.displayName = 'ConditionalLink'; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": "./", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@/components/common/Link'; 2 | 3 | const View = () => ( 4 |
5 |

6 | That’s a 404 7 |

8 |

9 | Page not found 10 |

11 | 12 |

13 | The page you’re looking for doesn’t exist. 14 |

15 | 16 | 17 | Go home 18 | 19 |
20 | ); 21 | 22 | export default View; 23 | -------------------------------------------------------------------------------- /src/components/features/story/Pagination/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import { Cell } from './Cell/Cell'; 2 | import { getCells } from './utils/getCells'; 3 | 4 | type Props = { 5 | count: number; 6 | page: number; 7 | siblingCount?: number; 8 | boundaryCount?: number; 9 | }; 10 | 11 | export const Pagination: React.VFC = (props) => { 12 | const cells = getCells(props); 13 | const { page, count } = props; 14 | 15 | return ( 16 |
17 |
18 | {cells.map((cell, idx) => ( 19 | 20 | ))} 21 |
22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/lib/markdownToHtml.ts: -------------------------------------------------------------------------------- 1 | import rehypePrism from '@mapbox/rehype-prism'; 2 | import rehypeAutolinkHeadings from 'rehype-autolink-headings'; 3 | import rehypeSlug from 'rehype-slug'; 4 | import rehypeStringify from 'rehype-stringify'; 5 | import remarkParse from 'remark-parse'; 6 | import remarkRehype from 'remark-rehype'; 7 | import { unified } from 'unified'; 8 | 9 | export default async function markdownToHtml(markdown: string) { 10 | const result = await unified() 11 | .use(remarkParse) 12 | .use(remarkRehype) 13 | .use(rehypePrism) 14 | .use(rehypeSlug) 15 | .use(rehypeAutolinkHeadings) 16 | .use(rehypeStringify) 17 | .process(markdown); 18 | 19 | return result.toString(); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/pages/tag/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { FaHashtag } from 'react-icons/fa'; 3 | import { MainLayout } from '@/components/features/app/Layout'; 4 | import { Profile } from '@/components/features/app/Profile'; 5 | import { Stories } from '@/components/features/story/Stories'; 6 | import { PostType } from '@/types/post'; 7 | 8 | type Props = { 9 | posts: PostType[]; 10 | }; 11 | 12 | export const Tag: React.VFC = ({ posts }) => { 13 | const tag = useRouter().query.tag; 14 | 15 | return ( 16 | 19 | } /> 20 |
21 | } 22 | aside={} 23 | /> 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/features/story/Pagination/Cell/StyledCell.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/cn'; 2 | 3 | const variants = { 4 | default: '', 5 | arrow: '', 6 | ellipsis: 'w-auto px-1', 7 | }; 8 | 9 | type Props = { 10 | children: React.ReactNode; 11 | variant?: keyof typeof variants; 12 | active?: boolean; 13 | disabled?: boolean; 14 | }; 15 | 16 | export const StyledCell: React.VFC = ({ 17 | children, 18 | variant = 'default', 19 | active, 20 | disabled, 21 | }) => ( 22 |
30 | {children} 31 |
32 | ); 33 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from 'next/app'; 2 | import '@/styles/index.css'; 3 | import { Footer } from '@/components/features/app/Footer'; 4 | import { Header } from '@/components/features/app/Header'; 5 | import { ContentLayout } from '@/components/features/app/Layout'; 6 | import { Seo } from '@/components/features/app/Seo'; 7 | 8 | export default function MyApp({ Component, pageProps }: AppProps) { 9 | return ( 10 | <> 11 | 12 | 13 |
17 |
18 | 19 | 20 | 21 |
22 |
23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/features/post/Toc/Toc.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { MdOutlineContentCopy } from 'react-icons/md'; 3 | import tocbot from 'tocbot'; 4 | 5 | export const Toc: React.VFC = () => { 6 | useEffect(() => { 7 | tocbot.init({ 8 | tocSelector: '.toc', 9 | contentSelector: '.post', 10 | headingSelector: 'h1, h2, h3', 11 | scrollSmoothOffset: -80, 12 | }); 13 | 14 | return () => tocbot.destroy(); 15 | }, []); 16 | 17 | return ( 18 |
19 |
20 |
21 | 22 | 目次 23 |
24 |
25 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/pages/home/index.tsx: -------------------------------------------------------------------------------- 1 | import { RiChatNewLine } from 'react-icons/ri'; 2 | import { Link } from '@/components/common/Link'; 3 | import { MainLayout } from '@/components/features/app/Layout'; 4 | import { Profile } from '@/components/features/app/Profile'; 5 | import { Stories } from '@/components/features/story/Stories'; 6 | import { PostType } from '@/types/post'; 7 | 8 | type Props = { 9 | posts: PostType[]; 10 | }; 11 | 12 | export const Home: React.VFC = ({ posts }) => { 13 | return ( 14 | 17 | } /> 18 | 19 | 20 | 記事一覧へ 21 | 22 | 23 | 24 | } 25 | aside={} 26 | /> 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/pages/page/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router'; 2 | import { RiChatNewLine } from 'react-icons/ri'; 3 | import { MainLayout } from '@/components/features/app/Layout'; 4 | import { Profile } from '@/components/features/app/Profile'; 5 | import { Pagination } from '@/components/features/story/Pagination'; 6 | import { Stories } from '@/components/features/story/Stories'; 7 | import { PostType } from '@/types/post'; 8 | 9 | type Props = { 10 | posts: PostType[]; 11 | maxPage: number; 12 | }; 13 | 14 | export const Page: React.VFC = ({ posts, maxPage }) => { 15 | const page = Number(useRouter().query.page); 16 | 17 | return ( 18 | 21 | } /> 22 | 23 | 24 | } 25 | aside={} 26 | /> 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/features/story/Stories/Stories.tsx: -------------------------------------------------------------------------------- 1 | import { PostType } from '@/types/post'; 2 | import { Story } from './Story'; 3 | 4 | type Props = { 5 | posts: PostType[]; 6 | icon?: React.ReactElement; 7 | title: React.ReactNode; 8 | }; 9 | 10 | export const Stories = ({ posts, icon, title }: Props) => { 11 | return ( 12 |
13 |
14 |

15 | {icon} {title} 16 |

17 |
18 | {posts.map((post) => ( 19 | 27 | ))} 28 |
29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/pages/tags/[tag].tsx: -------------------------------------------------------------------------------- 1 | import { Tag } from '@/components/pages/tag'; 2 | import { getAllPosts } from '@/lib/api'; 3 | 4 | type Props = React.ComponentPropsWithoutRef; 5 | 6 | const View: React.VFC = (props: Props) => ; 7 | 8 | export default View; 9 | 10 | type Params = { 11 | params: { 12 | tag: string; 13 | }; 14 | }; 15 | 16 | export const getStaticProps = async ({ params }: Params) => { 17 | const posts = getAllPosts([ 18 | 'title', 19 | 'date', 20 | 'slug', 21 | 'coverImage', 22 | 'excerpt', 23 | 'tags', 24 | ]).filter((post) => post.tags?.includes(params.tag)); 25 | 26 | return { 27 | props: { posts }, 28 | }; 29 | }; 30 | 31 | export async function getStaticPaths() { 32 | const tags = getAllPosts(['tags']).flatMap((post) => post.tags); 33 | 34 | return { 35 | paths: tags.map((tag) => { 36 | return { 37 | params: { 38 | tag, 39 | }, 40 | }; 41 | }), 42 | fallback: false, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/components/features/app/Layout/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Portal } from '@radix-ui/react-portal'; 2 | import { useBreakPoint } from '@/hooks/useBreakPoint'; 3 | import { cn } from '@/lib/cn'; 4 | import { Hamburger } from '../Hamburger'; 5 | 6 | type Props = { 7 | main: React.ReactElement; 8 | aside?: React.ReactNode; 9 | hamburgerMenu?: React.ReactNode; 10 | className?: string; 11 | }; 12 | 13 | export const MainLayout: React.VFC = ({ 14 | main, 15 | aside, 16 | hamburgerMenu, 17 | className, 18 | }) => { 19 | const lg = useBreakPoint('lg'); 20 | 21 | return ( 22 |
23 |
24 |
{main}
25 |
26 | 27 | {lg || ( 28 | 29 |
30 | {hamburgerMenu} 31 |
32 |
33 | )} 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/features/app/Profile/Profile.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from '@/components/common/Image'; 2 | import { Link } from '@/components/common/Link'; 3 | import { sns } from '@/config/sns'; 4 | 5 | export const Profile = () => ( 6 |
7 |
8 | avatar 13 |

Next.js

14 |
15 | 16 |

17 | Modern JavaScript Framework: hybrid static & server rendering, TypeScript 18 | support, smart bundling, route pre-fetching... 19 |

20 | 21 |
22 | {sns.map(({ href, icon, label }) => ( 23 | 24 | 25 | {icon} 26 | 27 | 28 | ))} 29 |
30 |
31 | ); 32 | -------------------------------------------------------------------------------- /src/components/features/app/Footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@/components/common/Link'; 2 | import { ContentLayout } from '@/components/features/app/Layout'; 3 | import { SITE_NAME } from '@/config/app'; 4 | import { sns } from '@/config/sns'; 5 | 6 | export const Footer = () => { 7 | return ( 8 |
9 | 10 |
11 |
12 | {sns.map(({ href, icon, label }) => ( 13 | 14 | 18 | {icon} 19 | 20 | 21 | ))} 22 |
23 |
24 | © 2022 - {SITE_NAME} 25 |
26 |
27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/pages/tags/index.tsx: -------------------------------------------------------------------------------- 1 | import { AiTwotoneTags } from 'react-icons/ai'; 2 | import { Link } from '@/components/common/Link'; 3 | import { MainLayout } from '@/components/features/app/Layout'; 4 | import { Profile } from '@/components/features/app/Profile'; 5 | 6 | type Props = { 7 | tags: string[]; 8 | }; 9 | 10 | export const Tags: React.VFC = ({ tags }) => { 11 | return ( 12 | 15 |
16 |

17 | 18 | タグ 19 |

20 |
21 | 22 |
23 | {tags.map((tag) => ( 24 | 25 | {tag} 26 | 27 | ))} 28 |
29 | 30 | } 31 | aside={} 32 | /> 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 subt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/features/app/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FiSun } from 'react-icons/fi'; 3 | import { Link } from '@/components/common/Link'; 4 | import { ContentLayout } from '@/components/features/app/Layout'; 5 | import { useDarkMode } from '@/hooks/useDarkMode'; 6 | import { NavBar } from './NavBar'; 7 | 8 | export const Header = () => { 9 | const { toggle } = useDarkMode(); 10 | 11 | return ( 12 | 13 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | 13 | - name: Setup Node 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: '16.x' 17 | cache: npm 18 | 19 | - name: Cache dependencies 20 | uses: actions/cache@v3 21 | with: 22 | path: | 23 | ~/.npm 24 | ${{ github.workspace }}/.next/cache 25 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} 26 | restore-keys: | 27 | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- 28 | 29 | - name: Install dependencies 30 | run: npm install 31 | 32 | - name: Build 33 | run: npm run build 34 | 35 | - name: Move 404 to root 36 | run: mv ./out/404/index.html ./out/404.html 37 | 38 | - name: Deploy 39 | uses: peaceiris/actions-gh-pages@v3 40 | with: 41 | github_token: ${{ secrets.GITHUB_TOKEN }} 42 | publish_dir: out 43 | -------------------------------------------------------------------------------- /src/pages/posts/page/[page].tsx: -------------------------------------------------------------------------------- 1 | import { Page } from '@/components/pages/page'; 2 | import { paginationOffset } from '@/config/pagination'; 3 | import { getAllPosts, getMaxPage } from '@/lib/api'; 4 | 5 | type Props = React.ComponentPropsWithoutRef; 6 | 7 | const View: React.VFC = (props: Props) => ; 8 | 9 | export default View; 10 | 11 | type Params = { 12 | params: { 13 | page: string; 14 | }; 15 | }; 16 | 17 | export const getStaticProps = async ({ params }: Params) => { 18 | const page = Number(params.page); 19 | 20 | const posts = getAllPosts([ 21 | 'title', 22 | 'date', 23 | 'slug', 24 | 'coverImage', 25 | 'excerpt', 26 | ]).slice((page - 1) * paginationOffset, page * paginationOffset); 27 | 28 | return { 29 | props: { posts, maxPage: getMaxPage() }, 30 | }; 31 | }; 32 | 33 | export async function getStaticPaths() { 34 | const pages = Array.from({ 35 | length: getMaxPage(), 36 | }).map((_, idx) => (idx + 1).toString()); 37 | 38 | return { 39 | paths: pages.map((page) => { 40 | return { 41 | params: { 42 | page, 43 | }, 44 | }; 45 | }), 46 | fallback: false, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/styles/post.css: -------------------------------------------------------------------------------- 1 | @import 'prism-themes/themes/prism-vsc-dark-plus.css'; 2 | 3 | .post { 4 | @apply my-5 text-base font-medium text-neutral-800 dark:text-white !important; 5 | } 6 | 7 | .post a { 8 | @apply text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-400 dark:to-blue-500 !important; 9 | } 10 | 11 | .post p { 12 | @apply mt-0 mb-9 text-[15px] sm:text-base !important; 13 | } 14 | 15 | .post h2 { 16 | @apply px-4 py-2 mt-16 mb-12 border-l-4 border-current text-2xl !important; 17 | } 18 | 19 | .post h3 { 20 | @apply px-4 py-2 mt-10 mb-8 border-b-2 text-xl !important; 21 | } 22 | 23 | .post h4 { 24 | @apply mb-4 text-lg !important; 25 | } 26 | 27 | .post ul + p { 28 | @apply mt-5 !important; 29 | } 30 | 31 | .post code { 32 | font-size: max(12px, 85%) !important; 33 | @apply whitespace-nowrap font-mono mx-[2px] py-[2px] px-[3px] rounded-sm text-neutral-700 bg-neutral-100 dark:text-neutral-100 dark:bg-neutral-700 !important; 34 | } 35 | 36 | .post pre code { 37 | @apply whitespace-pre m-0 p-0 text-[13px] sm:text-sm text-inherit bg-transparent dark:text-inherit dark:bg-transparent !important; 38 | } 39 | 40 | .post pre { 41 | @apply p-4 mb-9 rounded-md !important; 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/posts/[slug].tsx: -------------------------------------------------------------------------------- 1 | import { Posts } from '@/components/pages/posts'; 2 | import { getPostBySlug, getAllPosts } from '@/lib/api'; 3 | import markdownToHtml from '@/lib/markdownToHtml'; 4 | 5 | type Props = React.ComponentPropsWithoutRef; 6 | 7 | const View: React.VFC = (props: Props) => ; 8 | 9 | export default View; 10 | 11 | type Params = { 12 | params: { 13 | slug: string; 14 | }; 15 | }; 16 | 17 | export async function getStaticProps({ params }: Params) { 18 | const post = getPostBySlug(params.slug, [ 19 | 'title', 20 | 'date', 21 | 'slug', 22 | 'author', 23 | 'content', 24 | 'ogImage', 25 | 'coverImage', 26 | 'excerpt', 27 | 'tags', 28 | ]); 29 | const content = await markdownToHtml(post.content || ''); 30 | 31 | return { 32 | props: { 33 | post: { 34 | ...post, 35 | content, 36 | }, 37 | }, 38 | }; 39 | } 40 | 41 | export async function getStaticPaths() { 42 | const posts = getAllPosts(['slug']); 43 | 44 | return { 45 | paths: posts.map((post) => { 46 | return { 47 | params: { 48 | slug: post.slug, 49 | }, 50 | }; 51 | }), 52 | fallback: false, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @import url('./post.css'); 5 | @import url('./toc.css'); 6 | 7 | @layer base { 8 | /* body { 9 | background-color: var(--colors-loContrast); 10 | font-family: Yu Gothic, yugothic, ヒラギノ角ゴ ProN, Hiragino Kaku Gothic ProN, 11 | メイリオ, meiryo, sans-serif; 12 | } */ 13 | } 14 | 15 | @layer components { 16 | .bg-global { 17 | @apply bg-neutral-50 dark:bg-neutral-900; 18 | } 19 | .bg-primary-1 { 20 | @apply bg-white dark:bg-neutral-800; 21 | } 22 | .text-primary-1 { 23 | @apply text-neutral-800 dark:text-white; 24 | } 25 | .text-accent-1 { 26 | @apply text-teal-800 dark:text-teal-400; 27 | } 28 | .button { 29 | @apply center gap-2 py-3 px-4 rounded-lg text-base font-normal bg-teal-700 text-white; 30 | } 31 | .icon-btn { 32 | @apply text-primary-1 p-3; 33 | } 34 | .badge { 35 | @apply select-none text-xs flex items-center font-bold px-3 py-1 bg-black text-white; 36 | } 37 | } 38 | 39 | @layer utilities { 40 | .vstack { 41 | @apply flex flex-col; 42 | } 43 | .hstack { 44 | @apply flex items-center; 45 | } 46 | .wrap { 47 | @apply hstack flex-wrap; 48 | } 49 | .center { 50 | @apply flex items-center justify-center; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/features/story/Stories/Story.tsx: -------------------------------------------------------------------------------- 1 | import { Date } from '@/components/common/Date'; 2 | import { Image } from '@/components/common/Image'; 3 | import { Link } from '@/components/common/Link'; 4 | 5 | type Props = { 6 | title: string; 7 | coverImage: string; 8 | date: string; 9 | excerpt: string; 10 | slug: string; 11 | }; 12 | 13 | export const Story = ({ title, coverImage, date, excerpt, slug }: Props) => { 14 | return ( 15 | 16 | 17 |
18 | {`Cover 23 |
24 |
25 | 26 |

{title}

27 |

28 | {excerpt} 29 |

30 |
31 |
32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/features/post/Post/PostHeader.tsx: -------------------------------------------------------------------------------- 1 | import { AiTwotoneTags } from 'react-icons/ai'; 2 | import { Date } from '@/components/common/Date'; 3 | import { Image } from '@/components/common/Image'; 4 | import { Link } from '@/components/common/Link'; 5 | 6 | type Props = { 7 | title: string; 8 | coverImage: string; 9 | date: string; 10 | tags: string[]; 11 | }; 12 | 13 | export const PostHeader = ({ title, coverImage, date, tags }: Props) => { 14 | return ( 15 |
16 |
17 | {`Cover 22 |
23 |

24 | {title} 25 |

26 |
27 | 28 |
29 | 30 | 31 | 32 | {tags.map((tag) => ( 33 | 34 | {tag} 35 | 36 | ))} 37 |
38 |
39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/features/story/Pagination/Cell/Cell.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@/components/common/Link'; 2 | import { ConditionalLink } from '@/components/common/Link/ConditionalLink'; 3 | import { CellType } from '../types/cell'; 4 | import { StyledCell } from './StyledCell'; 5 | 6 | type Props = { 7 | cell: CellType; 8 | page: number; 9 | count: number; 10 | }; 11 | 12 | export const Cell: React.VFC = ({ cell, page, count }) => { 13 | switch (cell) { 14 | case '<': 15 | return ( 16 | 1} href={`/posts/page/${page - 1}`}> 17 | 22 | 23 | ); 24 | case '>': 25 | return ( 26 | 30 | 35 | 36 | ); 37 | case '...': 38 | return {cell}; 39 | default: 40 | return ( 41 | 42 | 43 | {cell} 44 | 45 | 46 | ); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/lib/api.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { resolve } from 'path'; 3 | import matter from 'gray-matter'; 4 | import { paginationOffset } from '@/config/pagination'; 5 | import { PostType } from '@/types/post'; 6 | 7 | const postsDirectory = resolve(process.cwd(), '_posts'); 8 | 9 | export const getPostSlugs = () => fs.readdirSync(postsDirectory); 10 | 11 | export const getMaxPage = () => { 12 | const postNum = getPostSlugs().length; 13 | return Math.ceil(postNum / paginationOffset); 14 | }; 15 | 16 | export const getPostBySlug = (slug: string, fields: string[] = []) => { 17 | const realSlug = slug.replace(/\.md$/, ''); 18 | const fullPath = resolve(postsDirectory, `${realSlug}.md`); 19 | const fileContents = fs.readFileSync(fullPath, 'utf8'); 20 | const { data, content } = matter(fileContents); 21 | 22 | type Items = { 23 | [key: string]: string; 24 | }; 25 | 26 | const items: Items = {}; 27 | 28 | // Ensure only the minimal needed data is exposed 29 | fields.forEach((field) => { 30 | if (field === 'slug') { 31 | items[field] = realSlug; 32 | } 33 | if (field === 'content') { 34 | items[field] = content; 35 | } 36 | 37 | if (typeof data[field] !== 'undefined') { 38 | items[field] = data[field]; 39 | } 40 | }); 41 | 42 | return items as Partial; 43 | }; 44 | 45 | type Field = keyof PostType; 46 | 47 | export const getAllPosts = (fields: Field[] = []) => { 48 | const slugs = getPostSlugs(); 49 | const posts = slugs 50 | .map((slug) => getPostBySlug(slug, fields)) 51 | .sort((post1, post2) => (post1.date! > post2.date! ? -1 : 1)); 52 | return posts; 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/features/app/Hamburger/Hamburger.tsx: -------------------------------------------------------------------------------- 1 | import { RiMenu4Line } from 'react-icons/ri'; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuItem, 5 | } from '@/components/common/DropdownMenu'; 6 | import { Link } from '@/components/common/Link'; 7 | import { links } from '@/config/links'; 8 | 9 | type Props = { 10 | children?: React.ReactNode; 11 | }; 12 | 13 | export const Hamburger: React.VFC = ({ children }) => { 14 | return ( 15 | 24 | 25 | 26 | } 27 | > 28 |
29 | {links.map(({ name, href, icon }) => ( 30 | 33 | document.dispatchEvent( 34 | new KeyboardEvent('keydown', { key: 'Escape' }), 35 | ) 36 | } 37 | className="text-md text-primary-1 hover:text-teal-800 hover:dark:text-teal-400 focus:text-teal-800 focus:dark:text-teal-400 capitalize select-none cursor-pointer" 38 | > 39 | 40 | 41 | {icon} {name} 42 | 43 | 44 | 45 | ))} 46 |
47 | {children} 48 |
49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/features/story/Pagination/utils/getCells.ts: -------------------------------------------------------------------------------- 1 | import { CellType } from '../types/cell'; 2 | 3 | type Config = { 4 | count: number; 5 | page: number; 6 | siblingCount?: number; 7 | boundaryCount?: number; 8 | }; 9 | 10 | const defaultConfig: Required = { 11 | count: -1, 12 | page: -1, 13 | siblingCount: 1, 14 | boundaryCount: 1, 15 | }; 16 | 17 | export const getCells = (config: Config): CellType[] => { 18 | const { count, page, siblingCount, boundaryCount } = { 19 | ...defaultConfig, 20 | ...config, 21 | }; 22 | 23 | // boundaryCount, ... , siblingCount 24 | const sideCount = boundaryCount + 1 + siblingCount; 25 | const collisionLeft = sideCount + 1 >= page; 26 | const collisionRight = count - sideCount <= page; 27 | const collisionBoth = collisionLeft && collisionRight; 28 | 29 | let cells; 30 | if (collisionBoth) { 31 | cells = range(1, count + 1); 32 | } else if (collisionLeft) { 33 | cells = [ 34 | ...range(1, sideCount + 1 + siblingCount + 1), 35 | '...', 36 | ...range(count + 1 - boundaryCount, count + 1), 37 | ]; 38 | } else if (collisionRight) { 39 | cells = [ 40 | ...range(1, boundaryCount + 1), 41 | '...', 42 | ...range(count + 1 - (siblingCount + 1 + sideCount), count + 1), 43 | ]; 44 | } else { 45 | cells = [ 46 | ...range(1, 1 + boundaryCount), 47 | '...', 48 | ...range(page - siblingCount, page + siblingCount + 1), 49 | '...', 50 | ...range(count + 1 - boundaryCount, count + 1), 51 | ]; 52 | } 53 | 54 | return ['<', ...(cells as CellType[]), '>']; 55 | }; 56 | 57 | const range = (begin: number, end: number) => 58 | Array.from({ length: end - begin }).map((_, idx) => begin + idx); 59 | -------------------------------------------------------------------------------- /src/components/common/DropdownMenu/DropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; 3 | import { Slot } from '@radix-ui/react-slot'; 4 | import { AnimatePresence } from 'framer-motion'; 5 | import { AnimationConfig, Motion } from '../Motion'; 6 | 7 | const animationConfig: AnimationConfig = { 8 | initial: { opacity: 0 }, 9 | animate: { opacity: 1 }, 10 | exit: { opacity: 0 }, 11 | transition: { duration: 0.2 }, 12 | }; 13 | 14 | type Props = React.ComponentPropsWithoutRef< 15 | typeof DropdownMenuPrimitive.Content 16 | > & { 17 | trigger?: React.ReactNode; 18 | }; 19 | 20 | export const DropdownMenu = React.forwardRef< 21 | React.ElementRef, 22 | Props 23 | >(({ children, trigger, ...props }, forwardedRef) => { 24 | const [open, setOpen] = useState(false); 25 | 26 | return ( 27 | setOpen(open)}> 28 | 29 | {trigger} 30 | 31 | 32 | 33 | {open && ( 34 | 35 | 36 | 41 | {children} 42 | 43 | 44 | 45 | )} 46 | 47 | 48 | ); 49 | }); 50 | 51 | DropdownMenu.displayName = 'DropdownMenu'; 52 | 53 | export const DropdownMenuGroup = DropdownMenuPrimitive.Group; 54 | export const DropdownMenuItem = DropdownMenuPrimitive.Item; 55 | -------------------------------------------------------------------------------- /src/components/features/post/Share/Share.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FacebookIcon, 3 | FacebookShareButton, 4 | PinterestIcon, 5 | PinterestShareButton, 6 | LineIcon, 7 | LineShareButton, 8 | HatenaIcon, 9 | HatenaShareButton, 10 | TwitterIcon, 11 | TwitterShareButton, 12 | } from 'react-share'; 13 | import { MdShare } from 'react-icons/md'; 14 | import { ROOT_URL, SITE_NAME } from '@/config/app'; 15 | import { PostType } from '@/types/post'; 16 | 17 | type Props = { 18 | post: PostType; 19 | }; 20 | 21 | const SIZE = 40; 22 | 23 | export const Share: React.VFC = ({ post }) => { 24 | const { title, slug, ogImage } = post; 25 | 26 | const url = `${ROOT_URL}/posts/${slug}`; 27 | const config = { title, url }; 28 | 29 | const tags = post.tags.map((tag) => tag.split(' ')[0]); 30 | 31 | return ( 32 |
33 |
34 | 35 | share 36 |
37 | 38 |
39 | 40 | 41 | 42 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 |
62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blog Template 2 | 3 | A simple personal blog template. 4 | 5 | You can start blogging just by adding markdown files to the `_posts` directory. Of course, you can also reuse articles from platforms like Zenn or Qiita. 6 | 7 | For more details, please refer to the following article: 8 | 9 | [Created a blog template with Next.js + GitHub Pages →](https://zenn.dev/subt/articles/957bd5d01485e1) 10 | 11 | ## Demo 12 | 13 | https://sub-t.github.io/blog-template/ 14 | 15 | ## Features 16 | 17 | - Responsive design 18 | - Dark mode support 19 | - Pagination on the article list page 20 | - Table of contents 21 | - SEO optimized with `next-seo` 22 | - OGP support 23 | 24 | ## Development 25 | 26 | ```bash 27 | git clone https://github.com/sub-t/blog-template project-name 28 | cd project-name 29 | yarn 30 | yarn dev 31 | ``` 32 | 33 | ## Deployment 34 | 35 | Refer to this article for deployment instructions: 36 | 37 | [Deploying a Next.js Blog to GitHub Pages →](https://jamband.github.io/blog/2021/08/deploy-nextjs-app-to-github-pages/) 38 | 39 | ## License 40 | 41 | MIT License 42 | 43 | --- 44 | 45 | # ブログテンプレート 46 | 47 | 個人ブログ向けのシンプルなテンプレートです。 48 | 49 | `_posts` ディレクトリに Markdown ファイルを追加するだけでブログを始められます。もちろん、Zenn や Qiita に投稿した記事を流用することも可能です。 50 | 51 | 詳細は以下の記事をご覧ください: 52 | 53 | [Next.js + GitHub Pagesのブログテンプレートを作った →](https://zenn.dev/subt/articles/957bd5d01485e1) 54 | 55 | ## デモ 56 | 57 | https://sub-t.github.io/blog-template/ 58 | 59 | ## 特徴 60 | 61 | - レスポンシブ対応 62 | - ダークモード対応 63 | - 記事一覧ページにページネーションあり 64 | - 目次付き 65 | - `next-seo` による SEO 対策済み 66 | - OGP 対応 67 | 68 | ## 開発方法 69 | 70 | ```bash 71 | git clone https://github.com/sub-t/blog-template project-name 72 | cd project-name 73 | yarn 74 | yarn dev 75 | ``` 76 | 77 | ## デプロイ方法 78 | 79 | こちらの記事を参考にしてください: 80 | 81 | [Next.js で作ったブログを GitHub Pages にデプロイする →](https://jamband.github.io/blog/2021/08/deploy-nextjs-app-to-github-pages/) 82 | 83 | ## ライセンス 84 | 85 | MIT ライセンス -------------------------------------------------------------------------------- /src/components/features/app/Seo/Seo.tsx: -------------------------------------------------------------------------------- 1 | import { DefaultSeo } from 'next-seo'; 2 | import { ROOT_URL } from '@/config/app'; 3 | import { useRootPath } from '@/hooks/useRootPath'; 4 | import { joinPath } from '@/lib/joinPath'; 5 | 6 | export const Seo = () => { 7 | const rootPath = useRootPath(); 8 | const imageURL = joinPath(ROOT_URL, '/assets/author.png'); 9 | 10 | return ( 11 | <> 12 | 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /public/favicons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/components/pages/posts/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextSeo } from 'next-seo'; 2 | import { MainLayout } from '@/components/features/app/Layout'; 3 | import { Profile } from '@/components/features/app/Profile'; 4 | import { Post } from '@/components/features/post/Post'; 5 | import { Share } from '@/components/features/post/Share'; 6 | import { Toc } from '@/components/features/post/Toc'; 7 | import { ROOT_URL } from '@/config/app'; 8 | import { useBreakPoint } from '@/hooks/useBreakPoint'; 9 | import { joinPath } from '@/lib/joinPath'; 10 | import { PostType } from '@/types/post'; 11 | 12 | type Props = { 13 | post: PostType; 14 | }; 15 | 16 | export const Posts: React.VFC = ({ post }) => { 17 | const lg = useBreakPoint('lg'); 18 | const imageURL = joinPath(ROOT_URL, post.ogImage.url); 19 | 20 | return ( 21 | <> 22 | 36 | 39 | 40 | 41 | } 42 | aside={ 43 |
44 | 45 |
46 | {lg && } 47 | 48 |
49 |
50 | } 51 | hamburgerMenu={ 52 |
56 | document.dispatchEvent( 57 | new KeyboardEvent('keydown', { key: 'Escape' }), 58 | ) 59 | } 60 | onKeyDown={() => {}} 61 | className="overflow-y-auto cursor-default" 62 | > 63 | 64 |
65 | } 66 | /> 67 | 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build && next export", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "lint:fix": "next lint --fix", 11 | "format": "prettier --write --ignore-path .gitignore './**/*.{js,jsx,ts,tsx,json,css}'", 12 | "prepare": "husky install" 13 | }, 14 | "devDependencies": { 15 | "@mapbox/rehype-prism": "^0.8.0", 16 | "@radix-ui/react-dialog": "1.0.0", 17 | "@radix-ui/react-dropdown-menu": "1.0.0", 18 | "@radix-ui/react-slot": "1.0.0", 19 | "@tailwindcss/line-clamp": "^0.4.0", 20 | "@types/node": "17.0.35", 21 | "@types/react": "^17.0.0", 22 | "@types/react-dom": "^17.0.0", 23 | "@typescript-eslint/eslint-plugin": "^5.26.0", 24 | "@typescript-eslint/parser": "^5.26.0", 25 | "autoprefixer": "^10.4.7", 26 | "babel-plugin-macros": "^3.1.0", 27 | "eslint": "8.16.0", 28 | "eslint-config-next": "12.1.6", 29 | "eslint-config-prettier": "^8.5.0", 30 | "eslint-import-resolver-typescript": "^2.7.1", 31 | "eslint-plugin-import": "^2.26.0", 32 | "eslint-plugin-jest-dom": "^4.0.2", 33 | "eslint-plugin-jsx-a11y": "^6.5.1", 34 | "eslint-plugin-react": "^7.30.0", 35 | "eslint-plugin-react-hooks": "^4.5.0", 36 | "eslint-plugin-testing-library": "^5.5.1", 37 | "eslint-plugin-unused-imports": "^2.0.0", 38 | "framer-motion": "^6.3.16", 39 | "gray-matter": "^4.0.3", 40 | "husky": "^8.0.1", 41 | "next": "12.1.6", 42 | "next-seo": "^5.4.0", 43 | "postcss": "^8.4.14", 44 | "prettier": "^2.6.2", 45 | "prism-themes": "^1.9.0", 46 | "react": "^17.0.2", 47 | "react-dom": "^17.0.2", 48 | "react-icons": "^4.4.0", 49 | "react-share": "^4.4.0", 50 | "rehype": "^12.0.1", 51 | "rehype-autolink-headings": "^6.1.1", 52 | "rehype-slug": "^5.0.1", 53 | "rehype-stringify": "^9.0.3", 54 | "remark-autolink-headings": "^7.0.1", 55 | "remark-parse": "^10.0.1", 56 | "remark-rehype": "^10.1.0", 57 | "tailwindcss": "^3.1.4", 58 | "tocbot": "^4.18.2", 59 | "typescript": "4.7.2", 60 | "unified": "^10.1.2", 61 | "zustand": "^4.0.0-rc.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | settings: { 5 | react: { 6 | version: 'detect', 7 | }, 8 | 'import/resolver': { 9 | typescript: { 10 | config: path.join(__dirname, './webpack.config.js'), 11 | alwaysTryTypes: true, 12 | }, 13 | }, 14 | }, 15 | env: { 16 | browser: true, 17 | es2021: true, 18 | }, 19 | extends: [ 20 | 'next/core-web-vitals', 21 | 'eslint:recommended', 22 | 'plugin:import/errors', 23 | 'plugin:import/warnings', 24 | 'plugin:import/typescript', 25 | 'plugin:@typescript-eslint/recommended', 26 | 'plugin:react/recommended', 27 | 'plugin:react-hooks/recommended', 28 | 'plugin:jsx-a11y/recommended', 29 | 'plugin:testing-library/react', 30 | 'plugin:jest-dom/recommended', 31 | 'prettier', 32 | ], 33 | parser: '@typescript-eslint/parser', 34 | parserOptions: { 35 | ecmaFeatures: { 36 | jsx: true, 37 | }, 38 | ecmaVersion: 'latest', 39 | sourceType: 'module', 40 | }, 41 | plugins: [ 42 | 'react', 43 | 'react-hooks', 44 | '@typescript-eslint', 45 | 'unused-imports', 46 | 'import', 47 | ], 48 | rules: { 49 | 'react-hooks/rules-of-hooks': 'error', 50 | 'react-hooks/exhaustive-deps': 'warn', 51 | 'react/react-in-jsx-scope': 'off', 52 | '@typescript-eslint/no-unused-vars': 'off', 53 | '@typescript-eslint/no-non-null-assertion': 'off', 54 | 'unused-imports/no-unused-imports': 'error', 55 | 'unused-imports/no-unused-vars': [ 56 | 'warn', 57 | { 58 | vars: 'all', 59 | varsIgnorePattern: '^_', 60 | args: 'after-used', 61 | argsIgnorePattern: '^_', 62 | }, 63 | ], 64 | 'import/order': [ 65 | 'error', 66 | { 67 | groups: [ 68 | 'builtin', 69 | 'external', 70 | 'internal', 71 | ['parent', 'sibling'], 72 | 'object', 73 | 'type', 74 | 'index', 75 | ], 76 | 'newlines-between': 'never', 77 | pathGroupsExcludedImportTypes: ['builtin'], 78 | alphabetize: { order: 'asc', caseInsensitive: true }, 79 | pathGroups: [ 80 | { 81 | pattern: '{react**,next**}', 82 | group: 'external', 83 | position: 'before', 84 | }, 85 | { 86 | pattern: '{@/libs/**,@/features/**,@/app', 87 | group: 'internal', 88 | position: 'before', 89 | }, 90 | { 91 | pattern: '{@/components/**,@/pages/**}', 92 | group: 'internal', 93 | position: 'before', 94 | }, 95 | { 96 | pattern: './**.module.css', 97 | group: 'index', 98 | position: 'after', 99 | }, 100 | ], 101 | }, 102 | ], 103 | 'import/default': 'off', 104 | 'import/no-named-as-default-member': 'off', 105 | 'import/no-named-as-default': 'off', 106 | 107 | 'jsx-a11y/anchor-is-valid': 'off', 108 | 109 | '@typescript-eslint/explicit-function-return-type': ['off'], 110 | '@typescript-eslint/explicit-module-boundary-types': ['off'], 111 | '@typescript-eslint/no-empty-function': ['off'], 112 | '@typescript-eslint/no-explicit-any': ['off'], 113 | 114 | 'react/prop-types': 'off', 115 | '@typescript-eslint/no-var-requires': 'off', 116 | 117 | '@next/next/no-img-element': 'off', 118 | }, 119 | }; 120 | -------------------------------------------------------------------------------- /_posts/motion-slot.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'アニメーションを使いまわすための「motion+Slot」戦略' 3 | excerpt: 'Framer MotionのmotionコンポーネントとRadix UIのユーティリティのコンポーネントSlotを組み合わせることで、アニメーションの再利用性を爆上げします。' 4 | coverImage: '/assets/blog/dynamic-routing/cover.jpg' 5 | date: '2022-07-01' 6 | ogImage: 7 | url: '/assets/blog/dynamic-routing/cover.jpg' 8 | tags: 9 | - 'React' 10 | - 'RadixUI' 11 | - 'FramerMotion' 12 | --- 13 | 14 | https://stitches.dev/ 15 | 16 | ## 概要 17 | 18 | Framer MotionをReact Slotで運用すると、アニメーションの再利用性が飛躍的に向上します。 19 | 20 | ### React Slot 21 | 22 | https://www.radix-ui.com/docs/primitives/utilities/slot 23 | 24 | `Slot`は子コンポーネントにpropsを渡す役割を持ちます。 25 | 26 | これが 27 | 28 | ```tsx 29 | 30 | ``` 31 | 32 | 実質的にこうなります。 33 | 34 | ```tsx 35 | 36 | ``` 37 | 38 | `Slot`本体は消えるものの、propsを介して任意のコンポーネントに機能を与えられるという点が重要です。余分な`div`を生成することはありません。 39 | 40 | 詳しくは[こちら](https://zenn.dev/subt/articles/b6aa48ccb0c884 41 | )を参照ください。 42 | 43 | ### Framer Motion 44 | 45 | https://www.framer.com/motion 46 | 47 | [Framer](https://www.framer.com/)が提供しているアニメーションライブラリです。 48 | 49 | どんなアニメーションでも、基本的に**propsだけ**で完結してしまうのが特徴です。 50 | 51 | ```tsx 52 | 58 | ``` 59 | 60 | カスタムコンポーネントは`motion`関数に渡してアニメーションをつけます。 61 | 62 | ```tsx 63 | const MotionComponent = motion(Component); 64 | ... 65 | 66 | ``` 67 | 68 | ## `motion`+`Slot`で何ができる? 69 | 70 | `motion`と`Slot`を組み合わせて`motion(Slot)`を作ります。すると、 71 | 72 | `motion`が提供してくれる手軽でリッチなアニメーション機能をそのまま、提供元を完全に隠蔽して提供する 73 | 74 | ことができるようになります。 75 | 76 | 何を言っているのか、私もよくわからないので具体例に移りましょう。 77 | 78 | ## 具体例 79 | 80 | `motion(Slot)`を使って、みんな大好き「[ふわっ](https://qiita.com/yuneco/items/24a209cb14661b8a7a20)」が手軽に実装できるようにしましょう。 81 | 82 | 83 | ### 実装 84 | 85 | ```tsx 86 | import React from 'react'; 87 | import { Slot } from '@radix-ui/react-slot'; 88 | import { motion } from 'framer-motion'; 89 | 90 | const ContentLayout = motion(Slot); 91 | 92 | type Custom = { 93 | y?: number; 94 | once?: boolean; 95 | amount?: number; 96 | duration?: number; 97 | }; 98 | 99 | const defaultCustom: Custom = { 100 | y: 20, 101 | once: true, 102 | amount: 0.3, 103 | duration: 0.6, 104 | }; 105 | 106 | const config = (custom?: Custom): React.ComponentProps => { 107 | const { y, once, amount, duration } = { ...defaultCustom, ...custom }; 108 | 109 | return { 110 | initial: { 111 | opacity: 0, 112 | y, 113 | }, 114 | whileInView: { 115 | opacity: 1, 116 | y: 0, 117 | }, 118 | viewport: { 119 | once, 120 | amount, 121 | }, 122 | transition: { 123 | duration, 124 | }, 125 | }; 126 | }; 127 | 128 | type Props = { 129 | children: React.ReactNode; 130 | custom?: Custom; 131 | }; 132 | 133 | export const Enter = React.forwardRef< 134 | React.ElementRef, 135 | Props 136 | >(({ children, custom }, forwardedRef) => ( 137 | 138 | {children} 139 | 140 | )); 141 | 142 | Enter.displayName = 'Enter'; 143 | ``` 144 | 145 | ### 使い方 146 | 147 | たったこれだけで、`h1`がふわっと入場します。 148 | 149 | ```tsx 150 | 151 |

Hello CodeSandbox

152 |
153 | ``` 154 | 155 | CodeSandboxに使用例を上げました。 156 | 157 | @[codesandbox](https://codesandbox.io/embed/nifty-fast-gs8cjy?fontsize=14&hidenavigation=1&theme=dark) 158 | 159 | ぜひ、別窓で開いて余計な`div`が生成されていないことをお確かめください。 160 | 161 | ### 注意点 162 | 163 | `motion`は内部的にrefを使用します。なので、対象のコンポーネントがカスタムコンポーネントである場合、正しくrefをフォワーディングしている必要があります。 164 | 165 | refや`forwardRef`をご存知ない方は、調べてみてください。きっと、`Slot`や`motion`に比べてはるかに多くの記事がヒットするでしょう。 166 | 167 | ## まとめ 168 | 169 | `motion(Slot)`で再利用性バツグンのアニメーションコンポーネントを作ることができました。 170 | 171 | 実装自体`motion`の軽い延長に過ぎないので、簡単にオリジナルのアニメーションコンポーネントが作れると思います。是非お試しください。 172 | 173 | 私も色々実装してみて、またの機会に紹介したいと思います。 --------------------------------------------------------------------------------