feat: 更新博客框架到 v2。 (#3)
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 10m33s
Some checks failed
🚀 Build and deploy by ftp / 🎉 Deploy (push) Failing after 10m33s
Co-authored-by: Ivan Li <ivanli2048@gmail.com> Reviewed-on: #3
This commit is contained in:
parent
02ab7d11b2
commit
de1da22508
243
.drone.yml
243
.drone.yml
@ -1,243 +0,0 @@
|
||||
---
|
||||
kind: pipeline
|
||||
name: deps
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: install
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: docker-registry.ivanli.cc
|
||||
username:
|
||||
from_secret: ivan-docker-username
|
||||
password:
|
||||
from_secret: ivan-docker-password
|
||||
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps
|
||||
cache_from:
|
||||
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
dockerfile: Dockerfile
|
||||
target: deps
|
||||
tags:
|
||||
- '${DRONE_COMMIT_SHA:0:8}-amd64'
|
||||
- '${DRONE_BRANCH}${DRONE_TAG}-amd64'
|
||||
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
when:
|
||||
status:
|
||||
- failure
|
||||
failure: ignore
|
||||
detach: true
|
||||
environment:
|
||||
PLUGIN_TOKEN:
|
||||
from_secret: drone-telegram-bot-token
|
||||
PLUGIN_TO:
|
||||
from_secret: telegram-notify-to
|
||||
settings:
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}
|
||||
✅ Install Deps #{{build.number}} of `{{repo.name}}` succeeded.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{else}}
|
||||
❌ Install Deps #{{build.number}} of `{{repo.name}}` failed.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
name: linux-amd64
|
||||
type: docker
|
||||
depends_on:
|
||||
- deps
|
||||
|
||||
steps:
|
||||
- name: build&publish
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: docker-registry.ivanli.cc
|
||||
username:
|
||||
from_secret: ivan-docker-username
|
||||
password:
|
||||
from_secret: ivan-docker-password
|
||||
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog
|
||||
dockerfile: Dockerfile
|
||||
target: release
|
||||
cache_from:
|
||||
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
tags:
|
||||
- '${DRONE_COMMIT_SHA:0:8}'
|
||||
- '${DRONE_BRANCH}${DRONE_TAG}'
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
failure: ignore
|
||||
detach: true
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
environment:
|
||||
PLUGIN_TOKEN:
|
||||
from_secret: drone-telegram-bot-token
|
||||
PLUGIN_TO:
|
||||
from_secret: telegram-notify-to
|
||||
settings:
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}
|
||||
✅ Build #{{build.number}} of `{{repo.name}}` succeeded.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{else}}
|
||||
❌ Build #{{build.number}} of `{{repo.name}}` failed.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: deploy
|
||||
clone:
|
||||
disable: false
|
||||
depends_on:
|
||||
- linux-amd64
|
||||
|
||||
steps:
|
||||
- name: watchtower-online
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
token_value:
|
||||
from_secret: watchtower-webhook-token
|
||||
token_type: Bearer
|
||||
urls: https://watchtower.ivanli.cc/v1/update
|
||||
content_type: application/json
|
||||
template: |
|
||||
{
|
||||
"owner": "{{ repo.owner }}",
|
||||
"repo": "{{ repo.name }}",
|
||||
"status": "{{ build.status }}",
|
||||
}
|
||||
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
failure: ignore
|
||||
detach: true
|
||||
environment:
|
||||
PLUGIN_TOKEN:
|
||||
from_secret: drone-telegram-bot-token
|
||||
PLUGIN_TO:
|
||||
from_secret: telegram-notify-to
|
||||
settings:
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}
|
||||
✅ Deploy #{{build.number}} of `{{repo.name}}` succeeded.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{else}}
|
||||
❌ Deploy #{{build.number}} of `{{repo.name}}` failed.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: deploy-to-zzidc
|
||||
clone:
|
||||
disable: false
|
||||
depends_on:
|
||||
- linux-amd64
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- master
|
||||
- develop
|
||||
|
||||
steps:
|
||||
- name: upload
|
||||
image: docker:dind
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- docker pull docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
- docker build --pull=true --target upload -t docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-upload:${DRONE_COMMIT_SHA:0:8} --cache-from docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64 .
|
||||
- docker run --rm -t -e FTP_ACCOUNT=$${FTP_ACCOUNT} -e FTP_PASSWORD=$${FTP_PASSWORD} -e FTP_HOST=$${FTP_HOST} docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-upload:${DRONE_COMMIT_SHA:0:8}
|
||||
environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
FTP_ACCOUNT:
|
||||
from_secret: zzidc_ftp_account
|
||||
FTP_PASSWORD:
|
||||
from_secret: zzidc_ftp_password
|
||||
FTP_HOST:
|
||||
from_secret: zzidc_ftp_host
|
||||
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
failure: ignore
|
||||
detach: true
|
||||
environment:
|
||||
PLUGIN_TOKEN:
|
||||
from_secret: drone-telegram-bot-token
|
||||
PLUGIN_TO:
|
||||
from_secret: telegram-notify-to
|
||||
settings:
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}
|
||||
✅ Deploy #{{build.number}} of `{{repo.name}}` to ZZIDC succeeded.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{else}}
|
||||
❌ Deploy #{{build.number}} of `{{repo.name}}` to ZZIDC failed.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
host:
|
||||
path: /var/run/docker.sock
|
@ -1,12 +0,0 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
10
.env.example
10
.env.example
@ -1,30 +1,26 @@
|
||||
# visit https://giscus.app to get your Giscus ids
|
||||
NEXT_PUBLIC_GISCUS_REPO=
|
||||
NEXT_PUBLIC_GISCUS_REPOSITORY_ID=
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY=
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY_ID=
|
||||
NEXT_PUBLIC_UTTERANCES_REPO=
|
||||
NEXT_PUBLIC_DISQUS_SHORTNAME=
|
||||
NEXT_PUBLIC_COMMENTO_URL=
|
||||
|
||||
|
||||
MAILCHIMP_API_KEY=
|
||||
MAILCHIMP_API_SERVER=
|
||||
MAILCHIMP_AUDIENCE_ID=
|
||||
|
||||
BUTTONDOWN_API_URL=https://api.buttondown.email/v1/
|
||||
BUTTONDOWN_API_KEY=
|
||||
|
||||
CONVERTKIT_API_URL=https://api.convertkit.com/v3/
|
||||
CONVERTKIT_API_KEY=
|
||||
// curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
|
||||
# curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
|
||||
CONVERTKIT_FORM_ID=
|
||||
|
||||
KLAVIYO_API_KEY=
|
||||
KLAVIYO_LIST_ID=
|
||||
|
||||
REVUE_API_URL=https://www.getrevue.co/api/v2/
|
||||
REVUE_API_KEY=
|
||||
|
||||
EMAILOCTOPUS_API_URL=https://emailoctopus.com/api/1.6/
|
||||
EMAILOCTOPUS_API_KEY=
|
||||
EMAILOCTOPUS_LIST_ID=
|
||||
EMAILOCTOPUS_LIST_ID=
|
||||
|
@ -1 +1,2 @@
|
||||
node_modules
|
||||
.eslintrc.js
|
29
.eslintrc.js
29
.eslintrc.js
@ -1,17 +1,42 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
env: {
|
||||
browser: true,
|
||||
amd: true,
|
||||
node: true,
|
||||
es6: true,
|
||||
},
|
||||
extends: ['eslint:recommended', 'plugin:prettier/recommended', 'next', 'next/core-web-vitals'],
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'next',
|
||||
'next/core-web-vitals',
|
||||
],
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'jsx-a11y/anchor-is-valid': [
|
||||
'error',
|
||||
{
|
||||
components: ['Link'],
|
||||
specialLink: ['hrefLeft', 'hrefRight'],
|
||||
aspects: ['invalidHref', 'preferButton'],
|
||||
},
|
||||
],
|
||||
'react/prop-types': 0,
|
||||
'no-unused-vars': 0,
|
||||
'@typescript-eslint/no-unused-vars': 0,
|
||||
'react/no-unescaped-entities': 0,
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
},
|
||||
}
|
||||
|
2
.gitattributes
vendored
2
.gitattributes
vendored
@ -1,5 +1,5 @@
|
||||
## Source: https://github.com/alexkaratarakis/gitattributes
|
||||
## Modified * text=auto to * text=auto eol=lf to force LF endings.
|
||||
## Modified * text=auto to * text=auto eol=lf eol=lf to force LF endings.
|
||||
|
||||
## GITATTRIBUTES FOR WEB PROJECTS
|
||||
#
|
||||
|
11
.gitignore
vendored
11
.gitignore
vendored
@ -4,6 +4,10 @@
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
/.yarn/*
|
||||
!/.yarn/releases
|
||||
!/.yarn/plugins
|
||||
!/.yarn/sdks
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
@ -17,9 +21,13 @@ public/sitemap.xml
|
||||
# production
|
||||
/build
|
||||
*.xml
|
||||
|
||||
# rss feed
|
||||
/public/feed.xml
|
||||
|
||||
# search
|
||||
/public/search.json
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@ -35,6 +43,9 @@ yarn-error.log*
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Contentlayer
|
||||
.contentlayer
|
||||
|
||||
secrets.txt
|
||||
|
||||
.pnpm-store
|
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -1,4 +1,6 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"cSpell.words": [
|
||||
"alpn",
|
||||
"appleboy",
|
||||
|
874
.yarn/releases/yarn-3.6.1.cjs
vendored
Executable file
874
.yarn/releases/yarn-3.6.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
3
.yarnrc.yml
Normal file
3
.yarnrc.yml
Normal file
@ -0,0 +1,3 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.6.1.cjs
|
192
README.md
192
README.md
@ -1,16 +1,136 @@
|
||||
![tailwind-nextjs-banner](/public/static/images/twitter-card.png)
|
||||
|
||||
# Ivan Li's Blog
|
||||
# Tailwind Nextjs Starter Blog
|
||||
|
||||
[![Build Status](https://ci.ivanli.cc/api/badges/Ivan/tailwind-nextjs-blog/status.svg)](https://ci.ivanli.cc/Ivan/tailwind-nextjs-blog)
|
||||
[![Website Status](https://uptime.sg.ivanli.cc/api/badge/18/uptime/720?label=30&labelSuffix=d)](https://ivanli.cc)
|
||||
[![GitHub Repo stars](https://img.shields.io/github/stars/timlrx/tailwind-nextjs-starter-blog?style=social)](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/stargazers/)
|
||||
[![GitHub forks](https://img.shields.io/github/forks/timlrx/tailwind-nextjs-starter-blog?style=social)](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/network/)
|
||||
[![Twitter URL](https://img.shields.io/twitter/url?style=social&url=https%3A%2F%2Ftwitter.com%2Ftimlrxx)](https://twitter.com/timlrxx)
|
||||
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&link=https://github.com/sponsors/timlrx)](https://github.com/sponsors/timlrx)
|
||||
|
||||
Ivan Li's Blog, base [timlrx/tailwind-nextjs-starter-blog](https://github.com/timlrx/tailwind-nextjs-starter-blog)
|
||||
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
|
||||
|
||||
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Version 2 is based on Next App directory with [React Server Component](https://nextjs.org/docs/getting-started/react-essentials#server-components) and uses [Contentlayer](https://www.contentlayer.dev/) to manage markdown content.
|
||||
|
||||
Probably the most feature-rich Next.js markdown blogging template out there. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
|
||||
|
||||
Check out the documentation below to get started.
|
||||
|
||||
Facing issues? Check the [FAQ page](https://github.com/timlrx/tailwind-nextjs-starter-blog/wiki) and do a search on past issues. Feel free to open a new issue if none has been posted previously.
|
||||
|
||||
Feature request? Check the past discussions to see if it has been brought up previously. Otherwise, feel free to start a new discussion thread. All ideas are welcomed!
|
||||
|
||||
## Examples V2
|
||||
|
||||
- [Demo Blog](https://tailwind-nextjs-starter-blog.vercel.app/) - this repo
|
||||
- [My personal blog](https://www.timlrx.com) - modified to auto-generate blog posts with dates
|
||||
- [ben.codes blog](https://ben.codes) - Benoit's personal blog about software development ([source code](https://github.com/bendotcodes/bendotcodes))
|
||||
|
||||
Using the template? Feel free to create a PR and add your blog to this list.
|
||||
|
||||
## Examples V1
|
||||
|
||||
[v1-blogs-showcase.webm](https://github.com/timlrx/tailwind-nextjs-starter-blog/assets/28362229/2124c81f-b99d-4431-839c-347e01a2616c)
|
||||
|
||||
Thanks to the community of users and contributers to the template! We are no longer accepting new blog listings over here. If you have updated from version 1 to version 2, feel free to remove your blog from this list and add it to the one above.
|
||||
|
||||
- [Aloisdg's cookbook](https://tambouille.vercel.app/) - with pictures and recipes!
|
||||
- [GautierArcin's demo with next translate](https://tailwind-nextjs-starter-blog-seven.vercel.app/) - includes translation of mdx posts, [source code](https://github.com/GautierArcin/tailwind-nextjs-starter-blog/tree/demo/next-translate)
|
||||
- [David Levai's digital garden](https://davidlevai.com/) - customized design and added email subscriptions
|
||||
- [Leo's Blog](https://leohuynh.dev) - Tuan Anh Huynh's personal site. Add Snippets Page, Author Profile Card, Image Lightbox, ...
|
||||
- [thvu.dev](https://thvu.dev) - Added `mdx-embed`, view count, reading minutes and more.
|
||||
- [irvin.dev](https://www.irvin.dev/) - Irvin Lin's personal site. Added YouTube embedding.
|
||||
- [KirillSo.com](https://www.kirillso.com/) - Personal blog & website.
|
||||
- [slightlysharpe.com](https://slightlysharpe.com) - [Tincre's](https://tincre.com) main company blog
|
||||
- [blog.b00st.com](https://blog.b00st.com) - [b00st.com's](https://b00st.com) main music promotion blog
|
||||
- [astrosaurus.me](https://astrosaurus.me/) - Ephraim Atta-Duncan's Personal Blog
|
||||
- [dhanrajsp.me](https://dhanrajsp.me/) - Dhanraj's personal site and blog.
|
||||
- [blog.r00ks.io](https://blog.r00ks.io/) - Austin Rooks's personal blog ([source code](https://github.com/Austionian/blog.r00ks)).
|
||||
- [honghong.me](https://honghong.me) - Tszhong's personal website ([source code](https://github.com/tszhong0411/home))
|
||||
- [marceloformentao.dev](https://marceloformentao.dev) - Marcelo Formentão personal website ([source code](https://github.com/marceloavf/marceloformentao.dev)).
|
||||
- [abiraja.com](https://www.abiraja.com/) - with a [runnable JS code snippet component!](https://www.abiraja.com/blog/querying-solana-blockchain)
|
||||
- [bpiggin.com](https://www.bpiggin.com) - Ben Piggin's personal blog
|
||||
- [maqib.cn](https://maqib.cn) - A blog of Chinese front-end developers 狂奔小马的博客 ([源码](https://github.com/maqi1520/nextjs-tailwind-blog))
|
||||
- [ambilena.com](https://ambilena.com/) - Electronic Music Blog & imprint for upcoming musicians.
|
||||
- [dalelarroder.com](https://dalelarroder.com) - Dale Larroder's personal website and blog ([source code](https://github.com/dlarroder/dalelarroder))
|
||||
- [0xchai.io](https://0xchai.io) - Chai's personal blog
|
||||
- [techipedia](https://techipedia.vercel.app) - Simple blogging progressive web app with custom installation button and top progress bar
|
||||
- [reubence.com](https://reubence.com) - Reuben Rapose's Digital Garden
|
||||
- [axolo.co/blog](https://axolo.co/blog) - Engineering management news & axolo.co updates (with image preview for article in the home page)
|
||||
- [musing.vercel.app](https://musing.vercel.app/) - Parth Desai's personal blog ([source code](https://github.com/pycoder2000/blog))
|
||||
- [onyourmental.com](https://www.onyourmental.com/) - [Curtis Warcup's](https://github.com/Cwarcup) website for the On Your Mental Podcast ([source code](https://github.com/Cwarcup/on-your-mental))
|
||||
- [cwarcup.com](https://www.cwarcup.com/) - Curtis Warcup's personal website and blog ([source code](https://github.com/Cwarcup/personal-blog)).
|
||||
- [ondiek-elijah.me](https://www.ondiek-elijah.me/) - Ondiek Elijah's website and blog ([source code](https://github.com/Dev-Elie/ondiek-elijah)).
|
||||
- [jmalvarez.dev](https://www.jmalvarez.dev/) - José Miguel Álvarez's personal blog ([source code](https://github.com/josemiguel-alvarez/nextjs-blog))
|
||||
- [justingosses.com](https://justingosses.com/) - Justin Gosses's personal website and blog ([source code](https://github.com/JustinGOSSES/justingosses-website))
|
||||
- [https://bitoflearning-9a57.fly.dev/](https://bitoflearning-9a57.fly.dev/) - Sangeet Agarwal's personal blog, replatformed to [remix](https://remix.run/remix) using the [indie stack](https://github.com/remix-run/indie-stack) ([source code](https://github.com/SangeetAgarwal/bitoflearning))
|
||||
- [raphaelchelly.com](https://www.raphaelchelly.com/) - Raphaël Chelly's personal website and blog ([source code](https://github.com/raphaelchelly/raph_www))
|
||||
- [kaveh.page](https://kaveh.page) - Kaveh Tehrani's personal blog. Added tags directory, profile card, time-to-read on posts directory, etc.
|
||||
|
||||
## Motivation
|
||||
|
||||
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
|
||||
|
||||
I wanted it to be nearly as feature-rich as popular blogging templates like [beautiful-jekyll](https://github.com/daattali/beautiful-jekyll) and [Hugo Academic](https://github.com/wowchemy/wowchemy-hugo-modules) but with the best of React's ecosystem and current web development's best practices.
|
||||
|
||||
## Features
|
||||
|
||||
- Next.js with Typescript
|
||||
- [Contentlayer](https://www.contentlayer.dev/) to manage content logic
|
||||
- Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute
|
||||
- [MDX - write JSX in markdown documents!](https://mdxjs.com/)
|
||||
- Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/230805_BiDcBQ_4H7)
|
||||
- Lightweight, 85kB first load JS
|
||||
- Mobile-friendly view
|
||||
- Light and dark theme
|
||||
- Font optimization with [next/font](https://nextjs.org/docs/app/api-reference/components/font)
|
||||
- Integration with [pliny](https://github.com/timlrx/pliny) that provides:
|
||||
- Multiple analytics options including [Umami](https://umami.is/), [Plausible](https://plausible.io/), [Simple Analytics](https://simpleanalytics.com/), Posthog and Google Analytics
|
||||
- Comments via [Giscus](https://github.com/laymonage/giscus), [Utterances](https://github.com/utterance/utterances) or Disqus
|
||||
- Newsletter API and component with support for Mailchimp, Buttondown, Convertkit, Klaviyo, Revue, and Emailoctopus
|
||||
- Command palette search with [Kbar](https://github.com/timc1/kbar) or Algolia
|
||||
- Server-side syntax highlighting with line numbers and line highlighting via [rehype-prism-plus](https://github.com/timlrx/rehype-prism-plus)
|
||||
- Math display supported via [KaTeX](https://katex.org/)
|
||||
- Citation and bibliography support via [rehype-citation](https://github.com/timlrx/rehype-citation)
|
||||
- Automatic image optimization via [next/image](https://nextjs.org/docs/basic-features/image-optimization)
|
||||
- Support for tags - each unique tag will be its own page
|
||||
- Support for multiple authors
|
||||
- 3 different blog layouts
|
||||
- 2 different blog listing layouts
|
||||
- Support for nested routing of blog posts
|
||||
- Projects page
|
||||
- Preconfigured security headers
|
||||
- SEO friendly with RSS feed, sitemaps and more!
|
||||
|
||||
## Sample posts
|
||||
|
||||
- [A markdown guide](https://tailwind-nextjs-starter-blog.vercel.app/blog/github-markdown-guide)
|
||||
- [Learn more about images in Next.js](https://tailwind-nextjs-starter-blog.vercel.app/blog/guide-to-using-images-in-nextjs)
|
||||
- [A tour of math typesetting](https://tailwind-nextjs-starter-blog.vercel.app/blog/deriving-ols-estimator)
|
||||
- [Simple MDX image grid](https://tailwind-nextjs-starter-blog.vercel.app/blog/pictures-of-canada)
|
||||
- [Example of long prose](https://tailwind-nextjs-starter-blog.vercel.app/blog/the-time-machine)
|
||||
- [Example of Nested Route Post](https://tailwind-nextjs-starter-blog.vercel.app/blog/nested-route/introducing-multi-part-posts-with-nested-routing)
|
||||
|
||||
## Quick Start Guide
|
||||
|
||||
1. Clone the repo
|
||||
|
||||
```bash
|
||||
npx degit 'timlrx/tailwind-nextjs-starter-blog'
|
||||
```
|
||||
|
||||
2. Personalize `siteMetadata.js` (site related information)
|
||||
3. Modify the content security policy in `next.config.js` if you want to use
|
||||
other analytics provider or a commenting solution other than giscus.
|
||||
4. Personalize `authors/default.md` (main author)
|
||||
5. Modify `projectsData.js`
|
||||
6. Modify `headerNavLinks.js` to customize navigation links
|
||||
7. Add blog posts
|
||||
8. Deploy on Vercel
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
yarn
|
||||
```
|
||||
|
||||
## Development
|
||||
@ -18,18 +138,12 @@ pnpm install
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
Edit the layout in `app` or content in `data`. With live reloading, the pages auto-updates as you edit them.
|
||||
|
||||
## Extend / Customize
|
||||
|
||||
@ -47,36 +161,41 @@ You can start editing the page by modifying `pages/index.js`. The page auto-upda
|
||||
|
||||
`public/static` - store assets such as images and favicons.
|
||||
|
||||
`tailwind.config.js` and `css/tailwind.css` - contain the tailwind stylesheet which can be modified to change the overall look and feel of the site.
|
||||
`tailwind.config.js` and `css/tailwind.css` - tailwind configuration and stylesheet which can be modified to change the overall look and feel of the site.
|
||||
|
||||
`css/prism.css` - controls the styles associated with the code blocks. Feel free to customize it and use your preferred prismjs theme e.g. [prism themes](https://github.com/PrismJS/prism-themes).
|
||||
|
||||
`components/social-icons` - to add other icons, simply copy an svg file from [Simple Icons](https://simpleicons.org/) and map them in `index.js`. Other icons use [heroicons](https://heroicons.com/).
|
||||
`contentlayer.config.ts` - configuration for Contentlayer, including definition of content sources and MDX plugins used. See [Contentlayer documentation](https://www.contentlayer.dev/docs/getting-started) for more information.
|
||||
|
||||
`components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then call them directly in the `.mdx` or `.md` file. By default, a custom link and image component is passed.
|
||||
`components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then use them directly in the `.mdx` or `.md` file. By default, a custom link, `next/image` component, table of contents component and Newsletter form are passed down. Note that the components should be default exported to avoid [existing issues with Next.js](https://github.com/vercel/next.js/issues/51593).
|
||||
|
||||
`layouts` - main templates used in pages.
|
||||
`layouts` - main templates used in pages:
|
||||
|
||||
`pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information.
|
||||
- There are currently 3 post layouts available: `PostLayout`, `PostSimple` and `PostBanner`. `PostLayout` is the default 2 column layout with meta and author information. `PostSimple` is a simplified version of `PostLayout`, while `PostBanner` features a banner image.
|
||||
- There are 2 blog listing layouts: `ListLayout`, the layout used in version 1 of the template with a search bar and `ListLayoutWithTags`, currently used in version 2, which omits the search bar but includes a sidebar with information on the tags.
|
||||
|
||||
`app` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs/app) for more information.
|
||||
|
||||
`next.config.js` - configuration related to Next.js. You need to adapt the Content Security Policy if you want to load scripts, images etc. from other domains.
|
||||
|
||||
## Post
|
||||
|
||||
Content is modelled using [Contentlayer](https://www.contentlayer.dev/), which allows you to define your own content schema and use it to generate typed content objects. See [Contentlayer documentation](https://www.contentlayer.dev/docs/getting-started) for more information.
|
||||
|
||||
### Frontmatter
|
||||
|
||||
Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/).
|
||||
|
||||
Currently 7 fields are supported.
|
||||
Please refer to `contentlayer.config.ts` for an up to date list of supported fields. The following fields are supported:
|
||||
|
||||
```
|
||||
title (required)
|
||||
date (required)
|
||||
tags (required, can be empty array)
|
||||
tags (optional)
|
||||
lastmod (optional)
|
||||
draft (optional)
|
||||
summary (optional)
|
||||
images (optional, if none provided defaults to socialBanner in siteMetadata config)
|
||||
images (optional)
|
||||
authors (optional list which should correspond to the file names in `data/authors`. Uses `default` if none is specified)
|
||||
layout (optional list which should correspond to the file names in `data/layouts`)
|
||||
canonicalUrl (optional, canonical url for the post for SEO)
|
||||
@ -99,12 +218,29 @@ canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/introducing-t
|
||||
---
|
||||
```
|
||||
|
||||
### Compose
|
||||
|
||||
Run `node ./scripts/compose.js` to bootstrap a new post.
|
||||
|
||||
Follow the interactive prompt to generate a post with pre-filled front matter.
|
||||
|
||||
## Deploy
|
||||
|
||||
Drone CI.
|
||||
**Vercel**
|
||||
The easiest way to deploy the template is to deploy on [Vercel](https://vercel.com). Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
**Netlify**
|
||||
[Netlify](https://www.netlify.com/)’s Next.js runtime configures enables key Next.js functionality on your website without the need for additional configurations. Netlify generates serverless functions that will handle Next.js functionalities such as server-side rendered (SSR) pages, incremental static regeneration (ISR), `next/images`, etc.
|
||||
|
||||
See [Next.js on Netlify](https://docs.netlify.com/integrations/frameworks/next-js/overview/#next-js-runtime) for suggested configuration values and more details.
|
||||
|
||||
**Static hosting services / GitHub Pages / S3 / Firebase etc.**
|
||||
|
||||
1. Add `output: 'export'` in `next.config.js`. See [static exports documentation](https://nextjs.org/docs/app/building-your-application/deploying/static-exports#configuration) for more information.
|
||||
2. Comment out `headers()` from `next.config.js`.
|
||||
3. Change `components/Image.tsx` to use a standard `<img>` tag instead of `next/image`. Alternatively, to continue using `next/image`, you can use an alternative image optimization provider such as Imgix, Cloudinary or Akamai. See [image optimization documentation](https://nextjs.org/docs/app/building-your-application/deploying/static-exports#image-optimization) for more details.
|
||||
4. Remove `api` folder and components which call the server-side function such as the Newsletter component. Not technically required and the site will build successfully, but the APIs cannot be used as they are server-side functions.
|
||||
5. Run `yarn build`. The generated static content is in the `out` folder.
|
||||
6. Deploy the `out` folder to your hosting service of choice or run `npx serve out` to view the website locally.
|
||||
|
||||
## Support
|
||||
|
||||
Using the template? Support this effort by giving a star on GitHub, sharing your own blog and giving a shoutout on Twitter or becoming a project [sponsor](https://github.com/sponsors/timlrx).
|
||||
|
||||
## Licence
|
||||
|
||||
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/main/LICENSE) © [Timothy Lin](https://www.timlrx.com)
|
||||
|
@ -1,45 +1,27 @@
|
||||
import Link from '@/components/Link';
|
||||
import { PageSEO } from '@/components/SEO';
|
||||
import Tag from '@/components/Tag';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { getAllFilesFrontMatter } from '@/lib/mdx';
|
||||
import formatDate from '@/lib/utils/formatDate';
|
||||
import { GetStaticProps, InferGetStaticPropsType } from 'next';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import NewsletterForm from '@/components/NewsletterForm';
|
||||
import Link from '@/components/Link'
|
||||
import Tag from '@/components/Tag'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { formatDate } from 'pliny/utils/formatDate'
|
||||
import NewsletterForm from 'pliny/ui/NewsletterForm'
|
||||
|
||||
const MAX_DISPLAY = 5;
|
||||
const MAX_DISPLAY = 5
|
||||
|
||||
export const getStaticProps: GetStaticProps<{
|
||||
posts: PostFrontMatter[];
|
||||
}> = async () => {
|
||||
const posts = await getAllFilesFrontMatter('blog');
|
||||
|
||||
return { props: { posts } };
|
||||
};
|
||||
|
||||
export default function Home({
|
||||
posts,
|
||||
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
export default function Home({ posts }) {
|
||||
return (
|
||||
<>
|
||||
<PageSEO
|
||||
title={siteMetadata.title}
|
||||
description={siteMetadata.description}
|
||||
/>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||
最近发布的文章
|
||||
Latest
|
||||
</h1>
|
||||
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
|
||||
{siteMetadata.description}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{!posts.length && '没有找到文章。 😭'}
|
||||
{posts.slice(0, MAX_DISPLAY).map((frontMatter) => {
|
||||
const { slug, date, title, summary, tags } = frontMatter;
|
||||
{!posts.length && 'No posts found.'}
|
||||
{posts.slice(0, MAX_DISPLAY).map((post) => {
|
||||
const { slug, date, title, summary, tags } = post
|
||||
return (
|
||||
<li key={slug} className="py-12">
|
||||
<article>
|
||||
@ -47,7 +29,7 @@ export default function Home({
|
||||
<dl>
|
||||
<dt className="sr-only">Published on</dt>
|
||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={date}>{formatDate(date)}</time>
|
||||
<time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time>
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="space-y-5 xl:col-span-3">
|
||||
@ -56,7 +38,8 @@ export default function Home({
|
||||
<h2 className="text-2xl font-bold leading-8 tracking-tight">
|
||||
<Link
|
||||
href={`/blog/${slug}`}
|
||||
className="text-gray-900 dark:text-gray-100">
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</h2>
|
||||
@ -74,7 +57,8 @@ export default function Home({
|
||||
<Link
|
||||
href={`/blog/${slug}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label={`Read "${title}"`}>
|
||||
aria-label={`Read "${title}"`}
|
||||
>
|
||||
Read more →
|
||||
</Link>
|
||||
</div>
|
||||
@ -82,7 +66,7 @@ export default function Home({
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
@ -91,16 +75,17 @@ export default function Home({
|
||||
<Link
|
||||
href="/blog"
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label="all posts">
|
||||
aria-label="All posts"
|
||||
>
|
||||
All Posts →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{siteMetadata.newsletter.provider !== '' && (
|
||||
{siteMetadata.newsletter?.provider && (
|
||||
<div className="flex items-center justify-center pt-4">
|
||||
<NewsletterForm />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
20
app/about/page.tsx
Normal file
20
app/about/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Authors, allAuthors } from 'contentlayer/generated'
|
||||
import { MDXLayoutRenderer } from 'pliny/mdx-components'
|
||||
import AuthorLayout from '@/layouts/AuthorLayout'
|
||||
import { coreContent } from 'pliny/utils/contentlayer'
|
||||
import { genPageMetadata } from 'app/seo'
|
||||
|
||||
export const metadata = genPageMetadata({ title: 'About' })
|
||||
|
||||
export default function Page() {
|
||||
const author = allAuthors.find((p) => p.slug === 'default') as Authors
|
||||
const mainContent = coreContent(author)
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthorLayout content={mainContent}>
|
||||
<MDXLayoutRenderer code={author.body.code} />
|
||||
</AuthorLayout>
|
||||
</>
|
||||
)
|
||||
}
|
9
app/api/newsletter/route.ts
Normal file
9
app/api/newsletter/route.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { NewsletterAPI } from 'pliny/newsletter'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const handler = NewsletterAPI({
|
||||
// @ts-ignore
|
||||
provider: siteMetadata.newsletter.provider,
|
||||
})
|
||||
|
||||
export { handler as GET, handler as POST }
|
130
app/blog/[...slug]/page.tsx
Normal file
130
app/blog/[...slug]/page.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import 'css/prism.css'
|
||||
import 'katex/dist/katex.css'
|
||||
|
||||
import PageTitle from '@/components/PageTitle'
|
||||
import { components } from '@/components/MDXComponents'
|
||||
import { MDXLayoutRenderer } from 'pliny/mdx-components'
|
||||
import { sortPosts, coreContent } from 'pliny/utils/contentlayer'
|
||||
import { allBlogs, allAuthors } from 'contentlayer/generated'
|
||||
import type { Authors, Blog } from 'contentlayer/generated'
|
||||
import PostSimple from '@/layouts/PostSimple'
|
||||
import PostLayout from '@/layouts/PostLayout'
|
||||
import PostBanner from '@/layouts/PostBanner'
|
||||
import { Metadata } from 'next'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production'
|
||||
const defaultLayout = 'PostLayout'
|
||||
const layouts = {
|
||||
PostSimple,
|
||||
PostLayout,
|
||||
PostBanner,
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string[] }
|
||||
}): Promise<Metadata | undefined> {
|
||||
const slug = decodeURI(params.slug.join('/'))
|
||||
const post = allBlogs.find((p) => p.slug === slug)
|
||||
const authorList = post?.authors || ['default']
|
||||
const authorDetails = authorList.map((author) => {
|
||||
const authorResults = allAuthors.find((p) => p.slug === author)
|
||||
return coreContent(authorResults as Authors)
|
||||
})
|
||||
if (!post) {
|
||||
return
|
||||
}
|
||||
|
||||
const publishedAt = new Date(post.date).toISOString()
|
||||
const modifiedAt = new Date(post.lastmod || post.date).toISOString()
|
||||
const authors = authorDetails.map((author) => author.name)
|
||||
let imageList = [siteMetadata.socialBanner]
|
||||
if (post.images) {
|
||||
imageList = typeof post.images === 'string' ? [post.images] : post.images
|
||||
}
|
||||
const ogImages = imageList.map((img) => {
|
||||
return {
|
||||
url: img.includes('http') ? img : siteMetadata.siteUrl + img,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.summary,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.summary,
|
||||
siteName: siteMetadata.title,
|
||||
locale: 'en_US',
|
||||
type: 'article',
|
||||
publishedTime: publishedAt,
|
||||
modifiedTime: modifiedAt,
|
||||
url: './',
|
||||
images: ogImages,
|
||||
authors: authors.length > 0 ? authors : [siteMetadata.author],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: post.title,
|
||||
description: post.summary,
|
||||
images: imageList,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const generateStaticParams = async () => {
|
||||
const paths = allBlogs.map((p) => ({ slug: p.slug.split('/') }))
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
export default async function Page({ params }: { params: { slug: string[] } }) {
|
||||
const slug = decodeURI(params.slug.join('/'))
|
||||
const sortedPosts = sortPosts(allBlogs) as Blog[]
|
||||
const postIndex = sortedPosts.findIndex((p) => p.slug === slug)
|
||||
const prev = coreContent(sortedPosts[postIndex + 1])
|
||||
const next = coreContent(sortedPosts[postIndex - 1])
|
||||
const post = sortedPosts.find((p) => p.slug === slug) as Blog
|
||||
const authorList = post?.authors || ['default']
|
||||
const authorDetails = authorList.map((author) => {
|
||||
const authorResults = allAuthors.find((p) => p.slug === author)
|
||||
return coreContent(authorResults as Authors)
|
||||
})
|
||||
const mainContent = coreContent(post)
|
||||
const jsonLd = post.structuredData
|
||||
jsonLd['author'] = authorDetails.map((author) => {
|
||||
return {
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
}
|
||||
})
|
||||
|
||||
const Layout = layouts[post.layout || defaultLayout]
|
||||
|
||||
return (
|
||||
<>
|
||||
{isProduction && post && 'draft' in post && post.draft === true ? (
|
||||
<div className="mt-24 text-center">
|
||||
<PageTitle>
|
||||
Under Construction{' '}
|
||||
<span role="img" aria-label="roadwork sign">
|
||||
🚧
|
||||
</span>
|
||||
</PageTitle>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<Layout content={mainContent} authorDetails={authorDetails} next={next} prev={prev}>
|
||||
<MDXLayoutRenderer code={post.body.code} components={components} toc={post.toc} />
|
||||
</Layout>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
30
app/blog/page.tsx
Normal file
30
app/blog/page.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import ListLayout from '@/layouts/ListLayoutWithTags'
|
||||
import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer'
|
||||
import { allBlogs } from 'contentlayer/generated'
|
||||
import { genPageMetadata } from 'app/seo'
|
||||
|
||||
const POSTS_PER_PAGE = 5
|
||||
|
||||
export const metadata = genPageMetadata({ title: 'Blog' })
|
||||
|
||||
export default function BlogPage() {
|
||||
const posts = allCoreContent(sortPosts(allBlogs))
|
||||
const pageNumber = 1
|
||||
const initialDisplayPosts = posts.slice(
|
||||
POSTS_PER_PAGE * (pageNumber - 1),
|
||||
POSTS_PER_PAGE * pageNumber
|
||||
)
|
||||
const pagination = {
|
||||
currentPage: pageNumber,
|
||||
totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
|
||||
}
|
||||
|
||||
return (
|
||||
<ListLayout
|
||||
posts={posts}
|
||||
initialDisplayPosts={initialDisplayPosts}
|
||||
pagination={pagination}
|
||||
title="All Posts"
|
||||
/>
|
||||
)
|
||||
}
|
34
app/blog/page/[page]/page.tsx
Normal file
34
app/blog/page/[page]/page.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import ListLayout from '@/layouts/ListLayoutWithTags'
|
||||
import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer'
|
||||
import { allBlogs } from 'contentlayer/generated'
|
||||
|
||||
const POSTS_PER_PAGE = 5
|
||||
|
||||
export const generateStaticParams = async () => {
|
||||
const totalPages = Math.ceil(allBlogs.length / POSTS_PER_PAGE)
|
||||
const paths = Array.from({ length: totalPages }, (_, i) => ({ page: (i + 1).toString() }))
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: { page: string } }) {
|
||||
const posts = allCoreContent(sortPosts(allBlogs))
|
||||
const pageNumber = parseInt(params.page as string)
|
||||
const initialDisplayPosts = posts.slice(
|
||||
POSTS_PER_PAGE * (pageNumber - 1),
|
||||
POSTS_PER_PAGE * pageNumber
|
||||
)
|
||||
const pagination = {
|
||||
currentPage: pageNumber,
|
||||
totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
|
||||
}
|
||||
|
||||
return (
|
||||
<ListLayout
|
||||
posts={posts}
|
||||
initialDisplayPosts={initialDisplayPosts}
|
||||
pagination={pagination}
|
||||
title="All Posts"
|
||||
/>
|
||||
)
|
||||
}
|
16
app/head.tsx
Normal file
16
app/head.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
export default function Head() {
|
||||
return (
|
||||
<>
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/static/favicons/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicons/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicons/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/static/favicons/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5" />
|
||||
<meta name="msapplication-TileColor" content="#000000" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000" />
|
||||
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
</>
|
||||
)
|
||||
}
|
92
app/layout.tsx
Normal file
92
app/layout.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import 'css/tailwind.css'
|
||||
import 'pliny/search/algolia.css'
|
||||
|
||||
import { Space_Grotesk } from 'next/font/google'
|
||||
import { Analytics, AnalyticsConfig } from 'pliny/analytics'
|
||||
import { SearchProvider, SearchConfig } from 'pliny/search'
|
||||
import Header from '@/components/Header'
|
||||
import SectionContainer from '@/components/SectionContainer'
|
||||
import Footer from '@/components/Footer'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { ThemeProviders } from './theme-providers'
|
||||
import { Metadata } from 'next'
|
||||
|
||||
const space_grotesk = Space_Grotesk({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--font-space-grotesk',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteMetadata.siteUrl),
|
||||
title: {
|
||||
default: siteMetadata.title,
|
||||
template: `%s | ${siteMetadata.title}`,
|
||||
},
|
||||
description: siteMetadata.description,
|
||||
openGraph: {
|
||||
title: siteMetadata.title,
|
||||
description: siteMetadata.description,
|
||||
url: './',
|
||||
siteName: siteMetadata.title,
|
||||
images: [siteMetadata.socialBanner],
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
alternates: {
|
||||
canonical: './',
|
||||
types: {
|
||||
'application/rss+xml': `${siteMetadata.siteUrl}/feed.xml`,
|
||||
},
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
twitter: {
|
||||
title: siteMetadata.title,
|
||||
card: 'summary_large_image',
|
||||
images: [siteMetadata.socialBanner],
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
lang={siteMetadata.language}
|
||||
className={`${space_grotesk.variable} scroll-smooth`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/static/favicons/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicons/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicons/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/static/favicons/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/static/favicons/safari-pinned-tab.svg" color="#5bbad5" />
|
||||
<meta name="msapplication-TileColor" content="#000000" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000" />
|
||||
<link rel="alternate" type="application/rss+xml" href="/feed.xml" />
|
||||
<body className="bg-white text-black antialiased dark:bg-gray-950 dark:text-white">
|
||||
<ThemeProviders>
|
||||
<Analytics analyticsConfig={siteMetadata.analytics as AnalyticsConfig} />
|
||||
<SectionContainer>
|
||||
<div className="flex h-screen flex-col justify-between font-sans">
|
||||
<SearchProvider searchConfig={siteMetadata.search as SearchConfig}>
|
||||
<Header />
|
||||
<main className="mb-auto">{children}</main>
|
||||
</SearchProvider>
|
||||
<Footer />
|
||||
</div>
|
||||
</SectionContainer>
|
||||
</ThemeProviders>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
25
app/not-found.tsx
Normal file
25
app/not-found.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import Link from '@/components/Link'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6">
|
||||
<div className="space-x-2 pb-8 pt-6 md:space-y-5">
|
||||
<h1 className="text-6xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 md:border-r-2 md:px-6 md:text-8xl md:leading-14">
|
||||
404
|
||||
</h1>
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<p className="mb-4 text-xl font-bold leading-normal md:text-2xl">
|
||||
Sorry we couldn't find this page.
|
||||
</p>
|
||||
<p className="mb-8">But dont worry, you can find plenty of other things on our homepage.</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500"
|
||||
>
|
||||
Back to homepage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
9
app/page.tsx
Normal file
9
app/page.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { sortPosts, allCoreContent } from 'pliny/utils/contentlayer'
|
||||
import { allBlogs } from 'contentlayer/generated'
|
||||
import Main from './Main'
|
||||
|
||||
export default async function Page() {
|
||||
const sortedPosts = sortPosts(allBlogs)
|
||||
const posts = allCoreContent(sortedPosts)
|
||||
return <Main posts={posts} />
|
||||
}
|
@ -1,22 +1,19 @@
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import projectsData from '@/data/projectsData';
|
||||
import Card from '@/components/Card';
|
||||
import { PageSEO } from '@/components/SEO';
|
||||
import projectsData from '@/data/projectsData'
|
||||
import Card from '@/components/Card'
|
||||
import { genPageMetadata } from 'app/seo'
|
||||
|
||||
export const metadata = genPageMetadata({ title: 'Projects' })
|
||||
|
||||
export default function Projects() {
|
||||
return (
|
||||
<>
|
||||
<PageSEO
|
||||
title={`Projects - ${siteMetadata.author}`}
|
||||
description={siteMetadata.description}
|
||||
/>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||
Projects
|
||||
</h1>
|
||||
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
|
||||
我的项目橱窗,欢迎交流。
|
||||
Showcase your projects with a hero image (16 x 9)
|
||||
</p>
|
||||
</div>
|
||||
<div className="container py-12">
|
||||
@ -34,5 +31,5 @@ export default function Projects() {
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
13
app/robots.ts
Normal file
13
app/robots.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
},
|
||||
sitemap: `${siteMetadata.siteUrl}/sitemap.xml`,
|
||||
host: siteMetadata.siteUrl,
|
||||
}
|
||||
}
|
31
app/seo.tsx
Normal file
31
app/seo.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { Metadata } from 'next'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
interface PageSEOProps {
|
||||
title: string
|
||||
description?: string
|
||||
image?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export function genPageMetadata({ title, description, image, ...rest }: PageSEOProps): Metadata {
|
||||
return {
|
||||
title,
|
||||
openGraph: {
|
||||
title: `${title} | ${siteMetadata.title}`,
|
||||
description: description || siteMetadata.description,
|
||||
url: './',
|
||||
siteName: siteMetadata.title,
|
||||
images: image ? [image] : [siteMetadata.socialBanner],
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
title: `${title} | ${siteMetadata.title}`,
|
||||
card: 'summary_large_image',
|
||||
images: image ? [image] : [siteMetadata.socialBanner],
|
||||
},
|
||||
...rest,
|
||||
}
|
||||
}
|
18
app/sitemap.ts
Normal file
18
app/sitemap.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import { allBlogs } from 'contentlayer/generated'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const siteUrl = siteMetadata.siteUrl
|
||||
const blogRoutes = allBlogs.map((post) => ({
|
||||
url: `${siteUrl}/${post.path}`,
|
||||
lastModified: post.lastmod || post.date,
|
||||
}))
|
||||
|
||||
const routes = ['', 'blog', 'projects', 'tags'].map((route) => ({
|
||||
url: `${siteUrl}/${route}`,
|
||||
lastModified: new Date().toISOString().split('T')[0],
|
||||
}))
|
||||
|
||||
return [...routes, ...blogRoutes]
|
||||
}
|
1
app/tag-data.json
Normal file
1
app/tag-data.json
Normal file
@ -0,0 +1 @@
|
||||
{"arch-linux":3,"环境搭建":3,"vps":3,"linux":1,"pve":2,"xray":2,"acme":1,"acmesh":1,"docker":3,"docker-compose":1,"内网穿透":1,"github-actions":1,"cicd":1,"de":1,"debian":1,"blog":1,"markdown":1,"nextjs":1,"tailwind-css":1,"verdaccio":1,"self-hosted":3,"caddy":2,"registry":1,"nodejs":1,"zerotier":1,"tailscale":1,"sd-wan":1,"nat":1,"frp":1,"react":1,"baas":1,"appwrite":1,"nhost":1,"supabase":1,"sni":1,"tls":1,"reverse-proxy":1,"反向代理":1,"vless":1}
|
43
app/tags/[tag]/page.tsx
Normal file
43
app/tags/[tag]/page.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { slug } from 'github-slugger'
|
||||
import { allCoreContent } from 'pliny/utils/contentlayer'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import ListLayout from '@/layouts/ListLayoutWithTags'
|
||||
import { allBlogs } from 'contentlayer/generated'
|
||||
import tagData from 'app/tag-data.json'
|
||||
import { genPageMetadata } from 'app/seo'
|
||||
import { Metadata } from 'next'
|
||||
|
||||
export async function generateMetadata({ params }: { params: { tag: string } }): Promise<Metadata> {
|
||||
const tag = params.tag
|
||||
return genPageMetadata({
|
||||
title: tag,
|
||||
description: `${siteMetadata.title} ${tag} tagged content`,
|
||||
alternates: {
|
||||
canonical: './',
|
||||
types: {
|
||||
'application/rss+xml': `${siteMetadata.siteUrl}/tags/${tag}/feed.xml`,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const generateStaticParams = async () => {
|
||||
const tagCounts = tagData as Record<string, number>
|
||||
const tagKeys = Object.keys(tagCounts)
|
||||
const paths = tagKeys.map((tag) => ({
|
||||
tag: tag,
|
||||
}))
|
||||
return paths
|
||||
}
|
||||
|
||||
export default function TagPage({ params }: { params: { tag: string } }) {
|
||||
const { tag } = params
|
||||
// Capitalize first letter and convert space to dash
|
||||
const title = tag[0].toUpperCase() + tag.split(' ').join('-').slice(1)
|
||||
const filteredPosts = allCoreContent(
|
||||
allBlogs.filter(
|
||||
(post) => post.draft !== true && post.tags && post.tags.map((t) => slug(t)).includes(tag)
|
||||
)
|
||||
)
|
||||
return <ListLayout posts={filteredPosts} title={title} />
|
||||
}
|
41
app/tags/page.tsx
Normal file
41
app/tags/page.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import Link from '@/components/Link'
|
||||
import Tag from '@/components/Tag'
|
||||
import { slug } from 'github-slugger'
|
||||
import tagData from 'app/tag-data.json'
|
||||
import { genPageMetadata } from 'app/seo'
|
||||
|
||||
export const metadata = genPageMetadata({ title: 'Tags', description: 'Things I blog about' })
|
||||
|
||||
export default async function Page() {
|
||||
const tagCounts = tagData as Record<string, number>
|
||||
const tagKeys = Object.keys(tagCounts)
|
||||
const sortedTags = tagKeys.sort((a, b) => tagCounts[b] - tagCounts[a])
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start justify-start divide-y divide-gray-200 dark:divide-gray-700 md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6 md:divide-y-0">
|
||||
<div className="space-x-2 pb-8 pt-6 md:space-y-5">
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:border-r-2 md:px-6 md:text-6xl md:leading-14">
|
||||
Tags
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex max-w-lg flex-wrap">
|
||||
{tagKeys.length === 0 && 'No tags found.'}
|
||||
{sortedTags.map((t) => {
|
||||
return (
|
||||
<div key={t} className="mb-2 mr-5 mt-2">
|
||||
<Tag text={t} />
|
||||
<Link
|
||||
href={`/tags/${slug(t)}`}
|
||||
className="-ml-2 text-sm font-semibold uppercase text-gray-600 dark:text-gray-300"
|
||||
aria-label={`View posts tagged ${t}`}
|
||||
>
|
||||
{` (${tagCounts[t]})`}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
12
app/theme-providers.tsx
Normal file
12
app/theme-providers.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
export function ThemeProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme={siteMetadata.theme} enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
@ -1,12 +1,13 @@
|
||||
import Image from './Image';
|
||||
import Link from './Link';
|
||||
import Image from './Image'
|
||||
import Link from './Link'
|
||||
|
||||
const Card = ({ title, description, imgSrc, href }) => (
|
||||
<div className="md p-4 md:w-1/2" style={{ maxWidth: '544px' }}>
|
||||
<div className="md max-w-[544px] p-4 md:w-1/2">
|
||||
<div
|
||||
className={`${
|
||||
imgSrc && 'h-full'
|
||||
} overflow-hidden rounded-md border-2 border-gray-200 border-opacity-60 dark:border-gray-700`}>
|
||||
} overflow-hidden rounded-md border-2 border-gray-200 border-opacity-60 dark:border-gray-700`}
|
||||
>
|
||||
{imgSrc &&
|
||||
(href ? (
|
||||
<Link href={href} aria-label={`Link to ${title}`}>
|
||||
@ -37,20 +38,19 @@ const Card = ({ title, description, imgSrc, href }) => (
|
||||
title
|
||||
)}
|
||||
</h2>
|
||||
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
<p className="prose mb-3 max-w-none text-gray-500 dark:text-gray-400">{description}</p>
|
||||
{href && (
|
||||
<Link
|
||||
href={href}
|
||||
className="text-base font-medium leading-6 text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label={`Link to ${title}`}>
|
||||
aria-label={`Link to ${title}`}
|
||||
>
|
||||
Learn more →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
|
||||
export default Card;
|
||||
export default Card
|
||||
|
@ -1,23 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import Router from 'next/router';
|
||||
|
||||
/**
|
||||
* Client-side complement to next-remote-watch
|
||||
* Re-triggers getStaticProps when watched mdx files change
|
||||
*
|
||||
*/
|
||||
export const ClientReload = () => {
|
||||
// Exclude socket.io from prod bundle
|
||||
useEffect(() => {
|
||||
import('socket.io-client').then((module) => {
|
||||
const socket = module.io();
|
||||
socket.on('reload', () => {
|
||||
Router.replace(Router.asPath, undefined, {
|
||||
scroll: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
17
components/Comments.tsx
Normal file
17
components/Comments.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { Comments as CommentsComponent } from 'pliny/comments'
|
||||
import { useState } from 'react'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
export default function Comments({ slug }: { slug: string }) {
|
||||
const [loadComments, setLoadComments] = useState(false)
|
||||
return (
|
||||
<>
|
||||
{!loadComments && <button onClick={() => setLoadComments(true)}>Load Comments</button>}
|
||||
{siteMetadata.comments && loadComments && (
|
||||
<CommentsComponent commentsConfig={siteMetadata.comments} slug={slug} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,17 +1,13 @@
|
||||
import Link from './Link';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import SocialIcon from '@/components/social-icons';
|
||||
import Link from './Link'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import SocialIcon from '@/components/social-icons'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer>
|
||||
<div className="mt-16 flex flex-col items-center">
|
||||
<div className="mb-3 flex space-x-4">
|
||||
<SocialIcon
|
||||
kind="mail"
|
||||
href={`mailto:${siteMetadata.email}`}
|
||||
size={6}
|
||||
/>
|
||||
<SocialIcon kind="mail" href={`mailto:${siteMetadata.email}`} size={6} />
|
||||
<SocialIcon kind="github" href={siteMetadata.github} size={6} />
|
||||
<SocialIcon kind="facebook" href={siteMetadata.facebook} size={6} />
|
||||
<SocialIcon kind="youtube" href={siteMetadata.youtube} size={6} />
|
||||
@ -31,13 +27,11 @@ export default function Footer() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mb-8 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Link
|
||||
href="https://github.com/timlrx/tailwind-nextjs-starter-blog"
|
||||
rel="nofollow">
|
||||
<Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
|
||||
Tailwind Nextjs Theme
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
48
components/Header.tsx
Normal file
48
components/Header.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import headerNavLinks from '@/data/headerNavLinks'
|
||||
import Logo from '@/data/logo.svg'
|
||||
import Link from './Link'
|
||||
import MobileNav from './MobileNav'
|
||||
import ThemeSwitch from './ThemeSwitch'
|
||||
import SearchButton from './SearchButton'
|
||||
|
||||
const Header = () => {
|
||||
return (
|
||||
<header className="flex items-center justify-between py-10">
|
||||
<div>
|
||||
<Link href="/" aria-label={siteMetadata.headerTitle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-3">
|
||||
<Logo />
|
||||
</div>
|
||||
{typeof siteMetadata.headerTitle === 'string' ? (
|
||||
<div className="hidden h-6 text-2xl font-semibold sm:block">
|
||||
{siteMetadata.headerTitle}
|
||||
</div>
|
||||
) : (
|
||||
siteMetadata.headerTitle
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center leading-5 space-x-4 sm:space-x-6">
|
||||
{headerNavLinks
|
||||
.filter((link) => link.href !== '/')
|
||||
.map((link) => (
|
||||
<Link
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
className="hidden sm:block font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
<SearchButton />
|
||||
<ThemeSwitch />
|
||||
<MobileNav />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
@ -1,5 +1,5 @@
|
||||
import NextImage, { ImageProps } from 'next/image';
|
||||
import NextImage, { ImageProps } from 'next/image'
|
||||
|
||||
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />;
|
||||
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />
|
||||
|
||||
export default Image;
|
||||
export default Image
|
||||
|
@ -1,58 +1,27 @@
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import headerNavLinks from '@/data/headerNavLinks';
|
||||
import Logo from '@/data/logo.svg';
|
||||
import Link from './Link';
|
||||
import SectionContainer from './SectionContainer';
|
||||
import Footer from './Footer';
|
||||
import MobileNav from './MobileNav';
|
||||
import ThemeSwitch from './ThemeSwitch';
|
||||
import { ReactNode } from 'react';
|
||||
import { Inter } from 'next/font/google'
|
||||
import SectionContainer from './SectionContainer'
|
||||
import Footer from './Footer'
|
||||
import { ReactNode } from 'react'
|
||||
import Header from './Header'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
})
|
||||
|
||||
const LayoutWrapper = ({ children }: Props) => {
|
||||
return (
|
||||
<SectionContainer>
|
||||
<div className="flex h-screen flex-col justify-between">
|
||||
<header className="flex items-center justify-between py-10">
|
||||
<div>
|
||||
<Link href="/" aria-label={siteMetadata.headerTitle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-3">
|
||||
<Logo />
|
||||
</div>
|
||||
{typeof siteMetadata.headerTitle === 'string' ? (
|
||||
<div className="hidden h-6 text-2xl font-semibold sm:block">
|
||||
{siteMetadata.headerTitle}
|
||||
</div>
|
||||
) : (
|
||||
siteMetadata.headerTitle
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center text-base leading-5">
|
||||
<div className="hidden sm:block">
|
||||
{headerNavLinks.map((link) => (
|
||||
<Link
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4">
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<ThemeSwitch />
|
||||
<MobileNav />
|
||||
</div>
|
||||
</header>
|
||||
<div className={`${inter.className} flex h-screen flex-col justify-between font-sans`}>
|
||||
<Header />
|
||||
<main className="mb-auto">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</SectionContainer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutWrapper;
|
||||
export default LayoutWrapper
|
||||
|
@ -1,30 +1,21 @@
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import Link from 'next/link';
|
||||
import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react';
|
||||
import Link from 'next/link'
|
||||
import type { LinkProps } from 'next/link'
|
||||
import { AnchorHTMLAttributes } from 'react'
|
||||
|
||||
const CustomLink = ({
|
||||
href,
|
||||
...rest
|
||||
}: DetailedHTMLProps<
|
||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>) => {
|
||||
const isInternalLink = href && href.startsWith('/');
|
||||
const isAnchorLink = href && href.startsWith('#');
|
||||
const CustomLink = ({ href, ...rest }: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
const isInternalLink = href && href.startsWith('/')
|
||||
const isAnchorLink = href && href.startsWith('#')
|
||||
|
||||
if (isInternalLink) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<a {...rest} />
|
||||
</Link>
|
||||
);
|
||||
return <Link href={href} {...rest} />
|
||||
}
|
||||
|
||||
if (isAnchorLink) {
|
||||
return <a href={href} {...rest} />;
|
||||
return <a href={href} {...rest} />
|
||||
}
|
||||
|
||||
return <a target="_blank" rel="noopener noreferrer" href={href} {...rest} />;
|
||||
};
|
||||
return <a target="_blank" rel="noopener noreferrer" href={href} {...rest} />
|
||||
}
|
||||
|
||||
export default CustomLink;
|
||||
export default CustomLink
|
||||
|
@ -1,39 +1,14 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import React, { useMemo } from 'react';
|
||||
import { ComponentMap, getMDXComponent } from 'mdx-bundler/client';
|
||||
import Image from './Image';
|
||||
import CustomLink from './Link';
|
||||
import TOCInline from './TOCInline';
|
||||
import Pre from './Pre';
|
||||
import { BlogNewsletterForm } from './NewsletterForm';
|
||||
import TOCInline from 'pliny/ui/TOCInline'
|
||||
import Pre from 'pliny/ui/Pre'
|
||||
import BlogNewsletterForm from 'pliny/ui/BlogNewsletterForm'
|
||||
import type { MDXComponents } from 'mdx/types'
|
||||
import Image from './Image'
|
||||
import CustomLink from './Link'
|
||||
|
||||
const Wrapper: React.ComponentType<{ layout: string }> = ({
|
||||
layout,
|
||||
...rest
|
||||
}) => {
|
||||
const Layout = require(`../layouts/${layout}`).default;
|
||||
return <Layout {...rest} />;
|
||||
};
|
||||
|
||||
export const MDXComponents: ComponentMap = {
|
||||
export const components: MDXComponents = {
|
||||
Image,
|
||||
//@ts-ignore
|
||||
TOCInline,
|
||||
a: CustomLink,
|
||||
pre: Pre,
|
||||
wrapper: Wrapper,
|
||||
//@ts-ignore
|
||||
BlogNewsletterForm,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
layout: string;
|
||||
mdxSource: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }: Props) => {
|
||||
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource]);
|
||||
|
||||
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />;
|
||||
};
|
||||
|
@ -1,73 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import Link from './Link';
|
||||
import headerNavLinks from '@/data/headerNavLinks';
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from './Link'
|
||||
import headerNavLinks from '@/data/headerNavLinks'
|
||||
|
||||
const MobileNav = () => {
|
||||
const [navShow, setNavShow] = useState(false);
|
||||
const [navShow, setNavShow] = useState(false)
|
||||
|
||||
const onToggleNav = () => {
|
||||
setNavShow((status) => {
|
||||
if (status) {
|
||||
document.body.style.overflow = 'auto';
|
||||
document.body.style.overflow = 'auto'
|
||||
} else {
|
||||
// Prevent scrolling
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return !status;
|
||||
});
|
||||
};
|
||||
return !status
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sm:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mr-1 h-8 w-8 rounded py-1"
|
||||
aria-label="Toggle Menu"
|
||||
onClick={onToggleNav}>
|
||||
<>
|
||||
<button aria-label="Toggle Menu" onClick={onToggleNav} className="sm:hidden">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100">
|
||||
{navShow ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
)}
|
||||
className="text-gray-900 dark:text-gray-100 h-8 w-8"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className={`fixed top-24 right-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
|
||||
className={`fixed left-0 top-0 z-10 h-full w-full transform opacity-95 dark:opacity-[0.98] bg-white duration-300 ease-in-out dark:bg-gray-950 ${
|
||||
navShow ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="toggle modal"
|
||||
className="fixed h-full w-full cursor-auto focus:outline-none"
|
||||
onClick={onToggleNav}></button>
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-end">
|
||||
<button className="mr-8 mt-11 h-8 w-8" aria-label="Toggle Menu" onClick={onToggleNav}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="fixed mt-8 h-full">
|
||||
{headerNavLinks.map((link) => (
|
||||
<div key={link.title} className="px-12 py-4">
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-2xl font-bold tracking-widest text-gray-900 dark:text-gray-100"
|
||||
onClick={onToggleNav}>
|
||||
onClick={onToggleNav}
|
||||
>
|
||||
{link.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileNav;
|
||||
export default MobileNav
|
||||
|
@ -1,93 +0,0 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
|
||||
const inputEl = useRef<HTMLInputElement>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [subscribed, setSubscribed] = useState(false);
|
||||
|
||||
const subscribe = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const res = await fetch(`/api/${siteMetadata.newsletter.provider}`, {
|
||||
body: JSON.stringify({
|
||||
email: inputEl.current.value,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const { error } = await res.json();
|
||||
if (error) {
|
||||
setError(true);
|
||||
setMessage(
|
||||
'Your e-mail address is invalid or you are already subscribed!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
inputEl.current.value = '';
|
||||
setError(false);
|
||||
setSubscribed(true);
|
||||
setMessage('Successfully! 🎉 You are now subscribed.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="pb-1 text-lg font-semibold text-gray-800 dark:text-gray-100">
|
||||
{title}
|
||||
</div>
|
||||
<form className="flex flex-col sm:flex-row" onSubmit={subscribe}>
|
||||
<div>
|
||||
<label className="sr-only" htmlFor="email-input">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
autoComplete="email"
|
||||
className="w-72 rounded-md px-4 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600 dark:bg-black"
|
||||
id="email-input"
|
||||
name="email"
|
||||
placeholder={
|
||||
subscribed ? "You're subscribed ! 🎉" : 'Enter your email'
|
||||
}
|
||||
ref={inputEl}
|
||||
required
|
||||
type="email"
|
||||
disabled={subscribed}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex w-full rounded-md shadow-sm sm:mt-0 sm:ml-3">
|
||||
<button
|
||||
className={`w-full rounded-md bg-primary-500 py-2 px-4 font-medium text-white sm:py-0 ${
|
||||
subscribed
|
||||
? 'cursor-default'
|
||||
: 'hover:bg-primary-700 dark:hover:bg-primary-400'
|
||||
} focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2 dark:ring-offset-black`}
|
||||
type="submit"
|
||||
disabled={subscribed}>
|
||||
{subscribed ? 'Thank you!' : 'Sign up'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="w-72 pt-2 text-sm text-red-500 dark:text-red-400 sm:w-96">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsletterForm;
|
||||
|
||||
export const BlogNewsletterForm = ({ title }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="bg-gray-100 p-6 dark:bg-gray-800 sm:px-14 sm:py-8">
|
||||
<NewsletterForm title={title} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
@ -1,7 +1,7 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function PageTitle({ children }: Props) {
|
||||
@ -9,5 +9,5 @@ export default function PageTitle({ children }: Props) {
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-5xl md:leading-14">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,48 +0,0 @@
|
||||
import Link from '@/components/Link';
|
||||
|
||||
interface Props {
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export default function Pagination({ totalPages, currentPage }: Props) {
|
||||
const prevPage = currentPage - 1 > 0;
|
||||
const nextPage = currentPage + 1 <= totalPages;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<nav className="flex justify-between">
|
||||
{!prevPage && (
|
||||
<button
|
||||
className="cursor-auto disabled:opacity-50"
|
||||
disabled={!prevPage}>
|
||||
Previous
|
||||
</button>
|
||||
)}
|
||||
{prevPage && (
|
||||
<Link
|
||||
href={
|
||||
currentPage - 1 === 1 ? `/blog/` : `/blog/page/${currentPage - 1}`
|
||||
}>
|
||||
<button>Previous</button>
|
||||
</Link>
|
||||
)}
|
||||
<span>
|
||||
{currentPage} of {totalPages}
|
||||
</span>
|
||||
{!nextPage && (
|
||||
<button
|
||||
className="cursor-auto disabled:opacity-50"
|
||||
disabled={!nextPage}>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
{nextPage && (
|
||||
<Link href={`/blog/page/${currentPage + 1}`}>
|
||||
<button>Next</button>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,77 +0,0 @@
|
||||
import { useState, useRef, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Pre = ({ children }: Props) => {
|
||||
const textInput = useRef(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onEnter = () => {
|
||||
setHovered(true);
|
||||
};
|
||||
const onExit = () => {
|
||||
setHovered(false);
|
||||
setCopied(false);
|
||||
};
|
||||
const onCopy = () => {
|
||||
setCopied(true);
|
||||
navigator.clipboard.writeText(textInput.current.textContent);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={textInput}
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={onExit}
|
||||
className="relative">
|
||||
{hovered && (
|
||||
<button
|
||||
aria-label="Copy code"
|
||||
type="button"
|
||||
className={`absolute right-2 top-2 h-8 w-8 rounded border-2 bg-gray-700 p-1 dark:bg-gray-800 ${
|
||||
copied
|
||||
? 'border-green-400 focus:border-green-400 focus:outline-none'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
onClick={onCopy}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
className={copied ? 'text-green-400' : 'text-gray-300'}>
|
||||
{copied ? (
|
||||
<>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<pre>{children}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pre;
|
@ -1,208 +0,0 @@
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
interface CommonSEOProps {
|
||||
title: string;
|
||||
description: string;
|
||||
ogType: string;
|
||||
ogImage:
|
||||
| string
|
||||
| {
|
||||
'@type': string;
|
||||
url: string;
|
||||
}[];
|
||||
twImage: string;
|
||||
canonicalUrl?: string;
|
||||
}
|
||||
|
||||
const CommonSEO = ({
|
||||
title,
|
||||
description,
|
||||
ogType,
|
||||
ogImage,
|
||||
twImage,
|
||||
canonicalUrl,
|
||||
}: CommonSEOProps) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
<meta name="robots" content="follow, index" />
|
||||
<meta name="description" content={description} />
|
||||
<meta
|
||||
property="og:url"
|
||||
content={`${siteMetadata.siteUrl}${router.asPath}`}
|
||||
/>
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:site_name" content={siteMetadata.title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:title" content={title} />
|
||||
{Array.isArray(ogImage) ? (
|
||||
ogImage.map(({ url }) => (
|
||||
<meta property="og:image" content={url} key={url} />
|
||||
))
|
||||
) : (
|
||||
<meta property="og:image" content={ogImage} key={ogImage} />
|
||||
)}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content={siteMetadata.twitter} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={twImage} />
|
||||
<link
|
||||
rel="canonical"
|
||||
href={
|
||||
canonicalUrl
|
||||
? canonicalUrl
|
||||
: `${siteMetadata.siteUrl}${router.asPath}`
|
||||
}
|
||||
/>
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageSEOProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const PageSEO = ({ title, description }: PageSEOProps) => {
|
||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
return (
|
||||
<CommonSEO
|
||||
title={title}
|
||||
description={description}
|
||||
ogType="website"
|
||||
ogImage={ogImageUrl}
|
||||
twImage={twImageUrl}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TagSEO = ({ title, description }: PageSEOProps) => {
|
||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<CommonSEO
|
||||
title={title}
|
||||
description={description}
|
||||
ogType="website"
|
||||
ogImage={ogImageUrl}
|
||||
twImage={twImageUrl}
|
||||
/>
|
||||
<Head>
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title={`${description} - RSS feed`}
|
||||
href={`${siteMetadata.siteUrl}${router.asPath}/feed.xml`}
|
||||
/>
|
||||
</Head>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface BlogSeoProps extends PostFrontMatter {
|
||||
authorDetails?: AuthorFrontMatter[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const BlogSEO = ({
|
||||
authorDetails,
|
||||
title,
|
||||
summary,
|
||||
date,
|
||||
lastmod,
|
||||
url,
|
||||
images = [],
|
||||
canonicalUrl,
|
||||
}: BlogSeoProps) => {
|
||||
const publishedAt = new Date(date).toISOString();
|
||||
const modifiedAt = new Date(lastmod || date).toISOString();
|
||||
const imagesArr =
|
||||
images.length === 0
|
||||
? [siteMetadata.socialBanner]
|
||||
: typeof images === 'string'
|
||||
? [images]
|
||||
: images;
|
||||
|
||||
const featuredImages = imagesArr.map((img) => {
|
||||
return {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteMetadata.siteUrl}${img}`,
|
||||
};
|
||||
});
|
||||
|
||||
let authorList;
|
||||
if (authorDetails) {
|
||||
authorList = authorDetails.map((author) => {
|
||||
return {
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
authorList = {
|
||||
'@type': 'Person',
|
||||
name: siteMetadata.author,
|
||||
};
|
||||
}
|
||||
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url,
|
||||
},
|
||||
headline: title,
|
||||
image: featuredImages,
|
||||
datePublished: publishedAt,
|
||||
dateModified: modifiedAt,
|
||||
author: authorList,
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: siteMetadata.author,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteMetadata.siteUrl}${siteMetadata.siteLogo}`,
|
||||
},
|
||||
},
|
||||
description: summary,
|
||||
};
|
||||
|
||||
const twImageUrl = featuredImages[0].url;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommonSEO
|
||||
title={title}
|
||||
description={summary}
|
||||
ogType="article"
|
||||
ogImage={featuredImages}
|
||||
twImage={twImageUrl}
|
||||
canonicalUrl={canonicalUrl}
|
||||
/>
|
||||
<Head>
|
||||
{date && (
|
||||
<meta property="article:published_time" content={publishedAt} />
|
||||
)}
|
||||
{lastmod && (
|
||||
<meta property="article:modified_time" content={modifiedAt} />
|
||||
)}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(structuredData, null, 2),
|
||||
}}
|
||||
/>
|
||||
</Head>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,47 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
'use client'
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const ScrollTopAndComment = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowScroll = () => {
|
||||
if (window.scrollY > 50) setShow(true);
|
||||
else setShow(false);
|
||||
};
|
||||
if (window.scrollY > 50) setShow(true)
|
||||
else setShow(false)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleWindowScroll);
|
||||
return () => window.removeEventListener('scroll', handleWindowScroll);
|
||||
}, []);
|
||||
window.addEventListener('scroll', handleWindowScroll)
|
||||
return () => window.removeEventListener('scroll', handleWindowScroll)
|
||||
}, [])
|
||||
|
||||
const handleScrollTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
window.scrollTo({ top: 0 })
|
||||
}
|
||||
const handleScrollToComment = () => {
|
||||
document.getElementById('comment').scrollIntoView();
|
||||
};
|
||||
document.getElementById('comment')?.scrollIntoView()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${
|
||||
show ? 'md:flex' : 'md:hidden'
|
||||
}`}>
|
||||
<button
|
||||
aria-label="Scroll To Comment"
|
||||
type="button"
|
||||
onClick={handleScrollToComment}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
className={`fixed bottom-8 right-8 hidden flex-col gap-3 ${show ? 'md:flex' : 'md:hidden'}`}
|
||||
>
|
||||
{siteMetadata.comments?.provider && (
|
||||
<button
|
||||
aria-label="Scroll To Comment"
|
||||
onClick={handleScrollToComment}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Scroll To Top"
|
||||
type="button"
|
||||
onClick={handleScrollTop}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
@ -51,7 +55,7 @@ const ScrollTopAndComment = () => {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollTopAndComment;
|
||||
export default ScrollTopAndComment
|
||||
|
34
components/SearchButton.tsx
Normal file
34
components/SearchButton.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { AlgoliaButton } from 'pliny/search/AlgoliaButton'
|
||||
import { KBarButton } from 'pliny/search/KBarButton'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
const SearchButton = () => {
|
||||
if (
|
||||
siteMetadata.search &&
|
||||
(siteMetadata.search.provider === 'algolia' || siteMetadata.search.provider === 'kbar')
|
||||
) {
|
||||
const SearchButtonWrapper =
|
||||
siteMetadata.search.provider === 'algolia' ? AlgoliaButton : KBarButton
|
||||
|
||||
return (
|
||||
<SearchButtonWrapper aria-label="Search">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100 h-6 w-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
</SearchButtonWrapper>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchButton
|
@ -1,13 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function SectionContainer({ children }: Props) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
<section className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">{children}</section>
|
||||
)
|
||||
}
|
||||
|
@ -1,74 +0,0 @@
|
||||
import { Toc } from 'types/Toc';
|
||||
|
||||
interface TOCInlineProps {
|
||||
toc: Toc;
|
||||
indentDepth?: number;
|
||||
fromHeading?: number;
|
||||
toHeading?: number;
|
||||
asDisclosure?: boolean;
|
||||
exclude?: string | string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an inline table of contents
|
||||
* Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')).
|
||||
* If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')).
|
||||
*
|
||||
* @param {TOCInlineProps} {
|
||||
* toc,
|
||||
* indentDepth = 3,
|
||||
* fromHeading = 1,
|
||||
* toHeading = 6,
|
||||
* asDisclosure = false,
|
||||
* exclude = '',
|
||||
* }
|
||||
*
|
||||
*/
|
||||
const TOCInline = ({
|
||||
toc,
|
||||
indentDepth = 3,
|
||||
fromHeading = 1,
|
||||
toHeading = 6,
|
||||
asDisclosure = false,
|
||||
exclude = '',
|
||||
}: TOCInlineProps) => {
|
||||
const re = Array.isArray(exclude)
|
||||
? new RegExp('^(' + exclude.join('|') + ')$', 'i')
|
||||
: new RegExp('^(' + exclude + ')$', 'i');
|
||||
|
||||
const filteredToc = toc.filter(
|
||||
(heading) =>
|
||||
heading.depth >= fromHeading &&
|
||||
heading.depth <= toHeading &&
|
||||
!re.test(heading.value)
|
||||
);
|
||||
|
||||
const tocList = (
|
||||
<ul>
|
||||
{filteredToc.map((heading) => (
|
||||
<li
|
||||
key={heading.value}
|
||||
className={`${heading.depth >= indentDepth && 'ml-6'}`}>
|
||||
<a href={heading.url}>{heading.value}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{asDisclosure ? (
|
||||
<details open>
|
||||
<summary className="ml-6 pt-2 pb-2 text-xl font-bold">
|
||||
Table of Contents
|
||||
</summary>
|
||||
<div className="ml-6">{tocList}</div>
|
||||
</details>
|
||||
) : (
|
||||
tocList
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TOCInline;
|
@ -1,18 +1,18 @@
|
||||
import Link from 'next/link';
|
||||
import kebabCase from '@/lib/utils/kebabCase';
|
||||
|
||||
import Link from 'next/link'
|
||||
import { slug } from 'github-slugger'
|
||||
interface Props {
|
||||
text: string;
|
||||
text: string
|
||||
}
|
||||
|
||||
const Tag = ({ text }: Props) => {
|
||||
return (
|
||||
<Link href={`/tags/${kebabCase(text)}`}>
|
||||
<a className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{text.split(' ').join('-')}
|
||||
</a>
|
||||
<Link
|
||||
href={`/tags/${slug(text)}`}
|
||||
className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
{text.split(' ').join('-')}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Tag;
|
||||
export default Tag
|
||||
|
@ -1,29 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
const ThemeSwitch = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
// When mounted on client, now we can show the UI
|
||||
useEffect(() => setMounted(true), []);
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Toggle Dark Mode"
|
||||
type="button"
|
||||
className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4"
|
||||
onClick={() =>
|
||||
setTheme(
|
||||
theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark'
|
||||
)
|
||||
}>
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100">
|
||||
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
|
||||
className="text-gray-900 dark:text-gray-100 h-6 w-6"
|
||||
>
|
||||
{mounted && theme === 'dark' ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
@ -34,7 +36,7 @@ const ThemeSwitch = () => {
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeSwitch;
|
||||
export default ThemeSwitch
|
||||
|
@ -1,36 +0,0 @@
|
||||
import Script from 'next/script';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const GAScript = () => {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
strategy="lazyOnload"
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${siteMetadata.analytics.googleAnalyticsId}`}
|
||||
/>
|
||||
|
||||
<Script strategy="lazyOnload" id="ga-script">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${siteMetadata.analytics.googleAnalyticsId}', {
|
||||
page_path: window.location.pathname,
|
||||
});
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GAScript;
|
||||
|
||||
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
|
||||
export const logEvent = (action, category, label, value) => {
|
||||
window.gtag?.('event', action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value: value,
|
||||
});
|
||||
};
|
@ -1,27 +0,0 @@
|
||||
import Script from 'next/script';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const PlausibleScript = () => {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
strategy="lazyOnload"
|
||||
data-domain={siteMetadata.analytics.plausibleDataDomain}
|
||||
src="https://plausible.io/js/plausible.js"
|
||||
/>
|
||||
<Script strategy="lazyOnload" id="plausible-script">
|
||||
{`
|
||||
window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
|
||||
`}
|
||||
</Script>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlausibleScript;
|
||||
|
||||
// https://plausible.io/docs/custom-event-goals
|
||||
export const logEvent = (eventName, ...rest) => {
|
||||
return window.plausible?.(eventName, ...rest);
|
||||
};
|
@ -1,28 +0,0 @@
|
||||
import Script from 'next/script';
|
||||
|
||||
const SimpleAnalyticsScript = () => {
|
||||
return (
|
||||
<>
|
||||
<Script strategy="lazyOnload" id="sa-script">
|
||||
{`
|
||||
window.sa_event=window.sa_event||function(){var a=[].slice.call(arguments);window.sa_event.q?window.sa_event.q.push(a):window.sa_event.q=[a]};
|
||||
`}
|
||||
</Script>
|
||||
<Script
|
||||
strategy="lazyOnload"
|
||||
src="https://scripts.simpleanalyticscdn.com/latest.js"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// https://docs.simpleanalytics.com/events
|
||||
export const logEvent = (eventName, callback) => {
|
||||
if (callback) {
|
||||
return window.sa_event?.(eventName, callback);
|
||||
} else {
|
||||
return window.sa_event?.(eventName);
|
||||
}
|
||||
};
|
||||
|
||||
export default SimpleAnalyticsScript;
|
@ -1,18 +0,0 @@
|
||||
import Script from 'next/script';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const UmamiScript = () => {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
async
|
||||
defer
|
||||
data-website-id={siteMetadata.analytics.umamiWebsiteId}
|
||||
src="https://umami.example.com/umami.js" // Replace with your umami instance
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UmamiScript;
|
@ -1,32 +0,0 @@
|
||||
import GA from './GoogleAnalytics';
|
||||
import Plausible from './Plausible';
|
||||
import SimpleAnalytics from './SimpleAnalytics';
|
||||
import Umami from './Umami';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag?: (...args: any[]) => void;
|
||||
plausible?: (...args: any[]) => void;
|
||||
sa_event?: (...args: any[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const Analytics = () => {
|
||||
return (
|
||||
<>
|
||||
{isProduction && siteMetadata.analytics.plausibleDataDomain && (
|
||||
<Plausible />
|
||||
)}
|
||||
{isProduction && siteMetadata.analytics.simpleAnalytics && (
|
||||
<SimpleAnalytics />
|
||||
)}
|
||||
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
|
||||
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
@ -1,33 +0,0 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import { useTheme } from 'next-themes';
|
||||
import ReactCommento from './commento/ReactCommento';
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
}
|
||||
|
||||
const Commento = ({ frontMatter }: Props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const commentsTheme = useMemo(() => {
|
||||
switch (resolvedTheme) {
|
||||
case 'light':
|
||||
case 'dark':
|
||||
return resolvedTheme;
|
||||
default:
|
||||
return 'auto';
|
||||
}
|
||||
}, [resolvedTheme]);
|
||||
return (
|
||||
<div className="my-2">
|
||||
<ReactCommento
|
||||
url={siteMetadata.comment.commentoConfig.url}
|
||||
pageId={frontMatter.slug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Commento;
|
@ -1,51 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
}
|
||||
|
||||
const Disqus = ({ frontMatter }: Props) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||
|
||||
const COMMENTS_ID = 'disqus_thread';
|
||||
|
||||
function LoadComments() {
|
||||
setEnabledLoadComments(false);
|
||||
|
||||
// @ts-ignore
|
||||
window.disqus_config = function () {
|
||||
this.page.url = window.location.href;
|
||||
this.page.identifier = frontMatter.slug;
|
||||
};
|
||||
// @ts-ignore
|
||||
if (window.DISQUS === undefined) {
|
||||
const script = document.createElement('script');
|
||||
script.src =
|
||||
'https://' +
|
||||
siteMetadata.comment.disqusConfig.shortname +
|
||||
'.disqus.com/embed.js';
|
||||
// @ts-ignore
|
||||
script.setAttribute('data-timestamp', +new Date());
|
||||
script.setAttribute('crossorigin', 'anonymous');
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
window.DISQUS.reset({ reload: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||
{enableLoadComments && (
|
||||
<button onClick={LoadComments}>Load Comments</button>
|
||||
)}
|
||||
<div className="disqus-frame" id={COMMENTS_ID} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Disqus;
|
@ -1,78 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
interface Props {
|
||||
mapping: string;
|
||||
}
|
||||
|
||||
const Giscus = ({ mapping }: Props) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
const commentsTheme =
|
||||
siteMetadata.comment.giscusConfig.themeURL === ''
|
||||
? theme === 'dark' || resolvedTheme === 'dark'
|
||||
? siteMetadata.comment.giscusConfig.darkTheme
|
||||
: siteMetadata.comment.giscusConfig.theme
|
||||
: siteMetadata.comment.giscusConfig.themeURL;
|
||||
|
||||
const COMMENTS_ID = 'comments-container';
|
||||
|
||||
const LoadComments = useCallback(() => {
|
||||
setEnabledLoadComments(false);
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://giscus.app/client.js';
|
||||
script.setAttribute('data-repo', siteMetadata.comment.giscusConfig.repo);
|
||||
script.setAttribute(
|
||||
'data-repo-id',
|
||||
siteMetadata.comment.giscusConfig.repositoryId
|
||||
);
|
||||
script.setAttribute(
|
||||
'data-category',
|
||||
siteMetadata.comment.giscusConfig.category
|
||||
);
|
||||
script.setAttribute(
|
||||
'data-category-id',
|
||||
siteMetadata.comment.giscusConfig.categoryId
|
||||
);
|
||||
script.setAttribute('data-mapping', mapping);
|
||||
script.setAttribute(
|
||||
'data-reactions-enabled',
|
||||
siteMetadata.comment.giscusConfig.reactions
|
||||
);
|
||||
script.setAttribute(
|
||||
'data-emit-metadata',
|
||||
siteMetadata.comment.giscusConfig.metadata
|
||||
);
|
||||
script.setAttribute('data-theme', commentsTheme);
|
||||
script.setAttribute('crossorigin', 'anonymous');
|
||||
script.async = true;
|
||||
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.appendChild(script);
|
||||
|
||||
return () => {
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.innerHTML = '';
|
||||
};
|
||||
}, [commentsTheme, mapping]);
|
||||
|
||||
// Reload on theme change
|
||||
useEffect(() => {
|
||||
const iframe = document.querySelector('iframe.giscus-frame');
|
||||
if (!iframe) return;
|
||||
LoadComments();
|
||||
}, [LoadComments]);
|
||||
|
||||
return (
|
||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||
{enableLoadComments && (
|
||||
<button onClick={LoadComments}>Load Comments</button>
|
||||
)}
|
||||
<div className="giscus" id={COMMENTS_ID} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Giscus;
|
@ -1,58 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
interface Props {
|
||||
issueTerm: string;
|
||||
}
|
||||
|
||||
const Utterances = ({ issueTerm }: Props) => {
|
||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
const commentsTheme =
|
||||
theme === 'dark' || resolvedTheme === 'dark'
|
||||
? siteMetadata.comment.utterancesConfig.darkTheme
|
||||
: siteMetadata.comment.utterancesConfig.theme;
|
||||
|
||||
const COMMENTS_ID = 'comments-container';
|
||||
|
||||
const LoadComments = useCallback(() => {
|
||||
setEnabledLoadComments(false);
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://utteranc.es/client.js';
|
||||
script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo);
|
||||
script.setAttribute('issue-term', issueTerm);
|
||||
script.setAttribute('label', siteMetadata.comment.utterancesConfig.label);
|
||||
script.setAttribute('theme', commentsTheme);
|
||||
script.setAttribute('crossorigin', 'anonymous');
|
||||
script.async = true;
|
||||
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.appendChild(script);
|
||||
|
||||
return () => {
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.innerHTML = '';
|
||||
};
|
||||
}, [commentsTheme, issueTerm]);
|
||||
|
||||
// Reload on theme change
|
||||
useEffect(() => {
|
||||
const iframe = document.querySelector('iframe.utterances-frame');
|
||||
if (!iframe) return;
|
||||
LoadComments();
|
||||
}, [LoadComments]);
|
||||
|
||||
// Added `relative` to fix a weird bug with `utterances-frame` position
|
||||
return (
|
||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
||||
{enableLoadComments && (
|
||||
<button onClick={LoadComments}>Load Comments</button>
|
||||
)}
|
||||
<div className="utterances-frame relative" id={COMMENTS_ID} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Utterances;
|
@ -1,93 +0,0 @@
|
||||
import { createRef } from 'preact';
|
||||
import React, { useLayoutEffect, useMemo, useRef } from 'react';
|
||||
|
||||
interface DataAttributes {
|
||||
[key: string]: string | boolean | undefined;
|
||||
}
|
||||
|
||||
const insertScript = (
|
||||
src: string,
|
||||
id: string,
|
||||
dataAttributes: DataAttributes,
|
||||
onload = () => {}
|
||||
) => {
|
||||
const script = window.document.createElement('script');
|
||||
script.async = true;
|
||||
script.src = src;
|
||||
script.id = id;
|
||||
if (document.getElementById(id)) {
|
||||
return;
|
||||
}
|
||||
script.addEventListener('load', onload, { capture: true, once: true });
|
||||
|
||||
Object.entries(dataAttributes).forEach(([key, value]) => {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
script.setAttribute(`data-${key}`, value.toString());
|
||||
});
|
||||
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
script.remove();
|
||||
};
|
||||
};
|
||||
|
||||
const ReactCommento = ({
|
||||
url,
|
||||
cssOverride,
|
||||
autoInit,
|
||||
noFonts,
|
||||
hideDeleted,
|
||||
pageId,
|
||||
}: {
|
||||
url: string;
|
||||
cssOverride?: string;
|
||||
autoInit?: boolean;
|
||||
noFonts?: boolean;
|
||||
hideDeleted?: boolean;
|
||||
pageId?: string;
|
||||
}) => {
|
||||
const containerId = useMemo(
|
||||
() => `commento-${Math.random().toString().slice(2, 8)}`,
|
||||
[]
|
||||
);
|
||||
const container = createRef<HTMLDivElement>();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!window) {
|
||||
return;
|
||||
}
|
||||
|
||||
window['commento'] = container.current;
|
||||
|
||||
const removeScript = insertScript(
|
||||
url,
|
||||
`${containerId}-script`,
|
||||
{
|
||||
'css-override': cssOverride,
|
||||
'auto-init': autoInit,
|
||||
'no-fonts': noFonts,
|
||||
'hide-deleted': hideDeleted,
|
||||
'page-id': pageId,
|
||||
'id-root': containerId,
|
||||
},
|
||||
() => {
|
||||
removeScript();
|
||||
}
|
||||
);
|
||||
}, [
|
||||
autoInit,
|
||||
cssOverride,
|
||||
hideDeleted,
|
||||
noFonts,
|
||||
pageId,
|
||||
url,
|
||||
containerId,
|
||||
container,
|
||||
]);
|
||||
|
||||
return <div ref={container} id={containerId} />;
|
||||
};
|
||||
export default ReactCommento;
|
@ -1,71 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
}
|
||||
|
||||
const UtterancesComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Utterances');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
const GiscusComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Giscus');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
const DisqusComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Disqus');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
const CommentoComponent = dynamic(
|
||||
() => {
|
||||
return import('@/components/comments/Commento');
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const Comments = ({ frontMatter }: Props) => {
|
||||
let term;
|
||||
switch (
|
||||
siteMetadata.comment.giscusConfig.mapping ||
|
||||
siteMetadata.comment.utterancesConfig.issueTerm
|
||||
) {
|
||||
case 'pathname':
|
||||
term = frontMatter.slug;
|
||||
break;
|
||||
case 'url':
|
||||
term = window.location.href;
|
||||
break;
|
||||
case 'title':
|
||||
term = frontMatter.title;
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<div id="comment">
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'giscus' && (
|
||||
<GiscusComponent mapping={term} />
|
||||
)}
|
||||
{siteMetadata.comment &&
|
||||
siteMetadata.comment.provider === 'utterances' && (
|
||||
<UtterancesComponent issueTerm={term} />
|
||||
)}
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'disqus' && (
|
||||
<DisqusComponent frontMatter={frontMatter} />
|
||||
)}
|
||||
{siteMetadata.comment && siteMetadata.comment.provider === 'commento' && (
|
||||
<CommentoComponent frontMatter={frontMatter} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Comments;
|
@ -1 +0,0 @@
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Facebook icon</title><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
Before Width: | Height: | Size: 403 B |
@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub icon</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
Before Width: | Height: | Size: 827 B |
61
components/social-icons/icons.tsx
Normal file
61
components/social-icons/icons.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { SVGProps } from 'react'
|
||||
|
||||
// Icons taken from: https://simpleicons.org/
|
||||
// To add a new icon, add a new function here and add it to components in social-icons/index.tsx
|
||||
|
||||
export function Facebook(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Github(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Linkedin(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Mail(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" {...svgProps}>
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"></path>
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Twitter(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Youtube(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<path d="M23.499 6.203a3.008 3.008 0 00-2.089-2.089c-1.87-.501-9.4-.501-9.4-.501s-7.509-.01-9.399.501a3.008 3.008 0 00-2.088 2.09A31.258 31.26 0 000 12.01a31.258 31.26 0 00.523 5.785 3.008 3.008 0 002.088 2.089c1.869.502 9.4.502 9.4.502s7.508 0 9.399-.502a3.008 3.008 0 002.089-2.09 31.258 31.26 0 00.5-5.784 31.258 31.26 0 00-.5-5.808zm-13.891 9.4V8.407l6.266 3.604z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Mastodon(svgProps: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
@ -1,11 +1,4 @@
|
||||
import Mail from './mail.svg';
|
||||
import Github from './github.svg';
|
||||
import Facebook from './facebook.svg';
|
||||
import Youtube from './youtube.svg';
|
||||
import Linkedin from './linkedin.svg';
|
||||
import Twitter from './twitter.svg';
|
||||
|
||||
// Icons taken from: https://simpleicons.org/
|
||||
import { Mail, Github, Facebook, Youtube, Linkedin, Twitter, Mastodon } from './icons'
|
||||
|
||||
const components = {
|
||||
mail: Mail,
|
||||
@ -14,30 +7,34 @@ const components = {
|
||||
youtube: Youtube,
|
||||
linkedin: Linkedin,
|
||||
twitter: Twitter,
|
||||
};
|
||||
mastodon: Mastodon,
|
||||
}
|
||||
|
||||
const SocialIcon = ({ kind, href, size = 8 }) => {
|
||||
if (
|
||||
!href ||
|
||||
(kind === 'mail' &&
|
||||
!/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href))
|
||||
)
|
||||
return null;
|
||||
type SocialIconProps = {
|
||||
kind: keyof typeof components
|
||||
href: string | undefined
|
||||
size?: number
|
||||
}
|
||||
|
||||
const SocialSvg = components[kind];
|
||||
const SocialIcon = ({ kind, href, size = 8 }: SocialIconProps) => {
|
||||
if (!href || (kind === 'mail' && !/^mailto:\w+([.-]?\w+)@\w+([.-]?\w+)(.\w{2,3})+$/.test(href)))
|
||||
return null
|
||||
|
||||
const SocialSvg = components[kind]
|
||||
|
||||
return (
|
||||
<a
|
||||
className="text-sm text-gray-500 transition hover:text-gray-600"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={href}>
|
||||
href={href}
|
||||
>
|
||||
<span className="sr-only">{kind}</span>
|
||||
<SocialSvg
|
||||
className={`fill-current text-gray-700 hover:text-blue-500 dark:text-gray-200 dark:hover:text-blue-400 h-${size} w-${size}`}
|
||||
className={`fill-current text-gray-700 hover:text-primary-500 dark:text-gray-200 dark:hover:text-primary-400 h-${size} w-${size}`}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default SocialIcon;
|
||||
export default SocialIcon
|
||||
|
@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>LinkedIn icon</title><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
Before Width: | Height: | Size: 615 B |
@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
Before Width: | Height: | Size: 224 B |
@ -1 +0,0 @@
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Twitter icon</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>
|
Before Width: | Height: | Size: 607 B |
@ -1 +0,0 @@
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>YouTube icon</title><path d="M23.499 6.203a3.008 3.008 0 00-2.089-2.089c-1.87-.501-9.4-.501-9.4-.501s-7.509-.01-9.399.501a3.008 3.008 0 00-2.088 2.09A31.258 31.26 0 000 12.01a31.258 31.26 0 00.523 5.785 3.008 3.008 0 002.088 2.089c1.869.502 9.4.502 9.4.502s7.508 0 9.399-.502a3.008 3.008 0 002.089-2.09 31.258 31.26 0 00.5-5.784 31.258 31.26 0 00-.5-5.808zm-13.891 9.4V8.407l6.266 3.604z"/></svg>
|
Before Width: | Height: | Size: 474 B |
157
contentlayer.config.ts
Normal file
157
contentlayer.config.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { defineDocumentType, ComputedFields, makeSource } from 'contentlayer/source-files'
|
||||
import { writeFileSync } from 'fs'
|
||||
import readingTime from 'reading-time'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import path from 'path'
|
||||
// Remark packages
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import {
|
||||
remarkExtractFrontmatter,
|
||||
remarkCodeTitles,
|
||||
remarkImgToJsx,
|
||||
extractTocHeadings,
|
||||
} from 'pliny/mdx-plugins/index.js'
|
||||
// Rehype packages
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeCitation from 'rehype-citation'
|
||||
import rehypePrismPlus from 'rehype-prism-plus'
|
||||
import rehypePresetMinify from 'rehype-preset-minify'
|
||||
import siteMetadata from './data/siteMetadata'
|
||||
import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer.js'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
const computedFields: ComputedFields = {
|
||||
readingTime: { type: 'json', resolve: (doc) => readingTime(doc.body.raw) },
|
||||
slug: {
|
||||
type: 'string',
|
||||
resolve: (doc) => doc._raw.flattenedPath.replace(/^.+?(\/)/, ''),
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
resolve: (doc) => doc._raw.flattenedPath,
|
||||
},
|
||||
filePath: {
|
||||
type: 'string',
|
||||
resolve: (doc) => doc._raw.sourceFilePath,
|
||||
},
|
||||
toc: { type: 'string', resolve: (doc) => extractTocHeadings(doc.body.raw) },
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the occurrences of all tags across blog posts and write to json file
|
||||
*/
|
||||
function createTagCount(allBlogs) {
|
||||
const tagCount: Record<string, number> = {}
|
||||
allBlogs.forEach((file) => {
|
||||
if (file.tags && file.draft !== true) {
|
||||
file.tags.forEach((tag) => {
|
||||
const formattedTag = GithubSlugger.slug(tag)
|
||||
if (formattedTag in tagCount) {
|
||||
tagCount[formattedTag] += 1
|
||||
} else {
|
||||
tagCount[formattedTag] = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
writeFileSync('./app/tag-data.json', JSON.stringify(tagCount))
|
||||
}
|
||||
|
||||
function createSearchIndex(allBlogs) {
|
||||
if (
|
||||
siteMetadata?.search?.provider === 'kbar' &&
|
||||
siteMetadata.search.kbarConfig.searchDocumentsPath
|
||||
) {
|
||||
writeFileSync(
|
||||
`public/${siteMetadata.search.kbarConfig.searchDocumentsPath}`,
|
||||
JSON.stringify(allCoreContent(sortPosts(allBlogs)))
|
||||
)
|
||||
console.log('Local search index generated...')
|
||||
}
|
||||
}
|
||||
|
||||
export const Blog = defineDocumentType(() => ({
|
||||
name: 'Blog',
|
||||
filePathPattern: 'blog/**/*.mdx',
|
||||
contentType: 'mdx',
|
||||
fields: {
|
||||
title: { type: 'string', required: true },
|
||||
date: { type: 'date', required: true },
|
||||
tags: { type: 'list', of: { type: 'string' }, default: [] },
|
||||
lastmod: { type: 'date' },
|
||||
draft: { type: 'boolean' },
|
||||
summary: { type: 'string' },
|
||||
images: { type: 'list', of: { type: 'string' } },
|
||||
authors: { type: 'list', of: { type: 'string' } },
|
||||
layout: { type: 'string' },
|
||||
bibliography: { type: 'string' },
|
||||
canonicalUrl: { type: 'string' },
|
||||
},
|
||||
computedFields: {
|
||||
...computedFields,
|
||||
structuredData: {
|
||||
type: 'json',
|
||||
resolve: (doc) => ({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: doc.title,
|
||||
datePublished: doc.date,
|
||||
dateModified: doc.lastmod || doc.date,
|
||||
description: doc.summary,
|
||||
image: doc.images ? doc.images[0] : siteMetadata.socialBanner,
|
||||
url: `${siteMetadata.siteUrl}/${doc._raw.flattenedPath}`,
|
||||
author: doc.authors,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
export const Authors = defineDocumentType(() => ({
|
||||
name: 'Authors',
|
||||
filePathPattern: 'authors/**/*.mdx',
|
||||
contentType: 'mdx',
|
||||
fields: {
|
||||
name: { type: 'string', required: true },
|
||||
avatar: { type: 'string' },
|
||||
occupation: { type: 'string' },
|
||||
company: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
twitter: { type: 'string' },
|
||||
linkedin: { type: 'string' },
|
||||
github: { type: 'string' },
|
||||
layout: { type: 'string' },
|
||||
},
|
||||
computedFields,
|
||||
}))
|
||||
|
||||
export default makeSource({
|
||||
contentDirPath: 'data',
|
||||
documentTypes: [Blog, Authors],
|
||||
mdx: {
|
||||
cwd: process.cwd(),
|
||||
remarkPlugins: [
|
||||
remarkExtractFrontmatter,
|
||||
remarkGfm,
|
||||
remarkCodeTitles,
|
||||
remarkMath,
|
||||
remarkImgToJsx,
|
||||
],
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
rehypeAutolinkHeadings,
|
||||
rehypeKatex,
|
||||
[rehypeCitation, { path: path.join(root, 'data') }],
|
||||
[rehypePrismPlus, { defaultLanguage: 'js', ignoreMissing: true }],
|
||||
rehypePresetMinify,
|
||||
],
|
||||
},
|
||||
onSuccess: async (importData) => {
|
||||
const { allBlogs } = await importData()
|
||||
createTagCount(allBlogs)
|
||||
createSearchIndex(allBlogs)
|
||||
},
|
||||
})
|
@ -7,7 +7,7 @@
|
||||
|
||||
/* Code title styles */
|
||||
.remark-code-title {
|
||||
@apply rounded-t bg-gray-700 px-5 py-3 font-mono text-sm font-bold text-gray-200;
|
||||
@apply rounded-t bg-gray-700 dark:bg-gray-800 px-5 py-3 font-mono text-sm font-bold text-gray-200;
|
||||
}
|
||||
|
||||
.remark-code-title + div > pre {
|
||||
@ -138,3 +138,7 @@
|
||||
.token.table {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.token.table {
|
||||
display: inline;
|
||||
}
|
||||
|
@ -11,7 +11,11 @@
|
||||
}
|
||||
|
||||
.footnotes {
|
||||
@apply pt-8 mt-12 border-t border-gray-200 dark:border-gray-700;
|
||||
@apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.data-footnote-backref {
|
||||
@apply no-underline;
|
||||
}
|
||||
|
||||
.csl-entry {
|
||||
@ -21,5 +25,7 @@
|
||||
/* https://stackoverflow.com/questions/61083813/how-to-avoid-internal-autofill-selected-style-to-be-applied */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:focus {
|
||||
transition: background-color 600000s 0s, color 600000s 0s;
|
||||
transition:
|
||||
background-color 600000s 0s,
|
||||
color 600000s 0s;
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: 在 PVE 宿主机上使用桌面环境
|
||||
date: '2022-10-28'
|
||||
tags: ['PVE', 'PVE', 'DE', '环境搭建', 'Debian']
|
||||
tags: ['PVE', 'DE', '环境搭建', 'Debian']
|
||||
draft: false
|
||||
summary: 虽然 PVE 宿主机不应该安装乱七八糟的东西,但是我穷,为了物尽其用,为了在主力电脑翻车时有一个立即可用的备用环境,所以还是安装了基础的桌面环境。现在的 Linux 桌面环境越来越好了,我选择安装 KDE Plasma 作为桌面环境,并且默认关闭,按需启用。
|
||||
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/qldEtP.png']
|
@ -1,13 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/components/*": ["components/*"],
|
||||
"@/data/*": ["data/*"],
|
||||
"@/layouts/*": ["layouts/*"],
|
||||
"@/lib/*": ["lib/*"],
|
||||
"@/css/*": ["css/*"]
|
||||
"@/css/*": ["css/*"],
|
||||
"contentlayer/generated": ["./.contentlayer/generated"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,47 +1,36 @@
|
||||
import SocialIcon from '@/components/social-icons';
|
||||
import Image from '@/components/Image';
|
||||
import { PageSEO } from '@/components/SEO';
|
||||
import { ReactNode } from 'react';
|
||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||
import { ReactNode } from 'react'
|
||||
import type { Authors } from 'contentlayer/generated'
|
||||
import SocialIcon from '@/components/social-icons'
|
||||
import Image from '@/components/Image'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
frontMatter: AuthorFrontMatter;
|
||||
children: ReactNode
|
||||
content: Omit<Authors, '_id' | '_raw' | 'body'>
|
||||
}
|
||||
|
||||
export default function AuthorLayout({ children, frontMatter }: Props) {
|
||||
const {
|
||||
name,
|
||||
avatar,
|
||||
occupation,
|
||||
company,
|
||||
email,
|
||||
twitter,
|
||||
linkedin,
|
||||
github,
|
||||
} = frontMatter;
|
||||
export default function AuthorLayout({ children, content }: Props) {
|
||||
const { name, avatar, occupation, company, email, twitter, linkedin, github } = content
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageSEO title={`About - ${name}`} description={`About me - ${name}`} />
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||
About
|
||||
</h1>
|
||||
</div>
|
||||
<div className="items-start space-y-2 xl:grid xl:grid-cols-3 xl:gap-x-8 xl:space-y-0">
|
||||
<div className="flex flex-col items-center pt-8">
|
||||
<Image
|
||||
src={avatar}
|
||||
alt="avatar"
|
||||
width={192}
|
||||
height={192}
|
||||
className="h-48 w-48 rounded-full"
|
||||
/>
|
||||
<h3 className="pt-4 pb-2 text-2xl font-bold leading-8 tracking-tight">
|
||||
{name}
|
||||
</h3>
|
||||
<div className="flex flex-col items-center space-x-2 pt-8">
|
||||
{avatar && (
|
||||
<Image
|
||||
src={avatar}
|
||||
alt="avatar"
|
||||
width={192}
|
||||
height={192}
|
||||
className="h-48 w-48 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<h3 className="pb-2 pt-4 text-2xl font-bold leading-8 tracking-tight">{name}</h3>
|
||||
<div className="text-gray-500 dark:text-gray-400">{occupation}</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">{company}</div>
|
||||
<div className="flex space-x-3 pt-6">
|
||||
@ -51,11 +40,11 @@ export default function AuthorLayout({ children, frontMatter }: Props) {
|
||||
<SocialIcon kind="twitter" href={twitter} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose max-w-none pt-8 pb-8 dark:prose-dark xl:col-span-2">
|
||||
<div className="prose max-w-none pb-8 pt-8 dark:prose-invert xl:col-span-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,14 +1,63 @@
|
||||
import Link from '@/components/Link';
|
||||
import Tag from '@/components/Tag';
|
||||
import { ComponentProps, useState } from 'react';
|
||||
import Pagination from '@/components/Pagination';
|
||||
import formatDate from '@/lib/utils/formatDate';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
interface Props {
|
||||
posts: PostFrontMatter[];
|
||||
title: string;
|
||||
initialDisplayPosts?: PostFrontMatter[];
|
||||
pagination?: ComponentProps<typeof Pagination>;
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { formatDate } from 'pliny/utils/formatDate'
|
||||
import { CoreContent } from 'pliny/utils/contentlayer'
|
||||
import type { Blog } from 'contentlayer/generated'
|
||||
import Link from '@/components/Link'
|
||||
import Tag from '@/components/Tag'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
|
||||
interface PaginationProps {
|
||||
totalPages: number
|
||||
currentPage: number
|
||||
}
|
||||
interface ListLayoutProps {
|
||||
posts: CoreContent<Blog>[]
|
||||
title: string
|
||||
initialDisplayPosts?: CoreContent<Blog>[]
|
||||
pagination?: PaginationProps
|
||||
}
|
||||
|
||||
function Pagination({ totalPages, currentPage }: PaginationProps) {
|
||||
const pathname = usePathname()
|
||||
const basePath = pathname.split('/')[1]
|
||||
const prevPage = currentPage - 1 > 0
|
||||
const nextPage = currentPage + 1 <= totalPages
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
||||
<nav className="flex justify-between">
|
||||
{!prevPage && (
|
||||
<button className="cursor-auto disabled:opacity-50" disabled={!prevPage}>
|
||||
Previous
|
||||
</button>
|
||||
)}
|
||||
{prevPage && (
|
||||
<Link
|
||||
href={currentPage - 1 === 1 ? `/${basePath}/` : `/${basePath}/page/${currentPage - 1}`}
|
||||
rel="prev"
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
<span>
|
||||
{currentPage} of {totalPages}
|
||||
</span>
|
||||
{!nextPage && (
|
||||
<button className="cursor-auto disabled:opacity-50" disabled={!nextPage}>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
{nextPage && (
|
||||
<Link href={`/${basePath}/page/${currentPage + 1}`} rel="next">
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ListLayout({
|
||||
@ -16,41 +65,42 @@ export default function ListLayout({
|
||||
title,
|
||||
initialDisplayPosts = [],
|
||||
pagination,
|
||||
}: Props) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const filteredBlogPosts = posts.filter((frontMatter) => {
|
||||
const searchContent =
|
||||
frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ');
|
||||
return searchContent.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}: ListLayoutProps) {
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const filteredBlogPosts = posts.filter((post) => {
|
||||
const searchContent = post.title + post.summary + post.tags?.join(' ')
|
||||
return searchContent.toLowerCase().includes(searchValue.toLowerCase())
|
||||
})
|
||||
|
||||
// If initialDisplayPosts exist, display it if no searchValue is specified
|
||||
const displayPosts =
|
||||
initialDisplayPosts.length > 0 && !searchValue
|
||||
? initialDisplayPosts
|
||||
: filteredBlogPosts;
|
||||
initialDisplayPosts.length > 0 && !searchValue ? initialDisplayPosts : filteredBlogPosts
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||
{title}
|
||||
</h1>
|
||||
<div className="relative max-w-lg">
|
||||
<input
|
||||
aria-label="Search articles"
|
||||
type="text"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
placeholder="Search articles"
|
||||
className="block w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
<label>
|
||||
<span className="sr-only">Search articles</span>
|
||||
<input
|
||||
aria-label="Search articles"
|
||||
type="text"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
placeholder="Search articles"
|
||||
className="block w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-primary-500 focus:ring-primary-500 dark:border-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
<svg
|
||||
className="absolute right-3 top-3 h-5 w-5 text-gray-400 dark:text-gray-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@ -62,30 +112,26 @@ export default function ListLayout({
|
||||
</div>
|
||||
<ul>
|
||||
{!filteredBlogPosts.length && 'No posts found.'}
|
||||
{displayPosts.map((frontMatter) => {
|
||||
const { slug, date, title, summary, tags } = frontMatter;
|
||||
{displayPosts.map((post) => {
|
||||
const { path, date, title, summary, tags } = post
|
||||
return (
|
||||
<li key={slug} className="py-4">
|
||||
<li key={path} className="py-4">
|
||||
<article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
|
||||
<dl>
|
||||
<dt className="sr-only">Published on</dt>
|
||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={date}>{formatDate(date)}</time>
|
||||
<time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time>
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="space-y-3 xl:col-span-3">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-8 tracking-tight">
|
||||
<Link
|
||||
href={`/blog/${slug}`}
|
||||
className="text-gray-900 dark:text-gray-100">
|
||||
<Link href={`/${path}`} className="text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</Link>
|
||||
</h3>
|
||||
<div className="flex flex-wrap">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} text={tag} />
|
||||
))}
|
||||
{tags?.map((tag) => <Tag key={tag} text={tag} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose max-w-none text-gray-500 dark:text-gray-400">
|
||||
@ -94,16 +140,13 @@ export default function ListLayout({
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
{pagination && pagination.totalPages > 1 && !searchValue && (
|
||||
<Pagination
|
||||
currentPage={pagination.currentPage}
|
||||
totalPages={pagination.totalPages}
|
||||
/>
|
||||
<Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
163
layouts/ListLayoutWithTags.tsx
Normal file
163
layouts/ListLayoutWithTags.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
/* eslint-disable jsx-a11y/anchor-is-valid */
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { slug } from 'github-slugger'
|
||||
import { formatDate } from 'pliny/utils/formatDate'
|
||||
import { CoreContent } from 'pliny/utils/contentlayer'
|
||||
import type { Blog } from 'contentlayer/generated'
|
||||
import Link from '@/components/Link'
|
||||
import Tag from '@/components/Tag'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import tagData from 'app/tag-data.json'
|
||||
|
||||
interface PaginationProps {
|
||||
totalPages: number
|
||||
currentPage: number
|
||||
}
|
||||
interface ListLayoutProps {
|
||||
posts: CoreContent<Blog>[]
|
||||
title: string
|
||||
initialDisplayPosts?: CoreContent<Blog>[]
|
||||
pagination?: PaginationProps
|
||||
}
|
||||
|
||||
function Pagination({ totalPages, currentPage }: PaginationProps) {
|
||||
const pathname = usePathname()
|
||||
const basePath = pathname.split('/')[1]
|
||||
const prevPage = currentPage - 1 > 0
|
||||
const nextPage = currentPage + 1 <= totalPages
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
||||
<nav className="flex justify-between">
|
||||
{!prevPage && (
|
||||
<button className="cursor-auto disabled:opacity-50" disabled={!prevPage}>
|
||||
Previous
|
||||
</button>
|
||||
)}
|
||||
{prevPage && (
|
||||
<Link
|
||||
href={currentPage - 1 === 1 ? `/${basePath}/` : `/${basePath}/page/${currentPage - 1}`}
|
||||
rel="prev"
|
||||
>
|
||||
Previous
|
||||
</Link>
|
||||
)}
|
||||
<span>
|
||||
{currentPage} of {totalPages}
|
||||
</span>
|
||||
{!nextPage && (
|
||||
<button className="cursor-auto disabled:opacity-50" disabled={!nextPage}>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
{nextPage && (
|
||||
<Link href={`/${basePath}/page/${currentPage + 1}`} rel="next">
|
||||
Next
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ListLayoutWithTags({
|
||||
posts,
|
||||
title,
|
||||
initialDisplayPosts = [],
|
||||
pagination,
|
||||
}: ListLayoutProps) {
|
||||
const pathname = usePathname()
|
||||
const tagCounts = tagData as Record<string, number>
|
||||
const tagKeys = Object.keys(tagCounts)
|
||||
const sortedTags = tagKeys.sort((a, b) => tagCounts[b] - tagCounts[a])
|
||||
|
||||
const displayPosts = initialDisplayPosts.length > 0 ? initialDisplayPosts : posts
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className="pb-6 pt-6">
|
||||
<h1 className="sm:hidden text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex sm:space-x-24">
|
||||
<div className="hidden max-h-screen h-full sm:flex flex-wrap bg-gray-50 dark:bg-gray-900/70 shadow-md pt-5 dark:shadow-gray-800/40 rounded min-w-[280px] max-w-[280px]">
|
||||
<div className="py-4 px-6">
|
||||
{pathname.startsWith('/blog') ? (
|
||||
<h3 className="text-primary-500 font-bold uppercase">All Posts</h3>
|
||||
) : (
|
||||
<Link
|
||||
href={`/blog`}
|
||||
className="font-bold uppercase text-gray-700 dark:text-gray-300 hover:text-primary-500 dark:hover:text-primary-500"
|
||||
>
|
||||
All Posts
|
||||
</Link>
|
||||
)}
|
||||
<ul>
|
||||
{sortedTags.map((t) => {
|
||||
return (
|
||||
<li key={t} className="my-3">
|
||||
{pathname.split('/tags/')[1] === slug(t) ? (
|
||||
<h3 className="inline py-2 px-3 uppercase text-sm font-bold text-primary-500">
|
||||
{`${t} (${tagCounts[t]})`}
|
||||
</h3>
|
||||
) : (
|
||||
<Link
|
||||
href={`/tags/${slug(t)}`}
|
||||
className="py-2 px-3 uppercase text-sm font-medium text-gray-500 dark:text-gray-300 hover:text-primary-500 dark:hover:text-primary-500"
|
||||
aria-label={`View posts tagged ${t}`}
|
||||
>
|
||||
{`${t} (${tagCounts[t]})`}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ul>
|
||||
{displayPosts.map((post) => {
|
||||
const { path, date, title, summary, tags } = post
|
||||
return (
|
||||
<li key={path} className="py-5">
|
||||
<article className="space-y-2 flex flex-col xl:space-y-0">
|
||||
<dl>
|
||||
<dt className="sr-only">Published on</dt>
|
||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time>
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold leading-8 tracking-tight">
|
||||
<Link href={`/${path}`} className="text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</Link>
|
||||
</h2>
|
||||
<div className="flex flex-wrap">
|
||||
{tags?.map((tag) => <Tag key={tag} text={tag} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose max-w-none text-gray-500 dark:text-gray-400">
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<Pagination currentPage={pagination.currentPage} totalPages={pagination.totalPages} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
78
layouts/PostBanner.tsx
Normal file
78
layouts/PostBanner.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { ReactNode } from 'react'
|
||||
import Image from '@/components/Image'
|
||||
import Bleed from 'pliny/ui/Bleed'
|
||||
import { CoreContent } from 'pliny/utils/contentlayer'
|
||||
import type { Blog } from 'contentlayer/generated'
|
||||
import Comments from '@/components/Comments'
|
||||
import Link from '@/components/Link'
|
||||
import PageTitle from '@/components/PageTitle'
|
||||
import SectionContainer from '@/components/SectionContainer'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
|
||||
|
||||
interface LayoutProps {
|
||||
content: CoreContent<Blog>
|
||||
children: ReactNode
|
||||
next?: { path: string; title: string }
|
||||
prev?: { path: string; title: string }
|
||||
}
|
||||
|
||||
export default function PostMinimal({ content, next, prev, children }: LayoutProps) {
|
||||
const { slug, title, images } = content
|
||||
const displayImage =
|
||||
images && images.length > 0 ? images[0] : 'https://picsum.photos/seed/picsum/800/400'
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<ScrollTopAndComment />
|
||||
<article>
|
||||
<div>
|
||||
<div className="space-y-1 pb-10 text-center dark:border-gray-700">
|
||||
<div className="w-full">
|
||||
<Bleed>
|
||||
<div className="aspect-[2/1] w-full relative">
|
||||
<Image src={displayImage} alt={title} fill className="object-cover" />
|
||||
</div>
|
||||
</Bleed>
|
||||
</div>
|
||||
<div className="pt-10 relative">
|
||||
<PageTitle>{title}</PageTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose max-w-none py-4 dark:prose-invert">{children}</div>
|
||||
{siteMetadata.comments && (
|
||||
<div className="pb-6 pt-6 text-center text-gray-700 dark:text-gray-300" id="comment">
|
||||
<Comments slug={slug} />
|
||||
</div>
|
||||
)}
|
||||
<footer>
|
||||
<div className="flex flex-col text-sm font-medium sm:flex-row sm:justify-between sm:text-base">
|
||||
{prev && prev.path && (
|
||||
<div className="pt-4 xl:pt-8">
|
||||
<Link
|
||||
href={`/${prev.path}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label={`Previous post: ${prev.title}`}
|
||||
>
|
||||
← {prev.title}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{next && next.path && (
|
||||
<div className="pt-4 xl:pt-8">
|
||||
<Link
|
||||
href={`/${next.path}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label={`Next post: ${next.title}`}
|
||||
>
|
||||
{next.title} →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
</SectionContainer>
|
||||
)
|
||||
}
|
@ -1,27 +1,23 @@
|
||||
import Link from '@/components/Link';
|
||||
import PageTitle from '@/components/PageTitle';
|
||||
import SectionContainer from '@/components/SectionContainer';
|
||||
import { BlogSEO } from '@/components/SEO';
|
||||
import Image from '@/components/Image';
|
||||
import Tag from '@/components/Tag';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import Comments from '@/components/comments';
|
||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||
import { ReactNode } from 'react'
|
||||
import { CoreContent } from 'pliny/utils/contentlayer'
|
||||
import type { Blog, Authors } from 'contentlayer/generated'
|
||||
import Comments from '@/components/Comments'
|
||||
import Link from '@/components/Link'
|
||||
import PageTitle from '@/components/PageTitle'
|
||||
import SectionContainer from '@/components/SectionContainer'
|
||||
import Image from '@/components/Image'
|
||||
import Tag from '@/components/Tag'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
|
||||
|
||||
const editUrl = (path) => `${siteMetadata.siteRepo}/raw/branch/master/data/${path}`
|
||||
|
||||
const editUrl = (fileName) =>
|
||||
`${siteMetadata.siteRepo}/raw/master/data/blog/${fileName}`;
|
||||
const discussUrl = (slug) =>
|
||||
`https://mobile.twitter.com/search?q=${encodeURIComponent(
|
||||
`${siteMetadata.siteUrl}/blog/${slug}`
|
||||
)}`;
|
||||
const Copyright = () => (
|
||||
<a
|
||||
rel="license"
|
||||
href="http://creativecommons.org/licenses/by-sa/4.0/"
|
||||
className="inline-flex self-center">
|
||||
className="inline-flex self-center"
|
||||
>
|
||||
<Image
|
||||
className="border-0"
|
||||
alt="知识共享许可协议"
|
||||
@ -30,73 +26,40 @@ const Copyright = () => (
|
||||
src="https://i.creativecommons.org/l/by-sa/4.0/80x15.png"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
)
|
||||
|
||||
const postDateTemplate: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
authorDetails: AuthorFrontMatter[];
|
||||
next?: { slug: string; title: string };
|
||||
prev?: { slug: string; title: string };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function PostLayout({
|
||||
frontMatter,
|
||||
authorDetails,
|
||||
next,
|
||||
prev,
|
||||
children,
|
||||
}: Props) {
|
||||
const { slug, fileName, date, title, images, tags } = frontMatter;
|
||||
interface LayoutProps {
|
||||
content: CoreContent<Blog>
|
||||
authorDetails: CoreContent<Authors>[]
|
||||
next?: { path: string; title: string }
|
||||
prev?: { path: string; title: string }
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const headerStyles = useMemo(
|
||||
() =>
|
||||
images?.[0]
|
||||
? {
|
||||
backgroundImage: `url(${images[0]})`,
|
||||
}
|
||||
: {},
|
||||
[images]
|
||||
);
|
||||
export default function PostLayout({ content, authorDetails, next, prev, children }: LayoutProps) {
|
||||
const { filePath, path, slug, date, title, tags } = content
|
||||
const basePath = path.split('/')[0]
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<BlogSEO
|
||||
url={`${siteMetadata.siteUrl}/blog/${slug}`}
|
||||
authorDetails={authorDetails}
|
||||
{...frontMatter}
|
||||
/>
|
||||
<ScrollTopAndComment />
|
||||
<article>
|
||||
<div className="xl:divide-y xl:divide-gray-200 xl:dark:divide-gray-700">
|
||||
<header className="relative h-48 pt-6 xl:pb-6">
|
||||
{images?.[0] && (
|
||||
<Image
|
||||
alt="background"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
src={images[0]}
|
||||
style={headerStyles}
|
||||
className="blur-xs -z-10 opacity-50 bg-blend-soft-light"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-5 text-center">
|
||||
<header className="pt-6 xl:pb-6">
|
||||
<div className="space-y-1 text-center">
|
||||
<dl className="space-y-10">
|
||||
<div>
|
||||
<dt className="sr-only">Published on</dt>
|
||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={date}>
|
||||
{new Date(date).toLocaleDateString(
|
||||
siteMetadata.locale,
|
||||
postDateTemplate
|
||||
)}
|
||||
{new Date(date).toLocaleDateString(siteMetadata.locale, postDateTemplate)}
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
@ -106,17 +69,13 @@ export default function PostLayout({
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:grid xl:grid-cols-4 xl:gap-x-6 xl:divide-y-0"
|
||||
style={{ gridTemplateRows: 'auto 1fr' }}>
|
||||
<dl className="pt-6 pb-10 xl:border-b xl:border-gray-200 xl:pt-11 xl:dark:border-gray-700">
|
||||
<div className="grid-rows-[auto_1fr] divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:grid xl:grid-cols-4 xl:gap-x-6 xl:divide-y-0">
|
||||
<dl className="pb-10 pt-6 xl:border-b xl:border-gray-200 xl:pt-11 xl:dark:border-gray-700">
|
||||
<dt className="sr-only">Authors</dt>
|
||||
<dd>
|
||||
<ul className="flex justify-center space-x-8 sm:space-x-12 xl:block xl:space-x-0 xl:space-y-8">
|
||||
<ul className="flex flex-wrap justify-center gap-4 sm:space-x-12 xl:block xl:space-x-0 xl:space-y-8">
|
||||
{authorDetails.map((author) => (
|
||||
<li
|
||||
className="flex items-center space-x-2"
|
||||
key={author.name}>
|
||||
<li className="flex items-center space-x-2" key={author.name}>
|
||||
{author.avatar && (
|
||||
<Image
|
||||
src={author.avatar}
|
||||
@ -128,19 +87,15 @@ export default function PostLayout({
|
||||
)}
|
||||
<dl className="whitespace-nowrap text-sm font-medium leading-5">
|
||||
<dt className="sr-only">Name</dt>
|
||||
<dd className="text-gray-900 dark:text-gray-100">
|
||||
{author.name}
|
||||
</dd>
|
||||
<dd className="text-gray-900 dark:text-gray-100">{author.name}</dd>
|
||||
<dt className="sr-only">Twitter</dt>
|
||||
<dd>
|
||||
{author.twitter && (
|
||||
<Link
|
||||
href={author.twitter}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{author.twitter.replace(
|
||||
'https://twitter.com/',
|
||||
'@'
|
||||
)}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
>
|
||||
{author.twitter.replace('https://twitter.com/', '@')}
|
||||
</Link>
|
||||
)}
|
||||
</dd>
|
||||
@ -151,14 +106,19 @@ export default function PostLayout({
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
|
||||
<div className="prose max-w-none pt-10 pb-8 dark:prose-dark">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pt-6 pb-6 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div className="prose max-w-none pb-8 pt-10 dark:prose-invert">{children}</div>
|
||||
<div className="pb-6 pt-6 text-sm text-gray-700 dark:text-gray-300 flex items-center gap-4 ">
|
||||
<Copyright />
|
||||
<Link href={editUrl(fileName)}>{'View source'}</Link>
|
||||
<Link href={editUrl(filePath)}>{'View source'}</Link>
|
||||
</div>
|
||||
<Comments frontMatter={frontMatter} />
|
||||
{siteMetadata.comments && (
|
||||
<div
|
||||
className="pb-6 pt-6 text-center text-gray-700 dark:text-gray-300"
|
||||
id="comment"
|
||||
>
|
||||
<Comments slug={slug} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<footer>
|
||||
<div className="divide-gray-200 text-sm font-medium leading-5 dark:divide-gray-700 xl:col-start-1 xl:row-start-2 xl:divide-y">
|
||||
@ -176,23 +136,23 @@ export default function PostLayout({
|
||||
)}
|
||||
{(next || prev) && (
|
||||
<div className="flex justify-between py-4 xl:block xl:space-y-8 xl:py-8">
|
||||
{prev && (
|
||||
{prev && prev.path && (
|
||||
<div>
|
||||
<h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Previous Article
|
||||
</h2>
|
||||
<div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<Link href={`/blog/${prev.slug}`}>{prev.title}</Link>
|
||||
<Link href={`/${prev.path}`}>{prev.title}</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{next && (
|
||||
{next && next.path && (
|
||||
<div>
|
||||
<h2 className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Next Article
|
||||
</h2>
|
||||
<div className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
<Link href={`/blog/${next.slug}`}>{next.title}</Link>
|
||||
<Link href={`/${next.path}`}>{next.title}</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -201,8 +161,10 @@ export default function PostLayout({
|
||||
</div>
|
||||
<div className="pt-4 xl:pt-8">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
href={`/${basePath}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label="Back to the blog"
|
||||
>
|
||||
← Back to the blog
|
||||
</Link>
|
||||
</div>
|
||||
@ -211,5 +173,5 @@ export default function PostLayout({
|
||||
</div>
|
||||
</article>
|
||||
</SectionContainer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,32 +1,26 @@
|
||||
import Link from '@/components/Link';
|
||||
import PageTitle from '@/components/PageTitle';
|
||||
import SectionContainer from '@/components/SectionContainer';
|
||||
import { BlogSEO } from '@/components/SEO';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import formatDate from '@/lib/utils/formatDate';
|
||||
import Comments from '@/components/comments';
|
||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment';
|
||||
import { ReactNode } from 'react';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import { ReactNode } from 'react'
|
||||
import { formatDate } from 'pliny/utils/formatDate'
|
||||
import { CoreContent } from 'pliny/utils/contentlayer'
|
||||
import type { Blog } from 'contentlayer/generated'
|
||||
import Comments from '@/components/Comments'
|
||||
import Link from '@/components/Link'
|
||||
import PageTitle from '@/components/PageTitle'
|
||||
import SectionContainer from '@/components/SectionContainer'
|
||||
import siteMetadata from '@/data/siteMetadata'
|
||||
import ScrollTopAndComment from '@/components/ScrollTopAndComment'
|
||||
|
||||
interface Props {
|
||||
frontMatter: PostFrontMatter;
|
||||
children: ReactNode;
|
||||
next?: { slug: string; title: string };
|
||||
prev?: { slug: string; title: string };
|
||||
interface LayoutProps {
|
||||
content: CoreContent<Blog>
|
||||
children: ReactNode
|
||||
next?: { path: string; title: string }
|
||||
prev?: { path: string; title: string }
|
||||
}
|
||||
|
||||
export default function PostLayout({
|
||||
frontMatter,
|
||||
next,
|
||||
prev,
|
||||
children,
|
||||
}: Props) {
|
||||
const { slug, date, title } = frontMatter;
|
||||
export default function PostLayout({ content, next, prev, children }: LayoutProps) {
|
||||
const { path, slug, date, title } = content
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<BlogSEO url={`${siteMetadata.siteUrl}/blog/${slug}`} {...frontMatter} />
|
||||
<ScrollTopAndComment />
|
||||
<article>
|
||||
<div>
|
||||
@ -36,7 +30,7 @@ export default function PostLayout({
|
||||
<div>
|
||||
<dt className="sr-only">Published on</dt>
|
||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={date}>{formatDate(date)}</time>
|
||||
<time dateTime={date}>{formatDate(date, siteMetadata.locale)}</time>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@ -45,31 +39,35 @@ export default function PostLayout({
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className="divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0 "
|
||||
style={{ gridTemplateRows: 'auto 1fr' }}>
|
||||
<div className="grid-rows-[auto_1fr] divide-y divide-gray-200 pb-8 dark:divide-gray-700 xl:divide-y-0">
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:col-span-3 xl:row-span-2 xl:pb-0">
|
||||
<div className="prose max-w-none pt-10 pb-8 dark:prose-dark">
|
||||
{children}
|
||||
</div>
|
||||
<div className="prose max-w-none pb-8 pt-10 dark:prose-invert">{children}</div>
|
||||
</div>
|
||||
<Comments frontMatter={frontMatter} />
|
||||
{siteMetadata.comments && (
|
||||
<div className="pb-6 pt-6 text-center text-gray-700 dark:text-gray-300" id="comment">
|
||||
<Comments slug={slug} />
|
||||
</div>
|
||||
)}
|
||||
<footer>
|
||||
<div className="flex flex-col text-sm font-medium sm:flex-row sm:justify-between sm:text-base">
|
||||
{prev && (
|
||||
{prev && prev.path && (
|
||||
<div className="pt-4 xl:pt-8">
|
||||
<Link
|
||||
href={`/blog/${prev.slug}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
href={`/${prev.path}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label={`Previous post: ${prev.title}`}
|
||||
>
|
||||
← {prev.title}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{next && (
|
||||
{next && next.path && (
|
||||
<div className="pt-4 xl:pt-8">
|
||||
<Link
|
||||
href={`/blog/${next.slug}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
href={`/${next.path}`}
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label={`Next post: ${next.title}`}
|
||||
>
|
||||
{next.title} →
|
||||
</Link>
|
||||
</div>
|
||||
@ -80,5 +78,5 @@ export default function PostLayout({
|
||||
</div>
|
||||
</article>
|
||||
</SectionContainer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
@ -1,37 +0,0 @@
|
||||
import { escape } from '@/lib/utils/htmlEscaper';
|
||||
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
|
||||
const generateRssItem = (post: PostFrontMatter) => `
|
||||
<item>
|
||||
<guid>${siteMetadata.siteUrl}/blog/${post.slug}</guid>
|
||||
<title>${escape(post.title)}</title>
|
||||
<link>${siteMetadata.siteUrl}/blog/${post.slug}</link>
|
||||
${post.summary && `<description>${escape(post.summary)}</description>`}
|
||||
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
|
||||
<author>${siteMetadata.email} (${siteMetadata.author})</author>
|
||||
${post.tags && post.tags.map((t) => `<category>${t}</category>`).join('')}
|
||||
</item>
|
||||
`;
|
||||
|
||||
const generateRss = (posts: PostFrontMatter[], page = 'feed.xml') => `
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escape(siteMetadata.title)}</title>
|
||||
<link>${siteMetadata.siteUrl}/blog</link>
|
||||
<description>${escape(siteMetadata.description)}</description>
|
||||
<language>${siteMetadata.language}</language>
|
||||
<managingEditor>${siteMetadata.email} (${
|
||||
siteMetadata.author
|
||||
})</managingEditor>
|
||||
<webMaster>${siteMetadata.email} (${siteMetadata.author})</webMaster>
|
||||
<lastBuildDate>${new Date(posts[0].date).toUTCString()}</lastBuildDate>
|
||||
<atom:link href="${
|
||||
siteMetadata.siteUrl
|
||||
}/${page}" rel="self" type="application/rss+xml"/>
|
||||
${posts.map(generateRssItem).join('')}
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
export default generateRss;
|
157
lib/mdx.ts
157
lib/mdx.ts
@ -1,157 +0,0 @@
|
||||
import { bundleMDX } from 'mdx-bundler';
|
||||
import fs from 'fs';
|
||||
import matter from 'gray-matter';
|
||||
import path from 'path';
|
||||
import readingTime from 'reading-time';
|
||||
import getAllFilesRecursively from './utils/files';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||
import { Toc } from 'types/Toc';
|
||||
// Remark packages
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkFootnotes from 'remark-footnotes';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkExtractFrontmatter from './remark-extract-frontmatter';
|
||||
import remarkCodeTitles from './remark-code-title';
|
||||
import remarkTocHeadings from './remark-toc-headings';
|
||||
import remarkImgToJsx from './remark-img-to-jsx';
|
||||
// Rehype packages
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeCitation from 'rehype-citation';
|
||||
import rehypePrismPlus from 'rehype-prism-plus';
|
||||
import rehypePresetMinify from 'rehype-preset-minify';
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
export function getFiles(type: 'blog' | 'authors') {
|
||||
const prefixPaths = path.join(root, 'data', type);
|
||||
const files = getAllFilesRecursively(prefixPaths);
|
||||
// Only want to return blog/path and ignore root, replace is needed to work on Windows
|
||||
return files.map((file) =>
|
||||
file.slice(prefixPaths.length + 1).replace(/\\/g, '/')
|
||||
);
|
||||
}
|
||||
|
||||
export function formatSlug(slug: string) {
|
||||
return slug.replace(/\.(mdx|md)/, '');
|
||||
}
|
||||
|
||||
export function dateSortDesc(a: string, b: string) {
|
||||
if (a > b) return -1;
|
||||
if (a < b) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function getFileBySlug<T>(
|
||||
type: 'authors' | 'blog',
|
||||
slug: string | string[]
|
||||
) {
|
||||
const mdxPath = path.join(root, 'data', type, `${slug}.mdx`);
|
||||
const mdPath = path.join(root, 'data', type, `${slug}.md`);
|
||||
const source = fs.existsSync(mdxPath)
|
||||
? fs.readFileSync(mdxPath, 'utf8')
|
||||
: fs.readFileSync(mdPath, 'utf8');
|
||||
|
||||
// https://github.com/kentcdodds/mdx-bundler#nextjs-esbuild-enoent
|
||||
if (process.platform === 'win32') {
|
||||
process.env.ESBUILD_BINARY_PATH = path.join(
|
||||
root,
|
||||
'node_modules',
|
||||
'esbuild',
|
||||
'esbuild.exe'
|
||||
);
|
||||
} else {
|
||||
process.env.ESBUILD_BINARY_PATH = path.join(
|
||||
root,
|
||||
'node_modules',
|
||||
'esbuild',
|
||||
'bin',
|
||||
'esbuild'
|
||||
);
|
||||
}
|
||||
|
||||
const toc: Toc = [];
|
||||
|
||||
const { code, frontmatter } = await bundleMDX({
|
||||
source,
|
||||
// mdx imports can be automatically source from the components directory
|
||||
cwd: path.join(root, 'components'),
|
||||
xdmOptions(options, frontmatter) {
|
||||
// this is the recommended way to add custom remark/rehype plugins:
|
||||
// The syntax might look weird, but it protects you in case we add/remove
|
||||
// plugins in the future.
|
||||
options.remarkPlugins = [
|
||||
...(options.remarkPlugins ?? []),
|
||||
remarkExtractFrontmatter,
|
||||
[remarkTocHeadings, { exportRef: toc }],
|
||||
remarkGfm,
|
||||
remarkCodeTitles,
|
||||
[remarkFootnotes, { inlineNotes: true }],
|
||||
remarkMath,
|
||||
remarkImgToJsx,
|
||||
];
|
||||
options.rehypePlugins = [
|
||||
...(options.rehypePlugins ?? []),
|
||||
rehypeSlug,
|
||||
rehypeAutolinkHeadings,
|
||||
rehypeKatex,
|
||||
[rehypeCitation, { path: path.join(root, 'data') }],
|
||||
[rehypePrismPlus, { ignoreMissing: true }],
|
||||
rehypePresetMinify,
|
||||
];
|
||||
return options;
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.loader = {
|
||||
...options.loader,
|
||||
'.js': 'jsx',
|
||||
};
|
||||
return options;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
mdxSource: code,
|
||||
toc,
|
||||
frontMatter: {
|
||||
readingTime: readingTime(code),
|
||||
slug: slug || null,
|
||||
fileName: fs.existsSync(mdxPath) ? `${slug}.mdx` : `${slug}.md`,
|
||||
...frontmatter,
|
||||
date: frontmatter.date ? new Date(frontmatter.date).toISOString() : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllFilesFrontMatter(folder: 'blog') {
|
||||
const prefixPaths = path.join(root, 'data', folder);
|
||||
|
||||
const files = getAllFilesRecursively(prefixPaths);
|
||||
|
||||
const allFrontMatter: PostFrontMatter[] = [];
|
||||
|
||||
files.forEach((file: string) => {
|
||||
// Replace is needed to work on Windows
|
||||
const fileName = file.slice(prefixPaths.length + 1).replace(/\\/g, '/');
|
||||
// Remove Unexpected File
|
||||
if (path.extname(fileName) !== '.md' && path.extname(fileName) !== '.mdx') {
|
||||
return;
|
||||
}
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
const matterFile = matter(source);
|
||||
const frontmatter = matterFile.data as AuthorFrontMatter | PostFrontMatter;
|
||||
if ('draft' in frontmatter && frontmatter.draft !== true) {
|
||||
allFrontMatter.push({
|
||||
...frontmatter,
|
||||
slug: formatSlug(fileName),
|
||||
date: frontmatter.date
|
||||
? new Date(frontmatter.date).toISOString()
|
||||
: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return allFrontMatter.sort((a, b) => dateSortDesc(a.date, b.date));
|
||||
}
|
@ -1,39 +0,0 @@
|
||||
import { Parent } from 'unist';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
export default function remarkCodeTitles() {
|
||||
return (tree: Parent & { lang?: string }) =>
|
||||
visit(
|
||||
tree,
|
||||
'code',
|
||||
(node: Parent & { lang?: string }, index, parent: Parent) => {
|
||||
const nodeLang = node.lang || '';
|
||||
let language = '';
|
||||
let title = '';
|
||||
|
||||
if (nodeLang.includes(':')) {
|
||||
language = nodeLang.slice(0, nodeLang.search(':'));
|
||||
title = nodeLang.slice(nodeLang.search(':') + 1, nodeLang.length);
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
const className = 'remark-code-title';
|
||||
|
||||
const titleNode = {
|
||||
type: 'mdxJsxFlowElement',
|
||||
name: 'div',
|
||||
attributes: [
|
||||
{ type: 'mdxJsxAttribute', name: 'className', value: className },
|
||||
],
|
||||
children: [{ type: 'text', value: title }],
|
||||
data: { _xdmExplicitJsx: true },
|
||||
};
|
||||
|
||||
parent.children.splice(index, 0, titleNode);
|
||||
node.lang = language;
|
||||
}
|
||||
);
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import { VFile } from 'vfile';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { load } from 'js-yaml';
|
||||
import { Parent } from 'unist';
|
||||
|
||||
export default function extractFrontmatter() {
|
||||
return (tree: Parent, file: VFile) => {
|
||||
visit(tree, 'yaml', (node: Parent) => {
|
||||
//@ts-ignore
|
||||
file.data.frontmatter = load(node.value);
|
||||
});
|
||||
};
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
import { Literal, Parent, Node } from 'unist';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import sizeOf from 'image-size';
|
||||
import fs from 'fs';
|
||||
|
||||
type ImageNode = Parent & {
|
||||
url: string;
|
||||
alt: string;
|
||||
name: string;
|
||||
attributes: (Literal & { name: string })[];
|
||||
};
|
||||
|
||||
export default function remarkImgToJsx() {
|
||||
return (tree: Node) => {
|
||||
visit(
|
||||
tree,
|
||||
// only visit p tags that contain an img element
|
||||
(node: Parent): node is Parent =>
|
||||
node.type === 'paragraph' &&
|
||||
node.children.some((n) => n.type === 'image'),
|
||||
(node: Parent) => {
|
||||
const imageNode = node.children.find(
|
||||
(n) => n.type === 'image'
|
||||
) as ImageNode;
|
||||
|
||||
// only local files
|
||||
if (fs.existsSync(`${process.cwd()}/public${imageNode.url}`)) {
|
||||
const dimensions = sizeOf(`${process.cwd()}/public${imageNode.url}`);
|
||||
|
||||
// Convert original node to next/image
|
||||
(imageNode.type = 'mdxJsxFlowElement'),
|
||||
(imageNode.name = 'Image'),
|
||||
(imageNode.attributes = [
|
||||
{ type: 'mdxJsxAttribute', name: 'alt', value: imageNode.alt },
|
||||
{ type: 'mdxJsxAttribute', name: 'src', value: imageNode.url },
|
||||
{
|
||||
type: 'mdxJsxAttribute',
|
||||
name: 'width',
|
||||
value: dimensions.width,
|
||||
},
|
||||
{
|
||||
type: 'mdxJsxAttribute',
|
||||
name: 'height',
|
||||
value: dimensions.height,
|
||||
},
|
||||
]);
|
||||
|
||||
// Change node type from p to div to avoid nesting error
|
||||
node.type = 'div';
|
||||
node.children = [imageNode];
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user