Compare commits
69 Commits
fd187a1370
...
master
Author | SHA1 | Date | |
---|---|---|---|
104fac9196 | |||
b03230f3a6 | |||
1f2a742467 | |||
5836fc2a02 | |||
44174c5f36 | |||
f3a25f7a46 | |||
7a9fe3fb2f | |||
ec61c5bb9f | |||
dc0969d175 | |||
87f9e54318 | |||
87d7f43afb | |||
423f908b83 | |||
3932a2b612 | |||
de1da22508 | |||
02ab7d11b2 | |||
0adaed6c97 | |||
ff13b8b2b2 | |||
2b59af89cc | |||
348d18a348 | |||
8dc5ffd39f | |||
00b8565dba | |||
1e7cb5c942 | |||
dbb35eb462 | |||
a0f5822bb8 | |||
90a6a3d9d9 | |||
4f06f56754 | |||
1536ffa319 | |||
24aadfa329 | |||
bd4a211c6c | |||
e263be3fe9 | |||
fede1341b0 | |||
e2af844823 | |||
e470ac241e | |||
a8e6ee073f | |||
14719936fc | |||
26350e033b | |||
91d3acc358 | |||
e41238fb60 | |||
404c7cba87 | |||
930953fc1a | |||
7ad3729ae0 | |||
9a3297e1c7 | |||
abb37dbcac | |||
0549b3c385 | |||
c2dca0e57b | |||
1b05eae89b | |||
c85737fa3f | |||
364b85cdc6 | |||
6e0b88bd1e | |||
d137dfac70 | |||
864085ea4a | |||
16d5d1f32e | |||
2f526f713c | |||
0b3bdfc36a | |||
b2c2b0eb98 | |||
97904d66b4 | |||
61efce68b5 | |||
de5081da69 | |||
8e0f1e4ff1 | |||
a8a1fe1e0d | |||
8d9020da3a | |||
4cc2f920a1 | |||
7a2d689a4f | |||
7550421a74 | |||
6557412554 | |||
49ad571864 | |||
1bde01a6a9 | |||
5bee02b567 | |||
35b92490d8 |
@ -11,4 +11,4 @@ FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
|
|||||||
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
|
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
|
||||||
|
|
||||||
# [Optional] Uncomment if you want to install more global node packages
|
# [Optional] Uncomment if you want to install more global node packages
|
||||||
# RUN su node -c "npm install -g pnpm"
|
RUN su node -c "npm install -g pnpm"
|
||||||
|
@ -8,10 +8,9 @@
|
|||||||
// 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": "16-bullseye"
|
"VARIANT": "18-bullseye"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Configure tool-specific properties.
|
// Configure tool-specific properties.
|
||||||
"customizations": {
|
"customizations": {
|
||||||
// Configure properties specific to VS Code.
|
// Configure properties specific to VS Code.
|
||||||
@ -36,25 +35,24 @@
|
|||||||
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||||
"forwardPorts": [3000],
|
"forwardPorts": [3000],
|
||||||
|
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
// Use 'postCreateCommand' to run commands after the container is created.
|
||||||
// "postCreateCommand": "yarn install",
|
// "postCreateCommand": "yarn install",
|
||||||
|
|
||||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||||
"remoteUser": "node",
|
"remoteUser": "node",
|
||||||
"features": {
|
"features": {
|
||||||
"git": "os-provided",
|
"git": "os-provided",
|
||||||
"git-lfs": "latest"
|
"git-lfs": "latest",
|
||||||
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||||
},
|
},
|
||||||
"mounts": [
|
"mounts": [],
|
||||||
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/node/.ssh,type=bind,consistency=cached"
|
"postAttachCommand": "pnpm install && npm run dev"
|
||||||
],
|
|
||||||
"postStartCommand": "npm ci && npm run dev"
|
|
||||||
}
|
}
|
||||||
|
172
.drone.yml
172
.drone.yml
@ -1,172 +0,0 @@
|
|||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
name: deps
|
|
||||||
type: docker
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: install
|
|
||||||
image: plugins/docker
|
|
||||||
settings:
|
|
||||||
registry: docker-registry.ivanli.cc
|
|
||||||
username:
|
|
||||||
from_secret: ivan-docker-username
|
|
||||||
password:
|
|
||||||
from_secret: ivan-docker-password
|
|
||||||
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps
|
|
||||||
cache_from:
|
|
||||||
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: deps
|
|
||||||
tags:
|
|
||||||
- '${DRONE_COMMIT_SHA:0:8}-amd64'
|
|
||||||
- '${DRONE_BRANCH}${DRONE_TAG}-amd64'
|
|
||||||
|
|
||||||
- name: notify
|
|
||||||
image: appleboy/drone-telegram
|
|
||||||
when:
|
|
||||||
status:
|
|
||||||
- failure
|
|
||||||
failure: ignore
|
|
||||||
detach: true
|
|
||||||
environment:
|
|
||||||
PLUGIN_TOKEN:
|
|
||||||
from_secret: drone-telegram-bot-token
|
|
||||||
PLUGIN_TO:
|
|
||||||
from_secret: telegram-notify-to
|
|
||||||
settings:
|
|
||||||
format: markdown
|
|
||||||
message: >
|
|
||||||
{{#success build.status}}
|
|
||||||
✅ Install Deps #{{build.number}} of `{{repo.name}}` succeeded.
|
|
||||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
|
||||||
```
|
|
||||||
{{commit.message}}
|
|
||||||
```
|
|
||||||
🌐 {{ build.link }}
|
|
||||||
{{else}}
|
|
||||||
❌ Install Deps #{{build.number}} of `{{repo.name}}` failed.
|
|
||||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
|
||||||
```
|
|
||||||
{{commit.message}}
|
|
||||||
```
|
|
||||||
🌐 {{ build.link }}
|
|
||||||
{{/success}}
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
name: linux-amd64
|
|
||||||
type: docker
|
|
||||||
depends_on:
|
|
||||||
- deps
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: build&publish
|
|
||||||
image: plugins/docker
|
|
||||||
settings:
|
|
||||||
registry: docker-registry.ivanli.cc
|
|
||||||
username:
|
|
||||||
from_secret: ivan-docker-username
|
|
||||||
password:
|
|
||||||
from_secret: ivan-docker-password
|
|
||||||
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: release
|
|
||||||
cache_from:
|
|
||||||
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
|
||||||
tags:
|
|
||||||
- '${DRONE_COMMIT_SHA:0:8}'
|
|
||||||
- '${DRONE_BRANCH}${DRONE_TAG}'
|
|
||||||
- name: notify
|
|
||||||
image: appleboy/drone-telegram
|
|
||||||
failure: ignore
|
|
||||||
detach: true
|
|
||||||
when:
|
|
||||||
status:
|
|
||||||
- success
|
|
||||||
- failure
|
|
||||||
environment:
|
|
||||||
PLUGIN_TOKEN:
|
|
||||||
from_secret: drone-telegram-bot-token
|
|
||||||
PLUGIN_TO:
|
|
||||||
from_secret: telegram-notify-to
|
|
||||||
settings:
|
|
||||||
format: markdown
|
|
||||||
message: >
|
|
||||||
{{#success build.status}}
|
|
||||||
✅ Build #{{build.number}} of `{{repo.name}}` succeeded.
|
|
||||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
|
||||||
```
|
|
||||||
{{commit.message}}
|
|
||||||
```
|
|
||||||
🌐 {{ build.link }}
|
|
||||||
{{else}}
|
|
||||||
❌ Build #{{build.number}} of `{{repo.name}}` failed.
|
|
||||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
|
||||||
```
|
|
||||||
{{commit.message}}
|
|
||||||
```
|
|
||||||
🌐 {{ build.link }}
|
|
||||||
{{/success}}
|
|
||||||
|
|
||||||
trigger:
|
|
||||||
branch:
|
|
||||||
- master
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
type: docker
|
|
||||||
name: deploy
|
|
||||||
clone:
|
|
||||||
disable: true
|
|
||||||
depends_on:
|
|
||||||
- linux-amd64
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: deploy
|
|
||||||
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}}
|
|
@ -1,12 +0,0 @@
|
|||||||
# EditorConfig is awesome: https://EditorConfig.org
|
|
||||||
|
|
||||||
# top-most EditorConfig file
|
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
charset = utf-8
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
insert_final_newline = false
|
|
12
.env.example
12
.env.example
@ -1,32 +1,26 @@
|
|||||||
|
# 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 +1,2 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
.eslintrc.js
|
29
.eslintrc.js
29
.eslintrc.js
@ -1,17 +1,42 @@
|
|||||||
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,
|
||||||
},
|
},
|
||||||
extends: ['eslint:recommended', 'plugin:prettier/recommended', 'next', 'next/core-web-vitals'],
|
plugins: ['@typescript-eslint'],
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/eslint-recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:jsx-a11y/recommended',
|
||||||
|
'plugin:prettier/recommended',
|
||||||
|
'next',
|
||||||
|
'next/core-web-vitals',
|
||||||
|
],
|
||||||
|
parserOptions: {
|
||||||
|
project: true,
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
rules: {
|
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,
|
||||||
'no-unused-vars': 0,
|
'@typescript-eslint/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 to force LF endings.
|
## Modified * text=auto to * text=auto eol=lf eol=lf to force LF endings.
|
||||||
|
|
||||||
## GITATTRIBUTES FOR WEB PROJECTS
|
## GITATTRIBUTES FOR WEB PROJECTS
|
||||||
#
|
#
|
||||||
|
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
@ -1,3 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: timlrx
|
|
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
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
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
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
Normal file
33
.github/workflows/build-and-deploy-by-ftp.yaml
vendored
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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,6 +4,10 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
/.yarn/*
|
||||||
|
!/.yarn/releases
|
||||||
|
!/.yarn/plugins
|
||||||
|
!/.yarn/sdks
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
@ -17,9 +21,13 @@ 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
|
||||||
|
|
||||||
@ -35,6 +43,9 @@ 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
|
@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
singleQuote: true,
|
|
||||||
trailingCommas: 'all',
|
|
||||||
bracketSpacing: true,
|
|
||||||
bracketSameLine: true,
|
|
||||||
};
|
|
16
.vscode/launch.json
vendored
Normal file
16
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"alpn",
|
"alpn",
|
||||||
"appleboy",
|
"appleboy",
|
||||||
@ -7,15 +9,21 @@
|
|||||||
"Commento",
|
"Commento",
|
||||||
"CONVERTKIT",
|
"CONVERTKIT",
|
||||||
"Cusdis",
|
"Cusdis",
|
||||||
|
"Discuz",
|
||||||
"Disqus",
|
"Disqus",
|
||||||
"dokodemo",
|
"dokodemo",
|
||||||
"EMAILOCTOPUS",
|
"EMAILOCTOPUS",
|
||||||
"fullchain",
|
"fullchain",
|
||||||
"Giscus",
|
"Giscus",
|
||||||
|
"Hackintosh",
|
||||||
"KLAVIYO",
|
"KLAVIYO",
|
||||||
|
"Kutt",
|
||||||
"lastmod",
|
"lastmod",
|
||||||
|
"Logseq",
|
||||||
"MAILCHIMP",
|
"MAILCHIMP",
|
||||||
|
"Miniflux",
|
||||||
"nextjs",
|
"nextjs",
|
||||||
|
"Nuxt",
|
||||||
"outbounds",
|
"outbounds",
|
||||||
"rprx",
|
"rprx",
|
||||||
"unist",
|
"unist",
|
||||||
|
874
.yarn/releases/yarn-3.6.1.cjs
vendored
Executable file
874
.yarn/releases/yarn-3.6.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
3
.yarnrc.yml
Normal file
3
.yarnrc.yml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-3.6.1.cjs
|
21
Dockerfile
21
Dockerfile
@ -12,11 +12,24 @@ FROM deps as build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=deps /app ./
|
COPY --from=deps /app ./
|
||||||
RUN pnpm build &&\
|
RUN pnpm build
|
||||||
pnpm prune --prod --config.ignore-scripts=true
|
|
||||||
|
FROM build as pre-release
|
||||||
|
WORKDIR /app
|
||||||
|
RUN pnpm prune --prod --config.ignore-scripts=true
|
||||||
|
|
||||||
FROM node:16-alpine as release
|
FROM node:16-alpine as release
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app ./
|
COPY --from=pre-release /app ./
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD npm run serve -- -p 80
|
CMD npm run serve -- -p 80
|
||||||
|
|
||||||
|
FROM build as export
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm run export
|
||||||
|
|
||||||
|
FROM alpine:latest as upload
|
||||||
|
RUN apk add lftp
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=export /app/out ./
|
||||||
|
CMD lftp -u "${FTP_ACCOUNT},${FTP_PASSWORD}" "${FTP_HOST}" -e 'set ftp:ssl-allow off && set use-feat no && mirror -c -R --use-pget-n=10 . ./WEB && exit'
|
173
README.md
173
README.md
@ -1,11 +1,17 @@
|
|||||||

|

|
||||||
|
|
||||||
# Ivan Li's Blog
|
# Tailwind Nextjs Starter Blog
|
||||||
|
|
||||||
[](https://ci.ivanli.cc/Ivan/tailwind-nextjs-blog)
|
[](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/stargazers/)
|
||||||
[](https://ivanli.cc)
|
[](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/network/)
|
||||||
|
[](https://twitter.com/timlrxx)
|
||||||
|
[](https://github.com/sponsors/timlrx)
|
||||||
|
|
||||||
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Probably the most feature-rich Next.js markdown blogging template out there. Comes out of the box configured with the latest technologies to make technical writing a breeze. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
|
[](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
|
||||||
|
|
||||||
|
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Version 2 is based on Next App directory with [React Server Component](https://nextjs.org/docs/getting-started/react-essentials#server-components) and uses [Contentlayer](https://www.contentlayer.dev/) to manage markdown content.
|
||||||
|
|
||||||
|
Probably the most feature-rich Next.js markdown blogging template out there. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
|
||||||
|
|
||||||
Check out the documentation below to get started.
|
Check out the documentation below to get started.
|
||||||
|
|
||||||
@ -13,28 +19,84 @@ Facing issues? Check the [FAQ page](https://github.com/timlrx/tailwind-nextjs-st
|
|||||||
|
|
||||||
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!
|
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
|
## 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
|
- Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute
|
||||||
- Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/210111_DiC1_08f3670c3430bf4a9b76fc3b927716c5/)
|
- [MDX - write JSX in markdown documents!](https://mdxjs.com/)
|
||||||
- Lightweight, 45kB first load JS, uses Preact in production build
|
- Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/230805_BiDcBQ_4H7)
|
||||||
|
- Lightweight, 85kB first load JS
|
||||||
- Mobile-friendly view
|
- Mobile-friendly view
|
||||||
- Light and dark theme
|
- Light and dark theme
|
||||||
- Self-hosted font with [Fontsource](https://fontsource.org/)
|
- Font optimization with [next/font](https://nextjs.org/docs/app/api-reference/components/font)
|
||||||
- Supports [plausible](https://plausible.io/), [simple analytics](https://simpleanalytics.com/) and google analytics
|
- Integration with [pliny](https://github.com/timlrx/pliny) that provides:
|
||||||
- [MDX - write JSX in markdown documents!](https://mdxjs.com/)
|
- 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)
|
- 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/)
|
- Math display supported via [KaTeX](https://katex.org/)
|
||||||
- Citation and bibliography support via [rehype-citation](https://github.com/timlrx/rehype-citation)
|
- 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)
|
- Automatic image optimization via [next/image](https://nextjs.org/docs/basic-features/image-optimization)
|
||||||
- Flexible data retrieval with [mdx-bundler](https://github.com/kentcdodds/mdx-bundler)
|
|
||||||
- Support for tags - each unique tag will be its own page
|
- Support for tags - each unique tag will be its own page
|
||||||
- Support for multiple authors
|
- Support for multiple authors
|
||||||
- Blog templates
|
- 3 different blog layouts
|
||||||
- TOC component
|
- 2 different blog listing layouts
|
||||||
- Support for nested routing of blog posts
|
- Support for nested routing of blog posts
|
||||||
- Newsletter component with support for mailchimp, buttondown, convertkit, klaviyo, revue, and emailoctopus
|
|
||||||
- Supports [giscus](https://github.com/laymonage/giscus), [utterances](https://github.com/utterance/utterances) or disqus
|
|
||||||
- Projects page
|
- Projects page
|
||||||
- Preconfigured security headers
|
- Preconfigured security headers
|
||||||
- SEO friendly with RSS feed, sitemaps and more!
|
- SEO friendly with RSS feed, sitemaps and more!
|
||||||
@ -50,30 +112,15 @@ Feature request? Check the past discussions to see if it has been brought up pre
|
|||||||
|
|
||||||
## Quick Start Guide
|
## Quick Start Guide
|
||||||
|
|
||||||
1. Try installing the starter using the new [Pliny project CLI](https://github.com/timlrx/pliny):
|
1. Clone the repo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm i -g @pliny/cli
|
npx degit 'timlrx/tailwind-nextjs-starter-blog'
|
||||||
pliny new --template=starter-blog my-blog
|
|
||||||
```
|
|
||||||
|
|
||||||
It supports the updated version of the blog with Contentlayer, optional choice of TS/JS and different package managers as well as more modularized components which will be the basis of the template going forward.
|
|
||||||
|
|
||||||
Alternatively to stick with the current version, TypeScript and Contentlayer:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx degit 'timlrx/tailwind-nextjs-starter-blog#contentlayer'
|
|
||||||
```
|
|
||||||
|
|
||||||
or JS (official support)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Personalize `siteMetadata.js` (site related information)
|
2. Personalize `siteMetadata.js` (site related information)
|
||||||
3. Modify the content security policy in `next.config.js` if you want to use
|
3. Modify the content security policy in `next.config.js` if you want to use
|
||||||
any analytics provider or a commenting solution other than giscus.
|
other analytics provider or a commenting solution other than giscus.
|
||||||
4. Personalize `authors/default.md` (main author)
|
4. Personalize `authors/default.md` (main author)
|
||||||
5. Modify `projectsData.js`
|
5. Modify `projectsData.js`
|
||||||
6. Modify `headerNavLinks.js` to customize navigation links
|
6. Modify `headerNavLinks.js` to customize navigation links
|
||||||
@ -83,7 +130,7 @@ npx degit https://github.com/timlrx/tailwind-nextjs-starter-blog.git
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
yarn
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@ -91,18 +138,12 @@ npm install
|
|||||||
First, run the development server:
|
First, run the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start
|
yarn dev
|
||||||
```
|
|
||||||
|
|
||||||
or
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm 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.
|
||||||
|
|
||||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
Edit the layout in `app` or content in `data`. With live reloading, the pages auto-updates as you edit them.
|
||||||
|
|
||||||
## Extend / Customize
|
## Extend / Customize
|
||||||
|
|
||||||
@ -120,36 +161,41 @@ You can start editing the page by modifying `pages/index.js`. The page auto-upda
|
|||||||
|
|
||||||
`public/static` - store assets such as images and favicons.
|
`public/static` - store assets such as images and favicons.
|
||||||
|
|
||||||
`tailwind.config.js` and `css/tailwind.css` - contain the tailwind stylesheet which can be modified to change the overall look and feel of the site.
|
`tailwind.config.js` and `css/tailwind.css` - tailwind configuration and stylesheet which can be modified to change the overall look and feel of the site.
|
||||||
|
|
||||||
`css/prism.css` - controls the styles associated with the code blocks. Feel free to customize it and use your preferred prismjs theme e.g. [prism themes](https://github.com/PrismJS/prism-themes).
|
`css/prism.css` - controls the styles associated with the code blocks. Feel free to customize it and use your preferred prismjs theme e.g. [prism themes](https://github.com/PrismJS/prism-themes).
|
||||||
|
|
||||||
`components/social-icons` - to add other icons, simply copy an svg file from [Simple Icons](https://simpleicons.org/) and map them in `index.js`. Other icons use [heroicons](https://heroicons.com/).
|
`contentlayer.config.ts` - configuration for Contentlayer, including definition of content sources and MDX plugins used. See [Contentlayer documentation](https://www.contentlayer.dev/docs/getting-started) for more information.
|
||||||
|
|
||||||
`components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then call them directly in the `.mdx` or `.md` file. By default, a custom link and image component is passed.
|
`components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then use them directly in the `.mdx` or `.md` file. By default, a custom link, `next/image` component, table of contents component and Newsletter form are passed down. Note that the components should be default exported to avoid [existing issues with Next.js](https://github.com/vercel/next.js/issues/51593).
|
||||||
|
|
||||||
`layouts` - main templates used in pages.
|
`layouts` - main templates used in pages:
|
||||||
|
|
||||||
`pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information.
|
- There are currently 3 post layouts available: `PostLayout`, `PostSimple` and `PostBanner`. `PostLayout` is the default 2 column layout with meta and author information. `PostSimple` is a simplified version of `PostLayout`, while `PostBanner` features a banner image.
|
||||||
|
- There are 2 blog listing layouts: `ListLayout`, the layout used in version 1 of the template with a search bar and `ListLayoutWithTags`, currently used in version 2, which omits the search bar but includes a sidebar with information on the tags.
|
||||||
|
|
||||||
|
`app` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs/app) for more information.
|
||||||
|
|
||||||
`next.config.js` - configuration related to Next.js. You need to adapt the Content Security Policy if you want to load scripts, images etc. from other domains.
|
`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/).
|
||||||
|
|
||||||
Currently 7 fields are supported.
|
Please refer to `contentlayer.config.ts` for an up to date list of supported fields. The following fields are supported:
|
||||||
|
|
||||||
```
|
```
|
||||||
title (required)
|
title (required)
|
||||||
date (required)
|
date (required)
|
||||||
tags (required, can be empty array)
|
tags (optional)
|
||||||
lastmod (optional)
|
lastmod (optional)
|
||||||
draft (optional)
|
draft (optional)
|
||||||
summary (optional)
|
summary (optional)
|
||||||
images (optional, if none provided defaults to socialBanner in siteMetadata config)
|
images (optional)
|
||||||
authors (optional list which should correspond to the file names in `data/authors`. Uses `default` if none is specified)
|
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)
|
||||||
@ -172,26 +218,29 @@ canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/introducing-t
|
|||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compose
|
|
||||||
|
|
||||||
Run `node ./scripts/compose.js` to bootstrap a new post.
|
|
||||||
|
|
||||||
Follow the interactive prompt to generate a post with pre-filled front matter.
|
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
**Vercel**
|
**Vercel**
|
||||||
The easiest way to deploy the template is to use the [Vercel Platform](https://vercel.com) from the creators of Next.js. Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
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 / GitHub Pages / Firebase etc.**
|
**Netlify**
|
||||||
As the template uses `next/image` for image optimization, additional configurations have to be made to deploy on other popular static hosting websites like [Netlify](https://www.netlify.com/) or [GitHub Pages](https://pages.github.com/). An alternative image optimization provider such as Imgix, Cloudinary or Akamai has to be used. Alternatively, replace the `next/image` component with a standard `<img>` tag. See [`next/image` documentation](https://nextjs.org/docs/basic-features/image-optimization) for more details.
|
[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.
|
||||||
|
|
||||||
The API routes used in the newsletter component cannot be used in a static site export. You will need to use a form API endpoint provider and substitute the route in the newsletter component accordingly. Other hosting platforms such as Netlify also offer alternative solutions - please refer to their docs for more information.
|
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
|
## 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).
|
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).
|
||||||
|
|
||||||
## License
|
## Licence
|
||||||
|
|
||||||
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/master/LICENSE) © [Timothy Lin](https://www.timlrx.com)
|
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/main/LICENSE) © [Timothy Lin](https://www.timlrx.com)
|
||||||
|
@ -1,45 +1,27 @@
|
|||||||
import Link from '@/components/Link';
|
import Link from '@/components/Link';
|
||||||
import { PageSEO } from '@/components/SEO';
|
|
||||||
import Tag from '@/components/Tag';
|
import Tag from '@/components/Tag';
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import { getAllFilesFrontMatter } from '@/lib/mdx';
|
import { formatDate } from 'pliny/utils/formatDate';
|
||||||
import formatDate from '@/lib/utils/formatDate';
|
import NewsletterForm from 'pliny/ui/NewsletterForm';
|
||||||
import { GetStaticProps, InferGetStaticPropsType } from 'next';
|
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
|
||||||
import NewsletterForm from '@/components/NewsletterForm';
|
|
||||||
|
|
||||||
const MAX_DISPLAY = 5;
|
const MAX_DISPLAY = 5;
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<{
|
export default function Home({ posts }) {
|
||||||
posts: PostFrontMatter[];
|
|
||||||
}> = async () => {
|
|
||||||
const posts = await getAllFilesFrontMatter('blog');
|
|
||||||
|
|
||||||
return { props: { posts } };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Home({
|
|
||||||
posts,
|
|
||||||
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageSEO
|
|
||||||
title={siteMetadata.title}
|
|
||||||
description={siteMetadata.description}
|
|
||||||
/>
|
|
||||||
<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 pt-6 pb-8 md:space-y-5">
|
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
||||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||||
最近发布的文章
|
Latest
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
|
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
|
||||||
{siteMetadata.description}
|
{siteMetadata.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{!posts.length && '没有找到文章。 😭'}
|
{!posts.length && 'No posts found.'}
|
||||||
{posts.slice(0, MAX_DISPLAY).map((frontMatter) => {
|
{posts.slice(0, MAX_DISPLAY).map((post) => {
|
||||||
const { slug, date, title, summary, tags } = frontMatter;
|
const { slug, date, title, summary, tags } = post;
|
||||||
return (
|
return (
|
||||||
<li key={slug} className="py-12">
|
<li key={slug} className="py-12">
|
||||||
<article>
|
<article>
|
||||||
@ -47,7 +29,9 @@ export default function Home({
|
|||||||
<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}>{formatDate(date)}</time>
|
<time dateTime={date}>
|
||||||
|
{formatDate(date, siteMetadata.locale)}
|
||||||
|
</time>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="space-y-5 xl:col-span-3">
|
<div className="space-y-5 xl:col-span-3">
|
||||||
@ -91,12 +75,12 @@ export default function Home({
|
|||||||
<Link
|
<Link
|
||||||
href="/blog"
|
href="/blog"
|
||||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||||
aria-label="all posts">
|
aria-label="All posts">
|
||||||
All Posts →
|
All Posts →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{siteMetadata.newsletter.provider !== '' && (
|
{siteMetadata.newsletter?.provider && (
|
||||||
<div className="flex items-center justify-center pt-4">
|
<div className="flex items-center justify-center pt-4">
|
||||||
<NewsletterForm />
|
<NewsletterForm />
|
||||||
</div>
|
</div>
|
20
app/about/page.tsx
Normal file
20
app/about/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Authors, allAuthors } from 'contentlayer/generated';
|
||||||
|
import { MDXLayoutRenderer } from 'pliny/mdx-components';
|
||||||
|
import AuthorLayout from '@/layouts/AuthorLayout';
|
||||||
|
import { coreContent } from 'pliny/utils/contentlayer';
|
||||||
|
import { genPageMetadata } from 'app/seo';
|
||||||
|
|
||||||
|
export const metadata = genPageMetadata({ title: 'About' });
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const author = allAuthors.find((p) => p.slug === 'default') as Authors;
|
||||||
|
const mainContent = coreContent(author);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AuthorLayout content={mainContent}>
|
||||||
|
<MDXLayoutRenderer code={author.body.code} />
|
||||||
|
</AuthorLayout>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
9
app/api/newsletter/route.ts
Normal file
9
app/api/newsletter/route.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { NewsletterAPI } from 'pliny/newsletter';
|
||||||
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
|
||||||
|
const handler = NewsletterAPI({
|
||||||
|
// @ts-ignore
|
||||||
|
provider: siteMetadata.newsletter.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST };
|
138
app/blog/[...slug]/page.tsx
Normal file
138
app/blog/[...slug]/page.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import 'css/prism.css';
|
||||||
|
import 'katex/dist/katex.css';
|
||||||
|
|
||||||
|
import PageTitle from '@/components/PageTitle';
|
||||||
|
import { components } from '@/components/MDXComponents';
|
||||||
|
import { MDXLayoutRenderer } from 'pliny/mdx-components';
|
||||||
|
import { sortPosts, coreContent } from 'pliny/utils/contentlayer';
|
||||||
|
import { allBlogs, allAuthors } from 'contentlayer/generated';
|
||||||
|
import type { Authors, Blog } from 'contentlayer/generated';
|
||||||
|
import PostSimple from '@/layouts/PostSimple';
|
||||||
|
import PostLayout from '@/layouts/PostLayout';
|
||||||
|
import PostBanner from '@/layouts/PostBanner';
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const defaultLayout = 'PostLayout';
|
||||||
|
const layouts = {
|
||||||
|
PostSimple,
|
||||||
|
PostLayout,
|
||||||
|
PostBanner,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { slug: string[] };
|
||||||
|
}): Promise<Metadata | undefined> {
|
||||||
|
const slug = decodeURI(params.slug.join('/'));
|
||||||
|
const post = allBlogs.find((p) => p.slug === slug);
|
||||||
|
const authorList = post?.authors || ['default'];
|
||||||
|
const authorDetails = authorList.map((author) => {
|
||||||
|
const authorResults = allAuthors.find((p) => p.slug === author);
|
||||||
|
return coreContent(authorResults as Authors);
|
||||||
|
});
|
||||||
|
if (!post) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedAt = new Date(post.date).toISOString();
|
||||||
|
const modifiedAt = new Date(post.lastmod || post.date).toISOString();
|
||||||
|
const authors = authorDetails.map((author) => author.name);
|
||||||
|
let imageList = [siteMetadata.socialBanner];
|
||||||
|
if (post.images) {
|
||||||
|
imageList = typeof post.images === 'string' ? [post.images] : post.images;
|
||||||
|
}
|
||||||
|
const ogImages = imageList.map((img) => {
|
||||||
|
return {
|
||||||
|
url: img.includes('http') ? img : siteMetadata.siteUrl + img,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: post.title,
|
||||||
|
description: post.summary,
|
||||||
|
openGraph: {
|
||||||
|
title: post.title,
|
||||||
|
description: post.summary,
|
||||||
|
siteName: siteMetadata.title,
|
||||||
|
locale: 'en_US',
|
||||||
|
type: 'article',
|
||||||
|
publishedTime: publishedAt,
|
||||||
|
modifiedTime: modifiedAt,
|
||||||
|
url: './',
|
||||||
|
images: ogImages,
|
||||||
|
authors: authors.length > 0 ? authors : [siteMetadata.author],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: post.title,
|
||||||
|
description: post.summary,
|
||||||
|
images: imageList,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateStaticParams = async () => {
|
||||||
|
const paths = allBlogs.map((p) => ({ slug: p.slug.split('/') }));
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: { slug: string[] } }) {
|
||||||
|
const slug = decodeURI(params.slug.join('/'));
|
||||||
|
const sortedPosts = sortPosts(allBlogs) as Blog[];
|
||||||
|
const postIndex = sortedPosts.findIndex((p) => p.slug === slug);
|
||||||
|
const prev = coreContent(sortedPosts[postIndex + 1]);
|
||||||
|
const next = coreContent(sortedPosts[postIndex - 1]);
|
||||||
|
const post = sortedPosts.find((p) => p.slug === slug) as Blog;
|
||||||
|
const authorList = post?.authors || ['default'];
|
||||||
|
const authorDetails = authorList.map((author) => {
|
||||||
|
const authorResults = allAuthors.find((p) => p.slug === author);
|
||||||
|
return coreContent(authorResults as Authors);
|
||||||
|
});
|
||||||
|
const mainContent = coreContent(post);
|
||||||
|
const jsonLd = post.structuredData;
|
||||||
|
jsonLd['author'] = authorDetails.map((author) => {
|
||||||
|
return {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: author.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const Layout = layouts[post.layout || defaultLayout];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isProduction && post && 'draft' in post && post.draft === true ? (
|
||||||
|
<div className="mt-24 text-center">
|
||||||
|
<PageTitle>
|
||||||
|
Under Construction{' '}
|
||||||
|
<span role="img" aria-label="roadwork sign">
|
||||||
|
🚧
|
||||||
|
</span>
|
||||||
|
</PageTitle>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<Layout
|
||||||
|
content={mainContent}
|
||||||
|
authorDetails={authorDetails}
|
||||||
|
next={next}
|
||||||
|
prev={prev}>
|
||||||
|
<MDXLayoutRenderer
|
||||||
|
code={post.body.code}
|
||||||
|
components={components}
|
||||||
|
toc={post.toc}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
30
app/blog/page.tsx
Normal file
30
app/blog/page.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import ListLayout from '@/layouts/ListLayoutWithTags';
|
||||||
|
import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer';
|
||||||
|
import { allBlogs } from 'contentlayer/generated';
|
||||||
|
import { genPageMetadata } from 'app/seo';
|
||||||
|
|
||||||
|
const POSTS_PER_PAGE = 5;
|
||||||
|
|
||||||
|
export const metadata = genPageMetadata({ title: 'Blog' });
|
||||||
|
|
||||||
|
export default function BlogPage() {
|
||||||
|
const posts = allCoreContent(sortPosts(allBlogs));
|
||||||
|
const pageNumber = 1;
|
||||||
|
const initialDisplayPosts = posts.slice(
|
||||||
|
POSTS_PER_PAGE * (pageNumber - 1),
|
||||||
|
POSTS_PER_PAGE * pageNumber,
|
||||||
|
);
|
||||||
|
const pagination = {
|
||||||
|
currentPage: pageNumber,
|
||||||
|
totalPages: Math.ceil(posts.length / POSTS_PER_PAGE),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListLayout
|
||||||
|
posts={posts}
|
||||||
|
initialDisplayPosts={initialDisplayPosts}
|
||||||
|
pagination={pagination}
|
||||||
|
title="All Posts"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
36
app/blog/page/[page]/page.tsx
Normal file
36
app/blog/page/[page]/page.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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
Normal file
42
app/head.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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
Normal file
124
app/layout.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
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 +1,9 @@
|
|||||||
import Link from '@/components/Link';
|
import Link from '@/components/Link';
|
||||||
|
|
||||||
export default function FourZeroFour() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start justify-start md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6">
|
<div className="flex flex-col items-start justify-start md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6">
|
||||||
<div className="space-x-2 pt-6 pb-8 md:space-y-5">
|
<div className="space-x-2 pb-8 pt-6 md:space-y-5">
|
||||||
<h1 className="text-6xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 md:border-r-2 md:px-6 md:text-8xl md:leading-14">
|
<h1 className="text-6xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 md:border-r-2 md:px-6 md:text-8xl md:leading-14">
|
||||||
404
|
404
|
||||||
</h1>
|
</h1>
|
||||||
@ -15,10 +15,10 @@ export default function FourZeroFour() {
|
|||||||
<p className="mb-8">
|
<p className="mb-8">
|
||||||
But dont worry, you can find plenty of other things on our homepage.
|
But dont worry, you can find plenty of other things on our homepage.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/">
|
<Link
|
||||||
<button className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500">
|
href="/"
|
||||||
Back to homepage
|
className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500">
|
||||||
</button>
|
Back to homepage
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
9
app/page.tsx
Normal file
9
app/page.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { sortPosts, allCoreContent } from 'pliny/utils/contentlayer';
|
||||||
|
import { allBlogs } from 'contentlayer/generated';
|
||||||
|
import Main from './Main';
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const sortedPosts = sortPosts(allBlogs);
|
||||||
|
const posts = allCoreContent(sortedPosts);
|
||||||
|
return <Main posts={posts} />;
|
||||||
|
}
|
@ -1,22 +1,19 @@
|
|||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
import projectsData from '@/data/projectsData';
|
import projectsData from '@/data/projectsData';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import { PageSEO } from '@/components/SEO';
|
import { genPageMetadata } from 'app/seo';
|
||||||
|
|
||||||
|
export const metadata = genPageMetadata({ title: 'Projects' });
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageSEO
|
|
||||||
title={`Projects - ${siteMetadata.author}`}
|
|
||||||
description={siteMetadata.description}
|
|
||||||
/>
|
|
||||||
<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 pt-6 pb-8 md:space-y-5">
|
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
||||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||||
Projects
|
Projects
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
|
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
|
||||||
我的项目橱窗,欢迎交流。
|
Showcase your projects with a hero image (16 x 9)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="container py-12">
|
<div className="container py-12">
|
13
app/robots.ts
Normal file
13
app/robots.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
},
|
||||||
|
sitemap: `${siteMetadata.siteUrl}/sitemap.xml`,
|
||||||
|
host: siteMetadata.siteUrl,
|
||||||
|
};
|
||||||
|
}
|
36
app/seo.tsx
Normal file
36
app/seo.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Metadata } from 'next';
|
||||||
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
|
||||||
|
interface PageSEOProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function genPageMetadata({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
...rest
|
||||||
|
}: PageSEOProps): Metadata {
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
openGraph: {
|
||||||
|
title: `${title} | ${siteMetadata.title}`,
|
||||||
|
description: description || siteMetadata.description,
|
||||||
|
url: './',
|
||||||
|
siteName: siteMetadata.title,
|
||||||
|
images: image ? [image] : [siteMetadata.socialBanner],
|
||||||
|
locale: 'en_US',
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
title: `${title} | ${siteMetadata.title}`,
|
||||||
|
card: 'summary_large_image',
|
||||||
|
images: image ? [image] : [siteMetadata.socialBanner],
|
||||||
|
},
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
18
app/sitemap.ts
Normal file
18
app/sitemap.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
import { allBlogs } from 'contentlayer/generated';
|
||||||
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const siteUrl = siteMetadata.siteUrl;
|
||||||
|
const blogRoutes = allBlogs.map((post) => ({
|
||||||
|
url: `${siteUrl}/${post.path}`,
|
||||||
|
lastModified: post.lastmod || post.date,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const routes = ['', 'blog', 'projects', 'tags'].map((route) => ({
|
||||||
|
url: `${siteUrl}/${route}`,
|
||||||
|
lastModified: new Date().toISOString().split('T')[0],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...routes, ...blogRoutes];
|
||||||
|
}
|
50
app/tag-data.json
Normal file
50
app/tag-data.json
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
50
app/tags/[tag]/page.tsx
Normal file
50
app/tags/[tag]/page.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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} />;
|
||||||
|
}
|
43
app/tags/page.tsx
Normal file
43
app/tags/page.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
15
app/theme-providers.tsx
Normal file
15
app/theme-providers.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
'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 p-4 md:w-1/2" style={{ maxWidth: '544px' }}>
|
<div className="md max-w-[544px] p-4 md:w-1/2">
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
imgSrc && 'h-full'
|
imgSrc && 'h-full'
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import Router from 'next/router';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client-side complement to next-remote-watch
|
|
||||||
* Re-triggers getStaticProps when watched mdx files change
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export const ClientReload = () => {
|
|
||||||
// Exclude socket.io from prod bundle
|
|
||||||
useEffect(() => {
|
|
||||||
import('socket.io-client').then((module) => {
|
|
||||||
const socket = module.io();
|
|
||||||
socket.on('reload', () => {
|
|
||||||
Router.replace(Router.asPath, undefined, {
|
|
||||||
scroll: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
19
components/Comments.tsx
Normal file
19
components/Comments.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
'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} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -25,10 +25,13 @@ export default function Footer() {
|
|||||||
<div>{` • `}</div>
|
<div>{` • `}</div>
|
||||||
<Link href="/">{siteMetadata.title}</Link>
|
<Link href="/">{siteMetadata.title}</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<Link href="https://beian.miit.gov.cn" rel="nofollow">
|
||||||
|
闽ICP备2023000043号
|
||||||
|
</Link>
|
||||||
|
</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
|
<Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
|
||||||
href="https://github.com/timlrx/tailwind-nextjs-starter-blog"
|
|
||||||
rel="nofollow">
|
|
||||||
Tailwind Nextjs Theme
|
Tailwind Nextjs Theme
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
47
components/Header.tsx
Normal file
47
components/Header.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
import headerNavLinks from '@/data/headerNavLinks';
|
||||||
|
import Logo from '@/data/logo.svg';
|
||||||
|
import Link from './Link';
|
||||||
|
import MobileNav from './MobileNav';
|
||||||
|
import ThemeSwitch from './ThemeSwitch';
|
||||||
|
import SearchButton from './SearchButton';
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
return (
|
||||||
|
<header className="flex items-center justify-between py-10">
|
||||||
|
<div>
|
||||||
|
<Link href="/" aria-label={siteMetadata.headerTitle}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="mr-3">
|
||||||
|
<Logo />
|
||||||
|
</div>
|
||||||
|
{typeof siteMetadata.headerTitle === 'string' ? (
|
||||||
|
<div className="hidden h-6 text-2xl font-semibold sm:block">
|
||||||
|
{siteMetadata.headerTitle}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
siteMetadata.headerTitle
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center leading-5 space-x-4 sm:space-x-6">
|
||||||
|
{headerNavLinks
|
||||||
|
.filter((link) => link.href !== '/')
|
||||||
|
.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.title}
|
||||||
|
href={link.href}
|
||||||
|
className="hidden sm:block font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{link.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<SearchButton />
|
||||||
|
<ThemeSwitch />
|
||||||
|
<MobileNav />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
@ -1,5 +1,8 @@
|
|||||||
import NextImage, { ImageProps } from 'next/image';
|
const Image = ({
|
||||||
|
...rest
|
||||||
const Image = ({ ...rest }: ImageProps) => <NextImage {...rest} />;
|
}: React.DetailedHTMLProps<
|
||||||
|
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||||
|
HTMLImageElement
|
||||||
|
>) => <img {...rest} />;
|
||||||
|
|
||||||
export default Image;
|
export default Image;
|
||||||
|
@ -1,53 +1,23 @@
|
|||||||
import siteMetadata from '@/data/siteMetadata';
|
import { Inter } from 'next/font/google';
|
||||||
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 className="flex h-screen flex-col justify-between">
|
<div
|
||||||
<header className="flex items-center justify-between py-10">
|
className={`${inter.className} flex h-screen flex-col justify-between font-sans`}>
|
||||||
<div>
|
<Header />
|
||||||
<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,23 +1,17 @@
|
|||||||
/* 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 { AnchorHTMLAttributes, DetailedHTMLProps } from 'react';
|
import type { LinkProps } from 'next/link';
|
||||||
|
import { AnchorHTMLAttributes } from 'react';
|
||||||
|
|
||||||
const CustomLink = ({
|
const CustomLink = ({
|
||||||
href,
|
href,
|
||||||
...rest
|
...rest
|
||||||
}: DetailedHTMLProps<
|
}: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
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 (
|
return <Link href={href} {...rest} />;
|
||||||
<Link href={href}>
|
|
||||||
<a {...rest} />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAnchorLink) {
|
if (isAnchorLink) {
|
||||||
|
@ -1,39 +1,14 @@
|
|||||||
/* eslint-disable react/display-name */
|
import TOCInline from 'pliny/ui/TOCInline';
|
||||||
import React, { useMemo } from 'react';
|
import Pre from 'pliny/ui/Pre';
|
||||||
import { ComponentMap, getMDXComponent } from 'mdx-bundler/client';
|
import BlogNewsletterForm from 'pliny/ui/BlogNewsletterForm';
|
||||||
|
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';
|
|
||||||
|
|
||||||
const Wrapper: React.ComponentType<{ layout: string }> = ({
|
export const components: MDXComponents = {
|
||||||
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,3 +1,5 @@
|
|||||||
|
'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';
|
||||||
@ -18,41 +20,45 @@ 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">
|
className="text-gray-900 dark:text-gray-100 h-8 w-8">
|
||||||
{navShow ? (
|
<path
|
||||||
<path
|
fillRule="evenodd"
|
||||||
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"
|
||||||
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"
|
||||||
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 top-24 right-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
|
className={`fixed left-0 top-0 z-10 h-full w-full transform opacity-95 dark:opacity-[0.98] bg-white duration-300 ease-in-out dark:bg-gray-950 ${
|
||||||
navShow ? 'translate-x-0' : 'translate-x-full'
|
navShow ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}>
|
}`}>
|
||||||
<button
|
<div className="flex justify-end">
|
||||||
type="button"
|
<button
|
||||||
aria-label="toggle modal"
|
className="mr-8 mt-11 h-8 w-8"
|
||||||
className="fixed h-full w-full cursor-auto focus:outline-none"
|
aria-label="Toggle Menu"
|
||||||
onClick={onToggleNav}></button>
|
onClick={onToggleNav}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
className="text-gray-900 dark:text-gray-100">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<nav className="fixed mt-8 h-full">
|
<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">
|
||||||
@ -66,7 +72,7 @@ const MobileNav = () => {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,93 +0,0 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
|
|
||||||
const NewsletterForm = ({ title = 'Subscribe to the newsletter' }) => {
|
|
||||||
const inputEl = useRef<HTMLInputElement>(null);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [subscribed, setSubscribed] = useState(false);
|
|
||||||
|
|
||||||
const subscribe = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const res = await fetch(`/api/${siteMetadata.newsletter.provider}`, {
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: inputEl.current.value,
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { error } = await res.json();
|
|
||||||
if (error) {
|
|
||||||
setError(true);
|
|
||||||
setMessage(
|
|
||||||
'Your e-mail address is invalid or you are already subscribed!'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
inputEl.current.value = '';
|
|
||||||
setError(false);
|
|
||||||
setSubscribed(true);
|
|
||||||
setMessage('Successfully! 🎉 You are now subscribed.');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="pb-1 text-lg font-semibold text-gray-800 dark:text-gray-100">
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
<form className="flex flex-col sm:flex-row" onSubmit={subscribe}>
|
|
||||||
<div>
|
|
||||||
<label className="sr-only" htmlFor="email-input">
|
|
||||||
Email address
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
autoComplete="email"
|
|
||||||
className="w-72 rounded-md px-4 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-600 dark:bg-black"
|
|
||||||
id="email-input"
|
|
||||||
name="email"
|
|
||||||
placeholder={
|
|
||||||
subscribed ? "You're subscribed ! 🎉" : 'Enter your email'
|
|
||||||
}
|
|
||||||
ref={inputEl}
|
|
||||||
required
|
|
||||||
type="email"
|
|
||||||
disabled={subscribed}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex w-full rounded-md shadow-sm sm:mt-0 sm:ml-3">
|
|
||||||
<button
|
|
||||||
className={`w-full rounded-md bg-primary-500 py-2 px-4 font-medium text-white sm:py-0 ${
|
|
||||||
subscribed
|
|
||||||
? 'cursor-default'
|
|
||||||
: 'hover:bg-primary-700 dark:hover:bg-primary-400'
|
|
||||||
} focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2 dark:ring-offset-black`}
|
|
||||||
type="submit"
|
|
||||||
disabled={subscribed}>
|
|
||||||
{subscribed ? 'Thank you!' : 'Sign up'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{error && (
|
|
||||||
<div className="w-72 pt-2 text-sm text-red-500 dark:text-red-400 sm:w-96">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NewsletterForm;
|
|
||||||
|
|
||||||
export const BlogNewsletterForm = ({ title }) => (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<div className="bg-gray-100 p-6 dark:bg-gray-800 sm:px-14 sm:py-8">
|
|
||||||
<NewsletterForm title={title} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
@ -1,48 +0,0 @@
|
|||||||
import Link from '@/components/Link';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
totalPages: number;
|
|
||||||
currentPage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Pagination({ totalPages, currentPage }: Props) {
|
|
||||||
const prevPage = currentPage - 1 > 0;
|
|
||||||
const nextPage = currentPage + 1 <= totalPages;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
|
||||||
<nav className="flex justify-between">
|
|
||||||
{!prevPage && (
|
|
||||||
<button
|
|
||||||
className="cursor-auto disabled:opacity-50"
|
|
||||||
disabled={!prevPage}>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{prevPage && (
|
|
||||||
<Link
|
|
||||||
href={
|
|
||||||
currentPage - 1 === 1 ? `/blog/` : `/blog/page/${currentPage - 1}`
|
|
||||||
}>
|
|
||||||
<button>Previous</button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
{currentPage} of {totalPages}
|
|
||||||
</span>
|
|
||||||
{!nextPage && (
|
|
||||||
<button
|
|
||||||
className="cursor-auto disabled:opacity-50"
|
|
||||||
disabled={!nextPage}>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{nextPage && (
|
|
||||||
<Link href={`/blog/page/${currentPage + 1}`}>
|
|
||||||
<button>Next</button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,77 +0,0 @@
|
|||||||
import { useState, useRef, ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Pre = ({ children }: Props) => {
|
|
||||||
const textInput = useRef(null);
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const onEnter = () => {
|
|
||||||
setHovered(true);
|
|
||||||
};
|
|
||||||
const onExit = () => {
|
|
||||||
setHovered(false);
|
|
||||||
setCopied(false);
|
|
||||||
};
|
|
||||||
const onCopy = () => {
|
|
||||||
setCopied(true);
|
|
||||||
navigator.clipboard.writeText(textInput.current.textContent);
|
|
||||||
setTimeout(() => {
|
|
||||||
setCopied(false);
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={textInput}
|
|
||||||
onMouseEnter={onEnter}
|
|
||||||
onMouseLeave={onExit}
|
|
||||||
className="relative">
|
|
||||||
{hovered && (
|
|
||||||
<button
|
|
||||||
aria-label="Copy code"
|
|
||||||
type="button"
|
|
||||||
className={`absolute right-2 top-2 h-8 w-8 rounded border-2 bg-gray-700 p-1 dark:bg-gray-800 ${
|
|
||||||
copied
|
|
||||||
? 'border-green-400 focus:border-green-400 focus:outline-none'
|
|
||||||
: 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
onClick={onCopy}>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="none"
|
|
||||||
className={copied ? 'text-green-400' : 'text-gray-300'}>
|
|
||||||
{copied ? (
|
|
||||||
<>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<pre>{children}</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Pre;
|
|
@ -1,208 +0,0 @@
|
|||||||
import Head from 'next/head';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
import { AuthorFrontMatter } from 'types/AuthorFrontMatter';
|
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
|
||||||
|
|
||||||
interface CommonSEOProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
ogType: string;
|
|
||||||
ogImage:
|
|
||||||
| string
|
|
||||||
| {
|
|
||||||
'@type': string;
|
|
||||||
url: string;
|
|
||||||
}[];
|
|
||||||
twImage: string;
|
|
||||||
canonicalUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommonSEO = ({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
ogType,
|
|
||||||
ogImage,
|
|
||||||
twImage,
|
|
||||||
canonicalUrl,
|
|
||||||
}: CommonSEOProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
return (
|
|
||||||
<Head>
|
|
||||||
<title>{title}</title>
|
|
||||||
<meta name="robots" content="follow, index" />
|
|
||||||
<meta name="description" content={description} />
|
|
||||||
<meta
|
|
||||||
property="og:url"
|
|
||||||
content={`${siteMetadata.siteUrl}${router.asPath}`}
|
|
||||||
/>
|
|
||||||
<meta property="og:type" content={ogType} />
|
|
||||||
<meta property="og:site_name" content={siteMetadata.title} />
|
|
||||||
<meta property="og:description" content={description} />
|
|
||||||
<meta property="og:title" content={title} />
|
|
||||||
{Array.isArray(ogImage) ? (
|
|
||||||
ogImage.map(({ url }) => (
|
|
||||||
<meta property="og:image" content={url} key={url} />
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<meta property="og:image" content={ogImage} key={ogImage} />
|
|
||||||
)}
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:site" content={siteMetadata.twitter} />
|
|
||||||
<meta name="twitter:title" content={title} />
|
|
||||||
<meta name="twitter:description" content={description} />
|
|
||||||
<meta name="twitter:image" content={twImage} />
|
|
||||||
<link
|
|
||||||
rel="canonical"
|
|
||||||
href={
|
|
||||||
canonicalUrl
|
|
||||||
? canonicalUrl
|
|
||||||
: `${siteMetadata.siteUrl}${router.asPath}`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Head>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PageSEOProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PageSEO = ({ title, description }: PageSEOProps) => {
|
|
||||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
|
||||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
|
||||||
return (
|
|
||||||
<CommonSEO
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
ogType="website"
|
|
||||||
ogImage={ogImageUrl}
|
|
||||||
twImage={twImageUrl}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TagSEO = ({ title, description }: PageSEOProps) => {
|
|
||||||
const ogImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
|
||||||
const twImageUrl = siteMetadata.siteUrl + siteMetadata.socialBanner;
|
|
||||||
const router = useRouter();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CommonSEO
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
ogType="website"
|
|
||||||
ogImage={ogImageUrl}
|
|
||||||
twImage={twImageUrl}
|
|
||||||
/>
|
|
||||||
<Head>
|
|
||||||
<link
|
|
||||||
rel="alternate"
|
|
||||||
type="application/rss+xml"
|
|
||||||
title={`${description} - RSS feed`}
|
|
||||||
href={`${siteMetadata.siteUrl}${router.asPath}/feed.xml`}
|
|
||||||
/>
|
|
||||||
</Head>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BlogSeoProps extends PostFrontMatter {
|
|
||||||
authorDetails?: AuthorFrontMatter[];
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BlogSEO = ({
|
|
||||||
authorDetails,
|
|
||||||
title,
|
|
||||||
summary,
|
|
||||||
date,
|
|
||||||
lastmod,
|
|
||||||
url,
|
|
||||||
images = [],
|
|
||||||
canonicalUrl,
|
|
||||||
}: BlogSeoProps) => {
|
|
||||||
const publishedAt = new Date(date).toISOString();
|
|
||||||
const modifiedAt = new Date(lastmod || date).toISOString();
|
|
||||||
const imagesArr =
|
|
||||||
images.length === 0
|
|
||||||
? [siteMetadata.socialBanner]
|
|
||||||
: typeof images === 'string'
|
|
||||||
? [images]
|
|
||||||
: images;
|
|
||||||
|
|
||||||
const featuredImages = imagesArr.map((img) => {
|
|
||||||
return {
|
|
||||||
'@type': 'ImageObject',
|
|
||||||
url: `${siteMetadata.siteUrl}${img}`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
let authorList;
|
|
||||||
if (authorDetails) {
|
|
||||||
authorList = authorDetails.map((author) => {
|
|
||||||
return {
|
|
||||||
'@type': 'Person',
|
|
||||||
name: author.name,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
authorList = {
|
|
||||||
'@type': 'Person',
|
|
||||||
name: siteMetadata.author,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const structuredData = {
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'Article',
|
|
||||||
mainEntityOfPage: {
|
|
||||||
'@type': 'WebPage',
|
|
||||||
'@id': url,
|
|
||||||
},
|
|
||||||
headline: title,
|
|
||||||
image: featuredImages,
|
|
||||||
datePublished: publishedAt,
|
|
||||||
dateModified: modifiedAt,
|
|
||||||
author: authorList,
|
|
||||||
publisher: {
|
|
||||||
'@type': 'Organization',
|
|
||||||
name: siteMetadata.author,
|
|
||||||
logo: {
|
|
||||||
'@type': 'ImageObject',
|
|
||||||
url: `${siteMetadata.siteUrl}${siteMetadata.siteLogo}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
description: summary,
|
|
||||||
};
|
|
||||||
|
|
||||||
const twImageUrl = featuredImages[0].url;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CommonSEO
|
|
||||||
title={title}
|
|
||||||
description={summary}
|
|
||||||
ogType="article"
|
|
||||||
ogImage={featuredImages}
|
|
||||||
twImage={twImageUrl}
|
|
||||||
canonicalUrl={canonicalUrl}
|
|
||||||
/>
|
|
||||||
<Head>
|
|
||||||
{date && (
|
|
||||||
<meta property="article:published_time" content={publishedAt} />
|
|
||||||
)}
|
|
||||||
{lastmod && (
|
|
||||||
<meta property="article:modified_time" content={modifiedAt} />
|
|
||||||
)}
|
|
||||||
<script
|
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: JSON.stringify(structuredData, null, 2),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Head>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,3 +1,6 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
const ScrollTopAndComment = () => {
|
const ScrollTopAndComment = () => {
|
||||||
@ -14,32 +17,32 @@ const ScrollTopAndComment = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleScrollTop = () => {
|
const handleScrollTop = () => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0 });
|
||||||
};
|
};
|
||||||
const handleScrollToComment = () => {
|
const handleScrollToComment = () => {
|
||||||
document.getElementById('comment').scrollIntoView();
|
document.getElementById('comment')?.scrollIntoView();
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${
|
className={`fixed bottom-8 right-8 hidden flex-col gap-3 ${
|
||||||
show ? 'md:flex' : 'md:hidden'
|
show ? 'md:flex' : 'md:hidden'
|
||||||
}`}>
|
}`}>
|
||||||
<button
|
{siteMetadata.comments?.provider && (
|
||||||
aria-label="Scroll To Comment"
|
<button
|
||||||
type="button"
|
aria-label="Scroll To Comment"
|
||||||
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">
|
||||||
|
34
components/SearchButton.tsx
Normal file
34
components/SearchButton.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { AlgoliaButton } from 'pliny/search/AlgoliaButton';
|
||||||
|
import { KBarButton } from 'pliny/search/KBarButton';
|
||||||
|
import siteMetadata from '@/data/siteMetadata';
|
||||||
|
|
||||||
|
const SearchButton = () => {
|
||||||
|
if (
|
||||||
|
siteMetadata.search &&
|
||||||
|
(siteMetadata.search.provider === 'algolia' ||
|
||||||
|
siteMetadata.search.provider === 'kbar')
|
||||||
|
) {
|
||||||
|
const SearchButtonWrapper =
|
||||||
|
siteMetadata.search.provider === 'algolia' ? AlgoliaButton : KBarButton;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchButtonWrapper aria-label="Search">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-gray-900 dark:text-gray-100 h-6 w-6">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</SearchButtonWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchButton;
|
@ -6,8 +6,8 @@ interface Props {
|
|||||||
|
|
||||||
export default function SectionContainer({ children }: Props) {
|
export default function SectionContainer({ children }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">
|
<section className="mx-auto max-w-3xl px-4 sm:px-6 xl:max-w-5xl xl:px-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,74 +0,0 @@
|
|||||||
import { Toc } from 'types/Toc';
|
|
||||||
|
|
||||||
interface TOCInlineProps {
|
|
||||||
toc: Toc;
|
|
||||||
indentDepth?: number;
|
|
||||||
fromHeading?: number;
|
|
||||||
toHeading?: number;
|
|
||||||
asDisclosure?: boolean;
|
|
||||||
exclude?: string | string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates an inline table of contents
|
|
||||||
* Exclude titles matching this string (new RegExp('^(' + string + ')$', 'i')).
|
|
||||||
* If an array is passed the array gets joined with a pipe (new RegExp('^(' + array.join('|') + ')$', 'i')).
|
|
||||||
*
|
|
||||||
* @param {TOCInlineProps} {
|
|
||||||
* toc,
|
|
||||||
* indentDepth = 3,
|
|
||||||
* fromHeading = 1,
|
|
||||||
* toHeading = 6,
|
|
||||||
* asDisclosure = false,
|
|
||||||
* exclude = '',
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
const TOCInline = ({
|
|
||||||
toc,
|
|
||||||
indentDepth = 3,
|
|
||||||
fromHeading = 1,
|
|
||||||
toHeading = 6,
|
|
||||||
asDisclosure = false,
|
|
||||||
exclude = '',
|
|
||||||
}: TOCInlineProps) => {
|
|
||||||
const re = Array.isArray(exclude)
|
|
||||||
? new RegExp('^(' + exclude.join('|') + ')$', 'i')
|
|
||||||
: new RegExp('^(' + exclude + ')$', 'i');
|
|
||||||
|
|
||||||
const filteredToc = toc.filter(
|
|
||||||
(heading) =>
|
|
||||||
heading.depth >= fromHeading &&
|
|
||||||
heading.depth <= toHeading &&
|
|
||||||
!re.test(heading.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
const tocList = (
|
|
||||||
<ul>
|
|
||||||
{filteredToc.map((heading) => (
|
|
||||||
<li
|
|
||||||
key={heading.value}
|
|
||||||
className={`${heading.depth >= indentDepth && 'ml-6'}`}>
|
|
||||||
<a href={heading.url}>{heading.value}</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{asDisclosure ? (
|
|
||||||
<details open>
|
|
||||||
<summary className="ml-6 pt-2 pb-2 text-xl font-bold">
|
|
||||||
Table of Contents
|
|
||||||
</summary>
|
|
||||||
<div className="ml-6">{tocList}</div>
|
|
||||||
</details>
|
|
||||||
) : (
|
|
||||||
tocList
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TOCInline;
|
|
@ -1,16 +1,15 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import kebabCase from '@/lib/utils/kebabCase';
|
import { slug } from 'github-slugger';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Tag = ({ text }: Props) => {
|
const Tag = ({ text }: Props) => {
|
||||||
return (
|
return (
|
||||||
<Link href={`/tags/${kebabCase(text)}`}>
|
<Link
|
||||||
<a className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
href={`/tags/${slug(text)}`}
|
||||||
{text.split(' ').join('-')}
|
className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||||
</a>
|
{text.split(' ').join('-')}
|
||||||
</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, resolvedTheme } = useTheme();
|
const { theme, setTheme } = 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"
|
||||||
type="button"
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
||||||
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">
|
className="text-gray-900 dark:text-gray-100 h-6 w-6">
|
||||||
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
|
{mounted && theme === '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"
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
import Script from 'next/script';
|
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
|
|
||||||
const GAScript = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Script
|
|
||||||
strategy="lazyOnload"
|
|
||||||
src={`https://www.googletagmanager.com/gtag/js?id=${siteMetadata.analytics.googleAnalyticsId}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Script strategy="lazyOnload" id="ga-script">
|
|
||||||
{`
|
|
||||||
window.dataLayer = window.dataLayer || [];
|
|
||||||
function gtag(){dataLayer.push(arguments);}
|
|
||||||
gtag('js', new Date());
|
|
||||||
gtag('config', '${siteMetadata.analytics.googleAnalyticsId}', {
|
|
||||||
page_path: window.location.pathname,
|
|
||||||
});
|
|
||||||
`}
|
|
||||||
</Script>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GAScript;
|
|
||||||
|
|
||||||
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
|
|
||||||
export const logEvent = (action, category, label, value) => {
|
|
||||||
window.gtag?.('event', action, {
|
|
||||||
event_category: category,
|
|
||||||
event_label: label,
|
|
||||||
value: value,
|
|
||||||
});
|
|
||||||
};
|
|
@ -1,27 +0,0 @@
|
|||||||
import Script from 'next/script';
|
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
|
|
||||||
const PlausibleScript = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Script
|
|
||||||
strategy="lazyOnload"
|
|
||||||
data-domain={siteMetadata.analytics.plausibleDataDomain}
|
|
||||||
src="https://plausible.io/js/plausible.js"
|
|
||||||
/>
|
|
||||||
<Script strategy="lazyOnload" id="plausible-script">
|
|
||||||
{`
|
|
||||||
window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }
|
|
||||||
`}
|
|
||||||
</Script>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PlausibleScript;
|
|
||||||
|
|
||||||
// https://plausible.io/docs/custom-event-goals
|
|
||||||
export const logEvent = (eventName, ...rest) => {
|
|
||||||
return window.plausible?.(eventName, ...rest);
|
|
||||||
};
|
|
@ -1,28 +0,0 @@
|
|||||||
import Script from 'next/script';
|
|
||||||
|
|
||||||
const SimpleAnalyticsScript = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Script strategy="lazyOnload" id="sa-script">
|
|
||||||
{`
|
|
||||||
window.sa_event=window.sa_event||function(){var a=[].slice.call(arguments);window.sa_event.q?window.sa_event.q.push(a):window.sa_event.q=[a]};
|
|
||||||
`}
|
|
||||||
</Script>
|
|
||||||
<Script
|
|
||||||
strategy="lazyOnload"
|
|
||||||
src="https://scripts.simpleanalyticscdn.com/latest.js"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// https://docs.simpleanalytics.com/events
|
|
||||||
export const logEvent = (eventName, callback) => {
|
|
||||||
if (callback) {
|
|
||||||
return window.sa_event?.(eventName, callback);
|
|
||||||
} else {
|
|
||||||
return window.sa_event?.(eventName);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SimpleAnalyticsScript;
|
|
@ -1,18 +0,0 @@
|
|||||||
import Script from 'next/script';
|
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
|
|
||||||
const UmamiScript = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Script
|
|
||||||
async
|
|
||||||
defer
|
|
||||||
data-website-id={siteMetadata.analytics.umamiWebsiteId}
|
|
||||||
src="https://umami.example.com/umami.js" // Replace with your umami instance
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UmamiScript;
|
|
@ -1,32 +0,0 @@
|
|||||||
import GA from './GoogleAnalytics';
|
|
||||||
import Plausible from './Plausible';
|
|
||||||
import SimpleAnalytics from './SimpleAnalytics';
|
|
||||||
import Umami from './Umami';
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
gtag?: (...args: any[]) => void;
|
|
||||||
plausible?: (...args: any[]) => void;
|
|
||||||
sa_event?: (...args: any[]) => void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
|
|
||||||
const Analytics = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isProduction && siteMetadata.analytics.plausibleDataDomain && (
|
|
||||||
<Plausible />
|
|
||||||
)}
|
|
||||||
{isProduction && siteMetadata.analytics.simpleAnalytics && (
|
|
||||||
<SimpleAnalytics />
|
|
||||||
)}
|
|
||||||
{isProduction && siteMetadata.analytics.umamiWebsiteId && <Umami />}
|
|
||||||
{isProduction && siteMetadata.analytics.googleAnalyticsId && <GA />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Analytics;
|
|
@ -1,33 +0,0 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
import ReactCommento from './commento/ReactCommento';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
frontMatter: PostFrontMatter;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Commento = ({ frontMatter }: Props) => {
|
|
||||||
const { resolvedTheme } = useTheme();
|
|
||||||
const commentsTheme = useMemo(() => {
|
|
||||||
switch (resolvedTheme) {
|
|
||||||
case 'light':
|
|
||||||
case 'dark':
|
|
||||||
return resolvedTheme;
|
|
||||||
default:
|
|
||||||
return 'auto';
|
|
||||||
}
|
|
||||||
}, [resolvedTheme]);
|
|
||||||
return (
|
|
||||||
<div className="my-2">
|
|
||||||
<ReactCommento
|
|
||||||
url={siteMetadata.comment.commentoConfig.url}
|
|
||||||
pageId={frontMatter.slug}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Commento;
|
|
@ -1,41 +0,0 @@
|
|||||||
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;
|
|
@ -1,51 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
frontMatter: PostFrontMatter;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Disqus = ({ frontMatter }: Props) => {
|
|
||||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
|
||||||
|
|
||||||
const COMMENTS_ID = 'disqus_thread';
|
|
||||||
|
|
||||||
function LoadComments() {
|
|
||||||
setEnabledLoadComments(false);
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
window.disqus_config = function () {
|
|
||||||
this.page.url = window.location.href;
|
|
||||||
this.page.identifier = frontMatter.slug;
|
|
||||||
};
|
|
||||||
// @ts-ignore
|
|
||||||
if (window.DISQUS === undefined) {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src =
|
|
||||||
'https://' +
|
|
||||||
siteMetadata.comment.disqusConfig.shortname +
|
|
||||||
'.disqus.com/embed.js';
|
|
||||||
// @ts-ignore
|
|
||||||
script.setAttribute('data-timestamp', +new Date());
|
|
||||||
script.setAttribute('crossorigin', 'anonymous');
|
|
||||||
script.async = true;
|
|
||||||
document.body.appendChild(script);
|
|
||||||
} else {
|
|
||||||
// @ts-ignore
|
|
||||||
window.DISQUS.reset({ reload: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
|
||||||
{enableLoadComments && (
|
|
||||||
<button onClick={LoadComments}>Load Comments</button>
|
|
||||||
)}
|
|
||||||
<div className="disqus-frame" id={COMMENTS_ID} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Disqus;
|
|
@ -1,78 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mapping: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Giscus = ({ mapping }: Props) => {
|
|
||||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
|
||||||
const { theme, resolvedTheme } = useTheme();
|
|
||||||
const commentsTheme =
|
|
||||||
siteMetadata.comment.giscusConfig.themeURL === ''
|
|
||||||
? theme === 'dark' || resolvedTheme === 'dark'
|
|
||||||
? siteMetadata.comment.giscusConfig.darkTheme
|
|
||||||
: siteMetadata.comment.giscusConfig.theme
|
|
||||||
: siteMetadata.comment.giscusConfig.themeURL;
|
|
||||||
|
|
||||||
const COMMENTS_ID = 'comments-container';
|
|
||||||
|
|
||||||
const LoadComments = useCallback(() => {
|
|
||||||
setEnabledLoadComments(false);
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://giscus.app/client.js';
|
|
||||||
script.setAttribute('data-repo', siteMetadata.comment.giscusConfig.repo);
|
|
||||||
script.setAttribute(
|
|
||||||
'data-repo-id',
|
|
||||||
siteMetadata.comment.giscusConfig.repositoryId
|
|
||||||
);
|
|
||||||
script.setAttribute(
|
|
||||||
'data-category',
|
|
||||||
siteMetadata.comment.giscusConfig.category
|
|
||||||
);
|
|
||||||
script.setAttribute(
|
|
||||||
'data-category-id',
|
|
||||||
siteMetadata.comment.giscusConfig.categoryId
|
|
||||||
);
|
|
||||||
script.setAttribute('data-mapping', mapping);
|
|
||||||
script.setAttribute(
|
|
||||||
'data-reactions-enabled',
|
|
||||||
siteMetadata.comment.giscusConfig.reactions
|
|
||||||
);
|
|
||||||
script.setAttribute(
|
|
||||||
'data-emit-metadata',
|
|
||||||
siteMetadata.comment.giscusConfig.metadata
|
|
||||||
);
|
|
||||||
script.setAttribute('data-theme', commentsTheme);
|
|
||||||
script.setAttribute('crossorigin', 'anonymous');
|
|
||||||
script.async = true;
|
|
||||||
|
|
||||||
const comments = document.getElementById(COMMENTS_ID);
|
|
||||||
if (comments) comments.appendChild(script);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const comments = document.getElementById(COMMENTS_ID);
|
|
||||||
if (comments) comments.innerHTML = '';
|
|
||||||
};
|
|
||||||
}, [commentsTheme, mapping]);
|
|
||||||
|
|
||||||
// Reload on theme change
|
|
||||||
useEffect(() => {
|
|
||||||
const iframe = document.querySelector('iframe.giscus-frame');
|
|
||||||
if (!iframe) return;
|
|
||||||
LoadComments();
|
|
||||||
}, [LoadComments]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
|
||||||
{enableLoadComments && (
|
|
||||||
<button onClick={LoadComments}>Load Comments</button>
|
|
||||||
)}
|
|
||||||
<div className="giscus" id={COMMENTS_ID} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Giscus;
|
|
@ -1,58 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
issueTerm: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Utterances = ({ issueTerm }: Props) => {
|
|
||||||
const [enableLoadComments, setEnabledLoadComments] = useState(true);
|
|
||||||
const { theme, resolvedTheme } = useTheme();
|
|
||||||
const commentsTheme =
|
|
||||||
theme === 'dark' || resolvedTheme === 'dark'
|
|
||||||
? siteMetadata.comment.utterancesConfig.darkTheme
|
|
||||||
: siteMetadata.comment.utterancesConfig.theme;
|
|
||||||
|
|
||||||
const COMMENTS_ID = 'comments-container';
|
|
||||||
|
|
||||||
const LoadComments = useCallback(() => {
|
|
||||||
setEnabledLoadComments(false);
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = 'https://utteranc.es/client.js';
|
|
||||||
script.setAttribute('repo', siteMetadata.comment.utterancesConfig.repo);
|
|
||||||
script.setAttribute('issue-term', issueTerm);
|
|
||||||
script.setAttribute('label', siteMetadata.comment.utterancesConfig.label);
|
|
||||||
script.setAttribute('theme', commentsTheme);
|
|
||||||
script.setAttribute('crossorigin', 'anonymous');
|
|
||||||
script.async = true;
|
|
||||||
|
|
||||||
const comments = document.getElementById(COMMENTS_ID);
|
|
||||||
if (comments) comments.appendChild(script);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const comments = document.getElementById(COMMENTS_ID);
|
|
||||||
if (comments) comments.innerHTML = '';
|
|
||||||
};
|
|
||||||
}, [commentsTheme, issueTerm]);
|
|
||||||
|
|
||||||
// Reload on theme change
|
|
||||||
useEffect(() => {
|
|
||||||
const iframe = document.querySelector('iframe.utterances-frame');
|
|
||||||
if (!iframe) return;
|
|
||||||
LoadComments();
|
|
||||||
}, [LoadComments]);
|
|
||||||
|
|
||||||
// Added `relative` to fix a weird bug with `utterances-frame` position
|
|
||||||
return (
|
|
||||||
<div className="pt-6 pb-6 text-center text-gray-700 dark:text-gray-300">
|
|
||||||
{enableLoadComments && (
|
|
||||||
<button onClick={LoadComments}>Load Comments</button>
|
|
||||||
)}
|
|
||||||
<div className="utterances-frame relative" id={COMMENTS_ID} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Utterances;
|
|
@ -1,93 +0,0 @@
|
|||||||
import { createRef } from 'preact';
|
|
||||||
import React, { useLayoutEffect, useMemo, useRef } from 'react';
|
|
||||||
|
|
||||||
interface DataAttributes {
|
|
||||||
[key: string]: string | boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertScript = (
|
|
||||||
src: string,
|
|
||||||
id: string,
|
|
||||||
dataAttributes: DataAttributes,
|
|
||||||
onload = () => {}
|
|
||||||
) => {
|
|
||||||
const script = window.document.createElement('script');
|
|
||||||
script.async = true;
|
|
||||||
script.src = src;
|
|
||||||
script.id = id;
|
|
||||||
if (document.getElementById(id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
script.addEventListener('load', onload, { capture: true, once: true });
|
|
||||||
|
|
||||||
Object.entries(dataAttributes).forEach(([key, value]) => {
|
|
||||||
if (value === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
script.setAttribute(`data-${key}`, value.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(script);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
script.remove();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const ReactCommento = ({
|
|
||||||
url,
|
|
||||||
cssOverride,
|
|
||||||
autoInit,
|
|
||||||
noFonts,
|
|
||||||
hideDeleted,
|
|
||||||
pageId,
|
|
||||||
}: {
|
|
||||||
url: string;
|
|
||||||
cssOverride?: string;
|
|
||||||
autoInit?: boolean;
|
|
||||||
noFonts?: boolean;
|
|
||||||
hideDeleted?: boolean;
|
|
||||||
pageId?: string;
|
|
||||||
}) => {
|
|
||||||
const containerId = useMemo(
|
|
||||||
() => `commento-${Math.random().toString().slice(2, 8)}`,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const container = createRef<HTMLDivElement>();
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!window) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window['commento'] = container.current;
|
|
||||||
|
|
||||||
const removeScript = insertScript(
|
|
||||||
url,
|
|
||||||
`${containerId}-script`,
|
|
||||||
{
|
|
||||||
'css-override': cssOverride,
|
|
||||||
'auto-init': autoInit,
|
|
||||||
'no-fonts': noFonts,
|
|
||||||
'hide-deleted': hideDeleted,
|
|
||||||
'page-id': pageId,
|
|
||||||
'id-root': containerId,
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
removeScript();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
autoInit,
|
|
||||||
cssOverride,
|
|
||||||
hideDeleted,
|
|
||||||
noFonts,
|
|
||||||
pageId,
|
|
||||||
url,
|
|
||||||
containerId,
|
|
||||||
container,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <div ref={container} id={containerId} />;
|
|
||||||
};
|
|
||||||
export default ReactCommento;
|
|
@ -1,80 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import siteMetadata from '@/data/siteMetadata';
|
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
frontMatter: PostFrontMatter;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UtterancesComponent = dynamic(
|
|
||||||
() => {
|
|
||||||
return import('@/components/comments/Utterances');
|
|
||||||
},
|
|
||||||
{ ssr: false }
|
|
||||||
);
|
|
||||||
const GiscusComponent = dynamic(
|
|
||||||
() => {
|
|
||||||
return import('@/components/comments/Giscus');
|
|
||||||
},
|
|
||||||
{ ssr: false }
|
|
||||||
);
|
|
||||||
const DisqusComponent = dynamic(
|
|
||||||
() => {
|
|
||||||
return import('@/components/comments/Disqus');
|
|
||||||
},
|
|
||||||
{ ssr: false }
|
|
||||||
);
|
|
||||||
const 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 +0,0 @@
|
|||||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Facebook icon</title><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
|
Before Width: | Height: | Size: 403 B |
@ -1 +0,0 @@
|
|||||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub icon</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
|
Before Width: | Height: | Size: 827 B |
61
components/social-icons/icons.tsx
Normal file
61
components/social-icons/icons.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { SVGProps } from 'react';
|
||||||
|
|
||||||
|
// Icons taken from: https://simpleicons.org/
|
||||||
|
// To add a new icon, add a new function here and add it to components in social-icons/index.tsx
|
||||||
|
|
||||||
|
export function Facebook(svgProps: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||||
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Github(svgProps: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||||
|
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Linkedin(svgProps: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Mail(svgProps: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" {...svgProps}>
|
||||||
|
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"></path>
|
||||||
|
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Twitter(svgProps: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||||
|
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Youtube(svgProps: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||||
|
<path d="M23.499 6.203a3.008 3.008 0 00-2.089-2.089c-1.87-.501-9.4-.501-9.4-.501s-7.509-.01-9.399.501a3.008 3.008 0 00-2.088 2.09A31.258 31.26 0 000 12.01a31.258 31.26 0 00.523 5.785 3.008 3.008 0 002.088 2.089c1.869.502 9.4.502 9.4.502s7.508 0 9.399-.502a3.008 3.008 0 002.089-2.09 31.258 31.26 0 00.5-5.784 31.258 31.26 0 00-.5-5.808zm-13.891 9.4V8.407l6.266 3.604z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Mastodon(svgProps: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...svgProps}>
|
||||||
|
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
@ -1,11 +1,12 @@
|
|||||||
import Mail from './mail.svg';
|
import {
|
||||||
import Github from './github.svg';
|
Mail,
|
||||||
import Facebook from './facebook.svg';
|
Github,
|
||||||
import Youtube from './youtube.svg';
|
Facebook,
|
||||||
import Linkedin from './linkedin.svg';
|
Youtube,
|
||||||
import Twitter from './twitter.svg';
|
Linkedin,
|
||||||
|
Twitter,
|
||||||
// Icons taken from: https://simpleicons.org/
|
Mastodon,
|
||||||
|
} from './icons';
|
||||||
|
|
||||||
const components = {
|
const components = {
|
||||||
mail: Mail,
|
mail: Mail,
|
||||||
@ -14,9 +15,16 @@ const components = {
|
|||||||
youtube: Youtube,
|
youtube: Youtube,
|
||||||
linkedin: Linkedin,
|
linkedin: Linkedin,
|
||||||
twitter: Twitter,
|
twitter: Twitter,
|
||||||
|
mastodon: Mastodon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SocialIcon = ({ kind, href, size = 8 }) => {
|
type SocialIconProps = {
|
||||||
|
kind: keyof typeof components;
|
||||||
|
href: string | undefined;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SocialIcon = ({ kind, href, size = 8 }: SocialIconProps) => {
|
||||||
if (
|
if (
|
||||||
!href ||
|
!href ||
|
||||||
(kind === 'mail' &&
|
(kind === 'mail' &&
|
||||||
@ -34,7 +42,7 @@ const SocialIcon = ({ kind, href, size = 8 }) => {
|
|||||||
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-blue-500 dark:text-gray-200 dark:hover:text-blue-400 h-${size} w-${size}`}
|
className={`fill-current text-gray-700 hover:text-primary-500 dark:text-gray-200 dark:hover:text-primary-400 h-${size} w-${size}`}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
@ -1 +0,0 @@
|
|||||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>LinkedIn icon</title><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
|
Before Width: | Height: | Size: 615 B |
@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
|
||||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
|
||||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 224 B |
@ -1 +0,0 @@
|
|||||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Twitter icon</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>
|
|
Before Width: | Height: | Size: 607 B |
@ -1 +0,0 @@
|
|||||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>YouTube icon</title><path d="M23.499 6.203a3.008 3.008 0 00-2.089-2.089c-1.87-.501-9.4-.501-9.4-.501s-7.509-.01-9.399.501a3.008 3.008 0 00-2.088 2.09A31.258 31.26 0 000 12.01a31.258 31.26 0 00.523 5.785 3.008 3.008 0 002.088 2.089c1.869.502 9.4.502 9.4.502s7.508 0 9.399-.502a3.008 3.008 0 002.089-2.09 31.258 31.26 0 00.5-5.784 31.258 31.26 0 00-.5-5.808zm-13.891 9.4V8.407l6.266 3.604z"/></svg>
|
|
Before Width: | Height: | Size: 474 B |
157
contentlayer.config.ts
Normal file
157
contentlayer.config.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { defineDocumentType, ComputedFields, makeSource } from 'contentlayer/source-files'
|
||||||
|
import { writeFileSync } from 'fs'
|
||||||
|
import readingTime from 'reading-time'
|
||||||
|
import GithubSlugger from 'github-slugger'
|
||||||
|
import path from 'path'
|
||||||
|
// Remark packages
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import remarkMath from 'remark-math'
|
||||||
|
import {
|
||||||
|
remarkExtractFrontmatter,
|
||||||
|
remarkCodeTitles,
|
||||||
|
remarkImgToJsx,
|
||||||
|
extractTocHeadings,
|
||||||
|
} from 'pliny/mdx-plugins/index.js'
|
||||||
|
// Rehype packages
|
||||||
|
import rehypeSlug from 'rehype-slug'
|
||||||
|
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||||
|
import rehypeKatex from 'rehype-katex'
|
||||||
|
import rehypeCitation from 'rehype-citation'
|
||||||
|
import rehypePrismPlus from 'rehype-prism-plus'
|
||||||
|
import rehypePresetMinify from 'rehype-preset-minify'
|
||||||
|
import siteMetadata from './data/siteMetadata'
|
||||||
|
import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer.js'
|
||||||
|
|
||||||
|
const root = process.cwd()
|
||||||
|
|
||||||
|
const computedFields: ComputedFields = {
|
||||||
|
readingTime: { type: 'json', resolve: (doc) => readingTime(doc.body.raw) },
|
||||||
|
slug: {
|
||||||
|
type: 'string',
|
||||||
|
resolve: (doc) => doc._raw.flattenedPath.replace(/^.+?(\/)/, ''),
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
type: 'string',
|
||||||
|
resolve: (doc) => doc._raw.flattenedPath,
|
||||||
|
},
|
||||||
|
filePath: {
|
||||||
|
type: 'string',
|
||||||
|
resolve: (doc) => doc._raw.sourceFilePath,
|
||||||
|
},
|
||||||
|
toc: { type: 'string', resolve: (doc) => extractTocHeadings(doc.body.raw) },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the occurrences of all tags across blog posts and write to json file
|
||||||
|
*/
|
||||||
|
function createTagCount(allBlogs) {
|
||||||
|
const tagCount: Record<string, number> = {}
|
||||||
|
allBlogs.forEach((file) => {
|
||||||
|
if (file.tags && file.draft !== true) {
|
||||||
|
file.tags.forEach((tag) => {
|
||||||
|
const formattedTag = GithubSlugger.slug(tag)
|
||||||
|
if (formattedTag in tagCount) {
|
||||||
|
tagCount[formattedTag] += 1
|
||||||
|
} else {
|
||||||
|
tagCount[formattedTag] = 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
writeFileSync('./app/tag-data.json', JSON.stringify(tagCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSearchIndex(allBlogs) {
|
||||||
|
if (
|
||||||
|
siteMetadata?.search?.provider === 'kbar' &&
|
||||||
|
siteMetadata.search.kbarConfig.searchDocumentsPath
|
||||||
|
) {
|
||||||
|
writeFileSync(
|
||||||
|
`public/${siteMetadata.search.kbarConfig.searchDocumentsPath}`,
|
||||||
|
JSON.stringify(allCoreContent(sortPosts(allBlogs)))
|
||||||
|
)
|
||||||
|
console.log('Local search index generated...')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Blog = defineDocumentType(() => ({
|
||||||
|
name: 'Blog',
|
||||||
|
filePathPattern: 'blog/**/*.mdx',
|
||||||
|
contentType: 'mdx',
|
||||||
|
fields: {
|
||||||
|
title: { type: 'string', required: true },
|
||||||
|
date: { type: 'date', required: true },
|
||||||
|
tags: { type: 'list', of: { type: 'string' }, default: [] },
|
||||||
|
lastmod: { type: 'date' },
|
||||||
|
draft: { type: 'boolean' },
|
||||||
|
summary: { type: 'string' },
|
||||||
|
images: { type: 'list', of: { type: 'string' } },
|
||||||
|
authors: { type: 'list', of: { type: 'string' } },
|
||||||
|
layout: { type: 'string' },
|
||||||
|
bibliography: { type: 'string' },
|
||||||
|
canonicalUrl: { type: 'string' },
|
||||||
|
},
|
||||||
|
computedFields: {
|
||||||
|
...computedFields,
|
||||||
|
structuredData: {
|
||||||
|
type: 'json',
|
||||||
|
resolve: (doc) => ({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'BlogPosting',
|
||||||
|
headline: doc.title,
|
||||||
|
datePublished: doc.date,
|
||||||
|
dateModified: doc.lastmod || doc.date,
|
||||||
|
description: doc.summary,
|
||||||
|
image: doc.images ? doc.images[0] : siteMetadata.socialBanner,
|
||||||
|
url: `${siteMetadata.siteUrl}/${doc._raw.flattenedPath}`,
|
||||||
|
author: doc.authors,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const Authors = defineDocumentType(() => ({
|
||||||
|
name: 'Authors',
|
||||||
|
filePathPattern: 'authors/**/*.mdx',
|
||||||
|
contentType: 'mdx',
|
||||||
|
fields: {
|
||||||
|
name: { type: 'string', required: true },
|
||||||
|
avatar: { type: 'string' },
|
||||||
|
occupation: { type: 'string' },
|
||||||
|
company: { type: 'string' },
|
||||||
|
email: { type: 'string' },
|
||||||
|
twitter: { type: 'string' },
|
||||||
|
linkedin: { type: 'string' },
|
||||||
|
github: { type: 'string' },
|
||||||
|
layout: { type: 'string' },
|
||||||
|
},
|
||||||
|
computedFields,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default makeSource({
|
||||||
|
contentDirPath: 'data',
|
||||||
|
documentTypes: [Blog, Authors],
|
||||||
|
mdx: {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
remarkPlugins: [
|
||||||
|
remarkExtractFrontmatter,
|
||||||
|
remarkGfm,
|
||||||
|
remarkCodeTitles,
|
||||||
|
remarkMath,
|
||||||
|
remarkImgToJsx,
|
||||||
|
],
|
||||||
|
rehypePlugins: [
|
||||||
|
rehypeSlug,
|
||||||
|
rehypeAutolinkHeadings,
|
||||||
|
rehypeKatex,
|
||||||
|
[rehypeCitation, { path: path.join(root, 'data') }],
|
||||||
|
[rehypePrismPlus, { defaultLanguage: 'js', ignoreMissing: true }],
|
||||||
|
rehypePresetMinify,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
onSuccess: async (importData) => {
|
||||||
|
const { allBlogs } = await importData()
|
||||||
|
createTagCount(allBlogs)
|
||||||
|
createSearchIndex(allBlogs)
|
||||||
|
},
|
||||||
|
})
|
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
/* Code title styles */
|
/* Code title styles */
|
||||||
.remark-code-title {
|
.remark-code-title {
|
||||||
@apply rounded-t bg-gray-700 px-5 py-3 font-mono text-sm font-bold text-gray-200;
|
@apply rounded-t bg-gray-700 dark:bg-gray-800 px-5 py-3 font-mono text-sm font-bold text-gray-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remark-code-title + div > pre {
|
.remark-code-title + div > pre {
|
||||||
@ -138,3 +138,7 @@
|
|||||||
.token.table {
|
.token.table {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.token.table {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
@ -11,7 +11,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footnotes {
|
.footnotes {
|
||||||
@apply pt-8 mt-12 border-t border-gray-200 dark:border-gray-700;
|
@apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-footnote-backref {
|
||||||
|
@apply no-underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.csl-entry {
|
.csl-entry {
|
||||||
@ -21,5 +25,7 @@
|
|||||||
/* 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: background-color 600000s 0s, color 600000s 0s;
|
transition:
|
||||||
|
background-color 600000s 0s,
|
||||||
|
color 600000s 0s;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Ivan Li
|
name: Ivan Li
|
||||||
avatar: https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0
|
avatar: https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png
|
||||||
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
|
189
data/blog/2023-build-another-hackintosh-itx-workstation.mdx
Normal file
189
data/blog/2023-build-another-hackintosh-itx-workstation.mdx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
---
|
||||||
|
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 结构的机箱。
|
||||||
|
|
||||||
|
### 直插式
|
||||||
|
|
||||||
|
我看了好多款,小体积下只能放 240mm 的显卡,我的显卡刚好是 240mm,所以非常的极限。有些机箱前置的 IO 可能会和显卡冲突,也有的机箱设计上就很难放入 240mm 的显卡,所以作罢。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### A4 结构
|
||||||
|
|
||||||
|
A4 结构的机箱,虽说成本会高出一条 PCIE 延长线,但是能放得下 300mm 的显卡。追求小,也是要成本的。不过体积上也会比直插的再小上一点点。大概代表就是蜂鸟 i100 了,同类产品还有闪鳞 S300。。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
两天前,我选择了最便宜也是比较轻的蜂鸟 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主板大横比](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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 3D Mark Time Spy: [18 091](https://www.3dmark.com/3dm/101278696)\n 
|
||||||
|
|
||||||
|
- 3D Mark Time Spy Extreme: [8727](https://www.3dmark.com/3dm/101277666)
|
||||||
|
- 
|
||||||
|
- 3D Mark Speed Way 压力测试:[99.3%](https://www.3dmark.com/3dm/101279293)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
看起来显卡还成,分数比平均值差一点,不过还好,作为二手默认矿卡表现应该能说得过去。
|
||||||
|
|
||||||
|
### 调教小结
|
||||||
|
|
||||||
|
我在机箱主板底部的风扇位安装了 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 的无线网卡,所以不支持。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
双系统 Blender Benchmark 得分
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
前两组是 Windows 的成绩,后两组是 Mac OS 的成绩。似乎都比 Blender 上记录的成绩好一些。至少这次测试能看出来,Mac OS 上显卡性能是有损失,但是 CPU 居然能跑得分更高。
|
||||||
|
|
||||||
|
## 使用体验
|
||||||
|
|
||||||
|
日常使用,没发现 CPU 因为散热问题降频。显卡一直稳定发挥,这二手显卡不知道矿没矿过,但是还算稳定。散热方面表现不错,基本上是因为我在机箱上面放了两把 12025 反向风扇,热量都能很好地被抽出来,小机箱现在不再是小闷罐。机箱底部的风扇,感觉有点聊胜于无。烤机的情况下,底部还是有一点积热的。或许和我只装了主板下的出风扇、没装电源下的出风扇有点关系,但是下部出风确实表现不理想,即使我把机箱放在通透的架子上。
|
||||||
|
|
||||||
|
现在我搞了两块 0.8mm 孔径的防尘网贴在机箱左右两侧,散热表现依然还可以,感觉我放上面的两个风扇真棒。
|
||||||
|
|
||||||
|
最后,放一张主机工作照吧。
|
||||||
|
|
||||||
|

|
16
data/blog/arch-linux-quick-setup.mdx
Normal file
16
data/blog/arch-linux-quick-setup.mdx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
title: 搭建日常使用的 Arch Linux
|
||||||
|
date: '2022-10-17'
|
||||||
|
tags: ['Arch Linux', '环境搭建', 'VPS']
|
||||||
|
draft: false
|
||||||
|
summary: 有了上次快速安装步骤后,接下来就是使用这个环境了。要使用环境,首先需要做一些初始化操作。我的步骤适合我,但不一定适合你,只是记录和参考。
|
||||||
|
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/RTr3IU.png']
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
登录私有仓库,以便拉取镜像。
|
||||||
|
|
||||||
|
```zsh
|
||||||
|
docker login -u="ivan+hk_nat" docker-registry.ivanli.cc
|
||||||
|
```
|
@ -22,6 +22,11 @@ Arch Linux 准入门槛确实有点高,在 PVE 中,使用 LCX 容器运行 A
|
|||||||
|
|
||||||
位置(Location)
|
位置(Location)
|
||||||
先编辑 `/etc/locale.gen`,取消 `en_US.UTF-8 UTF-8` 的注释。
|
先编辑 `/etc/locale.gen`,取消 `en_US.UTF-8 UTF-8` 的注释。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i "s/#en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/" /etc/locale.gen
|
||||||
|
```
|
||||||
|
|
||||||
然后执行:
|
然后执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -69,10 +74,17 @@ nano /etc/pacman.d/mirrorlist
|
|||||||
```
|
```
|
||||||
|
|
||||||
选择你喜欢并且方便连接的镜像,然后删除该行的“#”取消注释。可以选择一个或多个,在前面的优先级高。
|
选择你喜欢并且方便连接的镜像,然后删除该行的“#”取消注释。可以选择一个或多个,在前面的优先级高。
|
||||||
|
|
||||||
|
开启并行下载,在 `/etc/pacman.conf` 中取消 `ParallelDownloads` 前的注释,值为并行下载数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i "s/#ParallelDownloads = 5/ParallelDownloads = 5/" /etc/pacman.conf
|
||||||
|
```
|
||||||
|
|
||||||
接下来我们更新已安装的软件,我们的哲学就是时刻保持最新。
|
接下来我们更新已安装的软件,我们的哲学就是时刻保持最新。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pacman -Syu
|
pacman -Syu
|
||||||
```
|
```
|
||||||
|
|
||||||
**一般来说,执行上面的命令后,会拉取索引数据库,之后会优先更新 `archlinuxx-keyring`。如果不是这样的话,应当手动执行下面的代码:**
|
**一般来说,执行上面的命令后,会拉取索引数据库,之后会优先更新 `archlinuxx-keyring`。如果不是这样的话,应当手动执行下面的代码:**
|
||||||
@ -98,6 +110,12 @@ _参考:[Cant Upgrade because of keyring - Technical Issues and Assistance / P
|
|||||||
|
|
||||||
### 3. 创建用户
|
### 3. 创建用户
|
||||||
|
|
||||||
|
首先,安装 `sudo`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pacman -S sudo
|
||||||
|
```
|
||||||
|
|
||||||
让我们给自己分配一个具有 sudo 权限的账户
|
让我们给自己分配一个具有 sudo 权限的账户
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
@ -113,12 +131,6 @@ _参考:[Create a Sudo User on Arch Linux - Vultr.com](https://www.vultr.com/d
|
|||||||
EDITOR=vim visudo
|
EDITOR=vim visudo
|
||||||
```
|
```
|
||||||
|
|
||||||
安装 `sudo`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pacman -S sudo
|
|
||||||
```
|
|
||||||
|
|
||||||
接下来使用刚刚创建的用户登录吧!
|
接下来使用刚刚创建的用户登录吧!
|
||||||
|
|
||||||
### 4. 使用 SSH 远程登录
|
### 4. 使用 SSH 远程登录
|
282
data/blog/build-docker-image-for-other-project.mdx
Normal file
282
data/blog/build-docker-image-for-other-project.mdx
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
---
|
||||||
|
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” 发布版本:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
所以这里使用了 [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 的设置页面更改下权限:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
选择 “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
|
||||||
|
```
|
178
data/blog/debian-desktop-environment.mdx
Normal file
178
data/blog/debian-desktop-environment.mdx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
---
|
||||||
|
title: 在 PVE 宿主机上使用桌面环境
|
||||||
|
date: '2022-10-28'
|
||||||
|
tags: ['PVE', 'DE', '环境搭建', 'Debian']
|
||||||
|
draft: false
|
||||||
|
summary: 虽然 PVE 宿主机不应该安装乱七八糟的东西,但是我穷,为了物尽其用,为了在主力电脑翻车时有一个立即可用的备用环境,所以还是安装了基础的桌面环境。现在的 Linux 桌面环境越来越好了,我选择安装 KDE Plasma 作为桌面环境,并且默认关闭,按需启用。
|
||||||
|
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/qldEtP.png']
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前言
|
||||||
|
|
||||||
|
过几天就是双十一了,或许是我去购物网站上看了下显卡价格吧,我的显卡当晚就闹情绪,不工作还引发宕机。虽然拔了显卡,我还能用核显开机,但是我懒呀,所以我花了一个晚上在 PVE 宿主机上搭了一个临时环境,用于日常娱乐(看番、听歌)和一般工作(敲代码)。还别说,我在一开始装 PVE 时,就预先装上了桌面环境,这就是预判呀!
|
||||||
|
|
||||||
|
现在 Linux 桌面环境已经非常好了,相比 17 年左右的体验,又上了一个新的台阶。不过,作为临时应急环境,倒也不会去装那些没啥用的国产软件,本着够用就好的原则,主要是以 Web App > Web > Linux Client 的顺序挑选软件。一般来说,我用到的也不多:
|
||||||
|
|
||||||
|
- **浏览器:Google Chrome**。主要是好用,能同步,还能远程桌面。
|
||||||
|
|
||||||
|
## 准备
|
||||||
|
|
||||||
|
首先应该拥有自己的账户,否则你将会发现自己无法登录桌面环境。因为桌面环境默认在登录时没有 `root` 用户选项。
|
||||||
|
|
||||||
|
### 创建账户:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
useradd -m ivan
|
||||||
|
passwd ivan
|
||||||
|
usermod -aG wheel ivan
|
||||||
|
```
|
||||||
|
|
||||||
|
给刚刚创建的账户分配一个具有 sudo 权限的账户
|
||||||
|
|
||||||
|
```bash
|
||||||
|
EDITOR=vim visudo
|
||||||
|
```
|
||||||
|
|
||||||
|
找到 `%wheel ALL=(ALL: ALL) ALL` 这行,取消这行的注释。
|
||||||
|
|
||||||
|
现在,你自己的账号具有 sudo 权限了。
|
||||||
|
|
||||||
|
### 生成 SSH 密钥
|
||||||
|
|
||||||
|
2022 年,应该生成 `ed25519` 算法的密钥:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen -t ed25519
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启用和禁用桌面环境
|
||||||
|
|
||||||
|
**使用 `root` 账户执行下面的命令!**
|
||||||
|
|
||||||
|
查看当前的默认目标:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl get-default
|
||||||
|
```
|
||||||
|
|
||||||
|
临时禁用图形界面:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
init 3
|
||||||
|
```
|
||||||
|
|
||||||
|
临时启用图形界面:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
init 5
|
||||||
|
```
|
||||||
|
|
||||||
|
永久禁用图形界面:重启生效:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl set-default multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
永久启用图形界面,重启生效:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl set-default graphical.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## Google Chrome Browser
|
||||||
|
|
||||||
|
安装方式就是直接[官网下载](https://www.google.com/chrome/)。下载完成后双击打开安装。
|
||||||
|
|
||||||
|
或者通过命令行安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
|
|
||||||
|
sudo apt install ./google-chrome-stable_current_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
安装过程中可能会出错,可以使用命令进行安装,然后根据提示修复问题。修复过程中可能会重启电脑。具体情况我没留意,下次遇到的话再补充,嘿嘿。
|
||||||
|
|
||||||
|
## VS Code
|
||||||
|
|
||||||
|
同样从官网下载安装:[Download Visual Studio Code - Mac, Linux, Windows](https://code.visualstudio.com/download)
|
||||||
|
|
||||||
|
### 同步问题
|
||||||
|
|
||||||
|
参考:[Visual Studio Code 中的设置同步](https://code.visualstudio.com/docs/editor/settings-sync#_linux)
|
||||||
|
|
||||||
|
我用的是 KDE Plasma,似乎[再等等](https://github.com/microsoft/vscode/issues/104319#issuecomment-1250089491)就能直接正常使用了,所以我先忍受同步问题吧。
|
||||||
|
|
||||||
|
## 中文输入法
|
||||||
|
|
||||||
|
我使用 iBus + Rime + 小鹤音形.
|
||||||
|
执行以下命令安装 iBus + Rime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ibus ibus-rime
|
||||||
|
```
|
||||||
|
|
||||||
|
接下来配置小鹤音形方案。
|
||||||
|
访问[小鹤的网盘](http://flypy.ysepan.com/)下载小鹤音形的挂接文件,小狼毫、鼠须管的都可以。
|
||||||
|
下载完成后解压出来,把压缩文件里的 `rime` 目录复制到 `/home/ivan/.config/ibus/rime`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如果你没有 unzip,通过下面命令安装:
|
||||||
|
# sudo apt install unzip
|
||||||
|
|
||||||
|
cd ~/Downloads
|
||||||
|
unzip '小鹤音形“鼠须管”for macOS.zip'
|
||||||
|
cd '小鹤音形Rime平台鼠须管for macOS'
|
||||||
|
cp -r ./rime ~/.config/ibus/rime
|
||||||
|
```
|
||||||
|
|
||||||
|
创建 `~/.config/ibus/rime/default.custom.yaml` 文件,并设为以下内容:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
patch:
|
||||||
|
schema_list:
|
||||||
|
- { schema: flypy }
|
||||||
|
- { schema: luna_pinyin }
|
||||||
|
```
|
||||||
|
|
||||||
|
参考:[分享我的输入法配置 (Rime 小狼豪 + 小鹤音形) - 炒饭之道](https://itx.ink/2018/11/21/SHARE_MY_RIME/)
|
||||||
|
|
||||||
|
配置 iBus 环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat >> ~/.zshrc <<EOF
|
||||||
|
|
||||||
|
# ibus
|
||||||
|
export GTK_IM_MODULE=ibus
|
||||||
|
export XMODIFIERS=@im=ibus
|
||||||
|
export QT_IM_MODULE=ibus
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
启动 ibus
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ibus-setup
|
||||||
|
```
|
||||||
|
|
||||||
|
在打开的 GUI 中添加中文输入法,找到 Rime 并添加输入法:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
现在,新打开的软件应该能使用输入法了。像 Chrome 这类,关闭后还需要手动杀死进程后再打开才能使用。最简单的方法就是重启电脑啦~
|
||||||
|
|
||||||
|
## 快捷键
|
||||||
|
|
||||||
|
我习惯使用 Mac OS 系统的快捷键,所以 [Kinto](https://github.com/rbreaves/kinto) 是我的不二之选。key
|
||||||
|
|
||||||
|
安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/bin/bash -c "$(wget -qO- https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh || curl -fsSL https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh)"
|
||||||
|
```
|
||||||
|
|
||||||
|
卸载:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/bin/bash <( wget -qO- https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh || curl -fsSL https://raw.githubusercontent.com/rbreaves/kinto/HEAD/install/linux.sh ) -r
|
||||||
|
```
|
@ -4,10 +4,7 @@ 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:
|
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/3Dqijk.png']
|
||||||
[
|
|
||||||
'https://pan.ivanli.cc/api/v3/file/source/2233/verdaccio.png?sign=qpoeADXzbhHk2MY5CehgTftUJ67pnUj-Ylko9D5jscU%3D%3A0',
|
|
||||||
]
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 为何自建存储库?
|
## 为何自建存储库?
|
168
data/blog/initialization-of-my-macos-environment-in-2023.mdx
Normal file
168
data/blog/initialization-of-my-macos-environment-in-2023.mdx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
---
|
||||||
|
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,10 +4,7 @@ date: '2022-10-17'
|
|||||||
tags: ['Arch Linux', '环境搭建', 'VPS']
|
tags: ['Arch Linux', '环境搭建', 'VPS']
|
||||||
draft: false
|
draft: false
|
||||||
summary: 又到了新装 Arch Linux 的日子了。这次又是温故而知新的机会,把之前写的笔记稍微整理了一下,在这里记录下教徒搭窝的备忘录。
|
summary: 又到了新装 Arch Linux 的日子了。这次又是温故而知新的机会,把之前写的笔记稍微整理了一下,在这里记录下教徒搭窝的备忘录。
|
||||||
images:
|
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/RTr3IU.png']
|
||||||
[
|
|
||||||
'https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0',
|
|
||||||
]
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 起势
|
## 起势
|
||||||
@ -31,7 +28,15 @@ chmod +x vps2arch
|
|||||||
./vps2arch
|
./vps2arch
|
||||||
```
|
```
|
||||||
|
|
||||||
等待几分钟就完成了。如果是中国大陆境内的机子,建议全局代理或使用自定义的系统镜像源。可以从[这个网站](https://archlinux.org/mirrorlist/?country=HK&protocol=https&use_mirror_status=on)获取镜像地址。地址上有查询参数,可以根据自己需要修改。
|
等待几分钟就完成了。如果是中国大陆境内的机子,建议全局代理或使用自定义的系统镜像源。可以从下面的网站获取镜像地址。地址上有查询参数,可以根据自己需要修改。
|
||||||
|
|
||||||
|
> [https://archlinux.org/mirrorlist/?country=HK&protocol=https&use_mirror_status=on](https://archlinux.org/mirrorlist/?country=HK&protocol=https&use_mirror_status=on)
|
||||||
|
|
||||||
|
推荐使用 `https://hkg.mirror.rackspace.com/archlinux/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./vps2arch -m 'https://hkg.mirror.rackspace.com/archlinux/'
|
||||||
|
```
|
||||||
|
|
||||||
如果系统装不上,可以在 IDC 面板上重装其他系统后再试,推荐使用 Debian。
|
如果系统装不上,可以在 IDC 面板上重装其他系统后再试,推荐使用 Debian。
|
||||||
|
|
||||||
@ -57,7 +62,7 @@ ssh-keygen -R '[20.20.20.20]:20000'
|
|||||||
设置主机名:
|
设置主机名:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo hostnamectl set-hostname arch.example.com
|
hostnamectl set-hostname arch.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
启用 pacman 并行下载:
|
启用 pacman 并行下载:
|
||||||
@ -145,9 +150,12 @@ zinit ice depth=1; zinit light romkatv/powerlevel10k' >> ~/.zshrc
|
|||||||
避免优先匹配到子目录,在 `.zshrc` 中添加如下行:
|
避免优先匹配到子目录,在 `.zshrc` 中添加如下行:
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
|
cat >> ~/.zshrc <<EOF
|
||||||
|
|
||||||
# zsh-z
|
# zsh-z
|
||||||
ZSHZ_UNCOMMON=1
|
ZSHZ_UNCOMMON=1
|
||||||
ZSHZ_TRAILING_SLASH=1
|
ZSHZ_TRAILING_SLASH=1
|
||||||
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
#### History
|
#### History
|
||||||
@ -155,15 +163,37 @@ ZSHZ_TRAILING_SLASH=1
|
|||||||
配置历史记录,在 `.zshrc` 中添加如下行:
|
配置历史记录,在 `.zshrc` 中添加如下行:
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
|
cat >> ~/.zshrc <<EOF
|
||||||
|
|
||||||
|
# History
|
||||||
HISTFILE=~/.zsh_history
|
HISTFILE=~/.zsh_history
|
||||||
HISTSIZE=10000
|
HISTSIZE=10000
|
||||||
SAVEHIST=1000
|
SAVEHIST=1000
|
||||||
setopt INC_APPEND_HISTORY_TIME
|
setopt INC_APPEND_HISTORY_TIME
|
||||||
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
详细配置参考:[Better zsh history | SoberKoder](https://www.soberkoder.com/better-zsh-history/)
|
详细配置参考:[Better zsh history | SoberKoder](https://www.soberkoder.com/better-zsh-history/)
|
||||||
文档:[zsh: 16 Options](https://zsh.sourceforge.io/Doc/Release/Options.html)
|
文档:[zsh: 16 Options](https://zsh.sourceforge.io/Doc/Release/Options.html)
|
||||||
|
|
||||||
|
然后进入到 `zsh` 中,执行一次 `source ~/.zshrc`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
zsh
|
||||||
|
|
||||||
|
source ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
设置 Zsh 为默认的 shell 程序:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 列出所有已安装的 shell 程序
|
||||||
|
chsh -l
|
||||||
|
# 从上面的结果中找到 zsh 的完整路径
|
||||||
|
# 我的是 /bin/zsh
|
||||||
|
chsh -s /bin/zsh
|
||||||
|
```
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
安装 Docker 和 Docker Compose 也很简单:
|
安装 Docker 和 Docker Compose 也很简单:
|
@ -1,8 +1,19 @@
|
|||||||
---
|
---
|
||||||
title: 利用一台小鸡实现网络自由
|
title: 利用一台小鸡实现网络自由
|
||||||
date: '2022-10-06'
|
date: '2022-10-06'
|
||||||
tags: ['SNI', 'TLS', 'Reverse Proxy', '反向代理', '正向代理', ‘内网穿透', 'Caddy', 'Xray', 'Vless']
|
tags:
|
||||||
draft: false
|
[
|
||||||
|
'SNI',
|
||||||
|
'TLS',
|
||||||
|
'Reverse Proxy',
|
||||||
|
'反向代理',
|
||||||
|
'正向代理',
|
||||||
|
‘内网穿透',
|
||||||
|
'Caddy',
|
||||||
|
'Xray',
|
||||||
|
'Vless',
|
||||||
|
]
|
||||||
|
draft: true
|
||||||
summary: SNI Proxy 进行 TLS 分流;Caddy 对网站和 Xray 进行反向代理;Xray 实现正向、反向代理(内网穿透)。
|
summary: SNI Proxy 进行 TLS 分流;Caddy 对网站和 Xray 进行反向代理;Xray 实现正向、反向代理(内网穿透)。
|
||||||
---
|
---
|
||||||
|
|
74
data/blog/react-18-stricter-strict-mode.mdx
Normal file
74
data/blog/react-18-stricter-strict-mode.mdx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: React 18,新的严格模式
|
||||||
|
date: '2023-02-06'
|
||||||
|
tags: ['React']
|
||||||
|
draft: false
|
||||||
|
summary: 好好的 useMemo、useEffect 居然执行了两次,我明明传入了依赖,为什么会执行两次呢?原来是 React 18 的破坏性改动!
|
||||||
|
---
|
||||||
|
|
||||||
|
之前在开发模式时,一直记得 `useMemo` 在严格模式下不会二次执行, `useEffect` 在有传入 `deps` 时也不会二次执行。而今天我在排下面这段代码时,发现一个要命的事情!
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const camera = useMemo(/* .. */, []);
|
||||||
|
const renderer = props; // from parent
|
||||||
|
|
||||||
|
const controls = useMemo(
|
||||||
|
() => new OrbitControls(camera, renderer.domElement),
|
||||||
|
[camera, renderer],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 同一个 controls 进入两次
|
||||||
|
return () => {
|
||||||
|
controls.dispose(); // 执行一次,controls 被释放
|
||||||
|
};
|
||||||
|
}, [controls]);
|
||||||
|
```
|
||||||
|
|
||||||
|
`controls` 生成了两个,第一个似乎没用到了,第二个是后续要正常使用的对象,并且进入了 `useEffect` 中,而且进去了两次!
|
||||||
|
这导致两个严重的问题就是,第一个 `controls` 没有被销毁,第二个 `controls` 被销毁了!
|
||||||
|
第一个没销毁是内存泄漏,第二个被销毁了导致 `controls` 对象不可用了!
|
||||||
|
|
||||||
|
我一度以为是我对 useEffect 特性的记忆出现了偏差,后来我在官方文档翻了半天没啥收获,指到我看见了更新说明里的 [Stricter Strict Mode](https://github.com/facebook/react/blob/main/CHANGELOG.md#1820-june-14-2022:~:text=Stricter%20Strict%20Mode)。
|
||||||
|
|
||||||
|
## 新的严格模式
|
||||||
|
|
||||||
|
从更新日志可以看到,新的严格模式会自动卸载并再次重新挂载每个组件,这就解释了为什么 `useMemo` 和 `useEffect` 即使在有传入 `deps` 也会多执行一次。
|
||||||
|
|
||||||
|
这个特性是破坏性的,会影响之前版本的程序逻辑,所以 React 在[更新说明](https://github.com/facebook/react/blob/main/CHANGELOG.md#1820-june-14-2022:~:text=If%20this%20breaks%20your%20app%2C%20consider%20removing%20Strict%20Mode%20until%20you%20can%20fix%20the%20components%20to%20be%20resilient%20to%20remounting%20with%20existing%20state.)也建议如果旧的应用因为这个出现兼容性问题,建议先关掉 `strictMode`。
|
||||||
|
|
||||||
|
### 在 React 18 的测试代码
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
代码:[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)
|
||||||
|
|
||||||
|
红色是第一次渲染,绿色是第二次渲染。输出日志里淡些的是 React 18 在二次调用时输出的 log 的默认效果,在 React 17 中是被默认隐藏的。蓝色指向的是最终应用在界面上呈现的结果。
|
||||||
|
|
||||||
|
我在 V 站上也提出了我的[疑问](https://v2ex.com/t/913595),根据大佬们的回复,我总结了一下:
|
||||||
|
|
||||||
|
1. `useMemo(() => /* */, [])` 执行一此后,以新的严格模式的规则,进行了二次调用,第一次的值作废。
|
||||||
|
2. `useEffect(() => /* */, [])`执行一此后,以新的严格模式的规则,调用了 `destructor` 后,进行了二次调用。
|
||||||
|
|
||||||
|
在第 2 点中,两次 useEffect 都是使用同一个值,是因为严格模式的二次调用按钩子分别执行两次,所以 useMemo 两次的调用都完毕后,得到的值再被 useEffect 执行两次。我调整了一下代码,将测试代码复制了一份在后面,可以看到 “useMemo” 和 “useMemo 2” 先执行了一次,又再执行了一次,然后再到 “useEffect“ 和 “useEffect 2":
|
||||||
|

|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
`useEffect` 应该独自管理副作用,要做到自己创建,自己销毁。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const camera = useMemo(/* .. */, []);
|
||||||
|
const renderer = props; // from parent
|
||||||
|
|
||||||
|
const [controls, setControls] = useState<OrbitControls | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement) // 自己的锅
|
||||||
|
setControls(controls);
|
||||||
|
return () => {
|
||||||
|
controls.dispose(); // 自己背
|
||||||
|
};
|
||||||
|
}, [camera, renderer]);
|
||||||
|
```
|
||||||
|
|
||||||
|
今天深究了一下这个问题,解决方案其实我也知道,但是之前的写法突然以我不理解的方式失效了,还是要较个劲,万一是 React 不规范呢?(狗头
|
35
data/blog/self-hosted-baas.mdx
Normal file
35
data/blog/self-hosted-baas.mdx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: 自部署的 BaaS 服务对比
|
||||||
|
date: '2023-01-24'
|
||||||
|
tags: ['BaaS', 'Self-Hosted', 'appwrite', 'nhost', 'supabase']
|
||||||
|
draft: false
|
||||||
|
summary: supabase、nhost、appwrite 之间的对比,关注自部署方向。
|
||||||
|
---
|
||||||
|
|
||||||
|
BaaS,后端即服务。
|
||||||
|
最近关注 BaaS 自部署主要还是因为自己有一些简单的服务端开发需求,可能就一两个函数的事。
|
||||||
|
如果专门去写一个后端,显得有些铺张了。而且创建一个项目是个麻烦事,我也不愿意从旧项目复制可能已经过时代码进来,所以就想到 FaaS。
|
||||||
|
但是 FaaS 我搜罗了一圈,适合个人自部署的 FaaS 平台少之又少,最后找了个 Serverless 的函数项目 [Trusted CGI](https://trusted-cgi.reddec.net/)([Repo](https://github.com/reddec/trusted-cgi),Go Lang)。
|
||||||
|
这个挺适合个人使用,但是无状态意味着我还得自己搞状态存储,还是不太符合我的需求,所以就看上了 BaaS。
|
||||||
|
|
||||||
|
## BaaS 简要介绍
|
||||||
|
|
||||||
|
目前 BaaS 大概是用户管理、授权、认证,加上数据库设计和存储、对象存储,再加上各类的 event hook 和 push,再加上 Serverless Functions 构成的。这个组件构成非常适合原型、Demo 以及轻量的应用开发。
|
||||||
|
当然,如果后续有性能瓶颈,至少垂直扩展和 functions 层水平扩展是没有任何问题。至于持久层的水平扩展,也能像传统方案处理。
|
||||||
|
|
||||||
|
BaaS 最佳的应用场景就是各类 Apps 的服务端了。包括 Web Apps 在内,主要业务由 Apps 端处理的话,BaaS 就是绝佳的生产力工具。而且服务端的业务逻辑基本上都是写在 Serverless Functions 里,所以根本不需要考虑升级会暂停服务,因为它是无状态的,更新时是能做到零秒重载的。
|
||||||
|
|
||||||
|
更权威的定义可以看[这里](https://www.cloudflare.com/zh-cn/learning/serverless/glossary/backend-as-a-service-baas/)。
|
||||||
|
|
||||||
|
## 对比
|
||||||
|
|
||||||
|
适合生产的环境自然也就是各大平台自有的云 BaaS 平台了,在他们各自的云平台上,你可以享受到完整、轻松的开发体验。但是这是对于企业和专业用途的个人用户,而玩票性质的我就暂时用不上了,所以我的目标就是开源的、可自部署的 BaaS 服务。
|
||||||
|
|
||||||
|
我淘了好久,找到了三个不错的开源项目,分别是 supabase <img alt="Packagist Stars" class="inline" src="https://img.shields.io/packagist/stars/appwrite/appwrite?style=social"/>、appwrite <img alt="Packagist Stars" class="inline" src="https://img.shields.io/packagist/stars/nhost/nhost?style=social"/>、nhost <img alt="Packagist Stars" class="inline" src="https://img.shields.io/packagist/stars/appwrite/appwrite?style=social"/>。他们仨在 Github 上的 Stars 目前是从多到少的。
|
||||||
|
接下来就以自部署的角度来对比下他们三个之间的差异。
|
||||||
|
|
||||||
|
TL; DR, 如果小项目多,推荐使用 appwrite;如果现阶段需要表的关联,建议使用 nhost;supabase 不适合自部署,他没有可以自部署的 serverless functions。
|
||||||
|
|
||||||
|
### Supabase
|
||||||
|
|
||||||
|
Github 上的星星老多了,可以说是目前最火的 BaaS 项目了。他对标的是 Firebase,以 Firebase 的开源版本自居。
|
119
data/blog/thinks-for-2022.mdx
Normal file
119
data/blog/thinks-for-2022.mdx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
title: 再见 2022,你好 2023
|
||||||
|
date: '2022-12-31'
|
||||||
|
tags: ['总结']
|
||||||
|
draft: true
|
||||||
|
summary: 2022 年就要结束了,写个工作与学习的总结,记录下我的 2022 年。
|
||||||
|
---
|
||||||
|
|
||||||
|
二零二二,疫情不出意外地还在我们身边,而我们也早已经习惯了疫情。
|
||||||
|
|
||||||
|
## 电子电路与嵌入式开发
|
||||||
|
|
||||||
|
我一直对电子电路有着兴趣,而今年三月,我突然觉得我行了。购买了仪器和工具设备,开始自学电子电路知识。
|
||||||
|
软件开发一直和数字电路有着密不可分的联系,草草地学习完模拟电路和数字电路后,更加肯定软件和硬件不能独立,而是相辅相成的。
|
||||||
|
而我的第一个目标是构建一个锂电池 UPS,为的是让充当服务器的 J4125 工控机能从容应对突然的断电。
|
||||||
|
项目我拆分了好几部分,一个阶段,一个阶段地完成,向着目标努力。
|
||||||
|
印象比较深的还是第一个部分的实现。第一个部分是实现一个理想二极管。
|
||||||
|
所谓理想二极管就是电流单向流动,并且(几乎)没有普通二极管的压降。
|
||||||
|
还记得我开开心心地在各大电子电路论坛爬楼找思路时,找到了个方案,在电路模拟软件搭建好电路后,运行了模拟,结果挺有效的。
|
||||||
|
我就赶紧画电路板打样了。等待样板寄来的包裹的这几天,我思来想去这电路好像不应该有效啊,换了个模拟软件一跑,坏了,不对。
|
||||||
|
接着就在洞洞板上做实验,果然,效果不对,压降那叫一个大。
|
||||||
|
最后这样改方案,画了第三块板后,终于实现了这一电路。果然实践才是检验真理的唯一途径,三个月从零搞出这么个东西,其实没啥,但是过程确实有趣。
|
||||||
|
就这样,边学边试,软件和硬件都验证完了,过程充满松香味。
|
||||||
|
不过可惜的是,半年后的八月,我换了 AMD 迷你主机,UPS 设计供电已经不满足新主机的要求,项目搁置了。
|
||||||
|
电路的理论论证和各部分的功能验证板已经实现了,外壳也已经设计好了,但是还是没继续往下走,因为当时工作上发生了一些变动,就没余力折腾了。
|
||||||
|
之后又开始折腾屏幕氛围灯,挺有趣的,不过还没做完,目前一周也就花几小时在边学边弄。
|
||||||
|
|
||||||
|
我能想着开始折腾电子电路,还是因为国产单片机的崛起。
|
||||||
|
乐鑫公司的 ESP32 系列单片机真的是物美价廉,白嫖嘉立创的 PCB 也非常的香。
|
||||||
|
学习成本就低了很多,想想去年我入手的树莓派 4B。当时也有考虑折腾物联网,但是一个三四百,也只适合当个上位机干活了。
|
||||||
|
而现在用着 ESP32 模组和开发板,烧了也不心疼,嘿嘿。
|
||||||
|
|
||||||
|
嵌入式开发是一个陌生的领域,C 语言是一个上古的高级语言,而天天写着现代高级语言的我,一时之间回不到大一时写 C 语言的感觉,
|
||||||
|
所以用了一段 C 语言做嵌入式开发后,我看上了 C++。或许是 C++ 没看上我,编译器报错我是一个也看不懂,所以我想起了 Rust。
|
||||||
|
Rust 比着“耶”向我招手,我大意了,就进了这个跟大的坑。
|
||||||
|
|
||||||
|
Rust 语言确实费脑子,因为开发过程中将会被编译器一直教育,非常地严谨。还记得我被引用变量的生命周期教育得死去活来,被结构体没有实现 `Copy`、 `Send`、`Sync` 折磨得痛不欲生。
|
||||||
|
其实本来没这么复杂的,可是我却拿着嵌入式开发和 Rust 一起学,难度不能说陡增吧,只能说是经常看不到明天的太阳。
|
||||||
|
不过我还是磨出来了 UPS 的程序,还行,能用。之后开发屏幕氛围灯就滚回去用 C 语言写了,还别说,嵌入式开发我也学出了点感觉,写起来可亲切了。
|
||||||
|
因为示例多、论坛上的开发者主要也是用 C 写,资料很充足,也没有那群还在 0.0.x 版本的 Rust 库,感觉代码好写多了……
|
||||||
|
但是我贼心不死,拿着 Rust 转眼就配着 Tauri 开始开发氛围灯的上位机程序。
|
||||||
|
不知是没有了 rust-embedded 系的折磨,还是我懂得了 Rust 的脾气,开发得比较顺利,很舒服。
|
||||||
|
|
||||||
|
嵌入式的世界非常的美妙,将虚幻的软件借着硬件能更真实地让我们触碰到。
|
||||||
|
看好 IoT,这是极客们的方向,也是科技改变生活的方向。
|
||||||
|
|
||||||
|
## 元宇宙
|
||||||
|
|
||||||
|
年中因为业务调整,我的工作和元宇宙搭上了边。年中出去嗨皮了一周后,回来就开始学习 Unity 3D。
|
||||||
|
不得不说,国内疫情防控挺好的,走了好几个城市,回来也没阳,哈哈。
|
||||||
|
跑题了,不得不说,Unity 3D 作为入门 3D 游戏开发确实挺合适的,虽然我们当时本想着用 Unity 开发元宇宙项目。
|
||||||
|
正当我在庆幸我还没把 C Sharp 忘光光时,又被要求换成了现在正在使用的 Three.js 作为引擎,也就回到了 Web 领域。
|
||||||
|
接下来的半年,我和我的同事们便开始踩坑之旅。
|
||||||
|
|
||||||
|
因为是全新的领域,我和我的同事经历了 Unity 3D、Lingo 3D、r3f 这三个阶段,踩了许多的坑。
|
||||||
|
不知为什么,他们似乎对游戏开发好像并不觉得是全新的世界,极其低估了所需的知识储备。
|
||||||
|
现在回头看,每次的技术选型其实都不合理。
|
||||||
|
Lingo 3D 作为刚出现的框架,并没有经过市场的检验,也并没有基于该框架商业项目,使用这个框架和二开这个框架并没有什么区别。反对无力,作罢。
|
||||||
|
当我向 Lingo 3D 提了一个 PR 后,同事就抛弃了 Lingo 3D,转向 r3f + BVH 碰撞检测。
|
||||||
|
而接下来,继续遇到了大量的性能问题。
|
||||||
|
|
||||||
|
从项目开发的第一周起,我就有一个 3D 场景渲染性能优化的任务挂着。
|
||||||
|
不过滑稽的是我的开发任务基本上在 WebRTC 相关部分和后端,而前端游戏场景渲染这部分并不是我开发的……
|
||||||
|
虽然处于尴尬的位置,优化是没处优化了,但是问题还是能另起项目去发现和验证。
|
||||||
|
我断断续续地折腾这事,现在回头看看,其实得出的结论挺正确的,但是当时没人懂也没人信,我也是半验证半猜测,没想到正确率还行。
|
||||||
|
|
||||||
|
- 游戏开发确实很考验建模师的素质,调优后的模型性能直接翻倍;
|
||||||
|
- 模型拆分、复用、LOD 是真的有明显的性能提升;
|
||||||
|
- 内存瓶颈是存在的,降低内存占用量能够让程序更稳定
|
||||||
|
|
||||||
|
虽然我对元宇宙没啥兴趣,不过可视化这方向是很有价值的。
|
||||||
|
看好元宇宙的风口,也看好可视化的前景。
|
||||||
|
|
||||||
|
## 自建服务
|
||||||
|
|
||||||
|
八月换了新的迷你主机作为服务器,依然使用移动平台的 CPU,性能和功耗还可以。
|
||||||
|
比之前的机子性能好太多了,当然,满载时也学会芜湖起飞了。
|
||||||
|
我也多部署了几个服务。
|
||||||
|
|
||||||
|
### RSS 阅读器
|
||||||
|
|
||||||
|
现在信息茧房问题挺严重的,所以使用 Miniflux 自建了 RSS 阅读器。目前搜寻了一些个人博客、小众资讯站作为资讯源,感觉挺好的,没有乱七八糟的内容,很舒适。
|
||||||
|
|
||||||
|
### 标签打印机和短网址服务
|
||||||
|
|
||||||
|
另外,买了一个标签打印机,配合短网址服务 Kutt,给我买的一堆电子元件做分类打标签。标签上带了个二维码,里面存了描述元件信息文件的超链接地址。
|
||||||
|
|
||||||
|
### 日志分析服务
|
||||||
|
|
||||||
|
最后,因为部署了太多服务,自建服务又容易挂,所以跑了 Grafana + loki 用作日志分析服务。再配合在云服务器上部署的 Kuma 服务健康监控服务,目前服务状况了然于胸。
|
||||||
|
|
||||||
|
我还购入了国内的 NAT VPS,做内网穿透和 SD-WAN,比使用境外服务器快太多啦,还稳定。
|
||||||
|
公司网络不太稳定,直接组网性能经常断线;
|
||||||
|
使用 VPN 拨入 NAT VPS 后,再由 NAT VPS 与家里组网,就没在掉过线。
|
||||||
|
现在从公网访问我的自建服务是由从境外服务器反向代理的,延迟比较大。
|
||||||
|
目前域名重新备案,希望能套上国内 CDN,这样再由 NAT VPS 反向代理应该能改善国内访问速度。
|
||||||
|
|
||||||
|
## 开发容器与远程开发
|
||||||
|
|
||||||
|
那么,我为什么要组网呢,更大原因还是我想进行远程开发。
|
||||||
|
我在公司使用的是毕业时买的 MacBook Pro 2018 款,性能其实还不错,但是跑测试用例还是有些慢。
|
||||||
|
而且我今年也拥抱了 Dev Container 的开发方式,开发容器能很好地解决开发环境搭建问题。
|
||||||
|
再配合上数据库迁移脚本和数据生成工具,能极大地解决前后端开发时出现的数据污染问题。
|
||||||
|
可谓是 2022 年我最正确的选择了,哈哈哈哈。
|
||||||
|
这个选择是有代价的,那就是每开发一个项目都是有自己的持久化、缓存和应用组件,比较消耗硬件资源。
|
||||||
|
这不就换了个迷你主机嘛,AMD yes!
|
||||||
|
因为在家里,所以就搞起了远程开发,家里网络质量也比较好,拉依赖什么的速度和稳定性比在公司高出了不少,这也极大地改善了我的开发体验。
|
||||||
|
要说这远程开发有啥不好的,那就是怕家里突然断网断电,那就有可能痛失劳动成果了。
|
||||||
|
|
||||||
|
开发容器是个不错的东西,今年看很多开源项目都用上了开发容器,也有很多开源项目还没用上。
|
||||||
|
但很明显,这是未来。我今年在开源社区也算是小小地冒了个泡,无论项目是否有开发容器的配置,我都会拉完项目后在开发容器运行,不用担心弄乱我的电脑,也不用担心环境冲突。
|
||||||
|
而且最重要的,是不用那么担心去年的项目今年怎么也跑不起来。这感觉,经历过的都懂。
|
||||||
|
最后,洁癖万岁~
|
||||||
|
|
||||||
|
比较可惜,VS Code 的 Web 版本还不能支持开发容器,要是支持了,在 iPad 上快乐生产是多么值得的一件事呐。
|
||||||
|
|
||||||
|
## 未来可期
|
||||||
|
|
||||||
|
以上是我在 2022 年的经历,有些是我的计划,有些是命运的安排。贴近底层、满足兴趣,让我接触了嵌入式开发,意外的变动让我接触了 3D 游戏开发;心心念念的 Rust 终于安排上了,为了修自建的服务而改起了好几个 Go 语言项目。回顾过往,我已经摸了好多好多门编程语言了,也换了好多口味的编程风格,也大概摸清了自己向往的方向和风格。希望能够继续用着 TypeScript 和 Rust,快乐地写着后端,玩着前端,搞着偏后端的全栈开发,在 Arch Linux 上维护着服务。我可见不得 Java 和 PHP,希望依旧再也不见。对,还有 Python,我和你不熟,可别过来。
|
84
data/blog/using-navigation-mesh-for-pathfinding.mdx
Normal file
84
data/blog/using-navigation-mesh-for-pathfinding.mdx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
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/)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
漏斗算法能够让最终路径在绕过障碍物的同时,保证路径最短。从上面的文章中,可以总结一个核心逻辑,每次生成的路径如果超过左边界或右边界,就会增加一个节点,并从此处构造新的漏斗。直到到达终点。
|
7
data/blog/vscode-online.mdx
Normal file
7
data/blog/vscode-online.mdx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
title: 使用 code-server 自部署 Web 版 VS Code
|
||||||
|
date: '2022-12-24'
|
||||||
|
tags: ['Develop', 'VS Code', 'Self Hosted']
|
||||||
|
draft: true
|
||||||
|
summary: 心心念念的在浏览器中写代码,iPad 终于有希望回归生产力了。
|
||||||
|
---
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user