Compare commits
No commits in common. "master" and "develop" have entirely different histories.
@ -8,7 +8,7 @@
|
|||||||
// Append -bullseye or -buster to pin to an OS version.
|
// Append -bullseye or -buster to pin to an OS version.
|
||||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||||
"args": {
|
"args": {
|
||||||
"VARIANT": "18-bullseye"
|
"VARIANT": "16-bullseye"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Configure tool-specific properties.
|
// Configure tool-specific properties.
|
||||||
@ -35,10 +35,7 @@
|
|||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"shardulm94.trailing-spaces",
|
"shardulm94.trailing-spaces",
|
||||||
"lihui.vs-color-picker",
|
"lihui.vs-color-picker",
|
||||||
"bradlc.vscode-tailwindcss",
|
"bradlc.vscode-tailwindcss"
|
||||||
"github.vscode-github-actions",
|
|
||||||
"unifiedjs.vscode-mdx",
|
|
||||||
"Codeium.codeium"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -54,5 +51,5 @@
|
|||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||||
},
|
},
|
||||||
"mounts": [],
|
"mounts": [],
|
||||||
"postAttachCommand": "pnpm install && npm run dev"
|
"postStartCommand": "pnpm install && npm run dev"
|
||||||
}
|
}
|
||||||
|
243
.drone.yml
Normal file
243
.drone.yml
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
---
|
||||||
|
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
|
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# 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
|
12
.env.example
12
.env.example
@ -1,26 +1,32 @@
|
|||||||
# visit https://giscus.app to get your Giscus ids
|
|
||||||
NEXT_PUBLIC_GISCUS_REPO=
|
NEXT_PUBLIC_GISCUS_REPO=
|
||||||
NEXT_PUBLIC_GISCUS_REPOSITORY_ID=
|
NEXT_PUBLIC_GISCUS_REPOSITORY_ID=
|
||||||
NEXT_PUBLIC_GISCUS_CATEGORY=
|
NEXT_PUBLIC_GISCUS_CATEGORY=
|
||||||
NEXT_PUBLIC_GISCUS_CATEGORY_ID=
|
NEXT_PUBLIC_GISCUS_CATEGORY_ID=
|
||||||
NEXT_PUBLIC_UTTERANCES_REPO=
|
NEXT_PUBLIC_UTTERANCES_REPO=
|
||||||
NEXT_PUBLIC_DISQUS_SHORTNAME=
|
NEXT_PUBLIC_DISQUS_SHORTNAME=
|
||||||
|
NEXT_PUBLIC_CUSDIS_APPID=
|
||||||
|
NEXT_PUBLIC_CUSDIS_HOST=
|
||||||
|
NEXT_PUBLIC_COMMENTO_URL=
|
||||||
|
|
||||||
|
|
||||||
MAILCHIMP_API_KEY=
|
MAILCHIMP_API_KEY=
|
||||||
MAILCHIMP_API_SERVER=
|
MAILCHIMP_API_SERVER=
|
||||||
MAILCHIMP_AUDIENCE_ID=
|
MAILCHIMP_AUDIENCE_ID=
|
||||||
|
|
||||||
|
BUTTONDOWN_API_URL=https://api.buttondown.email/v1/
|
||||||
BUTTONDOWN_API_KEY=
|
BUTTONDOWN_API_KEY=
|
||||||
|
|
||||||
|
CONVERTKIT_API_URL=https://api.convertkit.com/v3/
|
||||||
CONVERTKIT_API_KEY=
|
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=
|
CONVERTKIT_FORM_ID=
|
||||||
|
|
||||||
KLAVIYO_API_KEY=
|
KLAVIYO_API_KEY=
|
||||||
KLAVIYO_LIST_ID=
|
KLAVIYO_LIST_ID=
|
||||||
|
|
||||||
|
REVUE_API_URL=https://www.getrevue.co/api/v2/
|
||||||
REVUE_API_KEY=
|
REVUE_API_KEY=
|
||||||
|
|
||||||
|
EMAILOCTOPUS_API_URL=https://emailoctopus.com/api/1.6/
|
||||||
EMAILOCTOPUS_API_KEY=
|
EMAILOCTOPUS_API_KEY=
|
||||||
EMAILOCTOPUS_LIST_ID=
|
EMAILOCTOPUS_LIST_ID=
|
@ -1,2 +1 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.eslintrc.js
|
|
29
.eslintrc.js
29
.eslintrc.js
@ -1,42 +1,17 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
root: true,
|
root: true,
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
amd: true,
|
amd: true,
|
||||||
node: true,
|
node: true,
|
||||||
es6: true,
|
es6: true,
|
||||||
},
|
},
|
||||||
plugins: ['@typescript-eslint'],
|
extends: ['eslint:recommended', 'plugin:prettier/recommended', 'next', 'next/core-web-vitals'],
|
||||||
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: {
|
rules: {
|
||||||
'prettier/prettier': 'error',
|
'prettier/prettier': 'error',
|
||||||
'react/react-in-jsx-scope': 'off',
|
'react/react-in-jsx-scope': 'off',
|
||||||
'jsx-a11y/anchor-is-valid': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
components: ['Link'],
|
|
||||||
specialLink: ['hrefLeft', 'hrefRight'],
|
|
||||||
aspects: ['invalidHref', 'preferButton'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'react/prop-types': 0,
|
'react/prop-types': 0,
|
||||||
'@typescript-eslint/no-unused-vars': 0,
|
'no-unused-vars': 0,
|
||||||
'react/no-unescaped-entities': 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
|
## Source: https://github.com/alexkaratarakis/gitattributes
|
||||||
## Modified * text=auto to * text=auto eol=lf eol=lf to force LF endings.
|
## Modified * text=auto to * text=auto eol=lf to force LF endings.
|
||||||
|
|
||||||
## GITATTRIBUTES FOR WEB PROJECTS
|
## GITATTRIBUTES FOR WEB PROJECTS
|
||||||
#
|
#
|
||||||
|
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: timlrx
|
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**System Info (if dev / build issue):**
|
||||||
|
- OS: [e.g. iOS]
|
||||||
|
- Node version (please ensure you are using 14+)
|
||||||
|
- Npm version
|
||||||
|
|
||||||
|
**Browser Info (if display / formatting issue):**
|
||||||
|
- Device [e.g. Desktop, iPhone6]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
33
.github/workflows/build-and-deploy-by-ftp.yaml
vendored
33
.github/workflows/build-and-deploy-by-ftp.yaml
vendored
@ -1,33 +0,0 @@
|
|||||||
name: 🚀 Build and deploy by ftp
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
ftp-build-and-deploy:
|
|
||||||
name: 🎉 Deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: 🚚 Get latest code
|
|
||||||
uses: https://github.com/actions/checkout@v3
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 18
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install
|
|
||||||
|
|
||||||
- run: yarn build
|
|
||||||
|
|
||||||
- name: 📂 Sync files
|
|
||||||
uses: https://github.com/airvzxf/ftp-deployment-action@latest
|
|
||||||
with:
|
|
||||||
server: ${{ secrets.ftp_server }}
|
|
||||||
user: ${{ secrets.ftp_username }}
|
|
||||||
password: ${{ secrets.ftp_password }}
|
|
||||||
remote_dir: ./WEB/
|
|
||||||
local_dir: ./out/
|
|
11
.gitignore
vendored
11
.gitignore
vendored
@ -4,10 +4,6 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
/.yarn/*
|
|
||||||
!/.yarn/releases
|
|
||||||
!/.yarn/plugins
|
|
||||||
!/.yarn/sdks
|
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
@ -21,13 +17,9 @@ public/sitemap.xml
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
*.xml
|
*.xml
|
||||||
|
|
||||||
# rss feed
|
# rss feed
|
||||||
/public/feed.xml
|
/public/feed.xml
|
||||||
|
|
||||||
# search
|
|
||||||
/public/search.json
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
@ -43,9 +35,6 @@ yarn-error.log*
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
# Contentlayer
|
|
||||||
.contentlayer
|
|
||||||
|
|
||||||
secrets.txt
|
secrets.txt
|
||||||
|
|
||||||
.pnpm-store
|
.pnpm-store
|
6
.prettierrc.js
Normal file
6
.prettierrc.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
singleQuote: true,
|
||||||
|
trailingCommas: 'all',
|
||||||
|
bracketSpacing: true,
|
||||||
|
bracketSameLine: true,
|
||||||
|
};
|
16
.vscode/launch.json
vendored
16
.vscode/launch.json
vendored
@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
// Use IntelliSense to learn about possible attributes.
|
|
||||||
// Hover to view descriptions of existing attributes.
|
|
||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"type": "chrome",
|
|
||||||
"request": "launch",
|
|
||||||
"name": "Launch Chrome against localhost",
|
|
||||||
"url": "http://localhost:3000/",
|
|
||||||
"runtimeArgs": ["--disable-web-security", "--enable-precise-memory-info"],
|
|
||||||
"userDataDir": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,6 +1,4 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"alpn",
|
"alpn",
|
||||||
"appleboy",
|
"appleboy",
|
||||||
@ -15,7 +13,6 @@
|
|||||||
"EMAILOCTOPUS",
|
"EMAILOCTOPUS",
|
||||||
"fullchain",
|
"fullchain",
|
||||||
"Giscus",
|
"Giscus",
|
||||||
"Hackintosh",
|
|
||||||
"KLAVIYO",
|
"KLAVIYO",
|
||||||
"Kutt",
|
"Kutt",
|
||||||
"lastmod",
|
"lastmod",
|
||||||
|
874
.yarn/releases/yarn-3.6.1.cjs
vendored
874
.yarn/releases/yarn-3.6.1.cjs
vendored
File diff suppressed because one or more lines are too long
@ -1,3 +0,0 @@
|
|||||||
nodeLinker: node-modules
|
|
||||||
|
|
||||||
yarnPath: .yarn/releases/yarn-3.6.1.cjs
|
|
192
README.md
192
README.md
@ -1,136 +1,16 @@
|
|||||||
![tailwind-nextjs-banner](/public/static/images/twitter-card.png)
|
![tailwind-nextjs-banner](/public/static/images/twitter-card.png)
|
||||||
|
|
||||||
# Tailwind Nextjs Starter Blog
|
# Ivan Li's Blog
|
||||||
|
|
||||||
[![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/)
|
[![Build Status](https://ci.ivanli.cc/api/badges/Ivan/tailwind-nextjs-blog/status.svg)](https://ci.ivanli.cc/Ivan/tailwind-nextjs-blog)
|
||||||
[![GitHub forks](https://img.shields.io/github/forks/timlrx/tailwind-nextjs-starter-blog?style=social)](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/network/)
|
[![Website Status](https://uptime.sg.ivanli.cc/api/badge/18/uptime/720?label=30&labelSuffix=d)](https://ivanli.cc)
|
||||||
[![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)
|
|
||||||
|
|
||||||
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
|
Ivan Li's Blog, base [timlrx/tailwind-nextjs-starter-blog](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
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@ -138,12 +18,18 @@ yarn
|
|||||||
First, run the development server:
|
First, run the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn dev
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
Edit the layout in `app` or content in `data`. With live reloading, the pages auto-updates as you edit them.
|
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
## Extend / Customize
|
## Extend / Customize
|
||||||
|
|
||||||
@ -161,41 +47,36 @@ Edit the layout in `app` or content in `data`. With live reloading, the pages au
|
|||||||
|
|
||||||
`public/static` - store assets such as images and favicons.
|
`public/static` - store assets such as images and favicons.
|
||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
`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).
|
`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).
|
||||||
|
|
||||||
`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/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/).
|
||||||
|
|
||||||
`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).
|
`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.
|
||||||
|
|
||||||
`layouts` - main templates used in pages:
|
`layouts` - main templates used in pages.
|
||||||
|
|
||||||
- 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.
|
`pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information.
|
||||||
- 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.
|
`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
|
## 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
|
||||||
|
|
||||||
Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/).
|
Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/).
|
||||||
|
|
||||||
Please refer to `contentlayer.config.ts` for an up to date list of supported fields. The following fields are supported:
|
Currently 7 fields are supported.
|
||||||
|
|
||||||
```
|
```
|
||||||
title (required)
|
title (required)
|
||||||
date (required)
|
date (required)
|
||||||
tags (optional)
|
tags (required, can be empty array)
|
||||||
lastmod (optional)
|
lastmod (optional)
|
||||||
draft (optional)
|
draft (optional)
|
||||||
summary (optional)
|
summary (optional)
|
||||||
images (optional)
|
images (optional, if none provided defaults to socialBanner in siteMetadata config)
|
||||||
authors (optional list which should correspond to the file names in `data/authors`. Uses `default` if none is specified)
|
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`)
|
layout (optional list which should correspond to the file names in `data/layouts`)
|
||||||
canonicalUrl (optional, canonical url for the post for SEO)
|
canonicalUrl (optional, canonical url for the post for SEO)
|
||||||
@ -218,29 +99,12 @@ 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
|
## Deploy
|
||||||
|
|
||||||
**Vercel**
|
Drone CI.
|
||||||
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,20 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
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 };
|
|
@ -1,138 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,30 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
42
app/head.tsx
42
app/head.tsx
@ -1,42 +0,0 @@
|
|||||||
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" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
124
app/layout.tsx
124
app/layout.tsx
@ -1,124 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
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,13 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
36
app/seo.tsx
36
app/seo.tsx
@ -1,36 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
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,50 +0,0 @@
|
|||||||
{
|
|
||||||
"itx": 1,
|
|
||||||
"主机": 1,
|
|
||||||
"hackintosh": 1,
|
|
||||||
"硬件": 1,
|
|
||||||
"threejs": 1,
|
|
||||||
"navigation-mesh": 1,
|
|
||||||
"3d": 1,
|
|
||||||
"game": 1,
|
|
||||||
"path-finding": 1,
|
|
||||||
"github-actions": 1,
|
|
||||||
"cicd": 1,
|
|
||||||
"docker": 3,
|
|
||||||
"react": 1,
|
|
||||||
"baas": 1,
|
|
||||||
"self-hosted": 3,
|
|
||||||
"appwrite": 1,
|
|
||||||
"nhost": 1,
|
|
||||||
"supabase": 1,
|
|
||||||
"pve": 2,
|
|
||||||
"de": 1,
|
|
||||||
"环境搭建": 3,
|
|
||||||
"debian": 1,
|
|
||||||
"arch-linux": 3,
|
|
||||||
"vps": 3,
|
|
||||||
"zerotier": 1,
|
|
||||||
"tailscale": 1,
|
|
||||||
"sd-wan": 1,
|
|
||||||
"nat": 1,
|
|
||||||
"frp": 1,
|
|
||||||
"verdaccio": 1,
|
|
||||||
"caddy": 2,
|
|
||||||
"registry": 1,
|
|
||||||
"nodejs": 1,
|
|
||||||
"sni": 1,
|
|
||||||
"tls": 1,
|
|
||||||
"reverse-proxy": 1,
|
|
||||||
"反向代理": 1,
|
|
||||||
"xray": 2,
|
|
||||||
"vless": 1,
|
|
||||||
"blog": 1,
|
|
||||||
"markdown": 1,
|
|
||||||
"nextjs": 1,
|
|
||||||
"tailwind-css": 1,
|
|
||||||
"acme": 1,
|
|
||||||
"acmesh": 1,
|
|
||||||
"docker-compose": 1,
|
|
||||||
"内网穿透": 1,
|
|
||||||
"linux": 1
|
|
||||||
}
|
|
@ -1,50 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
@ -1,43 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -2,7 +2,7 @@ import Image from './Image';
|
|||||||
import Link from './Link';
|
import Link from './Link';
|
||||||
|
|
||||||
const Card = ({ title, description, imgSrc, href }) => (
|
const Card = ({ title, description, imgSrc, href }) => (
|
||||||
<div className="md max-w-[544px] p-4 md:w-1/2">
|
<div className="md p-4 md:w-1/2" style={{ maxWidth: '544px' }}>
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
imgSrc && 'h-full'
|
imgSrc && 'h-full'
|
||||||
|
23
components/ClientReload.tsx
Normal file
23
components/ClientReload.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
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;
|
||||||
|
};
|
@ -1,19 +0,0 @@
|
|||||||
'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} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -31,7 +31,9 @@ export default function Footer() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-8 text-sm text-gray-500 dark:text-gray-400">
|
<div className="mb-8 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
|
<Link
|
||||||
|
href="https://github.com/timlrx/tailwind-nextjs-starter-blog"
|
||||||
|
rel="nofollow">
|
||||||
Tailwind Nextjs Theme
|
Tailwind Nextjs Theme
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,47 +0,0 @@
|
|||||||
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,8 +1,5 @@
|
|||||||
const Image = ({
|
import NextImage, { ImageProps } from 'next/image';
|
||||||
...rest
|
|
||||||
}: React.DetailedHTMLProps<
|
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />;
|
||||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
|
||||||
HTMLImageElement
|
|
||||||
>) => <img {...rest} />;
|
|
||||||
|
|
||||||
export default Image;
|
export default Image;
|
||||||
|
@ -1,23 +1,53 @@
|
|||||||
import { Inter } from 'next/font/google';
|
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 SectionContainer from './SectionContainer';
|
||||||
import Footer from './Footer';
|
import Footer from './Footer';
|
||||||
|
import MobileNav from './MobileNav';
|
||||||
|
import ThemeSwitch from './ThemeSwitch';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import Header from './Header';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inter = Inter({
|
|
||||||
subsets: ['latin'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const LayoutWrapper = ({ children }: Props) => {
|
const LayoutWrapper = ({ children }: Props) => {
|
||||||
return (
|
return (
|
||||||
<SectionContainer>
|
<SectionContainer>
|
||||||
<div
|
<div className="flex h-screen flex-col justify-between">
|
||||||
className={`${inter.className} flex h-screen flex-col justify-between font-sans`}>
|
<header className="flex items-center justify-between py-10">
|
||||||
<Header />
|
<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>
|
||||||
<main className="mb-auto">{children}</main>
|
<main className="mb-auto">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,17 +1,23 @@
|
|||||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { LinkProps } from 'next/link';
|
import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react';
|
||||||
import { AnchorHTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
const CustomLink = ({
|
const CustomLink = ({
|
||||||
href,
|
href,
|
||||||
...rest
|
...rest
|
||||||
}: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
}: DetailedHTMLProps<
|
||||||
|
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||||
|
HTMLAnchorElement
|
||||||
|
>) => {
|
||||||
const isInternalLink = href && href.startsWith('/');
|
const isInternalLink = href && href.startsWith('/');
|
||||||
const isAnchorLink = href && href.startsWith('#');
|
const isAnchorLink = href && href.startsWith('#');
|
||||||
|
|
||||||
if (isInternalLink) {
|
if (isInternalLink) {
|
||||||
return <Link href={href} {...rest} />;
|
return (
|
||||||
|
<Link href={href}>
|
||||||
|
<a {...rest} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAnchorLink) {
|
if (isAnchorLink) {
|
||||||
|
@ -1,14 +1,39 @@
|
|||||||
import TOCInline from 'pliny/ui/TOCInline';
|
/* eslint-disable react/display-name */
|
||||||
import Pre from 'pliny/ui/Pre';
|
import React, { useMemo } from 'react';
|
||||||
import BlogNewsletterForm from 'pliny/ui/BlogNewsletterForm';
|
import { ComponentMap, getMDXComponent } from 'mdx-bundler/client';
|
||||||
import type { MDXComponents } from 'mdx/types';
|
|
||||||
import Image from './Image';
|
import Image from './Image';
|
||||||
import CustomLink from './Link';
|
import CustomLink from './Link';
|
||||||
|
import TOCInline from './TOCInline';
|
||||||
|
import Pre from './Pre';
|
||||||
|
import { BlogNewsletterForm } from './NewsletterForm';
|
||||||
|
|
||||||
export const components: MDXComponents = {
|
const Wrapper: React.ComponentType<{ layout: string }> = ({
|
||||||
|
layout,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
const Layout = require(`../layouts/${layout}`).default;
|
||||||
|
return <Layout {...rest} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MDXComponents: ComponentMap = {
|
||||||
Image,
|
Image,
|
||||||
|
//@ts-ignore
|
||||||
TOCInline,
|
TOCInline,
|
||||||
a: CustomLink,
|
a: CustomLink,
|
||||||
pre: Pre,
|
pre: Pre,
|
||||||
|
wrapper: Wrapper,
|
||||||
|
//@ts-ignore
|
||||||
BlogNewsletterForm,
|
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,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from './Link';
|
import Link from './Link';
|
||||||
import headerNavLinks from '@/data/headerNavLinks';
|
import headerNavLinks from '@/data/headerNavLinks';
|
||||||
@ -20,45 +18,41 @@ const MobileNav = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="sm:hidden">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-1 mr-1 h-8 w-8 rounded py-1"
|
||||||
aria-label="Toggle Menu"
|
aria-label="Toggle Menu"
|
||||||
onClick={onToggleNav}
|
onClick={onToggleNav}>
|
||||||
className="sm:hidden">
|
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
className="text-gray-900 dark:text-gray-100 h-8 w-8">
|
className="text-gray-900 dark:text-gray-100">
|
||||||
<path
|
{navShow ? (
|
||||||
fillRule="evenodd"
|
<path
|
||||||
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"
|
fillRule="evenodd"
|
||||||
clipRule="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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
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 ${
|
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 ${
|
||||||
navShow ? 'translate-x-0' : 'translate-x-full'
|
navShow ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex justify-end">
|
<button
|
||||||
<button
|
type="button"
|
||||||
className="mr-8 mt-11 h-8 w-8"
|
aria-label="toggle modal"
|
||||||
aria-label="Toggle Menu"
|
className="fixed h-full w-full cursor-auto focus:outline-none"
|
||||||
onClick={onToggleNav}>
|
onClick={onToggleNav}></button>
|
||||||
<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">
|
<nav className="fixed mt-8 h-full">
|
||||||
{headerNavLinks.map((link) => (
|
{headerNavLinks.map((link) => (
|
||||||
<div key={link.title} className="px-12 py-4">
|
<div key={link.title} className="px-12 py-4">
|
||||||
@ -72,7 +66,7 @@ const MobileNav = () => {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
93
components/NewsletterForm.tsx
Normal file
93
components/NewsletterForm.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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>
|
||||||
|
);
|
48
components/Pagination.tsx
Normal file
48
components/Pagination.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
77
components/Pre.tsx
Normal file
77
components/Pre.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
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;
|
208
components/SEO.tsx
Normal file
208
components/SEO.tsx
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
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,6 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
const ScrollTopAndComment = () => {
|
const ScrollTopAndComment = () => {
|
||||||
@ -17,32 +14,32 @@ const ScrollTopAndComment = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleScrollTop = () => {
|
const handleScrollTop = () => {
|
||||||
window.scrollTo({ top: 0 });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
const handleScrollToComment = () => {
|
const handleScrollToComment = () => {
|
||||||
document.getElementById('comment')?.scrollIntoView();
|
document.getElementById('comment').scrollIntoView();
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed bottom-8 right-8 hidden flex-col gap-3 ${
|
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${
|
||||||
show ? 'md:flex' : 'md:hidden'
|
show ? 'md:flex' : 'md:hidden'
|
||||||
}`}>
|
}`}>
|
||||||
{siteMetadata.comments?.provider && (
|
<button
|
||||||
<button
|
aria-label="Scroll To Comment"
|
||||||
aria-label="Scroll To Comment"
|
type="button"
|
||||||
onClick={handleScrollToComment}
|
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">
|
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">
|
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
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"
|
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"
|
clipRule="evenodd"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
aria-label="Scroll To Top"
|
aria-label="Scroll To Top"
|
||||||
|
type="button"
|
||||||
onClick={handleScrollTop}
|
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">
|
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
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;
|
|
@ -6,8 +6,8 @@ interface Props {
|
|||||||
|
|
||||||
export default function SectionContainer({ children }: Props) {
|
export default function SectionContainer({ children }: Props) {
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">
|
<div className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
74
components/TOCInline.tsx
Normal file
74
components/TOCInline.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
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,15 +1,16 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { slug } from 'github-slugger';
|
import kebabCase from '@/lib/utils/kebabCase';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Tag = ({ text }: Props) => {
|
const Tag = ({ text }: Props) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link href={`/tags/${kebabCase(text)}`}>
|
||||||
href={`/tags/${slug(text)}`}
|
<a className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
{text.split(' ').join('-')}
|
||||||
{text.split(' ').join('-')}
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,29 +1,29 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
const ThemeSwitch = () => {
|
const ThemeSwitch = () => {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||||
|
|
||||||
// When mounted on client, now we can show the UI
|
// When mounted on client, now we can show the UI
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Toggle Dark Mode"
|
aria-label="Toggle Dark Mode"
|
||||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
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'
|
||||||
|
)
|
||||||
|
}>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
className="text-gray-900 dark:text-gray-100 h-6 w-6">
|
className="text-gray-900 dark:text-gray-100">
|
||||||
{mounted && theme === 'dark' ? (
|
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
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"
|
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"
|
||||||
|
36
components/analytics/GoogleAnalytics.tsx
Normal file
36
components/analytics/GoogleAnalytics.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
};
|
27
components/analytics/Plausible.tsx
Normal file
27
components/analytics/Plausible.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
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);
|
||||||
|
};
|
28
components/analytics/SimpleAnalytics.tsx
Normal file
28
components/analytics/SimpleAnalytics.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
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;
|
18
components/analytics/Umami.tsx
Normal file
18
components/analytics/Umami.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
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;
|
32
components/analytics/index.tsx
Normal file
32
components/analytics/index.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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;
|
33
components/comments/Commento.tsx
Normal file
33
components/comments/Commento.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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;
|
41
components/comments/Cusdis.tsx
Normal file
41
components/comments/Cusdis.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
|
import { ReactCusdis } from 'react-cusdis';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
frontMatter: PostFrontMatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Cusdis = ({ 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">
|
||||||
|
<ReactCusdis
|
||||||
|
key={commentsTheme}
|
||||||
|
lang={siteMetadata.language?.toLocaleLowerCase()}
|
||||||
|
attrs={{
|
||||||
|
appId: siteMetadata.comment.cusdisConfig.appId,
|
||||||
|
host: siteMetadata.comment.cusdisConfig.host,
|
||||||
|
pageId: frontMatter.slug,
|
||||||
|
pageUrl: window.location.href,
|
||||||
|
pageTitle: frontMatter.title,
|
||||||
|
theme: commentsTheme,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Cusdis;
|
51
components/comments/Disqus.tsx
Normal file
51
components/comments/Disqus.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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;
|
78
components/comments/Giscus.tsx
Normal file
78
components/comments/Giscus.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
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;
|
58
components/comments/Utterances.tsx
Normal file
58
components/comments/Utterances.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
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;
|
93
components/comments/commento/ReactCommento.tsx
Normal file
93
components/comments/commento/ReactCommento.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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;
|
80
components/comments/index.tsx
Normal file
80
components/comments/index.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
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 CusdisComponent = dynamic(
|
||||||
|
() => {
|
||||||
|
return import('@/components/comments/Cusdis');
|
||||||
|
},
|
||||||
|
{ 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 === 'cusdis' && (
|
||||||
|
<CusdisComponent frontMatter={frontMatter} />
|
||||||
|
)}
|
||||||
|
{siteMetadata.comment && siteMetadata.comment.provider === 'commento' && (
|
||||||
|
<CommentoComponent frontMatter={frontMatter} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Comments;
|
1
components/social-icons/facebook.svg
Normal file
1
components/social-icons/facebook.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 403 B |
1
components/social-icons/github.svg
Normal file
1
components/social-icons/github.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 827 B |
@ -1,61 +0,0 @@
|
|||||||
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,12 +1,11 @@
|
|||||||
import {
|
import Mail from './mail.svg';
|
||||||
Mail,
|
import Github from './github.svg';
|
||||||
Github,
|
import Facebook from './facebook.svg';
|
||||||
Facebook,
|
import Youtube from './youtube.svg';
|
||||||
Youtube,
|
import Linkedin from './linkedin.svg';
|
||||||
Linkedin,
|
import Twitter from './twitter.svg';
|
||||||
Twitter,
|
|
||||||
Mastodon,
|
// Icons taken from: https://simpleicons.org/
|
||||||
} from './icons';
|
|
||||||
|
|
||||||
const components = {
|
const components = {
|
||||||
mail: Mail,
|
mail: Mail,
|
||||||
@ -15,16 +14,9 @@ const components = {
|
|||||||
youtube: Youtube,
|
youtube: Youtube,
|
||||||
linkedin: Linkedin,
|
linkedin: Linkedin,
|
||||||
twitter: Twitter,
|
twitter: Twitter,
|
||||||
mastodon: Mastodon,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type SocialIconProps = {
|
const SocialIcon = ({ kind, href, size = 8 }) => {
|
||||||
kind: keyof typeof components;
|
|
||||||
href: string | undefined;
|
|
||||||
size?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SocialIcon = ({ kind, href, size = 8 }: SocialIconProps) => {
|
|
||||||
if (
|
if (
|
||||||
!href ||
|
!href ||
|
||||||
(kind === 'mail' &&
|
(kind === 'mail' &&
|
||||||
@ -42,7 +34,7 @@ const SocialIcon = ({ kind, href, size = 8 }: SocialIconProps) => {
|
|||||||
href={href}>
|
href={href}>
|
||||||
<span className="sr-only">{kind}</span>
|
<span className="sr-only">{kind}</span>
|
||||||
<SocialSvg
|
<SocialSvg
|
||||||
className={`fill-current text-gray-700 hover:text-primary-500 dark:text-gray-200 dark:hover:text-primary-400 h-${size} w-${size}`}
|
className={`fill-current text-gray-700 hover:text-blue-500 dark:text-gray-200 dark:hover:text-blue-400 h-${size} w-${size}`}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
1
components/social-icons/linkedin.svg
Normal file
1
components/social-icons/linkedin.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 615 B |
4
components/social-icons/mail.svg
Normal file
4
components/social-icons/mail.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 224 B |
1
components/social-icons/twitter.svg
Normal file
1
components/social-icons/twitter.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 607 B |
1
components/social-icons/youtube.svg
Normal file
1
components/social-icons/youtube.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 474 B |
@ -1,157 +0,0 @@
|
|||||||
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 */
|
/* Code title styles */
|
||||||
.remark-code-title {
|
.remark-code-title {
|
||||||
@apply rounded-t bg-gray-700 dark:bg-gray-800 px-5 py-3 font-mono text-sm font-bold text-gray-200;
|
@apply rounded-t bg-gray-700 px-5 py-3 font-mono text-sm font-bold text-gray-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remark-code-title + div > pre {
|
.remark-code-title + div > pre {
|
||||||
@ -138,7 +138,3 @@
|
|||||||
.token.table {
|
.token.table {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token.table {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
@ -11,11 +11,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footnotes {
|
.footnotes {
|
||||||
@apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700;
|
@apply pt-8 mt-12 border-t border-gray-200 dark:border-gray-700;
|
||||||
}
|
|
||||||
|
|
||||||
.data-footnote-backref {
|
|
||||||
@apply no-underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.csl-entry {
|
.csl-entry {
|
||||||
@ -25,7 +21,5 @@
|
|||||||
/* https://stackoverflow.com/questions/61083813/how-to-avoid-internal-autofill-selected-style-to-be-applied */
|
/* https://stackoverflow.com/questions/61083813/how-to-avoid-internal-autofill-selected-style-to-be-applied */
|
||||||
input:-webkit-autofill,
|
input:-webkit-autofill,
|
||||||
input:-webkit-autofill:focus {
|
input:-webkit-autofill:focus {
|
||||||
transition:
|
transition: background-color 600000s 0s, color 600000s 0s;
|
||||||
background-color 600000s 0s,
|
|
||||||
color 600000s 0s;
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Ivan Li
|
name: Ivan Li
|
||||||
avatar: https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png
|
avatar: https://pan.ivanli.cc/api/v3/file/source/1234/头像.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0
|
||||||
occupation: Web Full Stack Developer
|
occupation: Web Full Stack Developer
|
||||||
email: master@ivanli.cc
|
email: master@ivanli.cc
|
||||||
github: https://github.com/IvanLi-CN
|
github: https://github.com/IvanLi-CN
|
@ -1,189 +0,0 @@
|
|||||||
---
|
|
||||||
title: 2023 年,再组一台黑苹果 ITX 主机
|
|
||||||
date: '2023-10-20'
|
|
||||||
tags: ['ITX', '主机', 'Hackintosh', '硬件']
|
|
||||||
draft: false
|
|
||||||
summary: 这是第二次组黑苹果台式机了,上一次是第一次,所以保守地选择了 i5-10400 + B460M + 6600XT 的组合。因为最近感受到 CPU 性能有一些吃紧,并且 ITX 的遗憾又开始涌上心头,最后看到 V 站老哥出 10700K + Z490I。顺势入手了。但是夜长梦多,挑选了半天的配件,最后发现应该买 12600K 比较合适。只能友好地鸽掉了 TAT
|
|
||||||
---
|
|
||||||
|
|
||||||
这是第二次组黑苹果台式机了,上一次是第一次,所以保守地选择了 i5-10400 + B460M + 6600XT 的组合。因为最近感受到 CPU 性能有一些吃紧,并且 ITX 的遗憾又开始涌上心头,最后看到 V 站老哥出 10700K + Z490I。顺势入手了。但是夜长梦多,挑选了半天的配件,最后发现应该买 12600K 比较合适。只能友好地鸽掉了 TAT
|
|
||||||
|
|
||||||
## 机箱
|
|
||||||
|
|
||||||
由于一开始定位是小体积 ITX 主机,加上我依然想沿用 6600XT,所以可选的目标就两种,一种是直插式的机箱,以 K66 为代表的那些;另一种是 A4 结构的机箱。
|
|
||||||
|
|
||||||
### 直插式
|
|
||||||
|
|
||||||
![傻瓜超人 K66 青春版](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/c09ff5fd-f068-42d6-a259-6e80b0f856a7.png ' =269x198')我看了好多款,小体积下只能放 240mm 的显卡,我的显卡刚好是 240mm,所以非常的极限。有些机箱前置的 IO 可能会和显卡冲突,也有的机箱设计上就很难放入 240mm 的显卡,所以作罢。
|
|
||||||
|
|
||||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/917695f5-9592-4350-b3b4-088cae5d756d.png ' =269x284')
|
|
||||||
|
|
||||||
### A4 结构
|
|
||||||
|
|
||||||
A4 结构的机箱,虽说成本会高出一条 PCIE 延长线,但是能放得下 300mm 的显卡。追求小,也是要成本的。不过体积上也会比直插的再小上一点点。大概代表就是蜂鸟 i100 了,同类产品还有闪鳞 S300。。
|
|
||||||
|
|
||||||
![蜂鸟 i100](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/3cc7cc07-5b16-4f43-8ce4-b895b79e01d5.png ' =269x214')
|
|
||||||
|
|
||||||
两天前,我选择了最便宜也是比较轻的蜂鸟 i100,机箱隔天到,发现显卡厚了点,装不下。后来发现,同样 128mm 的机箱宽度,i100 的显卡限宽是 43mm,我的 6600XT 是 45mm的,i100 pro 不知道从什么地方挤了 5mm 给显卡,实现了 48mm 的显卡限宽。当时没留意这个差别,买了 i100,所以只能加钱换货换成 i100 pro 了。
|
|
||||||
|
|
||||||
昨天,我终于找到了官方旗舰店有货的 6800,一看尺寸,得,i100 pro 也放不下,差一点……然后看了眼闪鳞,完了,闪鳞 S300 才是我要的机箱,真正的 Mesh 侧板,8.1 L 的体积,下方能放两个薄扇出风。更重要的是价格和 i100 pro 相当。选配 PCIE 4.0 的线,价格比 i100 pro 便宜呀!
|
|
||||||
|
|
||||||
## 板 U 选择
|
|
||||||
|
|
||||||
因为机箱已经确定了,所以也只能用下压式散热器了。我选择的这家店可配超频三-降龙v53,解热功耗是 150W,再考虑我是来提升性能的,所以可选的 CPU 就三款了。这三款 CPU 分别是 Intel Core i7-10700K、i7-12600K、i7-12700。为啥没有 i7-10700?因为如果选 10700,我买个 CPU 换就好了,实在没必要为 ITX 组一台新的机子……
|
|
||||||
|
|
||||||
### i5-12600K 对比 i7-10700K
|
|
||||||
|
|
||||||
看[参数对比](https://www.cpu-monkey.com/en/compare_cpu-intel_core_i5_12600k-vs-intel_core_i7_10700k),能注意到 12600K 单核频率是有 0.1 GHz(睿频 0.2 GHz)的降低,但是实际单核表现是高 20% 左右的。虽然黑苹果用不了核显,但是我是装双系统,所以 Windows 下还能有加成的,不用想都知道 **[Intel UHD Graphics 770 ](https://www.cpu-monkey.com/en/igpu-intel_uhd_graphics_770)**的性能比 630 好不少。
|
|
||||||
|
|
||||||
前面说到,我是在 7.5 L 的 A4 机箱用下压散热器, 12600K 的 PL2 是 150W,而 10700K 是 229W(@56s),所以在散热压力上,i5-12600K 默频应该没什么问题。也因为是在这小体积机箱里,所以我也不想着超频了。
|
|
||||||
|
|
||||||
根据我在网络上冲浪几小时后获得的信息, 12600K 性能比 10700K 高一些。所以如果是购买全新的板 U,上 12600K 是不错的选择。而在我的情况,我本来是准备收二手的 10700K + Z490I 主板,但是因为小体积机箱散热问题,以及绝不可能超频,所以 2000 收这套配置(折算 CPU 1350 元左右)不台划算,毕竟目前 12600K 散片是 1500 元出头。150 元换来性能加成 + 店保三年 + 更低 LP2 是很值的。
|
|
||||||
|
|
||||||
### B660 还是 Z690?
|
|
||||||
|
|
||||||
首先要说明一点,因为 B660 与 B670、Z690 与 Z790 都支持 12 代和 13 代 CPU,所以我直接都叫 B660 和 Z690 了,后面选具体型号也不拘泥芯片组代数。
|
|
||||||
|
|
||||||
不是土豪的话,超频就选 Z690,否则选 B660,这个毋庸置疑了。但因为超频会更热,更热小机箱顶不住,所以短期内不会考虑超频。加算上价格能差大几百块,所以我只考虑 B660、B760 芯片组的主板。
|
|
||||||
|
|
||||||
那不超频的情况下,选带 K 的 12600K 是否合理呢?不知道 Intel 出于什么考虑划分的定位,12600K 是 12 代 i5 唯一一个有能效核的 CPU,这多加的四个能效核能带来更好的多核表现,至于功耗,因为在黑苹果下,目前能用得上能效核已经够我高兴很久了,还没去查功耗表现,但在 Windows 下,应该是能低负载待机功耗的。再对比不带 K 的 12600,高了 0.1GHz 的主频和睿频,多了四个能效核,但只贵 250 元(我都没找到有卖的)。在这情况下,选择 12600K + B660 其实很合适。
|
|
||||||
|
|
||||||
没考虑 H610 主要是怕太丐了带不动……主观臆测,没证实过,嘿嘿。
|
|
||||||
|
|
||||||
### 精粤 B760I 还是铭瑄B760ITX
|
|
||||||
|
|
||||||
![7块B760i ITX主板大横比,用魔法打败魔法,体验不输白果Windows+MacOS双系统性价比方案推荐_哔哩哔哩_bilibili](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/1c50b3b5-f4d8-4442-99a6-4f0503179744.png)
|
|
||||||
|
|
||||||
从[7块B760i ITX主板大横比](https://www.bilibili.com/video/BV15M4y117My/)的总结可以大概感受一下。既然在这个价位选,这两款本该丰俭由人。但是精粤目前睡眠问题好像还是比较迷惑,似乎还没有得到解决,尤其是 Snow Dream 版本。为了避免无法睡眠或者睡死导致电费增加、光污染之类的问题。我选了铭瑄。目前某宝 829 元拿下,精粤B760ITX Snow Dream 588 元。加 241 元换个稳定,加个 20G 的后置 USB C 口,带个无线网卡,少个千兆有线。我感觉是划得来。可惜的一点是铭瑄的主板不是白色的,与我的白色主题不太搭。不过装完机后应该不怎么可见,无伤大雅。
|
|
||||||
|
|
||||||
### i5-12600K 对比 i7-12700
|
|
||||||
|
|
||||||
现在,选择范围就是 12600K 与 12700 了。为何还要纠结 12700 呢?因为我不超频,那高一档的 12700 似乎也是个不错的选择。
|
|
||||||
|
|
||||||
看[对比](https://www.cpu-monkey.com/en/compare_cpu-intel_core_i5_12600k-vs-intel_core_i7_12700),12700 多两个性能核,性能核具有更低的主频、一样的睿频,核显睿频稍高 0.5GHz。性能表现会比默频 12600K 好一些。PL2 也来到 180W 了。
|
|
||||||
|
|
||||||
~~但是考虑到散热问题,估计不太可能能发挥多出来的这部分性能,我想没必要加三百元为了多两个性能核,虽然我很想要这两个性能核,因为我经常用 FFMPGE 转码 1 分钟左右的视频,这时 CPU 都是吃满的。~~
|
|
||||||
|
|
||||||
隔天,商家说 12600K 没货了,估计是这个价格的没了,市场价会再高一点,然后商家说 12700 主频低,低负载的情况下功耗表现会比 12600K 好。这话说得我差点就信了,CPU 空闲的时候频率可是能比主频低得多得多呀!
|
|
||||||
|
|
||||||
但是因为这个几十块的涨价,让我又开始想 12700 了,毕竟两个性能核呀!思来想去,加了三百五去别家买了 12700,希望在这极限散热的情况下,能有良好的日常使用表现。
|
|
||||||
|
|
||||||
综上,我决定入手 12700 + 铭瑄 B670I,2327 元。比收12700K + Z690I 贵六百五,但全新有保修,性能绝对更好,发热更低。算是合理。黑苹果同样比较完美,还成。
|
|
||||||
|
|
||||||
## 其他
|
|
||||||
|
|
||||||
电源计划选个 650W 的白色全模组电源,目前看上了 TT 家的金牌 SFX 电源。京东自营 659 元,应该还可以。
|
|
||||||
|
|
||||||
机箱上摆两个风扇,买俩可以直接拼接的风扇,65 元。
|
|
||||||
|
|
||||||
固态暂时用我手上有的 PCIE 3.0 的 M.2 固态。
|
|
||||||
|
|
||||||
内存直接从旧机子拔,DDR4 32G x2,正好装满,主板只支持 64G 内存。
|
|
||||||
|
|
||||||
## 装机小结
|
|
||||||
|
|
||||||
最终装机单:
|
|
||||||
|
|
||||||
| 项目 | 型号 | 备注 |
|
|
||||||
| ------------- | ----------------------------------------------------------------------------------- | -------------------------------------- |
|
|
||||||
| CPU | **[Intel Core i5-12700](https://www.cpu-monkey.com/zh-cn/cpu-intel_core_i7_12700)** | |
|
|
||||||
| 主板 | [MS-终结者 B760ITX D4 WIFI](https://www.maxsun.com.cn/2023/0206/5881.html) | |
|
|
||||||
| 显卡 | AMD Radeon RX 6800XT | |
|
|
||||||
| 硬盘 | Crucial 英睿达 P5 1TB 3D NAND NVMe\n京东京造 J.ZAO QL SERIES 1TB SSD(保修换新) | |
|
|
||||||
| 电源 | TT 钢影SFX 650W | |
|
|
||||||
| CPU 散热 | 超频三 降龙 V53 绚彩版 | |
|
|
||||||
| 机箱 | 闪鳞 S300 + PCIE 4.0 延长线 | |
|
|
||||||
| ~~8015 风扇~~ | | 最后没装。除了烤机,电源下方不怎么积热 |
|
|
||||||
| 12015 风扇 | | 装机箱下主板下方对应风扇位出风 |
|
|
||||||
| 12025 风扇 | x2 | 摆机箱上面,抽风 |
|
|
||||||
|
|
||||||
## 性能调教
|
|
||||||
|
|
||||||
### CPU
|
|
||||||
|
|
||||||
#### CPU-Z
|
|
||||||
|
|
||||||
通过 CPU-Z 的跑分来确定性能,加上五分钟左右的 CPU 单烤来验证功耗与性能发挥的稳定性。
|
|
||||||
|
|
||||||
| 微码 | AC | 电压偏移 | PWM | 单核 | 多核 | 烤机 | 链接 |
|
|
||||||
| ---- | ---- | -------- | --- | ---- | ---- | ------------------------------- | ---------------------------------------------------------- |
|
|
||||||
| 默认 | 默认 | 默认 | 1.7 | 739 | 8673 | | [https://valid.x86.fr/wh5k3k](https://valid.x86.fr/wh5k3k) |
|
|
||||||
| 104 | 90 | -100 | 1.7 | 747 | 7123 | | [https://valid.x86.fr/3i8s1u](https://valid.x86.fr/3i8s1u) |
|
|
||||||
| 104 | 90 | 0 | 1.7 | 742 | 8750 | | [https://valid.x86.fr/qsleu7](https://valid.x86.fr/qsleu7) |
|
|
||||||
| 104 | 90 | | 1.1 | 767 | 8834 | 4.2G 死机 | [https://valid.x86.fr/602lbe](https://valid.x86.fr/602lbe) |
|
|
||||||
| 104 | 100 | -50 | 1.1 | 777 | 8949 | 4G | [https://valid.x86.fr/mrdyb0](https://valid.x86.fr/mrdyb0) |
|
|
||||||
| 104 | 95 | -100 | 1.1 | 775 | 8940 | 4.3G | [https://valid.x86.fr/373lpt](https://valid.x86.fr/373lpt) |
|
|
||||||
| 104 | 95 | -150 | 1.1 | 750 | 8897 | 4.3G 容易掉到 4.2G | [https://valid.x86.fr/tbvs6a](https://valid.x86.fr/tbvs6a) |
|
|
||||||
| 104 | 80 | -100 | 1.1 | 776 | 8928 | 4.3G 容易掉到 4.2G | [https://valid.x86.fr/knssqd](https://valid.x86.fr/knssqd) |
|
|
||||||
| 104 | 80 | -100 | 1.1 | 780 | 8862 | 4.3G | [https://valid.x86.fr/e75pzj](https://valid.x86.fr/e75pzj) |
|
|
||||||
| 104 | 70 | -150 | 1.1 | 778 | 8914 | 4.4G (一分钟内 4.5G) | [https://valid.x86.fr/cyzsa5](https://valid.x86.fr/cyzsa5) |
|
|
||||||
| 104 | 60 | -200 | 1.1 | 777 | 8827 | 4.4G (一分钟内 4.5G) | [https://valid.x86.fr/pmz7wq](https://valid.x86.fr/pmz7wq) |
|
|
||||||
| 104 | 60 | -150 | 1.1 | 778 | 8801 | 4.4G (一分钟半内 4.5G,不稳定) | [https://valid.x86.fr/lmlcrs](https://valid.x86.fr/lmlcrs) |
|
|
||||||
| 104 | 70 | -200 | 1.1 | 777 | 8931 | 4.4G(波动有点大) | [https://valid.x86.fr/lw675p](https://valid.x86.fr/lw675p) |
|
|
||||||
| 104 | 65 | -200 | 1.1 | 777 | 8892 | 4.4G(波动有点大) | [https://valid.x86.fr/bqy58g](https://valid.x86.fr/bqy58g) |
|
|
||||||
| 104 | 70 | -250 | 1.1 | 778 | 8910 | 4.4G(波动有点大) | [https://valid.x86.fr/ua63zf](https://valid.x86.fr/ua63zf) |
|
|
||||||
|
|
||||||
从测试结果上来看,电压越低,一开始的功耗也越低,温度上升就越慢,所以全核心跑满 4.5G 的时间也从十几秒来到一分半。但是最后都会因为我选用的 CPU 下压散热器只有 160W 的解热功耗,所以撞上 100 摄氏度的温度墙而降频。
|
|
||||||
|
|
||||||
#### Cinebench 2024
|
|
||||||
|
|
||||||
从 CPU-Z 的成绩来看,挑选了两个表现比较好的参数来进行多核跑分。
|
|
||||||
|
|
||||||
| AC | 电压 | CPU 多线程 |
|
|
||||||
| --- | ---- | ---------- |
|
|
||||||
| 70 | -200 | 1105pts |
|
|
||||||
| 70 | -150 | 1112pts |
|
|
||||||
|
|
||||||
#### 双拷测试
|
|
||||||
|
|
||||||
最后选定 AC 70(70mΩ)、电压偏移 -150mV 来进行双烤 10 分钟稳定性测试。测试通过🥰
|
|
||||||
|
|
||||||
### GPU
|
|
||||||
|
|
||||||
目前我使用从之前主机上拆下来的 6600XT,能稳定发挥,散热表现也正常。准备过两天去某宝买 6800XT 默认矿卡,祝我好运。希望散热表现也能稳定,毕竟 A4 结构,显卡散热应该挺好的。
|
|
||||||
|
|
||||||
默认矿卡到手。盒盖跑分。
|
|
||||||
|
|
||||||
- Cinebench 2024:10531pts
|
|
||||||
|
|
||||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/5d8f16d0-be54-4655-a2b3-e4a80b287701.png)
|
|
||||||
|
|
||||||
- 3D Mark Time Spy: [18 091](https://www.3dmark.com/3dm/101278696)\n ![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/e7969870-e4f8-46ba-b9fb-35530aeeba3c.png)
|
|
||||||
|
|
||||||
- 3D Mark Time Spy Extreme: [8727](https://www.3dmark.com/3dm/101277666)
|
|
||||||
- ![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/e296ac7c-1d3f-4eae-be0e-21d04e5af121.png)
|
|
||||||
- 3D Mark Speed Way 压力测试:[99.3%](https://www.3dmark.com/3dm/101279293)
|
|
||||||
|
|
||||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/5749102a-8471-4ea2-b332-362a8c8469a2.png ' =806x594')
|
|
||||||
|
|
||||||
看起来显卡还成,分数比平均值差一点,不过还好,作为二手默认矿卡表现应该能说得过去。
|
|
||||||
|
|
||||||
### 调教小结
|
|
||||||
|
|
||||||
我在机箱主板底部的风扇位安装了 12015 风扇出风,在机箱上方摆了两个积木风扇抽风,形成侧进-上下出的风道。目前散热瓶颈在 CPU 的下压散热,CPU 撞 100 摄氏度温度墙,长时间烤机功耗在 160W\~170W 之间徘徊,CPU 频率在 4.4 GHz 左右,损失 0.1 GHz。显卡没有遇到什么问题。
|
|
||||||
|
|
||||||
## 黑苹果
|
|
||||||
|
|
||||||
本来想自己搞 OpenCore 的,奈何时间不够,最后朋友介绍了 B 站大佬[乌龙蜜桃来](https://space.bilibili.com/244390800?spm_id_from=333.337.0.0)帮我弄好了 EFI。大佬做完后分享的 EFI:
|
|
||||||
|
|
||||||
[hackintosh-club/MAXSUN-TERMINATOR-B760ITX-D4-WIFI-OpenCore: MS-B760i Hackintosh OpenCore macos 12 Monertey & 13 Ventura & 14 Sonoma](https://github.com/hackintosh-club/MAXSUN-TERMINATOR-B760ITX-D4-WIFI-OpenCore)
|
|
||||||
|
|
||||||
我试过了, Ventura 和 Sonoma 都是能稳定运行的,睡眠、WIFI、蓝牙都正常,随航和隔空投送因为是 Intel 的无线网卡,所以不支持。
|
|
||||||
|
|
||||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/23da98ef-8b5f-42b5-b515-f4d6fced10f5.png)
|
|
||||||
|
|
||||||
双系统 Blender Benchmark 得分
|
|
||||||
|
|
||||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/image.png)
|
|
||||||
|
|
||||||
前两组是 Windows 的成绩,后两组是 Mac OS 的成绩。似乎都比 Blender 上记录的成绩好一些。至少这次测试能看出来,Mac OS 上显卡性能是有损失,但是 CPU 居然能跑得分更高。
|
|
||||||
|
|
||||||
## 使用体验
|
|
||||||
|
|
||||||
日常使用,没发现 CPU 因为散热问题降频。显卡一直稳定发挥,这二手显卡不知道矿没矿过,但是还算稳定。散热方面表现不错,基本上是因为我在机箱上面放了两把 12025 反向风扇,热量都能很好地被抽出来,小机箱现在不再是小闷罐。机箱底部的风扇,感觉有点聊胜于无。烤机的情况下,底部还是有一点积热的。或许和我只装了主板下的出风扇、没装电源下的出风扇有点关系,但是下部出风确实表现不理想,即使我把机箱放在通透的架子上。
|
|
||||||
|
|
||||||
现在我搞了两块 0.8mm 孔径的防尘网贴在机箱左右两侧,散热表现依然还可以,感觉我放上面的两个风扇真棒。
|
|
||||||
|
|
||||||
最后,放一张主机工作照吧。
|
|
||||||
|
|
||||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/8aacafcc-c69c-4a1d-adf7-68f86e2edd3e.png)
|
|
@ -4,7 +4,10 @@ date: '2022-10-17'
|
|||||||
tags: ['Arch Linux', '环境搭建', 'VPS']
|
tags: ['Arch Linux', '环境搭建', 'VPS']
|
||||||
draft: false
|
draft: false
|
||||||
summary: 有了上次快速安装步骤后,接下来就是使用这个环境了。要使用环境,首先需要做一些初始化操作。我的步骤适合我,但不一定适合你,只是记录和参考。
|
summary: 有了上次快速安装步骤后,接下来就是使用这个环境了。要使用环境,首先需要做一些初始化操作。我的步骤适合我,但不一定适合你,只是记录和参考。
|
||||||
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/RTr3IU.png']
|
images:
|
||||||
|
[
|
||||||
|
'https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0',
|
||||||
|
]
|
||||||
---
|
---
|
||||||
|
|
||||||
## Docker
|
## Docker
|
@ -1,282 +0,0 @@
|
|||||||
---
|
|
||||||
title: 使用 Github Actions 为其他项目构建 Docker Image
|
|
||||||
date: '2023-07-09'
|
|
||||||
tags: ['Github Actions', 'CI/CD', 'Docker']
|
|
||||||
draft: false
|
|
||||||
summary: 使用 Github Actions,为自己喜爱的 Github 开源项目,快速、独立、自动化地构建 Docker 镜像,并推送到 ghcr (GitHub Container Registry)。
|
|
||||||
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/DZhPx7.jpg']
|
|
||||||
---
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
这两天搞起了自动追番,使用了 [AutoBangumi](https://github.com/EstrellaXD/Auto_Bangumi/tree/main) 及其部署文档使用的方案组了个套件。感觉还不错,使用 Plex 播放,也让我的 iPad 终于能好好当个播放器了。
|
|
||||||
|
|
||||||
其中就包括一个 qBittorrent 程序,自带的 Web UI 很工具化,不是很漂亮,也不是很好用。所以我就找了第三方的,正好它没有单独的 Docker image,所以我就做个自动化构建吧。
|
|
||||||
|
|
||||||
本来想在自建的 Gitea Actions 运行的,不过想了想,这个丢 Github 上跑比较合适,反正源头都在 Github 上。之前也有注意到做同样事情的库,但是一直想不起来是什么项目,就没找到……那作业没得抄,只能自己写了。
|
|
||||||
|
|
||||||
对了,我选择的 qBittorrent Web UI 是 [VueTorrent](https://github.com/WDaan/VueTorrent)。
|
|
||||||
|
|
||||||
## 方案
|
|
||||||
|
|
||||||
方案很简单,将目标项目作为 Git Submodule 放在我们的构建 Repo 中。然后分为两步:
|
|
||||||
|
|
||||||
1. 使用定时任务,每天检查最新发布的版本。将最新版本拉到项目中并提交。
|
|
||||||
2. 使用 push 触发器,将新版本构建成 Docker image 并推送到 ghcr (GitHub Container Registry)。
|
|
||||||
|
|
||||||
项目地址在这:[IvanLi-CN/vue-torrent-docker: Automatically build VueTorrent Docker images](https://github.com/IvanLi-CN/vue-torrent-docker)
|
|
||||||
|
|
||||||
## 实施
|
|
||||||
|
|
||||||
下面就是流水帐了。实施这个方案的话,我是先做第二步。因为我当时需要一个 Docker image 来替换原始方案。在文章里,为了流程顺畅,就按上帝视角,用正常的顺序来编写吧。毕竟不是教程,就不循序渐进了。
|
|
||||||
|
|
||||||
### 定时检查上游更新
|
|
||||||
|
|
||||||
Github Actions 支持使用 Cron 来创建一个定时任务。
|
|
||||||
所以触发 Action 的问题轻松解决。
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 2 * * *'
|
|
||||||
```
|
|
||||||
|
|
||||||
那么第二步就是获取上游最新的发布版本了。
|
|
||||||
上游使用 “Github Releases” 发布版本:
|
|
||||||
|
|
||||||
![Github Releases for VueTorrent](https://minio.ivanli.cc/ivan-public/uPic/2023/bHrczD.png)
|
|
||||||
|
|
||||||
所以这里使用了 [git-get-release-action](https://github.com/marketplace/actions/git-get-release-action),获取最新的版本号和 commitish hash。
|
|
||||||
其中版本号就是 tag name,而 commit-ish hash 就是平常使用的 commit hash 了。
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: git-get-release-action
|
|
||||||
id: git-get-release
|
|
||||||
uses: cardinalby/git-get-release-action@1.2.4
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
repo: WDaan/VueTorrent
|
|
||||||
latest: true
|
|
||||||
```
|
|
||||||
|
|
||||||
这里提供了几个参数:
|
|
||||||
|
|
||||||
- `GITHUB_TOKEN` 是自动注入的,我们将这个变量作为环境变量提供给 git-get-release-action 就行,缺少这个变量,这个步骤会报错。
|
|
||||||
- 我们通过 `repo` 提供了上游的仓库名称 “WDaan/VueTorrent”,这个仓库在 Github 上,所以可以直接这么简写。
|
|
||||||
- 因为只需要获取最新的发布版本,所以提供了 `latest` 为 `true`。
|
|
||||||
|
|
||||||
这一步骤会有两个关键的输出:
|
|
||||||
|
|
||||||
- `tag_name` 是我们想要的最新版本的版本号,因为他的 git tag 写的就是版本号,例如现在的 `v1.6.0`。
|
|
||||||
- `current_commitish` 就顾名思义了,接下来就是要用他作为当前 commit 的唯一标记。
|
|
||||||
|
|
||||||
我们有了最新版本的 commit-ish 值,那就要有上次构建 Docker image 时用的 commit-ish 值。
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Get current commitish
|
|
||||||
id: get-current-commitish
|
|
||||||
run: |
|
|
||||||
cd vue-torrent
|
|
||||||
echo "Current commitish: $(git rev-parse HEAD)"
|
|
||||||
echo "{current_commitish}=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
|
||||||
```
|
|
||||||
|
|
||||||
这里直接进入子模块的目录里,使用 `git rev-parse HEAD` 命令获取当前 repo 使用的上游的 commit-ish。
|
|
||||||
因为我们每次构建镜像前,都会先更新子模块的 commit 位置,所以这里就是上次发布镜像时的 commit-ish 了。
|
|
||||||
|
|
||||||
再因为我们一直是单调地往按时间往更新的构建,所以不需要比较 commit 的新旧,只要不一样,就是要构建新的镜像。
|
|
||||||
所以我们这一步,就直接比较最新的 commit 和现在的 commit 是否一直:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Compare versions
|
|
||||||
id: compare
|
|
||||||
run: |
|
|
||||||
echo "Current version: ${{ steps.git-get-release.outputs.tag_name }}"
|
|
||||||
echo "{should_update}=${{ steps.git-get-release.outputs.target_commitish != steps.get-current-commitish.outputs.current_commitish }}" >> $GITHUB_OUTPUT
|
|
||||||
```
|
|
||||||
|
|
||||||
下面就是重要的三个步骤了,一更新子模块,二提交更新,三打标签。
|
|
||||||
|
|
||||||
#### 更新子模块
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Update
|
|
||||||
if: steps.compare.outputs.should_update == 'true'
|
|
||||||
working-directory: ./vue-torrent
|
|
||||||
run: |
|
|
||||||
git fetch --depth=1 origin ${{ steps.git-get-release.outputs.tag_name }}
|
|
||||||
git checkout -b ${{ steps.git-get-release.outputs.tag_name }} ${{ steps.git-get-release.outputs.target_commitish }}
|
|
||||||
git reset --hard HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
_这里使用了 `working-directory` 更改执行目录到子模块中,用 `cd` 进去应该也一样。_
|
|
||||||
|
|
||||||
因为之前检出存储库时是只检出最新的那个 commit,所以需使用 `git fetch` 将我们需要的那个 commit 拉到运行环境中,否则会报形如 `fatal: Could not parse object '6ab00a179b9509ef162a14862fb828c78144caff'.` 的错误。
|
|
||||||
|
|
||||||
之后使用 `git checkout -b` 将目标的 commit 拉到新的分支上。
|
|
||||||
|
|
||||||
最后,将当前的位置设到 HEAD,即目标 commit。
|
|
||||||
|
|
||||||
后两步应该是可以直接改成 `git reset --hard ${{ steps.git-get-release.outputs.target_commitish }}`,不过我没试过,仅供参考。
|
|
||||||
|
|
||||||
#### 提交更新
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Commit changes
|
|
||||||
if: steps.compare.outputs.should_update == 'true'
|
|
||||||
run: |
|
|
||||||
git diff
|
|
||||||
git config user.name "GitHub Actionss"
|
|
||||||
git config user.email "bot@noreply.github.com"
|
|
||||||
git add .
|
|
||||||
git commit -m "Update to ${{ steps.git-get-release.outputs.tag_name }}"
|
|
||||||
git push origin ${{ github.ref_name }}
|
|
||||||
```
|
|
||||||
|
|
||||||
这就没什么好说的了,需要注意的一点就是权限问题。因为我们是 push 到当前的 repo 上,所以可以直接使用自动注入的 `GITHUB_TOKEN`,不过需要在 repo 的设置页面更改下权限:
|
|
||||||
|
|
||||||
![Github Actions Permissions Setting](https://minio.ivanli.cc/ivan-public/uPic/2023/QPvf6R.png)
|
|
||||||
|
|
||||||
选择 “Read and write permissions",这样就能写入当前的 repo。
|
|
||||||
|
|
||||||
#### 打标签
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Tag
|
|
||||||
if: steps.compare.outputs.should_update == 'true'
|
|
||||||
run: |
|
|
||||||
git tag ${{ steps.git-get-release.outputs.tag_name }}
|
|
||||||
git push origin ${{ steps.git-get-release.outputs.tag_name }}
|
|
||||||
```
|
|
||||||
|
|
||||||
目的就是后面构建镜像时,能方便地从这里取到版本号。
|
|
||||||
|
|
||||||
### 构建镜像
|
|
||||||
|
|
||||||
这个就比较简单了,代码在这:
|
|
||||||
|
|
||||||
[Action](https://github.com/IvanLi-CN/vue-torrent-docker/blob/7439b89cb87bb4627d87f0445e8c1fc39ed89f78/.github/workflows/auto-build.yaml)
|
|
||||||
|
|
||||||
[Dockerfile](https://github.com/IvanLi-CN/vue-torrent-docker/blob/7439b89cb87bb4627d87f0445e8c1fc39ed89f78/.github/workflows/auto-build.yaml)
|
|
||||||
|
|
||||||
因为直接做成子模块了,流程就简单很多了,只要检出代码时将子模块一并检出,之后直接构建 Docker 镜像就行。
|
|
||||||
|
|
||||||
我也不知道有没有人用,只构建了 x86 的自用。
|
|
||||||
|
|
||||||
## 最后
|
|
||||||
|
|
||||||
分享一下我现在用的追番的 Docker Compose 吧:
|
|
||||||
|
|
||||||
`docker-compose.yaml`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.2'
|
|
||||||
services:
|
|
||||||
caddy:
|
|
||||||
container_name: caddy
|
|
||||||
ports:
|
|
||||||
- ${QB_PORT}:80
|
|
||||||
networks:
|
|
||||||
- auto_bangumi
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./caddy:/etc/caddy
|
|
||||||
image: caddy:2
|
|
||||||
vuetorrent:
|
|
||||||
container_name: vuetorrent
|
|
||||||
expose:
|
|
||||||
- 3000
|
|
||||||
networks:
|
|
||||||
- auto_bangumi
|
|
||||||
restart: unless-stopped
|
|
||||||
image: ghcr.io/ivanli-cn/vue-torrent:main
|
|
||||||
qbittorrent:
|
|
||||||
container_name: qBittorrent
|
|
||||||
environment:
|
|
||||||
- TZ=Asia/Shanghai
|
|
||||||
- TemPath=/downloads
|
|
||||||
- SavePath=/downloads
|
|
||||||
- PGID=${GID}
|
|
||||||
- PUID=${UID}
|
|
||||||
- WEBUI_PORT=8080
|
|
||||||
volumes:
|
|
||||||
- qb_config:/config
|
|
||||||
- ${DOWNLOAD_PATH}:/downloads
|
|
||||||
ports:
|
|
||||||
- 6881:6881
|
|
||||||
- 6881:6881/udp
|
|
||||||
networks:
|
|
||||||
- auto_bangumi
|
|
||||||
restart: unless-stopped
|
|
||||||
image: superng6/qbittorrent:latest
|
|
||||||
|
|
||||||
auto_bangumi:
|
|
||||||
container_name: AutoBangumi
|
|
||||||
environment:
|
|
||||||
- TZ=Asia/Shanghai
|
|
||||||
- PGID=${GID}
|
|
||||||
- PUID=${UID}
|
|
||||||
- AB_DOWNLOADER_HOST=qbittorrent:${QB_PORT}
|
|
||||||
networks:
|
|
||||||
- auto_bangumi
|
|
||||||
volumes:
|
|
||||||
- ./auto_bangumi/config:/app/config
|
|
||||||
- ./auto_bangumi/data:/app/data
|
|
||||||
ports:
|
|
||||||
- 7892:7892
|
|
||||||
dns:
|
|
||||||
- 8.8.8.8
|
|
||||||
- 223.5.5.5
|
|
||||||
restart: unless-stopped
|
|
||||||
image: estrellaxd/auto_bangumi:latest
|
|
||||||
depends_on:
|
|
||||||
- qbittorrent
|
|
||||||
|
|
||||||
plex:
|
|
||||||
container_name: Plex
|
|
||||||
environment:
|
|
||||||
- TZ=Asia/Shanghai
|
|
||||||
- PUID=${UID}
|
|
||||||
- PGID=${GID}
|
|
||||||
- VERSION=docker
|
|
||||||
- PLEX_CLAIM=${PLEX_CLAIM}
|
|
||||||
networks:
|
|
||||||
- auto_bangumi
|
|
||||||
ports:
|
|
||||||
- 32400:32400
|
|
||||||
volumes:
|
|
||||||
- plex_config:/config
|
|
||||||
- ${DOWNLOAD_PATH}/Bangumi:/tv
|
|
||||||
restart: unless-stopped
|
|
||||||
image: lscr.io/linuxserver/plex:latest
|
|
||||||
|
|
||||||
networks:
|
|
||||||
auto_bangumi:
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
qb_config:
|
|
||||||
external: false
|
|
||||||
plex_config:
|
|
||||||
external: false
|
|
||||||
```
|
|
||||||
|
|
||||||
`caddy/Caddyfile`
|
|
||||||
|
|
||||||
```Caddyfile
|
|
||||||
:80 {
|
|
||||||
reverse_proxy /api/* qbittorrent:8080
|
|
||||||
reverse_proxy /* vuetorrent:3000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`.env`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
QB_PORT=8080
|
|
||||||
DOWNLOAD_PATH=/home/ivan/downloads
|
|
||||||
UID=1000
|
|
||||||
GID=1000
|
|
||||||
|
|
||||||
PLEX_CLAIM=claim-DwbcewEB7j3pmNotG_eT
|
|
||||||
```
|
|
@ -1,10 +1,13 @@
|
|||||||
---
|
---
|
||||||
title: 在 PVE 宿主机上使用桌面环境
|
title: 在 PVE 宿主机上使用桌面环境
|
||||||
date: '2022-10-28'
|
date: '2022-10-28'
|
||||||
tags: ['PVE', 'DE', '环境搭建', 'Debian']
|
tags: ['PVE', 'PVE', 'DE', '环境搭建', 'Debian']
|
||||||
draft: false
|
draft: false
|
||||||
summary: 虽然 PVE 宿主机不应该安装乱七八糟的东西,但是我穷,为了物尽其用,为了在主力电脑翻车时有一个立即可用的备用环境,所以还是安装了基础的桌面环境。现在的 Linux 桌面环境越来越好了,我选择安装 KDE Plasma 作为桌面环境,并且默认关闭,按需启用。
|
summary: 虽然 PVE 宿主机不应该安装乱七八糟的东西,但是我穷,为了物尽其用,为了在主力电脑翻车时有一个立即可用的备用环境,所以还是安装了基础的桌面环境。现在的 Linux 桌面环境越来越好了,我选择安装 KDE Plasma 作为桌面环境,并且默认关闭,按需启用。
|
||||||
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/qldEtP.png']
|
images:
|
||||||
|
[
|
||||||
|
'https://pan.ivanli.cc/api/v3/file/source/2243/1200px-Kde_dragons.png?sign=yGZL9jYeVt53Ve43ddhHt_0EzVV2cW_WbxHc0dEcwWY%3D%3A0',
|
||||||
|
]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 前言
|
## 前言
|
||||||
@ -157,7 +160,7 @@ ibus-setup
|
|||||||
|
|
||||||
在打开的 GUI 中添加中文输入法,找到 Rime 并添加输入法:
|
在打开的 GUI 中添加中文输入法,找到 Rime 并添加输入法:
|
||||||
|
|
||||||
![rime](https://minio.ivanli.cc/ivan-public/uPic/2023/E4SWeR.png)
|
![rime](https://pan.ivanli.cc/api/v3/file/source/2241/Screen%20Capture_select-area_20221028225457.png?sign=XVrl7rPk4Gd7QRFBCCDGruB2L7V1bvxDpK9-v9pC0Nc%3D%3A0)
|
||||||
|
|
||||||
现在,新打开的软件应该能使用输入法了。像 Chrome 这类,关闭后还需要手动杀死进程后再打开才能使用。最简单的方法就是重启电脑啦~
|
现在,新打开的软件应该能使用输入法了。像 Chrome 这类,关闭后还需要手动杀死进程后再打开才能使用。最简单的方法就是重启电脑啦~
|
||||||
|
|
@ -4,7 +4,10 @@ date: '2022-09-23'
|
|||||||
tags: ['Verdaccio', 'Self-Hosted', 'Docker', 'Caddy', 'registry', 'Node.js']
|
tags: ['Verdaccio', 'Self-Hosted', 'Docker', 'Caddy', 'registry', 'Node.js']
|
||||||
draft: false
|
draft: false
|
||||||
summary: 作为靠着 JavaScript 生态吃饭的 Web 开发者,自建一个 Node regsitry 是很有必要的,我这次继续选择 Verdaccio 来搭建存储库。这次使用 Docker Compose 部署 Verdaccio,并将 Caddy 用于反向代理该服务。
|
summary: 作为靠着 JavaScript 生态吃饭的 Web 开发者,自建一个 Node regsitry 是很有必要的,我这次继续选择 Verdaccio 来搭建存储库。这次使用 Docker Compose 部署 Verdaccio,并将 Caddy 用于反向代理该服务。
|
||||||
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/3Dqijk.png']
|
images:
|
||||||
|
[
|
||||||
|
'https://pan.ivanli.cc/api/v3/file/source/2233/verdaccio.png?sign=qpoeADXzbhHk2MY5CehgTftUJ67pnUj-Ylko9D5jscU%3D%3A0',
|
||||||
|
]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 为何自建存储库?
|
## 为何自建存储库?
|
@ -1,168 +0,0 @@
|
|||||||
---
|
|
||||||
title: 2023 年,我的 Mac OS 环境初始化
|
|
||||||
date: '2023-04-13'
|
|
||||||
tags: ['Mac OS', '环境搭建', 'Hackintosh', 'Develop', '装机']
|
|
||||||
draft: false
|
|
||||||
summary: 记录和分享我的 Mac OS 必备的基本软件。
|
|
||||||
---
|
|
||||||
|
|
||||||
## 初步环境
|
|
||||||
|
|
||||||
跑完系统的向导,正式进入系统后,先装 brew
|
|
||||||
|
|
||||||
```bash
|
|
||||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
|
||||||
```
|
|
||||||
|
|
||||||
参考:[macOS(或 Linux)缺失的软件包的管理器 — Homebrew](https://brew.sh/index_zh-cn)
|
|
||||||
|
|
||||||
### 安装第一批软件
|
|
||||||
|
|
||||||
我需要立即能够使用:
|
|
||||||
|
|
||||||
- Warp
|
|
||||||
- Chrome
|
|
||||||
- Syncthing
|
|
||||||
- Logseq(现在我抛弃它了,只用来在本地查阅以前记录的内容)
|
|
||||||
|
|
||||||
所以:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install --casks warp google-chrome syncthing logseq
|
|
||||||
```
|
|
||||||
|
|
||||||
恢复输入法——小鹤音型
|
|
||||||
因为使用 Rime 实现的,所以先安装 Squirrel
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install --cask squirrel
|
|
||||||
```
|
|
||||||
|
|
||||||
安装完毕后,到系统设置里选择启用鼠须管。
|
|
||||||
|
|
||||||
## 开发环境
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install --cask visual-studio-code
|
|
||||||
brew install cmake git-lfs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Node JS 环境
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install nvm
|
|
||||||
|
|
||||||
cat >> ~/.zshrc<<EOF
|
|
||||||
#nvm start
|
|
||||||
export NVM_DIR="$HOME/.nvm"
|
|
||||||
[ -s "/usr/local/opt/nvm/nvm.sh" ] && \. "/usr/local/opt/nvm/nvm.sh" # This loads nvm
|
|
||||||
[ -s "/usr/local/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/usr/local/opt/nvm/etc/bash_completion.d/nvm" # This loads nvm bash_completion
|
|
||||||
# nvm end
|
|
||||||
EOF
|
|
||||||
|
|
||||||
source ~/.zshrc
|
|
||||||
|
|
||||||
nvm install --lts
|
|
||||||
|
|
||||||
npm i -g pnpm && pnpm setup
|
|
||||||
source /Users/ivan/.zshrc
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据库运维
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew tap mongodb/brew
|
|
||||||
brew install mongodb-database-tools mongosh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rust
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### 消息队列
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install --casks mqttx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 设计软件
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install --casks blender
|
|
||||||
```
|
|
||||||
|
|
||||||
### 硬件开发
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install minicom
|
|
||||||
cargo install cargo-espmonitor
|
|
||||||
```
|
|
||||||
|
|
||||||
### 网络相关
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install --cask wireshark
|
|
||||||
```
|
|
||||||
|
|
||||||
## 日常环境
|
|
||||||
|
|
||||||
### 软件
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install --cask \
|
|
||||||
telegram-desktop \
|
|
||||||
iina \
|
|
||||||
monitorcontrol \
|
|
||||||
logitech-options \
|
|
||||||
microsoft-remote-desktop \
|
|
||||||
keycastr
|
|
||||||
|
|
||||||
brew install croc
|
|
||||||
```
|
|
||||||
|
|
||||||
### 终端
|
|
||||||
|
|
||||||
#### ZSH 插件
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sh -c "$(curl -fsSL https://git.io/zinit-install)"
|
|
||||||
|
|
||||||
echo 'zinit load zsh-users/zsh-syntax-highlighting
|
|
||||||
zinit load zsh-users/zsh-autosuggestions
|
|
||||||
zinit load ael-code/zsh-colored-man-pages
|
|
||||||
zinit load agkozak/zsh-z
|
|
||||||
export NVM_AUTO_USE=true
|
|
||||||
zinit load lukechilds/zsh-nvm
|
|
||||||
zinit ice depth=1; zinit light romkatv/powerlevel10k' >> ~/.zshrc
|
|
||||||
```
|
|
||||||
|
|
||||||
#### ZSH-Z 配置
|
|
||||||
|
|
||||||
避免优先匹配到子目录,在 `.zshrc` 中添加如下行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat >> ~/.zshrc <<EOF
|
|
||||||
|
|
||||||
# zsh-z
|
|
||||||
ZSHZ_UNCOMMON=1
|
|
||||||
ZSHZ_TRAILING_SLASH=1
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
#### History 配置
|
|
||||||
|
|
||||||
配置历史记录,在 `.zshrc` 中添加如下行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat >> ~/.zshrc <<EOF
|
|
||||||
|
|
||||||
# History
|
|
||||||
HISTFILE=~/.zsh_history
|
|
||||||
HISTSIZE=10000
|
|
||||||
SAVEHIST=1000
|
|
||||||
setopt INC_APPEND_HISTORY_TIME
|
|
||||||
EOF
|
|
||||||
```
|
|
@ -4,7 +4,10 @@ date: '2022-10-17'
|
|||||||
tags: ['Arch Linux', '环境搭建', 'VPS']
|
tags: ['Arch Linux', '环境搭建', 'VPS']
|
||||||
draft: false
|
draft: false
|
||||||
summary: 又到了新装 Arch Linux 的日子了。这次又是温故而知新的机会,把之前写的笔记稍微整理了一下,在这里记录下教徒搭窝的备忘录。
|
summary: 又到了新装 Arch Linux 的日子了。这次又是温故而知新的机会,把之前写的笔记稍微整理了一下,在这里记录下教徒搭窝的备忘录。
|
||||||
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/RTr3IU.png']
|
images:
|
||||||
|
[
|
||||||
|
'https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0',
|
||||||
|
]
|
||||||
---
|
---
|
||||||
|
|
||||||
## 起势
|
## 起势
|
@ -39,7 +39,7 @@ useEffect(() => {
|
|||||||
|
|
||||||
### 在 React 18 的测试代码
|
### 在 React 18 的测试代码
|
||||||
|
|
||||||
![React 18 Stricter Strict Mode.png](https://minio.ivanli.cc/ivan-public/uPic/2023/TWVx7v.png)
|
![React 18 Stricter Strict Mode.png](https://pan.ivanli.cc/api/v3/file/source/2753/React%2018%20Stricter%20Strict%20Mode.png?sign=ARQ8AVTh-NEaeJRypJlVokuUVhocPeaK8n7GRSDwqNw%3D%3A0)
|
||||||
|
|
||||||
代码:[Code Sandbox](https://codesandbox.io/p/sandbox/clever-cache-pm1oct?file=%2Fsrc%2FApp.tsx&selection=%5B%7B%22endColumn%22%3A20%2C%22endLineNumber%22%3A33%2C%22startColumn%22%3A20%2C%22startLineNumber%22%3A33%7D%5D)
|
代码:[Code Sandbox](https://codesandbox.io/p/sandbox/clever-cache-pm1oct?file=%2Fsrc%2FApp.tsx&selection=%5B%7B%22endColumn%22%3A20%2C%22endLineNumber%22%3A33%2C%22startColumn%22%3A20%2C%22startLineNumber%22%3A33%7D%5D)
|
||||||
|
|
||||||
@ -51,7 +51,7 @@ useEffect(() => {
|
|||||||
2. `useEffect(() => /* */, [])`执行一此后,以新的严格模式的规则,调用了 `destructor` 后,进行了二次调用。
|
2. `useEffect(() => /* */, [])`执行一此后,以新的严格模式的规则,调用了 `destructor` 后,进行了二次调用。
|
||||||
|
|
||||||
在第 2 点中,两次 useEffect 都是使用同一个值,是因为严格模式的二次调用按钩子分别执行两次,所以 useMemo 两次的调用都完毕后,得到的值再被 useEffect 执行两次。我调整了一下代码,将测试代码复制了一份在后面,可以看到 “useMemo” 和 “useMemo 2” 先执行了一次,又再执行了一次,然后再到 “useEffect“ 和 “useEffect 2":
|
在第 2 点中,两次 useEffect 都是使用同一个值,是因为严格模式的二次调用按钩子分别执行两次,所以 useMemo 两次的调用都完毕后,得到的值再被 useEffect 执行两次。我调整了一下代码,将测试代码复制了一份在后面,可以看到 “useMemo” 和 “useMemo 2” 先执行了一次,又再执行了一次,然后再到 “useEffect“ 和 “useEffect 2":
|
||||||
![加倍快乐](https://minio.ivanli.cc/ivan-public/uPic/2023/OwlDG6.png)
|
![加倍快乐](https://pan.ivanli.cc/api/v3/file/source/2754/React%2018%20Stricter%20Strict%20Mode%202.png?sign=iYz9KP9uMuccRCesjqoRPKejEoUOj4FZfnBPt8kCXnQ%3D%3A0)
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
@ -2,7 +2,7 @@
|
|||||||
title: 再见 2022,你好 2023
|
title: 再见 2022,你好 2023
|
||||||
date: '2022-12-31'
|
date: '2022-12-31'
|
||||||
tags: ['总结']
|
tags: ['总结']
|
||||||
draft: true
|
draft: false
|
||||||
summary: 2022 年就要结束了,写个工作与学习的总结,记录下我的 2022 年。
|
summary: 2022 年就要结束了,写个工作与学习的总结,记录下我的 2022 年。
|
||||||
---
|
---
|
||||||
|
|
@ -1,84 +0,0 @@
|
|||||||
---
|
|
||||||
title: 使用导航网格实现寻路
|
|
||||||
date: '2023-09-12'
|
|
||||||
tags: ['Three.js', 'Navigation Mesh', '3D', 'Game', 'Path Finding']
|
|
||||||
draft: false
|
|
||||||
summary: 本文结合 three-pathfinding 项目的源代码,简要分析如何使用导航网格实现寻路功能。 three-pathfinding 是一个 Three.js 导航网格寻路库。
|
|
||||||
images: ['https://s3.ivanli.cc/ivan-public/uPic/2023/mZ9HNo.jpeg']
|
|
||||||
---
|
|
||||||
|
|
||||||
本文结合 [donmccurdy/three-pathfinding](https://github.com/donmccurdy/three-pathfinding) 项目的源代码,简要分析如何使用导航网格实现寻路功能。 three-pathfinding 是一个基于 [PatrolJS](https://github.com/nickjanssen/PatrolJS) 实现的 Three.js 导航网格寻路库。
|
|
||||||
|
|
||||||
在 Three.js 中使用导航网格实现寻路功能,主要用到以下三个部分:
|
|
||||||
|
|
||||||
- 导航网格(Navmesh, Navigation Mesh):寻路用的地图
|
|
||||||
- A\*(A Star)搜索算法:用于寻路
|
|
||||||
- 漏斗算法(Funnel Algorithm): 用于在二维平面找到绕过障碍物的最短路径
|
|
||||||
|
|
||||||
## 导航网格
|
|
||||||
|
|
||||||
导航网格由若干个可供角色行走的、相邻的凸多边形组成,在我们的用例中,是三角形。导航网格的作用是寻路算法提供所需的顶点数据的,本身并没有任何算法。导航网格可以通过 [UPBGE](https://tl.ivanli.cc/m/28) 生成,生成后就是一个 Mesh 物体,所以顶点数据可以直接通过 GLTF 等格式分发。
|
|
||||||
|
|
||||||
## A Star 搜索
|
|
||||||
|
|
||||||
网络上有很多文章介绍这个算法:
|
|
||||||
|
|
||||||
- [A Star Algorithm 总结与实现 | Cheng Wei's Blog](https://shiori.ivanli.cc/bookmark/47/archive/)
|
|
||||||
|
|
||||||
本文结合实际应用再简要地说明下。
|
|
||||||
|
|
||||||
首先,我们除了会给算法传入三角形的定点数据,还会传了起点和终点,这里分为三个情况:
|
|
||||||
|
|
||||||
#### 起点和终点顶点都在同一个三角形之中
|
|
||||||
|
|
||||||
不需要执行 A Star 搜索算法,直接将两点用直线连接就是目标路径。
|
|
||||||
|
|
||||||
#### 起点或终点不在任意一个的三角形之中
|
|
||||||
|
|
||||||
要么通过其他算法将原始的起点和终点在三角形中找到最接近的点,要么放弃这次寻路。因为这是不可处理的意外情况。
|
|
||||||
|
|
||||||
#### 起点和终点在不同的三角形之中
|
|
||||||
|
|
||||||
这样就能正式执行 A Star 搜索算法了。
|
|
||||||
|
|
||||||
### 数据准备
|
|
||||||
|
|
||||||
[Builder.\_buildNavigationMesh()](https://github.com/donmccurdy/three-pathfinding/blob/538b57b65e78b37fe033ff845a09659ebed426dd/src/Builder.js#L150) 方法将从 NavMesh 提取所有三角形的顶点,并通过三角形的三个顶点的 x 分量作为 ID,找到了每个三角形相邻的三角形数组。
|
|
||||||
|
|
||||||
[Build_buildPolygonGroups()](https://github.com/donmccurdy/three-pathfinding/blob/538b57b65e78b37fe033ff845a09659ebed426dd/src/Builder.js#L103) 方法将从上一个方法中返回三角形及其相邻的三角形作为一个参数,返回了若干组三角形,每组内的三角形都能相互联通,也就是在这组三角形上的任意两点都能找到连接的路径。
|
|
||||||
|
|
||||||
最后每个三角形会被结构化成 Node,长这样:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
id: number, // ID
|
|
||||||
neighbours: nb[], // 相邻的三角形的 id 数组(Node.id 数组)
|
|
||||||
vertexIds: vertexIds[], // 顶点 ID,使用三个顶点的 X 分量组成
|
|
||||||
centroid: Vector3, // 重心
|
|
||||||
portals: [number, number][] // 与其他三角形公用的边的顶点的索引数组,
|
|
||||||
// 一般情况下是两个,即一个边与另一个三角形相邻,但是也不排除会三个边都相邻
|
|
||||||
// 如果三边都相邻,则是 [number, number, number]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 开始使用 A Star 搜索路径
|
|
||||||
|
|
||||||
A Star 算法怎么跑的本文就不赘述了。
|
|
||||||
|
|
||||||
执行 [Pathfinding.findPath()](https://github.com/donmccurdy/three-pathfinding/blob/abc331195143d7ea1242debed4b52500bda8b7fe/src/Pathfinding.js#L106) 时,需要传入 zoneID 和 groupID。通过前面对数据准备的分析,我们知道同组的三角形是相通的,只要保证起点和终点都在这组三角形的面上,正常情况下 findPath 就能求出路径了。
|
|
||||||
|
|
||||||
A Star 算法本质上是在若干个点之间求出一组点,连接这些点就是导航路径。这里使用三角形的重心作为这个点。
|
|
||||||
|
|
||||||
通过 A Star 算法搜索出路径后, 会获得一组有序的 Node。
|
|
||||||
|
|
||||||
## “拉绳”
|
|
||||||
|
|
||||||
A Star 算法寻得的路径是比较粗粒度的路径,接下来使用漏斗算法来拉出一条最短路径。
|
|
||||||
|
|
||||||
[Channel.js](https://github.com/donmccurdy/three-pathfinding/blob/364fdc5e6c41c6f3835d881edd00565c45ab0401/src/Channel.js) 里便是使用漏斗算法来获取最短路径。值得注意的一点是,这个算法适合平面,并不适合有高度落差联通的导航网格。
|
|
||||||
|
|
||||||
漏斗算法参考这篇文章:[图解NavMesh寻路中的漏斗算法 - PointerSMQ - 博客园](https://shiori.ivanli.cc/bookmark/48/archive/)
|
|
||||||
|
|
||||||
![](https://s3.ivanli.cc/ivan-public/uPic/2023/JJjp9f.png ' =666x428')
|
|
||||||
|
|
||||||
漏斗算法能够让最终路径在绕过障碍物的同时,保证路径最短。从上面的文章中,可以总结一个核心逻辑,每次生成的路径如果超过左边界或右边界,就会增加一个节点,并从此处构造新的漏斗。直到到达终点。
|
|
@ -8,8 +8,10 @@ const siteMetadata = {
|
|||||||
theme: 'system', // system, dark or light
|
theme: 'system', // system, dark or light
|
||||||
siteUrl: 'https://ivanli.cc/',
|
siteUrl: 'https://ivanli.cc/',
|
||||||
siteRepo: 'https://git.ivanli.cc/ivan/tailwind-nextjs-blog',
|
siteRepo: 'https://git.ivanli.cc/ivan/tailwind-nextjs-blog',
|
||||||
siteLogo: 'https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png',
|
siteLogo:
|
||||||
image: 'https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png',
|
'https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0',
|
||||||
|
image:
|
||||||
|
'https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0',
|
||||||
socialBanner: '/static/images/twitter-card.png',
|
socialBanner: '/static/images/twitter-card.png',
|
||||||
email: 'master@ivanli.cc',
|
email: 'master@ivanli.cc',
|
||||||
github: 'https://github.com/IvanLi-CN',
|
github: 'https://github.com/IvanLi-CN',
|
||||||
@ -21,21 +23,12 @@ const siteMetadata = {
|
|||||||
analytics: {
|
analytics: {
|
||||||
// If you want to use an analytics provider you have to add it to the
|
// If you want to use an analytics provider you have to add it to the
|
||||||
// content security policy in the `next.config.js` file.
|
// content security policy in the `next.config.js` file.
|
||||||
// supports Plausible, Simple Analytics, Umami, Posthog or Google Analytics.
|
// supports plausible, simpleAnalytics, umami or googleAnalytics
|
||||||
umamiAnalytics: {
|
plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
|
||||||
// We use an env variable for this site to avoid other users cloning our analytics ID
|
simpleAnalytics: false, // true or false
|
||||||
umamiWebsiteId: process.env.NEXT_UMAMI_ID, // e.g. 123e4567-e89b-12d3-a456-426614174000
|
umamiWebsiteId: '', // e.g. 123e4567-e89b-12d3-a456-426614174000
|
||||||
},
|
googleAnalyticsId: '', // e.g. UA-000000-2 or G-XXXXXXX
|
||||||
// plausibleAnalytics: {
|
posthogAnalyticsId: '', // posthog.init e.g. phc_5yXvArzvRdqtZIsHkEm3Fkkhm3d0bEYUXCaFISzqPSQ
|
||||||
// plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
|
|
||||||
// },
|
|
||||||
// simpleAnalytics: {},
|
|
||||||
// posthogAnalytics: {
|
|
||||||
// posthogProjectApiKey: '', // e.g. 123e4567-e89b-12d3-a456-426614174000
|
|
||||||
// },
|
|
||||||
// googleAnalytics: {
|
|
||||||
// googleAnalyticsId: '', // e.g. G-XXXXXXX
|
|
||||||
// },
|
|
||||||
},
|
},
|
||||||
newsletter: {
|
newsletter: {
|
||||||
// supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus
|
// supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus
|
||||||
@ -85,24 +78,14 @@ const siteMetadata = {
|
|||||||
// https://help.disqus.com/en/articles/1717111-what-s-a-shortname
|
// https://help.disqus.com/en/articles/1717111-what-s-a-shortname
|
||||||
shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME,
|
shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME,
|
||||||
},
|
},
|
||||||
|
cusdisConfig: {
|
||||||
|
appId: process.env.NEXT_PUBLIC_CUSDIS_APPID,
|
||||||
|
host: process.env.NEXT_PUBLIC_CUSDIS_HOST,
|
||||||
|
},
|
||||||
commentoConfig: {
|
commentoConfig: {
|
||||||
url: process.env.NEXT_PUBLIC_COMMENTO_URL,
|
url: process.env.NEXT_PUBLIC_COMMENTO_URL,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
search: {
|
}
|
||||||
provider: 'kbar', // kbar or algolia
|
|
||||||
kbarConfig: {
|
|
||||||
searchDocumentsPath: 'search.json', // path to load documents to search
|
|
||||||
},
|
|
||||||
// provider: 'algolia',
|
|
||||||
// algoliaConfig: {
|
|
||||||
// // The application ID provided by Algolia
|
|
||||||
// appId: 'R2IYF7ETH7',
|
|
||||||
// // Public API key: it is safe to commit it
|
|
||||||
// apiKey: '599cec31baffa4868cae4e79f180729b',
|
|
||||||
// indexName: 'docsearch',
|
|
||||||
// },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = siteMetadata;
|
module.exports = siteMetadata
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"jsx": "react",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/components/*": ["components/*"],
|
"@/components/*": ["components/*"],
|
||||||
"@/data/*": ["data/*"],
|
"@/data/*": ["data/*"],
|
||||||
"@/layouts/*": ["layouts/*"],
|
"@/layouts/*": ["layouts/*"],
|
||||||
"@/css/*": ["css/*"],
|
"@/lib/*": ["lib/*"],
|
||||||
"contentlayer/generated": ["./.contentlayer/generated"]
|
"@/css/*": ["css/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
import type { Authors } from 'contentlayer/generated';
|
|
||||||
import SocialIcon from '@/components/social-icons';
|
import SocialIcon from '@/components/social-icons';
|
||||||
import Image from '@/components/Image';
|
import Image from '@/components/Image';
|
||||||
|
import { PageSEO } from '@/components/SEO';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
content: Omit<Authors, '_id' | '_raw' | 'body'>;
|
frontMatter: AuthorFrontMatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthorLayout({ children, content }: Props) {
|
export default function AuthorLayout({ children, frontMatter }: Props) {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
avatar,
|
avatar,
|
||||||
@ -18,28 +19,27 @@ export default function AuthorLayout({ children, content }: Props) {
|
|||||||
twitter,
|
twitter,
|
||||||
linkedin,
|
linkedin,
|
||||||
github,
|
github,
|
||||||
} = content;
|
} = frontMatter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<PageSEO title={`About - ${name}`} description={`About me - ${name}`} />
|
||||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
<div className="space-y-2 pt-6 pb-8 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">
|
<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
|
About
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="items-start space-y-2 xl:grid xl:grid-cols-3 xl:gap-x-8 xl:space-y-0">
|
<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 space-x-2 pt-8">
|
<div className="flex flex-col items-center pt-8">
|
||||||
{avatar && (
|
<Image
|
||||||
<Image
|
src={avatar}
|
||||||
src={avatar}
|
alt="avatar"
|
||||||
alt="avatar"
|
width="192px"
|
||||||
width={192}
|
height="192px"
|
||||||
height={192}
|
className="h-48 w-48 rounded-full"
|
||||||
className="h-48 w-48 rounded-full"
|
/>
|
||||||
/>
|
<h3 className="pt-4 pb-2 text-2xl font-bold leading-8 tracking-tight">
|
||||||
)}
|
|
||||||
<h3 className="pb-2 pt-4 text-2xl font-bold leading-8 tracking-tight">
|
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-gray-500 dark:text-gray-400">{occupation}</div>
|
<div className="text-gray-500 dark:text-gray-400">{occupation}</div>
|
||||||
@ -51,7 +51,7 @@ export default function AuthorLayout({ children, content }: Props) {
|
|||||||
<SocialIcon kind="twitter" href={twitter} />
|
<SocialIcon kind="twitter" href={twitter} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="prose max-w-none pb-8 pt-8 dark:prose-invert xl:col-span-2">
|
<div className="prose max-w-none pt-8 pb-8 dark:prose-dark xl:col-span-2">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,70 +1,14 @@
|
|||||||
'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 Link from '@/components/Link';
|
||||||
import Tag from '@/components/Tag';
|
import Tag from '@/components/Tag';
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
import { ComponentProps, useState } from 'react';
|
||||||
|
import Pagination from '@/components/Pagination';
|
||||||
interface PaginationProps {
|
import formatDate from '@/lib/utils/formatDate';
|
||||||
totalPages: number;
|
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||||
currentPage: number;
|
interface Props {
|
||||||
}
|
posts: PostFrontMatter[];
|
||||||
interface ListLayoutProps {
|
|
||||||
posts: CoreContent<Blog>[];
|
|
||||||
title: string;
|
title: string;
|
||||||
initialDisplayPosts?: CoreContent<Blog>[];
|
initialDisplayPosts?: PostFrontMatter[];
|
||||||
pagination?: PaginationProps;
|
pagination?: ComponentProps<typeof Pagination>;
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
export default function ListLayout({
|
||||||
@ -72,10 +16,11 @@ export default function ListLayout({
|
|||||||
title,
|
title,
|
||||||
initialDisplayPosts = [],
|
initialDisplayPosts = [],
|
||||||
pagination,
|
pagination,
|
||||||
}: ListLayoutProps) {
|
}: Props) {
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const filteredBlogPosts = posts.filter((post) => {
|
const filteredBlogPosts = posts.filter((frontMatter) => {
|
||||||
const searchContent = post.title + post.summary + post.tags?.join(' ');
|
const searchContent =
|
||||||
|
frontMatter.title + frontMatter.summary + frontMatter.tags.join(' ');
|
||||||
return searchContent.toLowerCase().includes(searchValue.toLowerCase());
|
return searchContent.toLowerCase().includes(searchValue.toLowerCase());
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -88,21 +33,18 @@ export default function ListLayout({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
<div className="space-y-2 pt-6 pb-8 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">
|
<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}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="relative max-w-lg">
|
<div className="relative max-w-lg">
|
||||||
<label>
|
<input
|
||||||
<span className="sr-only">Search articles</span>
|
aria-label="Search articles"
|
||||||
<input
|
type="text"
|
||||||
aria-label="Search articles"
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
type="text"
|
placeholder="Search articles"
|
||||||
onChange={(e) => setSearchValue(e.target.value)}
|
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"
|
||||||
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
|
<svg
|
||||||
className="absolute right-3 top-3 h-5 w-5 text-gray-400 dark:text-gray-300"
|
className="absolute right-3 top-3 h-5 w-5 text-gray-400 dark:text-gray-300"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@ -120,30 +62,30 @@ export default function ListLayout({
|
|||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{!filteredBlogPosts.length && 'No posts found.'}
|
{!filteredBlogPosts.length && 'No posts found.'}
|
||||||
{displayPosts.map((post) => {
|
{displayPosts.map((frontMatter) => {
|
||||||
const { path, date, title, summary, tags } = post;
|
const { slug, date, title, summary, tags } = frontMatter;
|
||||||
return (
|
return (
|
||||||
<li key={path} className="py-4">
|
<li key={slug} className="py-4">
|
||||||
<article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
|
<article className="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="sr-only">Published on</dt>
|
<dt className="sr-only">Published on</dt>
|
||||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||||
<time dateTime={date}>
|
<time dateTime={date}>{formatDate(date)}</time>
|
||||||
{formatDate(date, siteMetadata.locale)}
|
|
||||||
</time>
|
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="space-y-3 xl:col-span-3">
|
<div className="space-y-3 xl:col-span-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-bold leading-8 tracking-tight">
|
<h3 className="text-2xl font-bold leading-8 tracking-tight">
|
||||||
<Link
|
<Link
|
||||||
href={`/${path}`}
|
href={`/blog/${slug}`}
|
||||||
className="text-gray-900 dark:text-gray-100">
|
className="text-gray-900 dark:text-gray-100">
|
||||||
{title}
|
{title}
|
||||||
</Link>
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap">
|
<div className="flex flex-wrap">
|
||||||
{tags?.map((tag) => <Tag key={tag} text={tag} />)}
|
{tags.map((tag) => (
|
||||||
|
<Tag key={tag} text={tag} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="prose max-w-none text-gray-500 dark:text-gray-400">
|
<div className="prose max-w-none text-gray-500 dark:text-gray-400">
|
||||||
|
@ -1,178 +0,0 @@
|
|||||||
/* 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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user