Compare commits
33 Commits
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 |
@ -8,7 +8,7 @@
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "16-bullseye"
|
||||
"VARIANT": "18-bullseye"
|
||||
}
|
||||
},
|
||||
// Configure tool-specific properties.
|
||||
@ -35,7 +35,10 @@
|
||||
"esbenp.prettier-vscode",
|
||||
"shardulm94.trailing-spaces",
|
||||
"lihui.vs-color-picker",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"github.vscode-github-actions",
|
||||
"unifiedjs.vscode-mdx",
|
||||
"Codeium.codeium"
|
||||
]
|
||||
}
|
||||
},
|
||||
@ -51,5 +54,5 @@
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
"mounts": [],
|
||||
"postStartCommand": "pnpm install && npm run dev"
|
||||
"postAttachCommand": "pnpm install && npm run dev"
|
||||
}
|
||||
|
243
.drone.yml
243
.drone.yml
@ -1,243 +0,0 @@
|
||||
---
|
||||
kind: pipeline
|
||||
name: deps
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: install
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: docker-registry.ivanli.cc
|
||||
username:
|
||||
from_secret: ivan-docker-username
|
||||
password:
|
||||
from_secret: ivan-docker-password
|
||||
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps
|
||||
cache_from:
|
||||
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
dockerfile: Dockerfile
|
||||
target: deps
|
||||
tags:
|
||||
- '${DRONE_COMMIT_SHA:0:8}-amd64'
|
||||
- '${DRONE_BRANCH}${DRONE_TAG}-amd64'
|
||||
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
when:
|
||||
status:
|
||||
- failure
|
||||
failure: ignore
|
||||
detach: true
|
||||
environment:
|
||||
PLUGIN_TOKEN:
|
||||
from_secret: drone-telegram-bot-token
|
||||
PLUGIN_TO:
|
||||
from_secret: telegram-notify-to
|
||||
settings:
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}
|
||||
✅ Install Deps #{{build.number}} of `{{repo.name}}` succeeded.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{else}}
|
||||
❌ Install Deps #{{build.number}} of `{{repo.name}}` failed.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
name: linux-amd64
|
||||
type: docker
|
||||
depends_on:
|
||||
- deps
|
||||
|
||||
steps:
|
||||
- name: build&publish
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: docker-registry.ivanli.cc
|
||||
username:
|
||||
from_secret: ivan-docker-username
|
||||
password:
|
||||
from_secret: ivan-docker-password
|
||||
repo: docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog
|
||||
dockerfile: Dockerfile
|
||||
target: release
|
||||
cache_from:
|
||||
- docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
tags:
|
||||
- '${DRONE_COMMIT_SHA:0:8}'
|
||||
- '${DRONE_BRANCH}${DRONE_TAG}'
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
failure: ignore
|
||||
detach: true
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
environment:
|
||||
PLUGIN_TOKEN:
|
||||
from_secret: drone-telegram-bot-token
|
||||
PLUGIN_TO:
|
||||
from_secret: telegram-notify-to
|
||||
settings:
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}
|
||||
✅ Build #{{build.number}} of `{{repo.name}}` succeeded.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{else}}
|
||||
❌ Build #{{build.number}} of `{{repo.name}}` failed.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- master
|
||||
- main
|
||||
- develop
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: deploy
|
||||
clone:
|
||||
disable: false
|
||||
depends_on:
|
||||
- linux-amd64
|
||||
|
||||
steps:
|
||||
- name: watchtower-online
|
||||
image: plugins/webhook
|
||||
settings:
|
||||
token_value:
|
||||
from_secret: watchtower-webhook-token
|
||||
token_type: Bearer
|
||||
urls: https://watchtower.ivanli.cc/v1/update
|
||||
content_type: application/json
|
||||
template: |
|
||||
{
|
||||
"owner": "{{ repo.owner }}",
|
||||
"repo": "{{ repo.name }}",
|
||||
"status": "{{ build.status }}",
|
||||
}
|
||||
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
failure: ignore
|
||||
detach: true
|
||||
environment:
|
||||
PLUGIN_TOKEN:
|
||||
from_secret: drone-telegram-bot-token
|
||||
PLUGIN_TO:
|
||||
from_secret: telegram-notify-to
|
||||
settings:
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}
|
||||
✅ Deploy #{{build.number}} of `{{repo.name}}` succeeded.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{else}}
|
||||
❌ Deploy #{{build.number}} of `{{repo.name}}` failed.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: deploy-to-zzidc
|
||||
clone:
|
||||
disable: false
|
||||
depends_on:
|
||||
- linux-amd64
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- master
|
||||
- develop
|
||||
|
||||
steps:
|
||||
- name: upload
|
||||
image: docker:dind
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- docker pull docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64
|
||||
- docker build --pull=true --target upload -t docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-upload:${DRONE_COMMIT_SHA:0:8} --cache-from docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-deps:${DRONE_BRANCH}${DRONE_TAG}-amd64 .
|
||||
- docker run --rm -t -e FTP_ACCOUNT=$${FTP_ACCOUNT} -e FTP_PASSWORD=$${FTP_PASSWORD} -e FTP_HOST=$${FTP_HOST} docker-registry.ivanli.cc/ivan/tailwind-nextjs-blog-upload:${DRONE_COMMIT_SHA:0:8}
|
||||
environment:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
FTP_ACCOUNT:
|
||||
from_secret: zzidc_ftp_account
|
||||
FTP_PASSWORD:
|
||||
from_secret: zzidc_ftp_password
|
||||
FTP_HOST:
|
||||
from_secret: zzidc_ftp_host
|
||||
|
||||
- name: notify
|
||||
image: appleboy/drone-telegram
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
failure: ignore
|
||||
detach: true
|
||||
environment:
|
||||
PLUGIN_TOKEN:
|
||||
from_secret: drone-telegram-bot-token
|
||||
PLUGIN_TO:
|
||||
from_secret: telegram-notify-to
|
||||
settings:
|
||||
format: markdown
|
||||
message: >
|
||||
{{#success build.status}}
|
||||
✅ Deploy #{{build.number}} of `{{repo.name}}` to ZZIDC succeeded.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{else}}
|
||||
❌ Deploy #{{build.number}} of `{{repo.name}}` to ZZIDC failed.
|
||||
📝 Commit by {{commit.author}} on `{{commit.branch}}`:
|
||||
```
|
||||
{{commit.message}}
|
||||
```
|
||||
🌐 {{ build.link }}
|
||||
{{/success}}
|
||||
|
||||
volumes:
|
||||
- name: dockersock
|
||||
host:
|
||||
path: /var/run/docker.sock
|
@ -1,12 +0,0 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
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_REPOSITORY_ID=
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY=
|
||||
NEXT_PUBLIC_GISCUS_CATEGORY_ID=
|
||||
NEXT_PUBLIC_UTTERANCES_REPO=
|
||||
NEXT_PUBLIC_DISQUS_SHORTNAME=
|
||||
NEXT_PUBLIC_CUSDIS_APPID=
|
||||
NEXT_PUBLIC_CUSDIS_HOST=
|
||||
NEXT_PUBLIC_COMMENTO_URL=
|
||||
|
||||
|
||||
MAILCHIMP_API_KEY=
|
||||
MAILCHIMP_API_SERVER=
|
||||
MAILCHIMP_AUDIENCE_ID=
|
||||
|
||||
BUTTONDOWN_API_URL=https://api.buttondown.email/v1/
|
||||
BUTTONDOWN_API_KEY=
|
||||
|
||||
CONVERTKIT_API_URL=https://api.convertkit.com/v3/
|
||||
CONVERTKIT_API_KEY=
|
||||
// curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
|
||||
# curl https://api.convertkit.com/v3/forms?api_key=<your_public_api_key> to get your form ID
|
||||
CONVERTKIT_FORM_ID=
|
||||
|
||||
KLAVIYO_API_KEY=
|
||||
KLAVIYO_LIST_ID=
|
||||
|
||||
REVUE_API_URL=https://www.getrevue.co/api/v2/
|
||||
REVUE_API_KEY=
|
||||
|
||||
EMAILOCTOPUS_API_URL=https://emailoctopus.com/api/1.6/
|
||||
EMAILOCTOPUS_API_KEY=
|
||||
EMAILOCTOPUS_LIST_ID=
|
||||
EMAILOCTOPUS_LIST_ID=
|
||||
|
@ -1 +1,2 @@
|
||||
node_modules
|
||||
.eslintrc.js
|
29
.eslintrc.js
29
.eslintrc.js
@ -1,17 +1,42 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
env: {
|
||||
browser: true,
|
||||
amd: true,
|
||||
node: true,
|
||||
es6: true,
|
||||
},
|
||||
extends: ['eslint:recommended', 'plugin:prettier/recommended', 'next', 'next/core-web-vitals'],
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'next',
|
||||
'next/core-web-vitals',
|
||||
],
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'error',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'jsx-a11y/anchor-is-valid': [
|
||||
'error',
|
||||
{
|
||||
components: ['Link'],
|
||||
specialLink: ['hrefLeft', 'hrefRight'],
|
||||
aspects: ['invalidHref', 'preferButton'],
|
||||
},
|
||||
],
|
||||
'react/prop-types': 0,
|
||||
'no-unused-vars': 0,
|
||||
'@typescript-eslint/no-unused-vars': 0,
|
||||
'react/no-unescaped-entities': 0,
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
},
|
||||
}
|
||||
|
2
.gitattributes
vendored
2
.gitattributes
vendored
@ -1,5 +1,5 @@
|
||||
## Source: https://github.com/alexkaratarakis/gitattributes
|
||||
## Modified * text=auto to * text=auto eol=lf to force LF endings.
|
||||
## Modified * text=auto to * text=auto eol=lf eol=lf to force LF endings.
|
||||
|
||||
## GITATTRIBUTES FOR WEB PROJECTS
|
||||
#
|
||||
|
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
|
||||
/.pnp
|
||||
.pnp.js
|
||||
/.yarn/*
|
||||
!/.yarn/releases
|
||||
!/.yarn/plugins
|
||||
!/.yarn/sdks
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
@ -17,9 +21,13 @@ public/sitemap.xml
|
||||
# production
|
||||
/build
|
||||
*.xml
|
||||
|
||||
# rss feed
|
||||
/public/feed.xml
|
||||
|
||||
# search
|
||||
/public/search.json
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@ -35,6 +43,9 @@ yarn-error.log*
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Contentlayer
|
||||
.contentlayer
|
||||
|
||||
secrets.txt
|
||||
|
||||
.pnpm-store
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -1,4 +1,6 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"cSpell.words": [
|
||||
"alpn",
|
||||
"appleboy",
|
||||
@ -13,6 +15,7 @@
|
||||
"EMAILOCTOPUS",
|
||||
"fullchain",
|
||||
"Giscus",
|
||||
"Hackintosh",
|
||||
"KLAVIYO",
|
||||
"Kutt",
|
||||
"lastmod",
|
||||
|
874
.yarn/releases/yarn-3.6.1.cjs
vendored
Executable file
874
.yarn/releases/yarn-3.6.1.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
3
.yarnrc.yml
Normal file
3
.yarnrc.yml
Normal file
@ -0,0 +1,3 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.6.1.cjs
|
192
README.md
192
README.md
@ -1,16 +1,136 @@
|
||||
![tailwind-nextjs-banner](/public/static/images/twitter-card.png)
|
||||
|
||||
# Ivan Li's Blog
|
||||
# Tailwind Nextjs Starter Blog
|
||||
|
||||
[![Build Status](https://ci.ivanli.cc/api/badges/Ivan/tailwind-nextjs-blog/status.svg)](https://ci.ivanli.cc/Ivan/tailwind-nextjs-blog)
|
||||
[![Website Status](https://uptime.sg.ivanli.cc/api/badge/18/uptime/720?label=30&labelSuffix=d)](https://ivanli.cc)
|
||||
[![GitHub Repo stars](https://img.shields.io/github/stars/timlrx/tailwind-nextjs-starter-blog?style=social)](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/stargazers/)
|
||||
[![GitHub forks](https://img.shields.io/github/forks/timlrx/tailwind-nextjs-starter-blog?style=social)](https://GitHub.com/timlrx/tailwind-nextjs-starter-blog/network/)
|
||||
[![Twitter URL](https://img.shields.io/twitter/url?style=social&url=https%3A%2F%2Ftwitter.com%2Ftimlrxx)](https://twitter.com/timlrxx)
|
||||
[![Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&link=https://github.com/sponsors/timlrx)](https://github.com/sponsors/timlrx)
|
||||
|
||||
Ivan Li's Blog, base [timlrx/tailwind-nextjs-starter-blog](https://github.com/timlrx/tailwind-nextjs-starter-blog)
|
||||
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/timlrx/tailwind-nextjs-starter-blog)
|
||||
|
||||
This is a [Next.js](https://nextjs.org/), [Tailwind CSS](https://tailwindcss.com/) blogging starter template. Version 2 is based on Next App directory with [React Server Component](https://nextjs.org/docs/getting-started/react-essentials#server-components) and uses [Contentlayer](https://www.contentlayer.dev/) to manage markdown content.
|
||||
|
||||
Probably the most feature-rich Next.js markdown blogging template out there. Easily configurable and customizable. Perfect as a replacement to existing Jekyll and Hugo individual blogs.
|
||||
|
||||
Check out the documentation below to get started.
|
||||
|
||||
Facing issues? Check the [FAQ page](https://github.com/timlrx/tailwind-nextjs-starter-blog/wiki) and do a search on past issues. Feel free to open a new issue if none has been posted previously.
|
||||
|
||||
Feature request? Check the past discussions to see if it has been brought up previously. Otherwise, feel free to start a new discussion thread. All ideas are welcomed!
|
||||
|
||||
## Examples V2
|
||||
|
||||
- [Demo Blog](https://tailwind-nextjs-starter-blog.vercel.app/) - this repo
|
||||
- [My personal blog](https://www.timlrx.com) - modified to auto-generate blog posts with dates
|
||||
- [ben.codes blog](https://ben.codes) - Benoit's personal blog about software development ([source code](https://github.com/bendotcodes/bendotcodes))
|
||||
|
||||
Using the template? Feel free to create a PR and add your blog to this list.
|
||||
|
||||
## Examples V1
|
||||
|
||||
[v1-blogs-showcase.webm](https://github.com/timlrx/tailwind-nextjs-starter-blog/assets/28362229/2124c81f-b99d-4431-839c-347e01a2616c)
|
||||
|
||||
Thanks to the community of users and contributers to the template! We are no longer accepting new blog listings over here. If you have updated from version 1 to version 2, feel free to remove your blog from this list and add it to the one above.
|
||||
|
||||
- [Aloisdg's cookbook](https://tambouille.vercel.app/) - with pictures and recipes!
|
||||
- [GautierArcin's demo with next translate](https://tailwind-nextjs-starter-blog-seven.vercel.app/) - includes translation of mdx posts, [source code](https://github.com/GautierArcin/tailwind-nextjs-starter-blog/tree/demo/next-translate)
|
||||
- [David Levai's digital garden](https://davidlevai.com/) - customized design and added email subscriptions
|
||||
- [Leo's Blog](https://leohuynh.dev) - Tuan Anh Huynh's personal site. Add Snippets Page, Author Profile Card, Image Lightbox, ...
|
||||
- [thvu.dev](https://thvu.dev) - Added `mdx-embed`, view count, reading minutes and more.
|
||||
- [irvin.dev](https://www.irvin.dev/) - Irvin Lin's personal site. Added YouTube embedding.
|
||||
- [KirillSo.com](https://www.kirillso.com/) - Personal blog & website.
|
||||
- [slightlysharpe.com](https://slightlysharpe.com) - [Tincre's](https://tincre.com) main company blog
|
||||
- [blog.b00st.com](https://blog.b00st.com) - [b00st.com's](https://b00st.com) main music promotion blog
|
||||
- [astrosaurus.me](https://astrosaurus.me/) - Ephraim Atta-Duncan's Personal Blog
|
||||
- [dhanrajsp.me](https://dhanrajsp.me/) - Dhanraj's personal site and blog.
|
||||
- [blog.r00ks.io](https://blog.r00ks.io/) - Austin Rooks's personal blog ([source code](https://github.com/Austionian/blog.r00ks)).
|
||||
- [honghong.me](https://honghong.me) - Tszhong's personal website ([source code](https://github.com/tszhong0411/home))
|
||||
- [marceloformentao.dev](https://marceloformentao.dev) - Marcelo Formentão personal website ([source code](https://github.com/marceloavf/marceloformentao.dev)).
|
||||
- [abiraja.com](https://www.abiraja.com/) - with a [runnable JS code snippet component!](https://www.abiraja.com/blog/querying-solana-blockchain)
|
||||
- [bpiggin.com](https://www.bpiggin.com) - Ben Piggin's personal blog
|
||||
- [maqib.cn](https://maqib.cn) - A blog of Chinese front-end developers 狂奔小马的博客 ([源码](https://github.com/maqi1520/nextjs-tailwind-blog))
|
||||
- [ambilena.com](https://ambilena.com/) - Electronic Music Blog & imprint for upcoming musicians.
|
||||
- [dalelarroder.com](https://dalelarroder.com) - Dale Larroder's personal website and blog ([source code](https://github.com/dlarroder/dalelarroder))
|
||||
- [0xchai.io](https://0xchai.io) - Chai's personal blog
|
||||
- [techipedia](https://techipedia.vercel.app) - Simple blogging progressive web app with custom installation button and top progress bar
|
||||
- [reubence.com](https://reubence.com) - Reuben Rapose's Digital Garden
|
||||
- [axolo.co/blog](https://axolo.co/blog) - Engineering management news & axolo.co updates (with image preview for article in the home page)
|
||||
- [musing.vercel.app](https://musing.vercel.app/) - Parth Desai's personal blog ([source code](https://github.com/pycoder2000/blog))
|
||||
- [onyourmental.com](https://www.onyourmental.com/) - [Curtis Warcup's](https://github.com/Cwarcup) website for the On Your Mental Podcast ([source code](https://github.com/Cwarcup/on-your-mental))
|
||||
- [cwarcup.com](https://www.cwarcup.com/) - Curtis Warcup's personal website and blog ([source code](https://github.com/Cwarcup/personal-blog)).
|
||||
- [ondiek-elijah.me](https://www.ondiek-elijah.me/) - Ondiek Elijah's website and blog ([source code](https://github.com/Dev-Elie/ondiek-elijah)).
|
||||
- [jmalvarez.dev](https://www.jmalvarez.dev/) - José Miguel Álvarez's personal blog ([source code](https://github.com/josemiguel-alvarez/nextjs-blog))
|
||||
- [justingosses.com](https://justingosses.com/) - Justin Gosses's personal website and blog ([source code](https://github.com/JustinGOSSES/justingosses-website))
|
||||
- [https://bitoflearning-9a57.fly.dev/](https://bitoflearning-9a57.fly.dev/) - Sangeet Agarwal's personal blog, replatformed to [remix](https://remix.run/remix) using the [indie stack](https://github.com/remix-run/indie-stack) ([source code](https://github.com/SangeetAgarwal/bitoflearning))
|
||||
- [raphaelchelly.com](https://www.raphaelchelly.com/) - Raphaël Chelly's personal website and blog ([source code](https://github.com/raphaelchelly/raph_www))
|
||||
- [kaveh.page](https://kaveh.page) - Kaveh Tehrani's personal blog. Added tags directory, profile card, time-to-read on posts directory, etc.
|
||||
|
||||
## Motivation
|
||||
|
||||
I wanted to port my existing blog to Nextjs and Tailwind CSS but there was no easy out of the box template to use so I decided to create one. Design is adapted from [Tailwindlabs blog](https://github.com/tailwindlabs/blog.tailwindcss.com).
|
||||
|
||||
I wanted it to be nearly as feature-rich as popular blogging templates like [beautiful-jekyll](https://github.com/daattali/beautiful-jekyll) and [Hugo Academic](https://github.com/wowchemy/wowchemy-hugo-modules) but with the best of React's ecosystem and current web development's best practices.
|
||||
|
||||
## Features
|
||||
|
||||
- Next.js with Typescript
|
||||
- [Contentlayer](https://www.contentlayer.dev/) to manage content logic
|
||||
- Easy styling customization with [Tailwind 3.0](https://tailwindcss.com/blog/tailwindcss-v3) and primary color attribute
|
||||
- [MDX - write JSX in markdown documents!](https://mdxjs.com/)
|
||||
- Near perfect lighthouse score - [Lighthouse report](https://www.webpagetest.org/result/230805_BiDcBQ_4H7)
|
||||
- Lightweight, 85kB first load JS
|
||||
- Mobile-friendly view
|
||||
- Light and dark theme
|
||||
- Font optimization with [next/font](https://nextjs.org/docs/app/api-reference/components/font)
|
||||
- Integration with [pliny](https://github.com/timlrx/pliny) that provides:
|
||||
- Multiple analytics options including [Umami](https://umami.is/), [Plausible](https://plausible.io/), [Simple Analytics](https://simpleanalytics.com/), Posthog and Google Analytics
|
||||
- Comments via [Giscus](https://github.com/laymonage/giscus), [Utterances](https://github.com/utterance/utterances) or Disqus
|
||||
- Newsletter API and component with support for Mailchimp, Buttondown, Convertkit, Klaviyo, Revue, and Emailoctopus
|
||||
- Command palette search with [Kbar](https://github.com/timc1/kbar) or Algolia
|
||||
- Server-side syntax highlighting with line numbers and line highlighting via [rehype-prism-plus](https://github.com/timlrx/rehype-prism-plus)
|
||||
- Math display supported via [KaTeX](https://katex.org/)
|
||||
- Citation and bibliography support via [rehype-citation](https://github.com/timlrx/rehype-citation)
|
||||
- Automatic image optimization via [next/image](https://nextjs.org/docs/basic-features/image-optimization)
|
||||
- Support for tags - each unique tag will be its own page
|
||||
- Support for multiple authors
|
||||
- 3 different blog layouts
|
||||
- 2 different blog listing layouts
|
||||
- Support for nested routing of blog posts
|
||||
- Projects page
|
||||
- Preconfigured security headers
|
||||
- SEO friendly with RSS feed, sitemaps and more!
|
||||
|
||||
## Sample posts
|
||||
|
||||
- [A markdown guide](https://tailwind-nextjs-starter-blog.vercel.app/blog/github-markdown-guide)
|
||||
- [Learn more about images in Next.js](https://tailwind-nextjs-starter-blog.vercel.app/blog/guide-to-using-images-in-nextjs)
|
||||
- [A tour of math typesetting](https://tailwind-nextjs-starter-blog.vercel.app/blog/deriving-ols-estimator)
|
||||
- [Simple MDX image grid](https://tailwind-nextjs-starter-blog.vercel.app/blog/pictures-of-canada)
|
||||
- [Example of long prose](https://tailwind-nextjs-starter-blog.vercel.app/blog/the-time-machine)
|
||||
- [Example of Nested Route Post](https://tailwind-nextjs-starter-blog.vercel.app/blog/nested-route/introducing-multi-part-posts-with-nested-routing)
|
||||
|
||||
## Quick Start Guide
|
||||
|
||||
1. Clone the repo
|
||||
|
||||
```bash
|
||||
npx degit 'timlrx/tailwind-nextjs-starter-blog'
|
||||
```
|
||||
|
||||
2. Personalize `siteMetadata.js` (site related information)
|
||||
3. Modify the content security policy in `next.config.js` if you want to use
|
||||
other analytics provider or a commenting solution other than giscus.
|
||||
4. Personalize `authors/default.md` (main author)
|
||||
5. Modify `projectsData.js`
|
||||
6. Modify `headerNavLinks.js` to customize navigation links
|
||||
7. Add blog posts
|
||||
8. Deploy on Vercel
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
yarn
|
||||
```
|
||||
|
||||
## Development
|
||||
@ -18,18 +138,12 @@ pnpm install
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
Edit the layout in `app` or content in `data`. With live reloading, the pages auto-updates as you edit them.
|
||||
|
||||
## Extend / Customize
|
||||
|
||||
@ -47,36 +161,41 @@ You can start editing the page by modifying `pages/index.js`. The page auto-upda
|
||||
|
||||
`public/static` - store assets such as images and favicons.
|
||||
|
||||
`tailwind.config.js` and `css/tailwind.css` - contain the tailwind stylesheet which can be modified to change the overall look and feel of the site.
|
||||
`tailwind.config.js` and `css/tailwind.css` - tailwind configuration and stylesheet which can be modified to change the overall look and feel of the site.
|
||||
|
||||
`css/prism.css` - controls the styles associated with the code blocks. Feel free to customize it and use your preferred prismjs theme e.g. [prism themes](https://github.com/PrismJS/prism-themes).
|
||||
|
||||
`components/social-icons` - to add other icons, simply copy an svg file from [Simple Icons](https://simpleicons.org/) and map them in `index.js`. Other icons use [heroicons](https://heroicons.com/).
|
||||
`contentlayer.config.ts` - configuration for Contentlayer, including definition of content sources and MDX plugins used. See [Contentlayer documentation](https://www.contentlayer.dev/docs/getting-started) for more information.
|
||||
|
||||
`components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then call them directly in the `.mdx` or `.md` file. By default, a custom link and image component is passed.
|
||||
`components/MDXComponents.js` - pass your own JSX code or React component by specifying it over here. You can then use them directly in the `.mdx` or `.md` file. By default, a custom link, `next/image` component, table of contents component and Newsletter form are passed down. Note that the components should be default exported to avoid [existing issues with Next.js](https://github.com/vercel/next.js/issues/51593).
|
||||
|
||||
`layouts` - main templates used in pages.
|
||||
`layouts` - main templates used in pages:
|
||||
|
||||
`pages` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs) for more information.
|
||||
- There are currently 3 post layouts available: `PostLayout`, `PostSimple` and `PostBanner`. `PostLayout` is the default 2 column layout with meta and author information. `PostSimple` is a simplified version of `PostLayout`, while `PostBanner` features a banner image.
|
||||
- There are 2 blog listing layouts: `ListLayout`, the layout used in version 1 of the template with a search bar and `ListLayoutWithTags`, currently used in version 2, which omits the search bar but includes a sidebar with information on the tags.
|
||||
|
||||
`app` - pages to route to. Read the [Next.js documentation](https://nextjs.org/docs/app) for more information.
|
||||
|
||||
`next.config.js` - configuration related to Next.js. You need to adapt the Content Security Policy if you want to load scripts, images etc. from other domains.
|
||||
|
||||
## Post
|
||||
|
||||
Content is modelled using [Contentlayer](https://www.contentlayer.dev/), which allows you to define your own content schema and use it to generate typed content objects. See [Contentlayer documentation](https://www.contentlayer.dev/docs/getting-started) for more information.
|
||||
|
||||
### Frontmatter
|
||||
|
||||
Frontmatter follows [Hugo's standards](https://gohugo.io/content-management/front-matter/).
|
||||
|
||||
Currently 7 fields are supported.
|
||||
Please refer to `contentlayer.config.ts` for an up to date list of supported fields. The following fields are supported:
|
||||
|
||||
```
|
||||
title (required)
|
||||
date (required)
|
||||
tags (required, can be empty array)
|
||||
tags (optional)
|
||||
lastmod (optional)
|
||||
draft (optional)
|
||||
summary (optional)
|
||||
images (optional, if none provided defaults to socialBanner in siteMetadata config)
|
||||
images (optional)
|
||||
authors (optional list which should correspond to the file names in `data/authors`. Uses `default` if none is specified)
|
||||
layout (optional list which should correspond to the file names in `data/layouts`)
|
||||
canonicalUrl (optional, canonical url for the post for SEO)
|
||||
@ -99,12 +218,29 @@ canonicalUrl: https://tailwind-nextjs-starter-blog.vercel.app/blog/introducing-t
|
||||
---
|
||||
```
|
||||
|
||||
### Compose
|
||||
|
||||
Run `node ./scripts/compose.js` to bootstrap a new post.
|
||||
|
||||
Follow the interactive prompt to generate a post with pre-filled front matter.
|
||||
|
||||
## Deploy
|
||||
|
||||
Drone CI.
|
||||
**Vercel**
|
||||
The easiest way to deploy the template is to deploy on [Vercel](https://vercel.com). Check out the [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
**Netlify**
|
||||
[Netlify](https://www.netlify.com/)’s Next.js runtime configures enables key Next.js functionality on your website without the need for additional configurations. Netlify generates serverless functions that will handle Next.js functionalities such as server-side rendered (SSR) pages, incremental static regeneration (ISR), `next/images`, etc.
|
||||
|
||||
See [Next.js on Netlify](https://docs.netlify.com/integrations/frameworks/next-js/overview/#next-js-runtime) for suggested configuration values and more details.
|
||||
|
||||
**Static hosting services / GitHub Pages / S3 / Firebase etc.**
|
||||
|
||||
1. Add `output: 'export'` in `next.config.js`. See [static exports documentation](https://nextjs.org/docs/app/building-your-application/deploying/static-exports#configuration) for more information.
|
||||
2. Comment out `headers()` from `next.config.js`.
|
||||
3. Change `components/Image.tsx` to use a standard `<img>` tag instead of `next/image`. Alternatively, to continue using `next/image`, you can use an alternative image optimization provider such as Imgix, Cloudinary or Akamai. See [image optimization documentation](https://nextjs.org/docs/app/building-your-application/deploying/static-exports#image-optimization) for more details.
|
||||
4. Remove `api` folder and components which call the server-side function such as the Newsletter component. Not technically required and the site will build successfully, but the APIs cannot be used as they are server-side functions.
|
||||
5. Run `yarn build`. The generated static content is in the `out` folder.
|
||||
6. Deploy the `out` folder to your hosting service of choice or run `npx serve out` to view the website locally.
|
||||
|
||||
## Support
|
||||
|
||||
Using the template? Support this effort by giving a star on GitHub, sharing your own blog and giving a shoutout on Twitter or becoming a project [sponsor](https://github.com/sponsors/timlrx).
|
||||
|
||||
## Licence
|
||||
|
||||
[MIT](https://github.com/timlrx/tailwind-nextjs-starter-blog/blob/main/LICENSE) © [Timothy Lin](https://www.timlrx.com)
|
||||
|
@ -1,45 +1,27 @@
|
||||
import Link from '@/components/Link';
|
||||
import { PageSEO } from '@/components/SEO';
|
||||
import Tag from '@/components/Tag';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import { getAllFilesFrontMatter } from '@/lib/mdx';
|
||||
import formatDate from '@/lib/utils/formatDate';
|
||||
import { GetStaticProps, InferGetStaticPropsType } from 'next';
|
||||
import { PostFrontMatter } from 'types/PostFrontMatter';
|
||||
import NewsletterForm from '@/components/NewsletterForm';
|
||||
import { formatDate } from 'pliny/utils/formatDate';
|
||||
import NewsletterForm from 'pliny/ui/NewsletterForm';
|
||||
|
||||
const MAX_DISPLAY = 5;
|
||||
|
||||
export const getStaticProps: GetStaticProps<{
|
||||
posts: PostFrontMatter[];
|
||||
}> = async () => {
|
||||
const posts = await getAllFilesFrontMatter('blog');
|
||||
|
||||
return { props: { posts } };
|
||||
};
|
||||
|
||||
export default function Home({
|
||||
posts,
|
||||
}: InferGetStaticPropsType<typeof getStaticProps>) {
|
||||
export default function Home({ posts }) {
|
||||
return (
|
||||
<>
|
||||
<PageSEO
|
||||
title={siteMetadata.title}
|
||||
description={siteMetadata.description}
|
||||
/>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||
最近发布的文章
|
||||
Latest
|
||||
</h1>
|
||||
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
|
||||
{siteMetadata.description}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{!posts.length && '没有找到文章。 😭'}
|
||||
{posts.slice(0, MAX_DISPLAY).map((frontMatter) => {
|
||||
const { slug, date, title, summary, tags } = frontMatter;
|
||||
{!posts.length && 'No posts found.'}
|
||||
{posts.slice(0, MAX_DISPLAY).map((post) => {
|
||||
const { slug, date, title, summary, tags } = post;
|
||||
return (
|
||||
<li key={slug} className="py-12">
|
||||
<article>
|
||||
@ -47,7 +29,9 @@ export default function Home({
|
||||
<dl>
|
||||
<dt className="sr-only">Published on</dt>
|
||||
<dd className="text-base font-medium leading-6 text-gray-500 dark:text-gray-400">
|
||||
<time dateTime={date}>{formatDate(date)}</time>
|
||||
<time dateTime={date}>
|
||||
{formatDate(date, siteMetadata.locale)}
|
||||
</time>
|
||||
</dd>
|
||||
</dl>
|
||||
<div className="space-y-5 xl:col-span-3">
|
||||
@ -91,12 +75,12 @@ export default function Home({
|
||||
<Link
|
||||
href="/blog"
|
||||
className="text-primary-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
aria-label="all posts">
|
||||
aria-label="All posts">
|
||||
All Posts →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{siteMetadata.newsletter.provider !== '' && (
|
||||
{siteMetadata.newsletter?.provider && (
|
||||
<div className="flex items-center justify-center pt-4">
|
||||
<NewsletterForm />
|
||||
</div>
|
20
app/about/page.tsx
Normal file
20
app/about/page.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Authors, allAuthors } from 'contentlayer/generated';
|
||||
import { MDXLayoutRenderer } from 'pliny/mdx-components';
|
||||
import AuthorLayout from '@/layouts/AuthorLayout';
|
||||
import { coreContent } from 'pliny/utils/contentlayer';
|
||||
import { genPageMetadata } from 'app/seo';
|
||||
|
||||
export const metadata = genPageMetadata({ title: 'About' });
|
||||
|
||||
export default function Page() {
|
||||
const author = allAuthors.find((p) => p.slug === 'default') as Authors;
|
||||
const mainContent = coreContent(author);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthorLayout content={mainContent}>
|
||||
<MDXLayoutRenderer code={author.body.code} />
|
||||
</AuthorLayout>
|
||||
</>
|
||||
);
|
||||
}
|
9
app/api/newsletter/route.ts
Normal file
9
app/api/newsletter/route.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { NewsletterAPI } from 'pliny/newsletter';
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
|
||||
const handler = NewsletterAPI({
|
||||
// @ts-ignore
|
||||
provider: siteMetadata.newsletter.provider,
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
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';
|
||||
|
||||
export default function FourZeroFour() {
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start md:mt-24 md:flex-row md:items-center md:justify-center md:space-x-6">
|
||||
<div className="space-x-2 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">
|
||||
404
|
||||
</h1>
|
||||
@ -15,10 +15,10 @@ export default function FourZeroFour() {
|
||||
<p className="mb-8">
|
||||
But dont worry, you can find plenty of other things on our homepage.
|
||||
</p>
|
||||
<Link href="/">
|
||||
<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">
|
||||
Back to homepage
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="focus:shadow-outline-blue inline rounded-lg border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium leading-5 text-white shadow transition-colors duration-150 hover:bg-blue-700 focus:outline-none dark:hover:bg-blue-500">
|
||||
Back to homepage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
9
app/page.tsx
Normal file
9
app/page.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { sortPosts, allCoreContent } from 'pliny/utils/contentlayer';
|
||||
import { allBlogs } from 'contentlayer/generated';
|
||||
import Main from './Main';
|
||||
|
||||
export default async function Page() {
|
||||
const sortedPosts = sortPosts(allBlogs);
|
||||
const posts = allCoreContent(sortedPosts);
|
||||
return <Main posts={posts} />;
|
||||
}
|
@ -1,22 +1,19 @@
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import projectsData from '@/data/projectsData';
|
||||
import Card from '@/components/Card';
|
||||
import { PageSEO } from '@/components/SEO';
|
||||
import { genPageMetadata } from 'app/seo';
|
||||
|
||||
export const metadata = genPageMetadata({ title: 'Projects' });
|
||||
|
||||
export default function Projects() {
|
||||
return (
|
||||
<>
|
||||
<PageSEO
|
||||
title={`Projects - ${siteMetadata.author}`}
|
||||
description={siteMetadata.description}
|
||||
/>
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="space-y-2 pt-6 pb-8 md:space-y-5">
|
||||
<div className="space-y-2 pb-8 pt-6 md:space-y-5">
|
||||
<h1 className="text-3xl font-extrabold leading-9 tracking-tight text-gray-900 dark:text-gray-100 sm:text-4xl sm:leading-10 md:text-6xl md:leading-14">
|
||||
Projects
|
||||
</h1>
|
||||
<p className="text-lg leading-7 text-gray-500 dark:text-gray-400">
|
||||
我的项目橱窗,欢迎交流。
|
||||
Showcase your projects with a hero image (16 x 9)
|
||||
</p>
|
||||
</div>
|
||||
<div className="container py-12">
|
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';
|
||||
|
||||
const Card = ({ title, description, imgSrc, href }) => (
|
||||
<div className="md p-4 md:w-1/2" style={{ maxWidth: '544px' }}>
|
||||
<div className="md max-w-[544px] p-4 md:w-1/2">
|
||||
<div
|
||||
className={`${
|
||||
imgSrc && 'h-full'
|
||||
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -31,9 +31,7 @@ export default function Footer() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mb-8 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Link
|
||||
href="https://github.com/timlrx/tailwind-nextjs-starter-blog"
|
||||
rel="nofollow">
|
||||
<Link href="https://github.com/timlrx/tailwind-nextjs-starter-blog">
|
||||
Tailwind Nextjs Theme
|
||||
</Link>
|
||||
</div>
|
||||
|
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 }: ImageProps) => <NextImage {...rest} />;
|
||||
const Image = ({
|
||||
...rest
|
||||
}: React.DetailedHTMLProps<
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
HTMLImageElement
|
||||
>) => <img {...rest} />;
|
||||
|
||||
export default Image;
|
||||
|
@ -1,53 +1,23 @@
|
||||
import siteMetadata from '@/data/siteMetadata';
|
||||
import headerNavLinks from '@/data/headerNavLinks';
|
||||
import Logo from '@/data/logo.svg';
|
||||
import Link from './Link';
|
||||
import { Inter } from 'next/font/google';
|
||||
import SectionContainer from './SectionContainer';
|
||||
import Footer from './Footer';
|
||||
import MobileNav from './MobileNav';
|
||||
import ThemeSwitch from './ThemeSwitch';
|
||||
import { ReactNode } from 'react';
|
||||
import Header from './Header';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const LayoutWrapper = ({ children }: Props) => {
|
||||
return (
|
||||
<SectionContainer>
|
||||
<div className="flex h-screen flex-col justify-between">
|
||||
<header className="flex items-center justify-between py-10">
|
||||
<div>
|
||||
<Link href="/" aria-label={siteMetadata.headerTitle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="mr-3">
|
||||
<Logo />
|
||||
</div>
|
||||
{typeof siteMetadata.headerTitle === 'string' ? (
|
||||
<div className="hidden h-6 text-2xl font-semibold sm:block">
|
||||
{siteMetadata.headerTitle}
|
||||
</div>
|
||||
) : (
|
||||
siteMetadata.headerTitle
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center text-base leading-5">
|
||||
<div className="hidden sm:block">
|
||||
{headerNavLinks.map((link) => (
|
||||
<Link
|
||||
key={link.title}
|
||||
href={link.href}
|
||||
className="p-1 font-medium text-gray-900 dark:text-gray-100 sm:p-4">
|
||||
{link.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<ThemeSwitch />
|
||||
<MobileNav />
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
className={`${inter.className} flex h-screen flex-col justify-between font-sans`}>
|
||||
<Header />
|
||||
<main className="mb-auto">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
@ -1,23 +1,17 @@
|
||||
/* eslint-disable jsx-a11y/anchor-has-content */
|
||||
import Link from 'next/link';
|
||||
import { AnchorHTMLAttributes, DetailedHTMLProps } from 'react';
|
||||
import type { LinkProps } from 'next/link';
|
||||
import { AnchorHTMLAttributes } from 'react';
|
||||
|
||||
const CustomLink = ({
|
||||
href,
|
||||
...rest
|
||||
}: DetailedHTMLProps<
|
||||
AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>) => {
|
||||
}: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
const isInternalLink = href && href.startsWith('/');
|
||||
const isAnchorLink = href && href.startsWith('#');
|
||||
|
||||
if (isInternalLink) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<a {...rest} />
|
||||
</Link>
|
||||
);
|
||||
return <Link href={href} {...rest} />;
|
||||
}
|
||||
|
||||
if (isAnchorLink) {
|
||||
|
@ -1,39 +1,14 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import React, { useMemo } from 'react';
|
||||
import { ComponentMap, getMDXComponent } from 'mdx-bundler/client';
|
||||
import TOCInline from 'pliny/ui/TOCInline';
|
||||
import Pre from 'pliny/ui/Pre';
|
||||
import BlogNewsletterForm from 'pliny/ui/BlogNewsletterForm';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import Image from './Image';
|
||||
import CustomLink from './Link';
|
||||
import TOCInline from './TOCInline';
|
||||
import Pre from './Pre';
|
||||
import { BlogNewsletterForm } from './NewsletterForm';
|
||||
|
||||
const Wrapper: React.ComponentType<{ layout: string }> = ({
|
||||
layout,
|
||||
...rest
|
||||
}) => {
|
||||
const Layout = require(`../layouts/${layout}`).default;
|
||||
return <Layout {...rest} />;
|
||||
};
|
||||
|
||||
export const MDXComponents: ComponentMap = {
|
||||
export const components: MDXComponents = {
|
||||
Image,
|
||||
//@ts-ignore
|
||||
TOCInline,
|
||||
a: CustomLink,
|
||||
pre: Pre,
|
||||
wrapper: Wrapper,
|
||||
//@ts-ignore
|
||||
BlogNewsletterForm,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
layout: string;
|
||||
mdxSource: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const MDXLayoutRenderer = ({ layout, mdxSource, ...rest }: Props) => {
|
||||
const MDXLayout = useMemo(() => getMDXComponent(mdxSource), [mdxSource]);
|
||||
|
||||
return <MDXLayout layout={layout} components={MDXComponents} {...rest} />;
|
||||
};
|
||||
|
@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from './Link';
|
||||
import headerNavLinks from '@/data/headerNavLinks';
|
||||
@ -18,41 +20,45 @@ const MobileNav = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sm:hidden">
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 mr-1 h-8 w-8 rounded py-1"
|
||||
aria-label="Toggle Menu"
|
||||
onClick={onToggleNav}>
|
||||
onClick={onToggleNav}
|
||||
className="sm:hidden">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100">
|
||||
{navShow ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
)}
|
||||
className="text-gray-900 dark:text-gray-100 h-8 w-8">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className={`fixed top-24 right-0 z-10 h-full w-full transform bg-gray-200 opacity-95 duration-300 ease-in-out dark:bg-gray-800 ${
|
||||
className={`fixed left-0 top-0 z-10 h-full w-full transform opacity-95 dark:opacity-[0.98] bg-white duration-300 ease-in-out dark:bg-gray-950 ${
|
||||
navShow ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="toggle modal"
|
||||
className="fixed h-full w-full cursor-auto focus:outline-none"
|
||||
onClick={onToggleNav}></button>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="mr-8 mt-11 h-8 w-8"
|
||||
aria-label="Toggle Menu"
|
||||
onClick={onToggleNav}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="fixed mt-8 h-full">
|
||||
{headerNavLinks.map((link) => (
|
||||
<div key={link.title} className="px-12 py-4">
|
||||
@ -66,7 +72,7 @@ const MobileNav = () => {
|
||||
))}
|
||||
</nav>
|
||||
</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';
|
||||
|
||||
const ScrollTopAndComment = () => {
|
||||
@ -14,32 +17,32 @@ const ScrollTopAndComment = () => {
|
||||
}, []);
|
||||
|
||||
const handleScrollTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
window.scrollTo({ top: 0 });
|
||||
};
|
||||
const handleScrollToComment = () => {
|
||||
document.getElementById('comment').scrollIntoView();
|
||||
document.getElementById('comment')?.scrollIntoView();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`fixed right-8 bottom-8 hidden flex-col gap-3 ${
|
||||
className={`fixed bottom-8 right-8 hidden flex-col gap-3 ${
|
||||
show ? 'md:flex' : 'md:hidden'
|
||||
}`}>
|
||||
<button
|
||||
aria-label="Scroll To Comment"
|
||||
type="button"
|
||||
onClick={handleScrollToComment}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{siteMetadata.comments?.provider && (
|
||||
<button
|
||||
aria-label="Scroll To Comment"
|
||||
onClick={handleScrollToComment}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10c0 3.866-3.582 7-8 7a8.841 8.841 0 01-4.083-.98L2 17l1.338-3.123C2.493 12.767 2 11.434 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7zM7 9H5v2h2V9zm8 0h-2v2h2V9zM9 9h2v2H9V9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Scroll To Top"
|
||||
type="button"
|
||||
onClick={handleScrollTop}
|
||||
className="rounded-full bg-gray-200 p-2 text-gray-500 transition-all hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600">
|
||||
<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) {
|
||||
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}
|
||||
</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 kebabCase from '@/lib/utils/kebabCase';
|
||||
|
||||
import { slug } from 'github-slugger';
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const Tag = ({ text }: Props) => {
|
||||
return (
|
||||
<Link href={`/tags/${kebabCase(text)}`}>
|
||||
<a className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{text.split(' ').join('-')}
|
||||
</a>
|
||||
<Link
|
||||
href={`/tags/${slug(text)}`}
|
||||
className="mr-3 text-sm font-medium uppercase text-primary-500 hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{text.split(' ').join('-')}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
@ -1,29 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
const ThemeSwitch = () => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
// When mounted on client, now we can show the UI
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Toggle Dark Mode"
|
||||
type="button"
|
||||
className="ml-1 mr-1 h-8 w-8 rounded p-1 sm:ml-4"
|
||||
onClick={() =>
|
||||
setTheme(
|
||||
theme === 'dark' || resolvedTheme === 'dark' ? 'light' : 'dark'
|
||||
)
|
||||
}>
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
className="text-gray-900 dark:text-gray-100">
|
||||
{mounted && (theme === 'dark' || resolvedTheme === 'dark') ? (
|
||||
className="text-gray-900 dark:text-gray-100 h-6 w-6">
|
||||
{mounted && theme === 'dark' ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
|
@ -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 Github from './github.svg';
|
||||
import Facebook from './facebook.svg';
|
||||
import Youtube from './youtube.svg';
|
||||
import Linkedin from './linkedin.svg';
|
||||
import Twitter from './twitter.svg';
|
||||
|
||||
// Icons taken from: https://simpleicons.org/
|
||||
import {
|
||||
Mail,
|
||||
Github,
|
||||
Facebook,
|
||||
Youtube,
|
||||
Linkedin,
|
||||
Twitter,
|
||||
Mastodon,
|
||||
} from './icons';
|
||||
|
||||
const components = {
|
||||
mail: Mail,
|
||||
@ -14,9 +15,16 @@ const components = {
|
||||
youtube: Youtube,
|
||||
linkedin: Linkedin,
|
||||
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 (
|
||||
!href ||
|
||||
(kind === 'mail' &&
|
||||
@ -34,7 +42,7 @@ const SocialIcon = ({ kind, href, size = 8 }) => {
|
||||
href={href}>
|
||||
<span className="sr-only">{kind}</span>
|
||||
<SocialSvg
|
||||
className={`fill-current text-gray-700 hover:text-blue-500 dark:text-gray-200 dark:hover:text-blue-400 h-${size} w-${size}`}
|
||||
className={`fill-current text-gray-700 hover:text-primary-500 dark:text-gray-200 dark:hover:text-primary-400 h-${size} w-${size}`}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
|
@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>LinkedIn icon</title><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
Before Width: | Height: | Size: 615 B |
@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
Before Width: | Height: | Size: 224 B |
@ -1 +0,0 @@
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Twitter icon</title><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>
|
Before Width: | Height: | Size: 607 B |
@ -1 +0,0 @@
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>YouTube icon</title><path d="M23.499 6.203a3.008 3.008 0 00-2.089-2.089c-1.87-.501-9.4-.501-9.4-.501s-7.509-.01-9.399.501a3.008 3.008 0 00-2.088 2.09A31.258 31.26 0 000 12.01a31.258 31.26 0 00.523 5.785 3.008 3.008 0 002.088 2.089c1.869.502 9.4.502 9.4.502s7.508 0 9.399-.502a3.008 3.008 0 002.089-2.09 31.258 31.26 0 00.5-5.784 31.258 31.26 0 00-.5-5.808zm-13.891 9.4V8.407l6.266 3.604z"/></svg>
|
Before Width: | Height: | Size: 474 B |
157
contentlayer.config.ts
Normal file
157
contentlayer.config.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { defineDocumentType, ComputedFields, makeSource } from 'contentlayer/source-files'
|
||||
import { writeFileSync } from 'fs'
|
||||
import readingTime from 'reading-time'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import path from 'path'
|
||||
// Remark packages
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import {
|
||||
remarkExtractFrontmatter,
|
||||
remarkCodeTitles,
|
||||
remarkImgToJsx,
|
||||
extractTocHeadings,
|
||||
} from 'pliny/mdx-plugins/index.js'
|
||||
// Rehype packages
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import rehypeCitation from 'rehype-citation'
|
||||
import rehypePrismPlus from 'rehype-prism-plus'
|
||||
import rehypePresetMinify from 'rehype-preset-minify'
|
||||
import siteMetadata from './data/siteMetadata'
|
||||
import { allCoreContent, sortPosts } from 'pliny/utils/contentlayer.js'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
const computedFields: ComputedFields = {
|
||||
readingTime: { type: 'json', resolve: (doc) => readingTime(doc.body.raw) },
|
||||
slug: {
|
||||
type: 'string',
|
||||
resolve: (doc) => doc._raw.flattenedPath.replace(/^.+?(\/)/, ''),
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
resolve: (doc) => doc._raw.flattenedPath,
|
||||
},
|
||||
filePath: {
|
||||
type: 'string',
|
||||
resolve: (doc) => doc._raw.sourceFilePath,
|
||||
},
|
||||
toc: { type: 'string', resolve: (doc) => extractTocHeadings(doc.body.raw) },
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the occurrences of all tags across blog posts and write to json file
|
||||
*/
|
||||
function createTagCount(allBlogs) {
|
||||
const tagCount: Record<string, number> = {}
|
||||
allBlogs.forEach((file) => {
|
||||
if (file.tags && file.draft !== true) {
|
||||
file.tags.forEach((tag) => {
|
||||
const formattedTag = GithubSlugger.slug(tag)
|
||||
if (formattedTag in tagCount) {
|
||||
tagCount[formattedTag] += 1
|
||||
} else {
|
||||
tagCount[formattedTag] = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
writeFileSync('./app/tag-data.json', JSON.stringify(tagCount))
|
||||
}
|
||||
|
||||
function createSearchIndex(allBlogs) {
|
||||
if (
|
||||
siteMetadata?.search?.provider === 'kbar' &&
|
||||
siteMetadata.search.kbarConfig.searchDocumentsPath
|
||||
) {
|
||||
writeFileSync(
|
||||
`public/${siteMetadata.search.kbarConfig.searchDocumentsPath}`,
|
||||
JSON.stringify(allCoreContent(sortPosts(allBlogs)))
|
||||
)
|
||||
console.log('Local search index generated...')
|
||||
}
|
||||
}
|
||||
|
||||
export const Blog = defineDocumentType(() => ({
|
||||
name: 'Blog',
|
||||
filePathPattern: 'blog/**/*.mdx',
|
||||
contentType: 'mdx',
|
||||
fields: {
|
||||
title: { type: 'string', required: true },
|
||||
date: { type: 'date', required: true },
|
||||
tags: { type: 'list', of: { type: 'string' }, default: [] },
|
||||
lastmod: { type: 'date' },
|
||||
draft: { type: 'boolean' },
|
||||
summary: { type: 'string' },
|
||||
images: { type: 'list', of: { type: 'string' } },
|
||||
authors: { type: 'list', of: { type: 'string' } },
|
||||
layout: { type: 'string' },
|
||||
bibliography: { type: 'string' },
|
||||
canonicalUrl: { type: 'string' },
|
||||
},
|
||||
computedFields: {
|
||||
...computedFields,
|
||||
structuredData: {
|
||||
type: 'json',
|
||||
resolve: (doc) => ({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: doc.title,
|
||||
datePublished: doc.date,
|
||||
dateModified: doc.lastmod || doc.date,
|
||||
description: doc.summary,
|
||||
image: doc.images ? doc.images[0] : siteMetadata.socialBanner,
|
||||
url: `${siteMetadata.siteUrl}/${doc._raw.flattenedPath}`,
|
||||
author: doc.authors,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
export const Authors = defineDocumentType(() => ({
|
||||
name: 'Authors',
|
||||
filePathPattern: 'authors/**/*.mdx',
|
||||
contentType: 'mdx',
|
||||
fields: {
|
||||
name: { type: 'string', required: true },
|
||||
avatar: { type: 'string' },
|
||||
occupation: { type: 'string' },
|
||||
company: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
twitter: { type: 'string' },
|
||||
linkedin: { type: 'string' },
|
||||
github: { type: 'string' },
|
||||
layout: { type: 'string' },
|
||||
},
|
||||
computedFields,
|
||||
}))
|
||||
|
||||
export default makeSource({
|
||||
contentDirPath: 'data',
|
||||
documentTypes: [Blog, Authors],
|
||||
mdx: {
|
||||
cwd: process.cwd(),
|
||||
remarkPlugins: [
|
||||
remarkExtractFrontmatter,
|
||||
remarkGfm,
|
||||
remarkCodeTitles,
|
||||
remarkMath,
|
||||
remarkImgToJsx,
|
||||
],
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
rehypeAutolinkHeadings,
|
||||
rehypeKatex,
|
||||
[rehypeCitation, { path: path.join(root, 'data') }],
|
||||
[rehypePrismPlus, { defaultLanguage: 'js', ignoreMissing: true }],
|
||||
rehypePresetMinify,
|
||||
],
|
||||
},
|
||||
onSuccess: async (importData) => {
|
||||
const { allBlogs } = await importData()
|
||||
createTagCount(allBlogs)
|
||||
createSearchIndex(allBlogs)
|
||||
},
|
||||
})
|
@ -7,7 +7,7 @@
|
||||
|
||||
/* Code title styles */
|
||||
.remark-code-title {
|
||||
@apply rounded-t bg-gray-700 px-5 py-3 font-mono text-sm font-bold text-gray-200;
|
||||
@apply rounded-t bg-gray-700 dark:bg-gray-800 px-5 py-3 font-mono text-sm font-bold text-gray-200;
|
||||
}
|
||||
|
||||
.remark-code-title + div > pre {
|
||||
@ -138,3 +138,7 @@
|
||||
.token.table {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.token.table {
|
||||
display: inline;
|
||||
}
|
||||
|
@ -11,7 +11,11 @@
|
||||
}
|
||||
|
||||
.footnotes {
|
||||
@apply pt-8 mt-12 border-t border-gray-200 dark:border-gray-700;
|
||||
@apply mt-12 border-t border-gray-200 pt-8 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.data-footnote-backref {
|
||||
@apply no-underline;
|
||||
}
|
||||
|
||||
.csl-entry {
|
||||
@ -21,5 +25,7 @@
|
||||
/* https://stackoverflow.com/questions/61083813/how-to-avoid-internal-autofill-selected-style-to-be-applied */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:focus {
|
||||
transition: background-color 600000s 0s, color 600000s 0s;
|
||||
transition:
|
||||
background-color 600000s 0s,
|
||||
color 600000s 0s;
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Ivan Li
|
||||
avatar: https://pan.ivanli.cc/api/v3/file/source/1234/头像.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0
|
||||
avatar: https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png
|
||||
occupation: Web Full Stack Developer
|
||||
email: master@ivanli.cc
|
||||
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 结构的机箱。
|
||||
|
||||
### 直插式
|
||||
|
||||
![傻瓜超人 K66 青春版](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/c09ff5fd-f068-42d6-a259-6e80b0f856a7.png ' =269x198')我看了好多款,小体积下只能放 240mm 的显卡,我的显卡刚好是 240mm,所以非常的极限。有些机箱前置的 IO 可能会和显卡冲突,也有的机箱设计上就很难放入 240mm 的显卡,所以作罢。
|
||||
|
||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/917695f5-9592-4350-b3b4-088cae5d756d.png ' =269x284')
|
||||
|
||||
### A4 结构
|
||||
|
||||
A4 结构的机箱,虽说成本会高出一条 PCIE 延长线,但是能放得下 300mm 的显卡。追求小,也是要成本的。不过体积上也会比直插的再小上一点点。大概代表就是蜂鸟 i100 了,同类产品还有闪鳞 S300。。
|
||||
|
||||
![蜂鸟 i100](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/3cc7cc07-5b16-4f43-8ce4-b895b79e01d5.png ' =269x214')
|
||||
|
||||
两天前,我选择了最便宜也是比较轻的蜂鸟 i100,机箱隔天到,发现显卡厚了点,装不下。后来发现,同样 128mm 的机箱宽度,i100 的显卡限宽是 43mm,我的 6600XT 是 45mm的,i100 pro 不知道从什么地方挤了 5mm 给显卡,实现了 48mm 的显卡限宽。当时没留意这个差别,买了 i100,所以只能加钱换货换成 i100 pro 了。
|
||||
|
||||
昨天,我终于找到了官方旗舰店有货的 6800,一看尺寸,得,i100 pro 也放不下,差一点……然后看了眼闪鳞,完了,闪鳞 S300 才是我要的机箱,真正的 Mesh 侧板,8.1 L 的体积,下方能放两个薄扇出风。更重要的是价格和 i100 pro 相当。选配 PCIE 4.0 的线,价格比 i100 pro 便宜呀!
|
||||
|
||||
## 板 U 选择
|
||||
|
||||
因为机箱已经确定了,所以也只能用下压式散热器了。我选择的这家店可配超频三-降龙v53,解热功耗是 150W,再考虑我是来提升性能的,所以可选的 CPU 就三款了。这三款 CPU 分别是 Intel Core i7-10700K、i7-12600K、i7-12700。为啥没有 i7-10700?因为如果选 10700,我买个 CPU 换就好了,实在没必要为 ITX 组一台新的机子……
|
||||
|
||||
### i5-12600K 对比 i7-10700K
|
||||
|
||||
看[参数对比](https://www.cpu-monkey.com/en/compare_cpu-intel_core_i5_12600k-vs-intel_core_i7_10700k),能注意到 12600K 单核频率是有 0.1 GHz(睿频 0.2 GHz)的降低,但是实际单核表现是高 20% 左右的。虽然黑苹果用不了核显,但是我是装双系统,所以 Windows 下还能有加成的,不用想都知道 **[Intel UHD Graphics 770 ](https://www.cpu-monkey.com/en/igpu-intel_uhd_graphics_770)**的性能比 630 好不少。
|
||||
|
||||
前面说到,我是在 7.5 L 的 A4 机箱用下压散热器, 12600K 的 PL2 是 150W,而 10700K 是 229W(@56s),所以在散热压力上,i5-12600K 默频应该没什么问题。也因为是在这小体积机箱里,所以我也不想着超频了。
|
||||
|
||||
根据我在网络上冲浪几小时后获得的信息, 12600K 性能比 10700K 高一些。所以如果是购买全新的板 U,上 12600K 是不错的选择。而在我的情况,我本来是准备收二手的 10700K + Z490I 主板,但是因为小体积机箱散热问题,以及绝不可能超频,所以 2000 收这套配置(折算 CPU 1350 元左右)不台划算,毕竟目前 12600K 散片是 1500 元出头。150 元换来性能加成 + 店保三年 + 更低 LP2 是很值的。
|
||||
|
||||
### B660 还是 Z690?
|
||||
|
||||
首先要说明一点,因为 B660 与 B670、Z690 与 Z790 都支持 12 代和 13 代 CPU,所以我直接都叫 B660 和 Z690 了,后面选具体型号也不拘泥芯片组代数。
|
||||
|
||||
不是土豪的话,超频就选 Z690,否则选 B660,这个毋庸置疑了。但因为超频会更热,更热小机箱顶不住,所以短期内不会考虑超频。加算上价格能差大几百块,所以我只考虑 B660、B760 芯片组的主板。
|
||||
|
||||
那不超频的情况下,选带 K 的 12600K 是否合理呢?不知道 Intel 出于什么考虑划分的定位,12600K 是 12 代 i5 唯一一个有能效核的 CPU,这多加的四个能效核能带来更好的多核表现,至于功耗,因为在黑苹果下,目前能用得上能效核已经够我高兴很久了,还没去查功耗表现,但在 Windows 下,应该是能低负载待机功耗的。再对比不带 K 的 12600,高了 0.1GHz 的主频和睿频,多了四个能效核,但只贵 250 元(我都没找到有卖的)。在这情况下,选择 12600K + B660 其实很合适。
|
||||
|
||||
没考虑 H610 主要是怕太丐了带不动……主观臆测,没证实过,嘿嘿。
|
||||
|
||||
### 精粤 B760I 还是铭瑄B760ITX
|
||||
|
||||
![7块B760i ITX主板大横比,用魔法打败魔法,体验不输白果Windows+MacOS双系统性价比方案推荐_哔哩哔哩_bilibili](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/1c50b3b5-f4d8-4442-99a6-4f0503179744.png)
|
||||
|
||||
从[7块B760i ITX主板大横比](https://www.bilibili.com/video/BV15M4y117My/)的总结可以大概感受一下。既然在这个价位选,这两款本该丰俭由人。但是精粤目前睡眠问题好像还是比较迷惑,似乎还没有得到解决,尤其是 Snow Dream 版本。为了避免无法睡眠或者睡死导致电费增加、光污染之类的问题。我选了铭瑄。目前某宝 829 元拿下,精粤B760ITX Snow Dream 588 元。加 241 元换个稳定,加个 20G 的后置 USB C 口,带个无线网卡,少个千兆有线。我感觉是划得来。可惜的一点是铭瑄的主板不是白色的,与我的白色主题不太搭。不过装完机后应该不怎么可见,无伤大雅。
|
||||
|
||||
### i5-12600K 对比 i7-12700
|
||||
|
||||
现在,选择范围就是 12600K 与 12700 了。为何还要纠结 12700 呢?因为我不超频,那高一档的 12700 似乎也是个不错的选择。
|
||||
|
||||
看[对比](https://www.cpu-monkey.com/en/compare_cpu-intel_core_i5_12600k-vs-intel_core_i7_12700),12700 多两个性能核,性能核具有更低的主频、一样的睿频,核显睿频稍高 0.5GHz。性能表现会比默频 12600K 好一些。PL2 也来到 180W 了。
|
||||
|
||||
~~但是考虑到散热问题,估计不太可能能发挥多出来的这部分性能,我想没必要加三百元为了多两个性能核,虽然我很想要这两个性能核,因为我经常用 FFMPGE 转码 1 分钟左右的视频,这时 CPU 都是吃满的。~~
|
||||
|
||||
隔天,商家说 12600K 没货了,估计是这个价格的没了,市场价会再高一点,然后商家说 12700 主频低,低负载的情况下功耗表现会比 12600K 好。这话说得我差点就信了,CPU 空闲的时候频率可是能比主频低得多得多呀!
|
||||
|
||||
但是因为这个几十块的涨价,让我又开始想 12700 了,毕竟两个性能核呀!思来想去,加了三百五去别家买了 12700,希望在这极限散热的情况下,能有良好的日常使用表现。
|
||||
|
||||
综上,我决定入手 12700 + 铭瑄 B670I,2327 元。比收12700K + Z690I 贵六百五,但全新有保修,性能绝对更好,发热更低。算是合理。黑苹果同样比较完美,还成。
|
||||
|
||||
## 其他
|
||||
|
||||
电源计划选个 650W 的白色全模组电源,目前看上了 TT 家的金牌 SFX 电源。京东自营 659 元,应该还可以。
|
||||
|
||||
机箱上摆两个风扇,买俩可以直接拼接的风扇,65 元。
|
||||
|
||||
固态暂时用我手上有的 PCIE 3.0 的 M.2 固态。
|
||||
|
||||
内存直接从旧机子拔,DDR4 32G x2,正好装满,主板只支持 64G 内存。
|
||||
|
||||
## 装机小结
|
||||
|
||||
最终装机单:
|
||||
|
||||
| 项目 | 型号 | 备注 |
|
||||
| ------------- | ----------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| CPU | **[Intel Core i5-12700](https://www.cpu-monkey.com/zh-cn/cpu-intel_core_i7_12700)** | |
|
||||
| 主板 | [MS-终结者 B760ITX D4 WIFI](https://www.maxsun.com.cn/2023/0206/5881.html) | |
|
||||
| 显卡 | AMD Radeon RX 6800XT | |
|
||||
| 硬盘 | Crucial 英睿达 P5 1TB 3D NAND NVMe\n京东京造 J.ZAO QL SERIES 1TB SSD(保修换新) | |
|
||||
| 电源 | TT 钢影SFX 650W | |
|
||||
| CPU 散热 | 超频三 降龙 V53 绚彩版 | |
|
||||
| 机箱 | 闪鳞 S300 + PCIE 4.0 延长线 | |
|
||||
| ~~8015 风扇~~ | | 最后没装。除了烤机,电源下方不怎么积热 |
|
||||
| 12015 风扇 | | 装机箱下主板下方对应风扇位出风 |
|
||||
| 12025 风扇 | x2 | 摆机箱上面,抽风 |
|
||||
|
||||
## 性能调教
|
||||
|
||||
### CPU
|
||||
|
||||
#### CPU-Z
|
||||
|
||||
通过 CPU-Z 的跑分来确定性能,加上五分钟左右的 CPU 单烤来验证功耗与性能发挥的稳定性。
|
||||
|
||||
| 微码 | AC | 电压偏移 | PWM | 单核 | 多核 | 烤机 | 链接 |
|
||||
| ---- | ---- | -------- | --- | ---- | ---- | ------------------------------- | ---------------------------------------------------------- |
|
||||
| 默认 | 默认 | 默认 | 1.7 | 739 | 8673 | | [https://valid.x86.fr/wh5k3k](https://valid.x86.fr/wh5k3k) |
|
||||
| 104 | 90 | -100 | 1.7 | 747 | 7123 | | [https://valid.x86.fr/3i8s1u](https://valid.x86.fr/3i8s1u) |
|
||||
| 104 | 90 | 0 | 1.7 | 742 | 8750 | | [https://valid.x86.fr/qsleu7](https://valid.x86.fr/qsleu7) |
|
||||
| 104 | 90 | | 1.1 | 767 | 8834 | 4.2G 死机 | [https://valid.x86.fr/602lbe](https://valid.x86.fr/602lbe) |
|
||||
| 104 | 100 | -50 | 1.1 | 777 | 8949 | 4G | [https://valid.x86.fr/mrdyb0](https://valid.x86.fr/mrdyb0) |
|
||||
| 104 | 95 | -100 | 1.1 | 775 | 8940 | 4.3G | [https://valid.x86.fr/373lpt](https://valid.x86.fr/373lpt) |
|
||||
| 104 | 95 | -150 | 1.1 | 750 | 8897 | 4.3G 容易掉到 4.2G | [https://valid.x86.fr/tbvs6a](https://valid.x86.fr/tbvs6a) |
|
||||
| 104 | 80 | -100 | 1.1 | 776 | 8928 | 4.3G 容易掉到 4.2G | [https://valid.x86.fr/knssqd](https://valid.x86.fr/knssqd) |
|
||||
| 104 | 80 | -100 | 1.1 | 780 | 8862 | 4.3G | [https://valid.x86.fr/e75pzj](https://valid.x86.fr/e75pzj) |
|
||||
| 104 | 70 | -150 | 1.1 | 778 | 8914 | 4.4G (一分钟内 4.5G) | [https://valid.x86.fr/cyzsa5](https://valid.x86.fr/cyzsa5) |
|
||||
| 104 | 60 | -200 | 1.1 | 777 | 8827 | 4.4G (一分钟内 4.5G) | [https://valid.x86.fr/pmz7wq](https://valid.x86.fr/pmz7wq) |
|
||||
| 104 | 60 | -150 | 1.1 | 778 | 8801 | 4.4G (一分钟半内 4.5G,不稳定) | [https://valid.x86.fr/lmlcrs](https://valid.x86.fr/lmlcrs) |
|
||||
| 104 | 70 | -200 | 1.1 | 777 | 8931 | 4.4G(波动有点大) | [https://valid.x86.fr/lw675p](https://valid.x86.fr/lw675p) |
|
||||
| 104 | 65 | -200 | 1.1 | 777 | 8892 | 4.4G(波动有点大) | [https://valid.x86.fr/bqy58g](https://valid.x86.fr/bqy58g) |
|
||||
| 104 | 70 | -250 | 1.1 | 778 | 8910 | 4.4G(波动有点大) | [https://valid.x86.fr/ua63zf](https://valid.x86.fr/ua63zf) |
|
||||
|
||||
从测试结果上来看,电压越低,一开始的功耗也越低,温度上升就越慢,所以全核心跑满 4.5G 的时间也从十几秒来到一分半。但是最后都会因为我选用的 CPU 下压散热器只有 160W 的解热功耗,所以撞上 100 摄氏度的温度墙而降频。
|
||||
|
||||
#### Cinebench 2024
|
||||
|
||||
从 CPU-Z 的成绩来看,挑选了两个表现比较好的参数来进行多核跑分。
|
||||
|
||||
| AC | 电压 | CPU 多线程 |
|
||||
| --- | ---- | ---------- |
|
||||
| 70 | -200 | 1105pts |
|
||||
| 70 | -150 | 1112pts |
|
||||
|
||||
#### 双拷测试
|
||||
|
||||
最后选定 AC 70(70mΩ)、电压偏移 -150mV 来进行双烤 10 分钟稳定性测试。测试通过🥰
|
||||
|
||||
### GPU
|
||||
|
||||
目前我使用从之前主机上拆下来的 6600XT,能稳定发挥,散热表现也正常。准备过两天去某宝买 6800XT 默认矿卡,祝我好运。希望散热表现也能稳定,毕竟 A4 结构,显卡散热应该挺好的。
|
||||
|
||||
默认矿卡到手。盒盖跑分。
|
||||
|
||||
- Cinebench 2024:10531pts
|
||||
|
||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/5d8f16d0-be54-4655-a2b3-e4a80b287701.png)
|
||||
|
||||
- 3D Mark Time Spy: [18 091](https://www.3dmark.com/3dm/101278696)\n ![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/e7969870-e4f8-46ba-b9fb-35530aeeba3c.png)
|
||||
|
||||
- 3D Mark Time Spy Extreme: [8727](https://www.3dmark.com/3dm/101277666)
|
||||
- ![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/e296ac7c-1d3f-4eae-be0e-21d04e5af121.png)
|
||||
- 3D Mark Speed Way 压力测试:[99.3%](https://www.3dmark.com/3dm/101279293)
|
||||
|
||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/5749102a-8471-4ea2-b332-362a8c8469a2.png ' =806x594')
|
||||
|
||||
看起来显卡还成,分数比平均值差一点,不过还好,作为二手默认矿卡表现应该能说得过去。
|
||||
|
||||
### 调教小结
|
||||
|
||||
我在机箱主板底部的风扇位安装了 12015 风扇出风,在机箱上方摆了两个积木风扇抽风,形成侧进-上下出的风道。目前散热瓶颈在 CPU 的下压散热,CPU 撞 100 摄氏度温度墙,长时间烤机功耗在 160W\~170W 之间徘徊,CPU 频率在 4.4 GHz 左右,损失 0.1 GHz。显卡没有遇到什么问题。
|
||||
|
||||
## 黑苹果
|
||||
|
||||
本来想自己搞 OpenCore 的,奈何时间不够,最后朋友介绍了 B 站大佬[乌龙蜜桃来](https://space.bilibili.com/244390800?spm_id_from=333.337.0.0)帮我弄好了 EFI。大佬做完后分享的 EFI:
|
||||
|
||||
[hackintosh-club/MAXSUN-TERMINATOR-B760ITX-D4-WIFI-OpenCore: MS-B760i Hackintosh OpenCore macos 12 Monertey & 13 Ventura & 14 Sonoma](https://github.com/hackintosh-club/MAXSUN-TERMINATOR-B760ITX-D4-WIFI-OpenCore)
|
||||
|
||||
我试过了, Ventura 和 Sonoma 都是能稳定运行的,睡眠、WIFI、蓝牙都正常,随航和隔空投送因为是 Intel 的无线网卡,所以不支持。
|
||||
|
||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/23da98ef-8b5f-42b5-b515-f4d6fced10f5.png)
|
||||
|
||||
双系统 Blender Benchmark 得分
|
||||
|
||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/image.png)
|
||||
|
||||
前两组是 Windows 的成绩,后两组是 Mac OS 的成绩。似乎都比 Blender 上记录的成绩好一些。至少这次测试能看出来,Mac OS 上显卡性能是有损失,但是 CPU 居然能跑得分更高。
|
||||
|
||||
## 使用体验
|
||||
|
||||
日常使用,没发现 CPU 因为散热问题降频。显卡一直稳定发挥,这二手显卡不知道矿没矿过,但是还算稳定。散热方面表现不错,基本上是因为我在机箱上面放了两把 12025 反向风扇,热量都能很好地被抽出来,小机箱现在不再是小闷罐。机箱底部的风扇,感觉有点聊胜于无。烤机的情况下,底部还是有一点积热的。或许和我只装了主板下的出风扇、没装电源下的出风扇有点关系,但是下部出风确实表现不理想,即使我把机箱放在通透的架子上。
|
||||
|
||||
现在我搞了两块 0.8mm 孔径的防尘网贴在机箱左右两侧,散热表现依然还可以,感觉我放上面的两个风扇真棒。
|
||||
|
||||
最后,放一张主机工作照吧。
|
||||
|
||||
![](https://s3.ivanli.cc/ivan-public/manual/2023-build-another-hackintosh-itx-workstation/8aacafcc-c69c-4a1d-adf7-68f86e2edd3e.png)
|
@ -4,10 +4,7 @@ date: '2022-10-17'
|
||||
tags: ['Arch Linux', '环境搭建', 'VPS']
|
||||
draft: false
|
||||
summary: 有了上次快速安装步骤后,接下来就是使用这个环境了。要使用环境,首先需要做一些初始化操作。我的步骤适合我,但不一定适合你,只是记录和参考。
|
||||
images:
|
||||
[
|
||||
'https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0',
|
||||
]
|
||||
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/RTr3IU.png']
|
||||
---
|
||||
|
||||
## Docker
|
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” 发布版本:
|
||||
|
||||
![Github Releases for VueTorrent](https://minio.ivanli.cc/ivan-public/uPic/2023/bHrczD.png)
|
||||
|
||||
所以这里使用了 [git-get-release-action](https://github.com/marketplace/actions/git-get-release-action),获取最新的版本号和 commitish hash。
|
||||
其中版本号就是 tag name,而 commit-ish hash 就是平常使用的 commit hash 了。
|
||||
|
||||
```yaml
|
||||
- name: git-get-release-action
|
||||
id: git-get-release
|
||||
uses: cardinalby/git-get-release-action@1.2.4
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
repo: WDaan/VueTorrent
|
||||
latest: true
|
||||
```
|
||||
|
||||
这里提供了几个参数:
|
||||
|
||||
- `GITHUB_TOKEN` 是自动注入的,我们将这个变量作为环境变量提供给 git-get-release-action 就行,缺少这个变量,这个步骤会报错。
|
||||
- 我们通过 `repo` 提供了上游的仓库名称 “WDaan/VueTorrent”,这个仓库在 Github 上,所以可以直接这么简写。
|
||||
- 因为只需要获取最新的发布版本,所以提供了 `latest` 为 `true`。
|
||||
|
||||
这一步骤会有两个关键的输出:
|
||||
|
||||
- `tag_name` 是我们想要的最新版本的版本号,因为他的 git tag 写的就是版本号,例如现在的 `v1.6.0`。
|
||||
- `current_commitish` 就顾名思义了,接下来就是要用他作为当前 commit 的唯一标记。
|
||||
|
||||
我们有了最新版本的 commit-ish 值,那就要有上次构建 Docker image 时用的 commit-ish 值。
|
||||
|
||||
```yaml
|
||||
- name: Get current commitish
|
||||
id: get-current-commitish
|
||||
run: |
|
||||
cd vue-torrent
|
||||
echo "Current commitish: $(git rev-parse HEAD)"
|
||||
echo "{current_commitish}=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
```
|
||||
|
||||
这里直接进入子模块的目录里,使用 `git rev-parse HEAD` 命令获取当前 repo 使用的上游的 commit-ish。
|
||||
因为我们每次构建镜像前,都会先更新子模块的 commit 位置,所以这里就是上次发布镜像时的 commit-ish 了。
|
||||
|
||||
再因为我们一直是单调地往按时间往更新的构建,所以不需要比较 commit 的新旧,只要不一样,就是要构建新的镜像。
|
||||
所以我们这一步,就直接比较最新的 commit 和现在的 commit 是否一直:
|
||||
|
||||
```yaml
|
||||
- name: Compare versions
|
||||
id: compare
|
||||
run: |
|
||||
echo "Current version: ${{ steps.git-get-release.outputs.tag_name }}"
|
||||
echo "{should_update}=${{ steps.git-get-release.outputs.target_commitish != steps.get-current-commitish.outputs.current_commitish }}" >> $GITHUB_OUTPUT
|
||||
```
|
||||
|
||||
下面就是重要的三个步骤了,一更新子模块,二提交更新,三打标签。
|
||||
|
||||
#### 更新子模块
|
||||
|
||||
```yaml
|
||||
- name: Update
|
||||
if: steps.compare.outputs.should_update == 'true'
|
||||
working-directory: ./vue-torrent
|
||||
run: |
|
||||
git fetch --depth=1 origin ${{ steps.git-get-release.outputs.tag_name }}
|
||||
git checkout -b ${{ steps.git-get-release.outputs.tag_name }} ${{ steps.git-get-release.outputs.target_commitish }}
|
||||
git reset --hard HEAD
|
||||
```
|
||||
|
||||
_这里使用了 `working-directory` 更改执行目录到子模块中,用 `cd` 进去应该也一样。_
|
||||
|
||||
因为之前检出存储库时是只检出最新的那个 commit,所以需使用 `git fetch` 将我们需要的那个 commit 拉到运行环境中,否则会报形如 `fatal: Could not parse object '6ab00a179b9509ef162a14862fb828c78144caff'.` 的错误。
|
||||
|
||||
之后使用 `git checkout -b` 将目标的 commit 拉到新的分支上。
|
||||
|
||||
最后,将当前的位置设到 HEAD,即目标 commit。
|
||||
|
||||
后两步应该是可以直接改成 `git reset --hard ${{ steps.git-get-release.outputs.target_commitish }}`,不过我没试过,仅供参考。
|
||||
|
||||
#### 提交更新
|
||||
|
||||
```yaml
|
||||
- name: Commit changes
|
||||
if: steps.compare.outputs.should_update == 'true'
|
||||
run: |
|
||||
git diff
|
||||
git config user.name "GitHub Actionss"
|
||||
git config user.email "bot@noreply.github.com"
|
||||
git add .
|
||||
git commit -m "Update to ${{ steps.git-get-release.outputs.tag_name }}"
|
||||
git push origin ${{ github.ref_name }}
|
||||
```
|
||||
|
||||
这就没什么好说的了,需要注意的一点就是权限问题。因为我们是 push 到当前的 repo 上,所以可以直接使用自动注入的 `GITHUB_TOKEN`,不过需要在 repo 的设置页面更改下权限:
|
||||
|
||||
![Github Actions Permissions Setting](https://minio.ivanli.cc/ivan-public/uPic/2023/QPvf6R.png)
|
||||
|
||||
选择 “Read and write permissions",这样就能写入当前的 repo。
|
||||
|
||||
#### 打标签
|
||||
|
||||
```yaml
|
||||
- name: Tag
|
||||
if: steps.compare.outputs.should_update == 'true'
|
||||
run: |
|
||||
git tag ${{ steps.git-get-release.outputs.tag_name }}
|
||||
git push origin ${{ steps.git-get-release.outputs.tag_name }}
|
||||
```
|
||||
|
||||
目的就是后面构建镜像时,能方便地从这里取到版本号。
|
||||
|
||||
### 构建镜像
|
||||
|
||||
这个就比较简单了,代码在这:
|
||||
|
||||
[Action](https://github.com/IvanLi-CN/vue-torrent-docker/blob/7439b89cb87bb4627d87f0445e8c1fc39ed89f78/.github/workflows/auto-build.yaml)
|
||||
|
||||
[Dockerfile](https://github.com/IvanLi-CN/vue-torrent-docker/blob/7439b89cb87bb4627d87f0445e8c1fc39ed89f78/.github/workflows/auto-build.yaml)
|
||||
|
||||
因为直接做成子模块了,流程就简单很多了,只要检出代码时将子模块一并检出,之后直接构建 Docker 镜像就行。
|
||||
|
||||
我也不知道有没有人用,只构建了 x86 的自用。
|
||||
|
||||
## 最后
|
||||
|
||||
分享一下我现在用的追番的 Docker Compose 吧:
|
||||
|
||||
`docker-compose.yaml`
|
||||
|
||||
```yaml
|
||||
version: '3.2'
|
||||
services:
|
||||
caddy:
|
||||
container_name: caddy
|
||||
ports:
|
||||
- ${QB_PORT}:80
|
||||
networks:
|
||||
- auto_bangumi
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./caddy:/etc/caddy
|
||||
image: caddy:2
|
||||
vuetorrent:
|
||||
container_name: vuetorrent
|
||||
expose:
|
||||
- 3000
|
||||
networks:
|
||||
- auto_bangumi
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/ivanli-cn/vue-torrent:main
|
||||
qbittorrent:
|
||||
container_name: qBittorrent
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- TemPath=/downloads
|
||||
- SavePath=/downloads
|
||||
- PGID=${GID}
|
||||
- PUID=${UID}
|
||||
- WEBUI_PORT=8080
|
||||
volumes:
|
||||
- qb_config:/config
|
||||
- ${DOWNLOAD_PATH}:/downloads
|
||||
ports:
|
||||
- 6881:6881
|
||||
- 6881:6881/udp
|
||||
networks:
|
||||
- auto_bangumi
|
||||
restart: unless-stopped
|
||||
image: superng6/qbittorrent:latest
|
||||
|
||||
auto_bangumi:
|
||||
container_name: AutoBangumi
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- PGID=${GID}
|
||||
- PUID=${UID}
|
||||
- AB_DOWNLOADER_HOST=qbittorrent:${QB_PORT}
|
||||
networks:
|
||||
- auto_bangumi
|
||||
volumes:
|
||||
- ./auto_bangumi/config:/app/config
|
||||
- ./auto_bangumi/data:/app/data
|
||||
ports:
|
||||
- 7892:7892
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 223.5.5.5
|
||||
restart: unless-stopped
|
||||
image: estrellaxd/auto_bangumi:latest
|
||||
depends_on:
|
||||
- qbittorrent
|
||||
|
||||
plex:
|
||||
container_name: Plex
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- PUID=${UID}
|
||||
- PGID=${GID}
|
||||
- VERSION=docker
|
||||
- PLEX_CLAIM=${PLEX_CLAIM}
|
||||
networks:
|
||||
- auto_bangumi
|
||||
ports:
|
||||
- 32400:32400
|
||||
volumes:
|
||||
- plex_config:/config
|
||||
- ${DOWNLOAD_PATH}/Bangumi:/tv
|
||||
restart: unless-stopped
|
||||
image: lscr.io/linuxserver/plex:latest
|
||||
|
||||
networks:
|
||||
auto_bangumi:
|
||||
|
||||
volumes:
|
||||
qb_config:
|
||||
external: false
|
||||
plex_config:
|
||||
external: false
|
||||
```
|
||||
|
||||
`caddy/Caddyfile`
|
||||
|
||||
```Caddyfile
|
||||
:80 {
|
||||
reverse_proxy /api/* qbittorrent:8080
|
||||
reverse_proxy /* vuetorrent:3000
|
||||
}
|
||||
```
|
||||
|
||||
`.env`
|
||||
|
||||
```bash
|
||||
QB_PORT=8080
|
||||
DOWNLOAD_PATH=/home/ivan/downloads
|
||||
UID=1000
|
||||
GID=1000
|
||||
|
||||
PLEX_CLAIM=claim-DwbcewEB7j3pmNotG_eT
|
||||
```
|
@ -1,13 +1,10 @@
|
||||
---
|
||||
title: 在 PVE 宿主机上使用桌面环境
|
||||
date: '2022-10-28'
|
||||
tags: ['PVE', 'PVE', 'DE', '环境搭建', 'Debian']
|
||||
tags: ['PVE', 'DE', '环境搭建', 'Debian']
|
||||
draft: false
|
||||
summary: 虽然 PVE 宿主机不应该安装乱七八糟的东西,但是我穷,为了物尽其用,为了在主力电脑翻车时有一个立即可用的备用环境,所以还是安装了基础的桌面环境。现在的 Linux 桌面环境越来越好了,我选择安装 KDE Plasma 作为桌面环境,并且默认关闭,按需启用。
|
||||
images:
|
||||
[
|
||||
'https://pan.ivanli.cc/api/v3/file/source/2243/1200px-Kde_dragons.png?sign=yGZL9jYeVt53Ve43ddhHt_0EzVV2cW_WbxHc0dEcwWY%3D%3A0',
|
||||
]
|
||||
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/qldEtP.png']
|
||||
---
|
||||
|
||||
## 前言
|
||||
@ -160,7 +157,7 @@ ibus-setup
|
||||
|
||||
在打开的 GUI 中添加中文输入法,找到 Rime 并添加输入法:
|
||||
|
||||
![rime](https://pan.ivanli.cc/api/v3/file/source/2241/Screen%20Capture_select-area_20221028225457.png?sign=XVrl7rPk4Gd7QRFBCCDGruB2L7V1bvxDpK9-v9pC0Nc%3D%3A0)
|
||||
![rime](https://minio.ivanli.cc/ivan-public/uPic/2023/E4SWeR.png)
|
||||
|
||||
现在,新打开的软件应该能使用输入法了。像 Chrome 这类,关闭后还需要手动杀死进程后再打开才能使用。最简单的方法就是重启电脑啦~
|
||||
|
@ -4,10 +4,7 @@ date: '2022-09-23'
|
||||
tags: ['Verdaccio', 'Self-Hosted', 'Docker', 'Caddy', 'registry', 'Node.js']
|
||||
draft: false
|
||||
summary: 作为靠着 JavaScript 生态吃饭的 Web 开发者,自建一个 Node regsitry 是很有必要的,我这次继续选择 Verdaccio 来搭建存储库。这次使用 Docker Compose 部署 Verdaccio,并将 Caddy 用于反向代理该服务。
|
||||
images:
|
||||
[
|
||||
'https://pan.ivanli.cc/api/v3/file/source/2233/verdaccio.png?sign=qpoeADXzbhHk2MY5CehgTftUJ67pnUj-Ylko9D5jscU%3D%3A0',
|
||||
]
|
||||
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/3Dqijk.png']
|
||||
---
|
||||
|
||||
## 为何自建存储库?
|
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']
|
||||
draft: false
|
||||
summary: 又到了新装 Arch Linux 的日子了。这次又是温故而知新的机会,把之前写的笔记稍微整理了一下,在这里记录下教徒搭窝的备忘录。
|
||||
images:
|
||||
[
|
||||
'https://pan.ivanli.cc/api/v3/file/source/2238/archlinux-logo-light.png?sign=bWxqFFy3RUDT5UsWb4UD5byt-_L4h79wede3runRKFc%3D%3A0',
|
||||
]
|
||||
images: ['https://minio.ivanli.cc/ivan-public/uPic/2023/RTr3IU.png']
|
||||
---
|
||||
|
||||
## 起势
|
@ -39,7 +39,7 @@ useEffect(() => {
|
||||
|
||||
### 在 React 18 的测试代码
|
||||
|
||||
![React 18 Stricter Strict Mode.png](https://pan.ivanli.cc/api/v3/file/source/2753/React%2018%20Stricter%20Strict%20Mode.png?sign=ARQ8AVTh-NEaeJRypJlVokuUVhocPeaK8n7GRSDwqNw%3D%3A0)
|
||||
![React 18 Stricter Strict Mode.png](https://minio.ivanli.cc/ivan-public/uPic/2023/TWVx7v.png)
|
||||
|
||||
代码:[Code Sandbox](https://codesandbox.io/p/sandbox/clever-cache-pm1oct?file=%2Fsrc%2FApp.tsx&selection=%5B%7B%22endColumn%22%3A20%2C%22endLineNumber%22%3A33%2C%22startColumn%22%3A20%2C%22startLineNumber%22%3A33%7D%5D)
|
||||
|
||||
@ -51,7 +51,7 @@ useEffect(() => {
|
||||
2. `useEffect(() => /* */, [])`执行一此后,以新的严格模式的规则,调用了 `destructor` 后,进行了二次调用。
|
||||
|
||||
在第 2 点中,两次 useEffect 都是使用同一个值,是因为严格模式的二次调用按钩子分别执行两次,所以 useMemo 两次的调用都完毕后,得到的值再被 useEffect 执行两次。我调整了一下代码,将测试代码复制了一份在后面,可以看到 “useMemo” 和 “useMemo 2” 先执行了一次,又再执行了一次,然后再到 “useEffect“ 和 “useEffect 2":
|
||||
![加倍快乐](https://pan.ivanli.cc/api/v3/file/source/2754/React%2018%20Stricter%20Strict%20Mode%202.png?sign=iYz9KP9uMuccRCesjqoRPKejEoUOj4FZfnBPt8kCXnQ%3D%3A0)
|
||||
![加倍快乐](https://minio.ivanli.cc/ivan-public/uPic/2023/OwlDG6.png)
|
||||
|
||||
## 结论
|
||||
|
@ -2,7 +2,7 @@
|
||||
title: 再见 2022,你好 2023
|
||||
date: '2022-12-31'
|
||||
tags: ['总结']
|
||||
draft: false
|
||||
draft: true
|
||||
summary: 2022 年就要结束了,写个工作与学习的总结,记录下我的 2022 年。
|
||||
---
|
||||
|
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/)
|
||||
|
||||
![](https://s3.ivanli.cc/ivan-public/uPic/2023/JJjp9f.png ' =666x428')
|
||||
|
||||
漏斗算法能够让最终路径在绕过障碍物的同时,保证路径最短。从上面的文章中,可以总结一个核心逻辑,每次生成的路径如果超过左边界或右边界,就会增加一个节点,并从此处构造新的漏斗。直到到达终点。
|
@ -8,10 +8,8 @@ const siteMetadata = {
|
||||
theme: 'system', // system, dark or light
|
||||
siteUrl: 'https://ivanli.cc/',
|
||||
siteRepo: 'https://git.ivanli.cc/ivan/tailwind-nextjs-blog',
|
||||
siteLogo:
|
||||
'https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0',
|
||||
image:
|
||||
'https://pan.ivanli.cc/api/v3/file/source/1234/%E5%A4%B4%E5%83%8F.png?sign=xIgy54DyFRYupxjZJbK02HmpKX8C53YR-O0I18Rxm70%3D%3A0',
|
||||
siteLogo: 'https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png',
|
||||
image: 'https://minio.ivanli.cc/ivan-public/uPic/2023/Urpetm.png',
|
||||
socialBanner: '/static/images/twitter-card.png',
|
||||
email: 'master@ivanli.cc',
|
||||
github: 'https://github.com/IvanLi-CN',
|
||||
@ -23,12 +21,21 @@ const siteMetadata = {
|
||||
analytics: {
|
||||
// If you want to use an analytics provider you have to add it to the
|
||||
// content security policy in the `next.config.js` file.
|
||||
// supports plausible, simpleAnalytics, umami or googleAnalytics
|
||||
plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
|
||||
simpleAnalytics: false, // true or false
|
||||
umamiWebsiteId: '', // e.g. 123e4567-e89b-12d3-a456-426614174000
|
||||
googleAnalyticsId: '', // e.g. UA-000000-2 or G-XXXXXXX
|
||||
posthogAnalyticsId: '', // posthog.init e.g. phc_5yXvArzvRdqtZIsHkEm3Fkkhm3d0bEYUXCaFISzqPSQ
|
||||
// supports Plausible, Simple Analytics, Umami, Posthog or Google Analytics.
|
||||
umamiAnalytics: {
|
||||
// We use an env variable for this site to avoid other users cloning our analytics ID
|
||||
umamiWebsiteId: process.env.NEXT_UMAMI_ID, // e.g. 123e4567-e89b-12d3-a456-426614174000
|
||||
},
|
||||
// plausibleAnalytics: {
|
||||
// plausibleDataDomain: '', // e.g. tailwind-nextjs-starter-blog.vercel.app
|
||||
// },
|
||||
// simpleAnalytics: {},
|
||||
// posthogAnalytics: {
|
||||
// posthogProjectApiKey: '', // e.g. 123e4567-e89b-12d3-a456-426614174000
|
||||
// },
|
||||
// googleAnalytics: {
|
||||
// googleAnalyticsId: '', // e.g. G-XXXXXXX
|
||||
// },
|
||||
},
|
||||
newsletter: {
|
||||
// supports mailchimp, buttondown, convertkit, klaviyo, revue, emailoctopus
|
||||
@ -78,14 +85,24 @@ const siteMetadata = {
|
||||
// https://help.disqus.com/en/articles/1717111-what-s-a-shortname
|
||||
shortname: process.env.NEXT_PUBLIC_DISQUS_SHORTNAME,
|
||||
},
|
||||
cusdisConfig: {
|
||||
appId: process.env.NEXT_PUBLIC_CUSDIS_APPID,
|
||||
host: process.env.NEXT_PUBLIC_CUSDIS_HOST,
|
||||
},
|
||||
commentoConfig: {
|
||||
url: process.env.NEXT_PUBLIC_COMMENTO_URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
search: {
|
||||
provider: 'kbar', // kbar or algolia
|
||||
kbarConfig: {
|
||||
searchDocumentsPath: 'search.json', // path to load documents to search
|
||||
},
|
||||
// provider: 'algolia',
|
||||
// algoliaConfig: {
|
||||
// // The application ID provided by Algolia
|
||||
// appId: 'R2IYF7ETH7',
|
||||
// // Public API key: it is safe to commit it
|
||||
// apiKey: '599cec31baffa4868cae4e79f180729b',
|
||||
// indexName: 'docsearch',
|
||||
// },
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = siteMetadata
|
||||
module.exports = siteMetadata;
|
||||
|
@ -1,13 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/components/*": ["components/*"],
|
||||
"@/data/*": ["data/*"],
|
||||
"@/layouts/*": ["layouts/*"],
|
||||
"@/lib/*": ["lib/*"],
|
||||
"@/css/*": ["css/*"]
|
||||
"@/css/*": ["css/*"],
|
||||
"contentlayer/generated": ["./.contentlayer/generated"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user