Compare commits

..

117 commits

Author SHA1 Message Date
ClovertaTheTrilobita
f47501d79d
Merge pull request #27 from ClovertaTheTrilobita/dev
Some checks failed
Sync and Deploy Astro Blog / sync-blog-content (push) Has been cancelled
Sync and Deploy Astro Blog / manual-deploy (push) Has been cancelled
Sync and Deploy Astro Blog / build-and-deploy (push) Has been cancelled
Seperate zh-cn and en-us rss
2026-06-18 15:46:07 +08:00
e6fb5a4519 Seperate zh-cn and en-us rss 2026-06-18 15:45:32 +08:00
ClovertaTheTrilobita
715d6c9b7b
Merge pull request #26 from ClovertaTheTrilobita/dev
Some checks failed
Sync and Deploy Astro Blog / sync-blog-content (push) Has been cancelled
Sync and Deploy Astro Blog / manual-deploy (push) Has been cancelled
Sync and Deploy Astro Blog / build-and-deploy (push) Has been cancelled
fix css
2026-06-02 02:46:38 +08:00
ClovertaTheTrilobita
1bb9d56e03
Update global.css 2026-06-02 02:43:54 +08:00
ClovertaTheTrilobita
5673fc7ddb
Update global.css 2026-06-02 02:42:42 +08:00
ClovertaTheTrilobita
7067e43cb4
Merge pull request #25 from ClovertaTheTrilobita/dev
fix css
2026-06-02 02:35:44 +08:00
ClovertaTheTrilobita
1901b50062
Update MarkdownPostLayout.astro 2026-06-02 02:35:05 +08:00
ClovertaTheTrilobita
94cbaf7707
Update global.css 2026-06-02 02:33:56 +08:00
c9d1bacec4 Merge branch 'dev' 2026-06-02 01:56:52 +08:00
6e84ef4884 added comments desc 2026-06-02 01:55:28 +08:00
06999dae80 Merge branch 'dev' 2026-06-02 01:37:43 +08:00
eeff9fa4f6 update dependencies 2026-06-02 01:37:14 +08:00
9d4c8d9f76 Merge branch 'dev' 2026-06-02 01:11:29 +08:00
b60b8d8e61 update github cd 2026-06-02 01:10:24 +08:00
664fa20b0a Merge branch 'dev' 2026-06-02 01:00:37 +08:00
04aca48a3b added css for quote and dropdown 2026-06-02 00:57:49 +08:00
ClovertaTheTrilobita
9b8f3794c8
Merge pull request #24 from ClovertaTheTrilobita/dev
Some checks are pending
Sync and Deploy Astro Blog / sync-blog-content (push) Waiting to run
Sync and Deploy Astro Blog / build-and-deploy (push) Blocked by required conditions
Sync and Deploy Astro Blog / manual-deploy (push) Waiting to run
Added inline latex support
2026-06-01 12:53:34 +03:00
f54acb3ac3 Added inline latex support 2026-06-01 17:50:06 +08:00
eabd15fc74 Merge branch 'dev'
Some checks failed
Sync and Deploy Astro Blog / sync-blog-content (push) Has been cancelled
Sync and Deploy Astro Blog / manual-deploy (push) Has been cancelled
Sync and Deploy Astro Blog / build-and-deploy (push) Has been cancelled
2026-04-25 01:22:22 +03:00
db6deb3205 update preview radius 2026-04-25 01:22:07 +03:00
ClovertaTheTrilobita
c69e72cd2f
Merge pull request #23 from ClovertaTheTrilobita/dev
Add image preview
2026-04-25 01:17:26 +03:00
ae259e84e6 Refactor dialog as a component 2026-04-25 01:15:55 +03:00
f86ac62e04 Added image preview 2026-04-25 01:15:55 +03:00
d30148c021 Merge branch 'dev' 2026-04-25 00:56:21 +03:00
9c53ea922e Added runtime darkmode to remark42 2026-04-25 00:56:06 +03:00
ClovertaTheTrilobita
9b017117d8
Merge pull request #22 from ClovertaTheTrilobita/dev
Added menu feature in post
2026-04-25 00:24:25 +03:00
7292affc04 fix menu dark colors 2026-04-25 00:23:39 +03:00
18ddbb82f1 update pc menu behavior 2026-04-25 00:13:55 +03:00
0541250822 update mobile menu color 2026-04-25 00:05:17 +03:00
5b4ba442ed update menu style, refactor css 2026-04-25 00:01:08 +03:00
b10aec8d15 update menu actions 2026-04-19 02:20:01 +03:00
2f48907035 added menu 2026-04-18 22:29:51 +03:00
ClovertaTheTrilobita
0936ea2c58
Merge pull request #21 from ClovertaTheTrilobita/dev
Some checks failed
Sync and Deploy Astro Blog / sync-blog-content (push) Has been cancelled
Sync and Deploy Astro Blog / manual-deploy (push) Has been cancelled
Sync and Deploy Astro Blog / build-and-deploy (push) Has been cancelled
Added Prev & Next post in post layout
2026-04-18 22:29:45 +03:00
fabe058bfe added animation to postnav 2026-04-18 21:52:28 +03:00
d9ae38197c update post nav format 2026-04-18 21:46:01 +03:00
804bfbd080 Added navigation in posts 2026-04-18 21:38:42 +03:00
1e389aeb44 Merge branch 'dev'
Some checks are pending
Sync and Deploy Astro Blog / sync-blog-content (push) Waiting to run
Sync and Deploy Astro Blog / build-and-deploy (push) Blocked by required conditions
Sync and Deploy Astro Blog / manual-deploy (push) Waiting to run
2026-04-17 21:56:46 +03:00
7dd21ea510 update font link 2026-04-17 21:56:28 +03:00
8fe898a281 Merge branch 'dev' 2026-04-17 21:04:48 +03:00
9db3347970 update abouot 2026-04-17 21:04:33 +03:00
ClovertaTheTrilobita
55c446a4e4
Merge pull request #20 from ClovertaTheTrilobita/dev
Update friends
2026-04-17 19:16:38 +03:00
27badbee83 Update friends 2026-04-17 19:16:02 +03:00
1160e375d4 Merge branch 'dev'
Some checks are pending
Sync and Deploy Astro Blog / sync-blog-content (push) Waiting to run
Sync and Deploy Astro Blog / build-and-deploy (push) Blocked by required conditions
Sync and Deploy Astro Blog / manual-deploy (push) Waiting to run
2026-04-17 14:26:36 +03:00
9973a78d4a update title 2026-04-17 14:26:16 +03:00
965bde4bed Merge branch 'dev' 2026-04-17 14:02:55 +03:00
8c4eae8a96 update font settings 2026-04-17 14:02:45 +03:00
e96ccb6841 Merge branch 'dev' 2026-04-17 13:42:39 +03:00
cce78260c3 fix html lang and font 2026-04-17 13:42:26 +03:00
ClovertaTheTrilobita
d43a1d61db
Merge pull request #18 from ClovertaTheTrilobita/dev
Some checks are pending
Sync and Deploy Astro Blog / sync-blog-content (push) Waiting to run
Sync and Deploy Astro Blog / build-and-deploy (push) Blocked by required conditions
Sync and Deploy Astro Blog / manual-deploy (push) Waiting to run
Update Color Palette (.light) & Rewrite Footer
2026-04-16 22:21:40 +03:00
20d185adb7 update footer 2026-04-16 22:20:09 +03:00
d76f371c72 hash test 2026-04-16 21:10:25 +03:00
7ccdcad866 adjust color 2026-04-16 20:57:00 +03:00
c2ed2992d9 adjusted width 2026-04-16 20:53:26 +03:00
0f3b22b303 Merge branch 'dev' of https://github.com/ClovertaTheTrilobita/SanYeCao-blog into dev 2026-04-16 20:51:00 +03:00
3e461eda7a adjust color palette 2026-04-16 20:50:59 +03:00
16d7d4275b Merge branch 'dev'
Some checks are pending
Sync and Deploy Astro Blog / sync-blog-content (push) Waiting to run
Sync and Deploy Astro Blog / build-and-deploy (push) Blocked by required conditions
Sync and Deploy Astro Blog / manual-deploy (push) Waiting to run
2026-04-16 12:25:02 +03:00
f397d14fe2 Minor updates on postitem css 2026-04-16 12:24:41 +03:00
c95b9c978a Merge branch 'dev'
Some checks are pending
Sync and Deploy Astro Blog / sync-blog-content (push) Waiting to run
Sync and Deploy Astro Blog / build-and-deploy (push) Blocked by required conditions
Sync and Deploy Astro Blog / manual-deploy (push) Waiting to run
2026-04-16 03:04:51 +03:00
4b1726ac00 remove uneeded dep 2026-04-16 03:04:35 +03:00
983ef3bfb5 Merge branch 'dev' 2026-04-16 02:54:46 +03:00
9b1e1f6b93 remove uneeded packages 2026-04-16 02:53:33 +03:00
0a239f53c2 Merge branch 'dev' 2026-04-16 02:48:34 +03:00
f44d976330 merge test 2026-04-16 02:48:19 +03:00
ClovertaTheTrilobita
e3b38c20f0
Merge pull request #17 from ClovertaTheTrilobita/dev
Expanded transitions
2026-04-16 02:45:36 +03:00
ClovertaTheTrilobita
8168e1fed2
Merge pull request #16 from ClovertaTheTrilobita/dev
Deleted All Maple Mono CN for better performance
2026-04-16 02:44:33 +03:00
69e0e4e666 Expanded transitions 2026-04-16 02:43:42 +03:00
07a42ea123 deleted not needed fonts 2026-04-16 02:10:00 +03:00
fc005b4944 remove maplemono cn 2026-04-16 01:42:44 +03:00
7566503db7 Merge branch 'dev'
Some checks failed
Sync and Deploy Astro Blog / sync-blog-content (push) Has been cancelled
Sync and Deploy Astro Blog / manual-deploy (push) Has been cancelled
Sync and Deploy Astro Blog / build-and-deploy (push) Has been cancelled
2026-04-14 15:48:08 +03:00
d7fa430317 update gh workflow 2026-04-14 15:47:58 +03:00
f4425d8680 Merge branch 'dev' 2026-04-14 15:43:15 +03:00
10cad307eb update gh workflow 2026-04-14 15:43:05 +03:00
975ecf9185 Merge branch 'dev' 2026-04-14 15:37:49 +03:00
ee6112b3f8 revert spinner changes 2026-04-14 15:37:38 +03:00
131a5f8c7c Merge branch 'dev' 2026-04-14 15:32:28 +03:00
b2b7ecb34a udpate scrollbar
fix spinner
2026-04-14 15:32:18 +03:00
bdb1560dfb Merge branch 'dev'
Some checks are pending
Sync and Deploy Astro Blog / sync-blog-content (push) Waiting to run
Sync and Deploy Astro Blog / build-and-deploy (push) Blocked by required conditions
Sync and Deploy Astro Blog / manual-deploy (push) Waiting to run
2026-04-14 13:48:33 +03:00
daa458f813 update gh workflow 2026-04-14 13:48:18 +03:00
ClovertaTheTrilobita
0ae9e746a8
Merge pull request #14 from ClovertaTheTrilobita/dev
Added mermaid support
2026-04-14 13:41:20 +03:00
490c66f6b7 changed animation 2026-04-14 13:40:46 +03:00
f53b492431 fix mermaid chart 2026-04-14 13:30:40 +03:00
c1db660259 Merge branch 'dev'
Some checks are pending
Sync and Deploy Astro Blog / sync-blog-content (push) Waiting to run
Sync and Deploy Astro Blog / build-and-deploy (push) Blocked by required conditions
Sync and Deploy Astro Blog / manual-deploy (push) Waiting to run
2026-04-14 00:10:56 +03:00
2f279848b9 update comment display 2026-04-14 00:10:41 +03:00
ClovertaTheTrilobita
e12bcc0b24
Merge pull request #13 from ClovertaTheTrilobita/dev
Refactore Remark42 components & Added animation
2026-04-13 23:48:44 +03:00
8dee31ca16 Added spinner 2026-04-13 23:47:23 +03:00
9c8c0e186f refactor floating actions 2026-04-13 23:24:35 +03:00
74b3b723cf update latest posts num 2026-04-13 23:21:08 +03:00
2b2222c740 update timeline animation 2026-04-13 23:19:40 +03:00
a06f3d524f chore: deleted uneeded files 2026-04-13 23:03:50 +03:00
fdd55fb068 refactored counter 2026-04-13 23:02:21 +03:00
07fbc4b731 feat(latest comments): added reply to 2026-04-13 22:57:37 +03:00
018ad4ab1e update animation
refactor remark42 components
2026-04-13 22:35:33 +03:00
703a63fffc Added mermaid support 2026-04-13 20:59:08 +03:00
c2aa6c2a68 Merge branch 'dev'
Some checks are pending
Sync and Deploy Astro Blog / sync-blog-content (push) Waiting to run
Sync and Deploy Astro Blog / build-and-deploy (push) Blocked by required conditions
Sync and Deploy Astro Blog / manual-deploy (push) Waiting to run
2026-04-13 02:15:48 +03:00
c626bdbb07 combine workflow to one 2026-04-13 02:15:33 +03:00
ClovertaTheTrilobita
6ac21acc63
Merge pull request #12 from ClovertaTheTrilobita/dev
feat(workflow): auto merge master to blog content
2026-04-13 02:09:35 +03:00
6b804c3519 feat(workflow): auto merge master to blog content 2026-04-13 02:08:56 +03:00
141c15f557 fix post title font 2026-04-13 01:57:59 +03:00
04d81bd3a5 update doc 2026-04-13 01:53:09 +03:00
cd7e72caee update gh workflow trigger 2026-04-13 01:48:35 +03:00
ClovertaTheTrilobita
11ddcb4ae0
Merge pull request #11 from ClovertaTheTrilobita/dev
Added tags to postItem
2026-04-13 01:44:48 +03:00
0775eb7927 Added tags to postItem 2026-04-13 01:42:40 +03:00
08bdca532a update font weight 2026-04-12 23:51:15 +03:00
56e8ad5b0d update font 2026-04-12 02:38:52 +03:00
b92817088c update padding in postitem 2026-04-12 02:26:38 +03:00
38c6090edc update homepage
update progress bar
2026-04-11 20:46:53 +03:00
31849ed5ab update fontsize on phone 2026-04-11 01:33:35 +03:00
c0e7882278 added nav button
fixed invalid url in tags
2026-04-11 01:22:44 +03:00
f945f3ffef update footer 2026-04-09 12:28:50 +03:00
3f3ba829f5 update readme link 2026-04-07 15:09:24 +03:00
3b08b0960c update readme link 2026-04-07 15:07:09 +03:00
b33402746b update readme link 2026-04-07 15:04:15 +03:00
54337cf9ec update readme link 2026-04-07 15:03:39 +03:00
ade1d8f67d revert readme changes 2026-04-07 15:01:50 +03:00
77eb37fa55 update readme 2026-04-07 15:01:07 +03:00
f834b16dbe update bg and readme 2026-04-07 12:45:09 +03:00
a3eac9adb4 update bg 2026-04-07 12:38:04 +03:00
54 changed files with 4870 additions and 4314 deletions

View file

@ -1,44 +1,54 @@
name: Deploy Astro Blog name: Sync and Deploy Astro Blog
on: on:
push: push:
branches: branches:
- blog-content - master
paths:
- 'src/blog/**'
- 'src/friends/**'
- 'package.json'
- 'package-lock.json'
- 'astro.config.mjs'
- 'astro.config.ts'
- 'public/**'
- '.github/workflows/deploy-blog.yml'
workflow_dispatch: workflow_dispatch:
discussion:
types: [created, category_changed]
discussion_comment:
types: [created, edited, deleted]
concurrency: concurrency:
group: deploy-blog group: deploy-blog
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
build-and-deploy: sync-blog-content:
if: > if: github.event_name == 'push'
github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'discussion' &&
github.event.discussion.category.slug == 'comments'
) ||
(
github.event_name == 'discussion_comment' &&
github.event.discussion.category.slug == 'comments'
)
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Checkout blog-content branch
run: |
git checkout blog-content
git pull origin blog-content
- name: Merge master into blog-content
run: |
git merge origin/master --no-edit
- name: Push blog-content
run: |
git push origin blog-content
build-and-deploy:
needs: sync-blog-content
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment:
name: production
url: https://blog.cloverta.top
env: env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }} DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
@ -48,7 +58,7 @@ jobs:
steps: steps:
- name: Checkout blog-content branch - name: Checkout blog-content branch
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
ref: blog-content ref: blog-content
@ -61,6 +71,71 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Create .env file
run: |
cat > .env <<'EOF'
${{ secrets.ENV_FILE }}
EOF
- name: Build Astro site
run: npm run build
- name: Install rsync and ssh client
run: sudo apt-get update && sudo apt-get install -y rsync openssh-client
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy to server
run: |
rsync -avz --delete \
--omit-dir-times \
--no-perms \
--no-owner \
--no-group \
-e "ssh -p ${DEPLOY_PORT}" \
dist/ \
${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/
manual-deploy:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
environment:
name: production
url: https://blog.cloverta.top
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
steps:
- name: Checkout blog-content branch
uses: actions/checkout@v4
with:
ref: blog-content
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Create .env file - name: Create .env file
run: | run: |
cat > .env <<'EOF' cat > .env <<'EOF'

View file

@ -46,6 +46,12 @@ Then clone it to your local machine.
For details about environment variables, see: <b>[EnvVariables-en.md](docs/EnvVariables-en.md)</b> For details about environment variables, see: <b>[EnvVariables-en.md](docs/EnvVariables-en.md)</b>
- Install dependencies
```shell
npm install
```
- Local development - Local development
```shell ```shell
@ -80,6 +86,10 @@ For installation and deployment details, please refer to the official documentat
For details, see: <b>[ChangeLog-en.md](./docs/ChangeLog-en.md)</b> For details, see: <b>[ChangeLog-en.md](./docs/ChangeLog-en.md)</b>
### 6. Older Versions
Older versions of GISCUS are available on the <b>[deprecated/giscus](https://github.com/ClovertaTheTrilobita/SanYeCao-blog/tree/deprecated/giscus)</b> branch, but they havent been updated in a while and may contain numerous issues.
<hr> <hr>
## ⚖️ License ## ⚖️ License

View file

@ -1,6 +1,7 @@
# SanYeCao-Blog # SanYeCao-Blog
<p align="right">[<a href="./README.md">中文</a> | <a href="./README-en.md">English</a>]</p> <p align="right">[<a href="./README.md">中文</a> | <a href="./README-en.md">English</a>]</p>
<p align="center"><br> <p align="center"><br>
✨三叶草Blog✨<br> ✨三叶草Blog✨<br>
🌊轻度/快速/美观🌊<br><br> 🌊轻度/快速/美观🌊<br><br>
@ -47,6 +48,12 @@
有关环境变量的说明详见:<b>[EnvVariables.md](docs/EnvVariables.md)</b> 有关环境变量的说明详见:<b>[EnvVariables.md](docs/EnvVariables.md)</b>
- 安装依赖
```shell
npm install
```
- 本地调试 - 本地调试
```shell ```shell
@ -81,6 +88,10 @@
详情请看:<b>[ChangeLog.md](./docs/ChangeLog.md)</b> 详情请看:<b>[ChangeLog.md](./docs/ChangeLog.md)</b>
### 6. 旧版本
支持GISCUS的旧版本在 <b>[deprecated/giscus](https://github.com/ClovertaTheTrilobita/SanYeCao-blog/tree/deprecated/giscus)</b> 分支上,但是它有些时候没更新了,可能会有诸多问题。
<hr> <hr>
## ⚖️ 许可 ## ⚖️ 许可

View file

@ -2,6 +2,9 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import sitemap from "@astrojs/sitemap"; import sitemap from "@astrojs/sitemap";
import svelte from "@astrojs/svelte"; import svelte from "@astrojs/svelte";
import rehypeMermaid from "rehype-mermaid";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
// https://astro.build/config // https://astro.build/config
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
@ -18,4 +21,15 @@ export default defineConfig({
"/": "/zh", "/": "/zh",
}, },
integrations: [sitemap(), svelte()], integrations: [sitemap(), svelte()],
markdown: {
syntaxHighlight: {
type: 'shiki',
excludeLangs: ['mermaid', 'math'],
},
rehypePlugins: [
rehypeMermaid,
rehypeKatex,
],
remarkPlugins: [remarkMath],
},
}) })

View file

@ -1,95 +0,0 @@
import fs from "fs";
import path from "path";
import Fontmin from "fontmin";
function getFiles(dir) {
const results = [];
const list = fs.readdirSync(dir);
for (const file of list) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
results.push(...getFiles(filePath));
} else {
results.push(filePath);
}
}
return results;
}
function scanDirectory(dir) {
let set = new Set();
const files = getFiles(dir);
for (const file of files) {
const ignoredExtensions = [
".ttf",
".otf",
".woff",
".woff2",
".eot",
".png",
".jpg",
".jpeg",
".webp",
".gif",
".ico",
".pdf",
];
if (ignoredExtensions.some((ext) => file.endsWith(ext))) continue;
try {
const content = fs.readFileSync(file, "utf8");
const currentSet = new Set(content);
set = new Set([...set, ...currentSet]);
} catch {
// 跳过二进制等不可读文件
}
}
return set;
}
function subsetFont(src, text) {
return new Promise((resolve, reject) => {
const fontmin = new Fontmin()
.src(src)
.use(
Fontmin.glyph({
text,
hinting: false,
})
)
.use(Fontmin.ttf2woff2())
.dest("public/fonts/subset");
fontmin.run((err) => {
if (err) return reject(err);
resolve();
});
});
}
async function main() {
const baseChars =
"首页文章标签关于作者评论发布于切换主题,。!?:“”‘’()《》【】、—…·-_/\\'\"()[]{}<>:;.!? ";
const scanned = Array.from(scanDirectory("src")).join("");
const chars = Array.from(new Set((scanned + baseChars).split(""))).join("");
await Promise.all([
subsetFont("public/fonts/MapleMono-CN-Regular.ttf", chars),
subsetFont("public/fonts/MapleMono-CN-Bold.ttf", chars),
subsetFont("public/fonts/MapleMono-CN-Italic.ttf", chars),
]);
console.log(`中文子集字体生成完成,共收集 ${chars.length} 个字符`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

5527
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,21 +6,23 @@
"node": ">=22.12.0" "node": ">=22.12.0"
}, },
"scripts": { "scripts": {
"dev": "astro dev", "dev": "PUBLIC_GIT_COMMIT=$(git rev-parse HEAD) astro dev",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"subset-font": "node build/fontmin.js", "build": "PUBLIC_GIT_COMMIT=$(git rev-parse HEAD) astro build"
"build": "npm run subset-font && astro build"
}, },
"dependencies": { "dependencies": {
"@astrojs/rss": "^4.0.17", "@astrojs/rss": "^4.0.17",
"@astrojs/sitemap": "^3.7.1", "@astrojs/sitemap": "^3.7.1",
"@astrojs/svelte": "^8.0.4", "@astrojs/svelte": "^8.0.4",
"astro": "^6.0.8", "astro": "^6.0.8",
"url": "^0.11.4" "katex": "^0.17.0",
"playwright": "^1.59.1",
"rehype-katex": "^7.0.1",
"rehype-mermaid": "^3.0.0",
"remark-math": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.5.0", "@types/node": "^25.5.0"
"fontmin": "^1.1.1"
} }
} }

Binary file not shown.

Binary file not shown.

1
public/images/email.svg Normal file
View file

@ -0,0 +1 @@
<svg class="svg-icon" style="width: 1em; height: 1em;vertical-align: middle;fill: currentColor;overflow: hidden;" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M957.1 183.2c-0.2-0.4-0.3-0.8-0.5-1.2-0.7-1.7-1.6-3.3-2.5-4.8-0.3-0.5-0.7-1.1-1-1.6-0.5-0.8-1.1-1.6-1.6-2.3-0.2-0.3-0.4-0.6-0.7-0.9-0.4-0.6-0.9-1.1-1.4-1.7-0.5-0.5-1-1.1-1.5-1.6-2.3-2.4-4.9-4.6-7.7-6.4-0.7-0.4-1.4-0.9-2.1-1.3-2.8-1.7-5.9-3-9-4l-0.6-0.2c-0.5-0.2-1.1-0.3-1.7-0.4-3.7-1-7.6-1.5-11.4-1.5H106.7l-0.9 0.1h-0.2C82.2 157 64 176.6 64.1 200.1c0 12.2 5 23.9 13.8 32.3l404.8 404.8c18 17.6 46.7 17.7 64.8 0.2l323.3-323.3v468.6h-717V648.2c0-24.7-20.1-44.8-44.8-44.8-24.7 0-44.8 20.1-44.8 44.8v179.3c0 24.7 20.1 44.8 44.8 44.8h806.6c24.7 0 44.8-20.1 44.8-44.8V200.1c-0.1-5.8-1.2-11.5-3.3-16.9z m-442 359.7l-298-298h596l-298 298z" fill="" /><path d="M108.9 513.8c24.7 0 44.8-20.1 44.8-44.8v-44.8c0-24.7-20.1-44.8-44.8-44.8-24.7 0-44.8 20.1-44.8 44.8V469c0 24.7 20 44.8 44.8 44.8z" fill="" /></svg>

After

Width:  |  Height:  |  Size: 994 B

View file

@ -0,0 +1 @@
<svg class="svg-icon" style="width: 1em;height: 1em;vertical-align: middle;fill: currentColor;overflow: hidden;" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M692.906667 938.666667a42.666667 42.666667 0 0 1-42.666667-42.666667v-110.933333a91.733333 91.733333 0 0 0-23.04-70.826667 42.666667 42.666667 0 0 1 26.026667-71.253333C757.333333 630.613333 853.333333 597.333333 853.333333 416.853333a170.666667 170.666667 0 0 0-28.586666-94.72 117.333333 117.333333 0 0 1-17.493334-87.893333 158.293333 158.293333 0 0 0 0-60.16 326.4 326.4 0 0 0-89.173333 46.506667 42.666667 42.666667 0 0 1-35.84 6.4 433.066667 433.066667 0 0 0-235.52 0 42.666667 42.666667 0 0 1-35.84-6.4 315.733333 315.733333 0 0 0-90.026667-46.506667 150.186667 150.186667 0 0 0 0 60.16 121.173333 121.173333 0 0 1-18.346666 88.746667 173.653333 173.653333 0 0 0-28.586667 95.146666c0 165.973333 80.213333 210.346667 200.533333 225.706667a42.666667 42.666667 0 0 1 34.986667 28.16 42.666667 42.666667 0 0 1-8.96 42.666667 87.893333 87.893333 0 0 0-23.466667 66.56V896a42.666667 42.666667 0 0 1-85.333333 0v-24.32a256 256 0 0 1-224.853333-89.173333 166.4 166.4 0 0 0-49.493334-37.546667 42.666667 42.666667 0 1 1 21.333334-82.773333 210.346667 210.346667 0 0 1 85.333333 58.026666c42.666667 42.666667 85.333333 80.213333 166.4 64.853334a165.973333 165.973333 0 0 1 9.813333-67.413334c-87.893333-22.186667-213.333333-85.333333-213.333333-298.666666a256 256 0 0 1 42.666667-142.08 36.266667 36.266667 0 0 0 5.546666-26.453334 242.773333 242.773333 0 0 1 14.08-136.96 42.666667 42.666667 0 0 1 26.88-24.32c14.506667-4.266667 66.56-12.8 165.12 51.2a518.826667 518.826667 0 0 1 242.773334 0c98.56-64 150.613333-55.893333 164.693333-51.2a42.666667 42.666667 0 0 1 26.88 24.32 243.626667 243.626667 0 0 1 14.08 137.386667 32 32 0 0 0 4.693333 24.32 256 256 0 0 1 42.666667 142.506667c0 216.32-124.586667 279.04-213.333333 298.666666a182.613333 182.613333 0 0 1 9.386666 71.253334V896a42.666667 42.666667 0 0 1-40.106666 42.666667z" /></svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,47 @@
---
const rawHash = (import.meta.env.PUBLIC_GIT_COMMIT || "")
.replace(/[^a-fA-F0-9]/g, "")
.toLowerCase();
const safeHash = rawHash || "0000000000000000000000000000000000000000";
const blockCount = 6;
const hexPerBlock = 6;
const blocks: string[] = Array.from({ length: blockCount }, (_, i) => {
const start = i * hexPerBlock;
const part = safeHash.slice(start, start + hexPerBlock).padEnd(6, "0");
return `#${part}`;
});
---
<div
class="build-hash"
title={`Build ${safeHash}`}
aria-label={`Build ${safeHash}`}
>
<div class="build-hint">$build.onCommit.hash =</div>
{
blocks.map((color: string) => (
<span class="build-block" style={`background:${color}`} />
))
}
</div>
<style>
.build-hint {
margin-right: 5px;
font-size: 0.8rem;
}
.build-hash {
display: inline-flex;
align-items: center;
/* vertical-align: middle; */
}
.build-block {
width: 1rem;
height: 1rem;
display: inline-block;
}
</style>

View file

@ -0,0 +1,224 @@
<div class="floating-actions">
<button
id="theme-toggle-fab"
aria-label="Toggle theme"
title="Toggle theme"
>
<svg
class="theme-toggle-icon"
viewBox="0 0 24 24"
width="20"
height="20"
aria-hidden="true"
>
<path
class="sun"
fill-rule="evenodd"
d="M12 17.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0 1.5a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm12-7a.8.8 0 0 1-.8.8h-2.4a.8.8 0 0 1 0-1.6h2.4a.8.8 0 0 1 .8.8zM4 12a.8.8 0 0 1-.8.8H.8a.8.8 0 0 1 0-1.6h2.5a.8.8 0 0 1 .8.8zm16.5-8.5a.8.8 0 0 1 0 1l-1.8 1.8a.8.8 0 0 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM6.3 17.7a.8.8 0 0 1 0 1l-1.7 1.8a.8.8 0 1 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM12 0a.8.8 0 0 1 .8.8v2.5a.8.8 0 0 1-1.6 0V.8A.8.8 0 0 1 12 0zm0 20a.8.8 0 0 1 .8.8v2.4a.8.8 0 0 1-1.6 0v-2.4a.8.8 0 0 1 .8-.8zM3.5 3.5a.8.8 0 0 1 1 0l1.8 1.8a.8.8 0 1 1-1 1L3.5 4.6a.8.8 0 0 1 0-1zm14.2 14.2a.8.8 0 0 1 1 0l1.8 1.7a.8.8 0 0 1-1 1l-1.8-1.7a.8.8 0 0 1 0-1z"
></path>
<path
class="moon"
fill-rule="evenodd"
d="M16.5 6A10.5 10.5 0 0 1 4.7 16.4 8.5 8.5 0 1 0 16.4 4.7l.1 1.3zm-1.7-2a9 9 0 0 1 .2 2 9 9 0 0 1-11 8.8 9.4 9.4 0 0 1-.8-.3c-.4 0-.8.3-.7.7a10 10 0 0 0 .3.8 10 10 0 0 0 9.2 6 10 10 0 0 0 4-19.2 9.7 9.7 0 0 0-.9-.3c-.3-.1-.7.3-.6.7a9 9 0 0 1 .3.8z"
></path>
</svg>
</button>
<button id="back-to-top" aria-label="Back to top" title="Back to top">
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 15l-6-6-6 6"></path>
</svg>
</button>
</div>
<style>
.floating-actions {
position: fixed;
right: 1.25rem;
bottom: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 1000;
}
#theme-toggle-fab,
#back-to-top {
width: 3rem;
height: 3rem;
border: #a7a7a7 1.5px solid;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: #222;
/* box-shadow: 0 4px 14px rgba(0, 0, 0, 0.16); */
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(6px);
}
#theme-toggle-fab {
position: relative;
}
#theme-toggle-fab {
position: relative;
}
.theme-toggle-icon {
width: 1.2rem;
height: 1.2rem;
fill: currentColor;
}
.theme-toggle-icon .sun,
.theme-toggle-icon .moon {
transform-origin: center;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.theme-toggle-icon .sun {
opacity: 1;
transform: scale(1) rotate(0deg);
}
.theme-toggle-icon .moon {
opacity: 0;
transform: scale(0.75) rotate(-20deg);
}
:global(.dark) .theme-toggle-icon .sun {
opacity: 0;
transform: scale(0.75) rotate(20deg);
}
:global(.dark) .theme-toggle-icon .moon {
opacity: 1;
transform: scale(1) rotate(0deg);
}
#theme-toggle-fab,
#back-to-top {
/* opacity: 0; */
visibility: hidden;
transform: translateY(150px);
transition:
opacity 0.2s ease,
transform 0.27s ease,
visibility 0.2s ease;
}
#theme-toggle-fab.show,
#back-to-top.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
#theme-toggle-fab.show:hover,
#back-to-top.show:hover {
transform: translateY(-2px);
}
:global(.dark) #theme-toggle-fab,
:global(.dark) #back-to-top {
background: rgba(34, 34, 34, 0.92);
color: #f5f5f5;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.32);
border: #515151 2px solid;
}
@media (max-width: 640px) {
.floating-actions {
right: 1rem;
bottom: 1rem;
gap: 0.65rem;
}
#theme-toggle-fab,
#back-to-top {
width: 2.75rem;
height: 2.75rem;
}
}
</style>
<script>
const initFloatingActions = () => {
const backToTop = document.getElementById("back-to-top");
const themeToggle = document.getElementById("theme-toggle-fab");
if (backToTop && backToTop.dataset.bound !== "true") {
const toggleBackToTop = () => {
if (window.scrollY > 300) {
backToTop.classList.add("show");
} else {
backToTop.classList.remove("show");
}
};
backToTop.addEventListener("click", () => {
window.scrollTo({
top: 0,
behavior: "smooth",
});
});
window.addEventListener("scroll", toggleBackToTop, {
passive: true,
});
toggleBackToTop();
backToTop.dataset.bound = "true";
}
if (themeToggle && themeToggle.dataset.bound !== "true") {
const togglethemeToggle = () => {
if (window.scrollY > 300) {
themeToggle.classList.add("show");
} else {
themeToggle.classList.remove("show");
}
};
const root = document.documentElement;
const applyTheme = (theme: string) => {
if (theme === "dark") {
root.classList.add("dark");
localStorage.setItem("color-theme", "dark");
} else {
root.classList.remove("dark");
localStorage.setItem("color-theme", "light");
}
};
themeToggle.addEventListener("click", () => {
const isDark = root.classList.contains("dark");
applyTheme(isDark ? "light" : "dark");
});
window.addEventListener("scroll", togglethemeToggle, {
passive: true,
});
togglethemeToggle();
themeToggle.dataset.bound = "true";
}
};
initFloatingActions();
document.addEventListener("astro:page-load", initFloatingActions);
</script>

View file

@ -1,22 +1,85 @@
--- ---
import { getLangFromUrl, getTranslations, type Lang } from "@/i18n"; import { getLangFromUrl, getTranslations, type Lang } from "@/i18n";
import BuildHashBlocks from "@/components/BuildHashBlocks.astro";
const commit = import.meta.env.PUBLIC_GIT_COMMIT || "unknown";
const lang = getLangFromUrl(Astro.url); const lang = getLangFromUrl(Astro.url);
const t = getTranslations(lang); const t = getTranslations(lang);
--- ---
<footer class="footer"> <footer class="footer">
<p set:html={t.footer.githubIntro} /> <a
<p set:html={t.footer.repoIntro} /> href="https://github.com/ClovertaTheTrilobita/SanYeCao-blog"
class="github"
aria-label="GitHub repository"></a>
<a href="mailto:cloverta@petalmail.com" class="email" aria-label="Email"></a>
<p class="footer-content-pc">
© 2026 ClovertaTheTrilobita · <a
href="https://github.com/ClovertaTheTrilobita/SanYeCao-blog"
>Source code</a
> licensed under MIT. Content rights reserved.
<!-- <code class="build-commit">{commit.slice(0, 7)}</code> -->
</p>
<div class="footer-content-mobile">
<p>© 2026 ClovertaTheTrilobita</p>
<p>Content rights reserved.</p>
</div>
<BuildHashBlocks />
</footer> </footer>
<style> <style>
.github {
display: inline-block;
width: 30px;
height: 30px;
background-color: #1f2328;
-webkit-mask: url("/images/github-outline-7.svg") no-repeat center / contain;
mask: url("/images/github-outline-7.svg") no-repeat center / contain;
vertical-align: middle;
margin-bottom: 0.5rem;
margin-right: 0.5rem;
transition:
transform 0.18s ease,
opacity 0.18s ease;
}
.email {
display: inline-block;
width: 30px;
height: 30px;
background-color: #1f2328;
-webkit-mask: url("/images/email.svg") no-repeat center / contain;
mask: url("/images/email.svg") no-repeat center / contain;
vertical-align: middle;
margin-bottom: 0.5rem;
transition:
transform 0.18s ease,
opacity 0.18s ease;
}
.github:hover {
transform: translateY(-2px);
}
.email:hover {
transform: translateY(-2px);
}
:global(html.dark .github) {
background-color: #e6e6e6;
}
:global(html.dark .email) {
background-color: #e6e6e6;
}
.footer { .footer {
margin-top: 1rem; margin-top: 1rem;
padding: 1.5rem 0 0; padding: 1.5rem 0 0;
font-size: 0.95rem; font-size: 0.95rem;
opacity: 0.9; opacity: 0.9;
position: relative; position: relative;
text-align: center;
} }
.footer::before { .footer::before {
@ -27,22 +90,22 @@ const t = getTranslations(lang);
margin-bottom: 1rem; margin-bottom: 1rem;
background: repeating-linear-gradient( background: repeating-linear-gradient(
-45deg, -45deg,
#e96b6b 0 14px, var(--deep-red) 0 14px,
transparent 14px 28px, transparent 14px 28px,
#7da2ff 28px 42px, var(--deep-blue) 28px 42px,
transparent 42px 56px transparent 42px 56px
); );
pointer-events: none; pointer-events: none;
} }
.footer p { .footer-content-mobile {
margin: 0.4rem 0; display: none;
} }
.footer a { .footer p {
color: #7fb3ff; margin: 0.4rem 0;
font-weight: 700; font-size: 0.8rem;
text-decoration: underline; /* display: inline-flex; */
} }
:global(.dark) .footer::before { :global(.dark) .footer::before {
@ -54,4 +117,14 @@ const t = getTranslations(lang);
transparent 42px 56px transparent 42px 56px
); );
} }
@media (max-width: 700px) {
.footer-content-pc {
display: none;
}
.footer-content-mobile {
display: block;
}
}
</style> </style>

View file

@ -23,6 +23,8 @@ const links = await getCollection("friends");
<style> <style>
.friendly-link-list { .friendly-link-list {
max-width: 770px;
margin: 0 auto;
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem; gap: 1rem;

View file

@ -0,0 +1,108 @@
---
import { getLangFromUrl, getTranslations } from "@/i18n";
import "@/styles/global.css";
const lang = getLangFromUrl(Astro.url);
const t = getTranslations(lang);
---
<div class="description">
<div class="image-wrap">
<img
src="https://files.seeusercontent.com/2026/04/17/nOg4/7tx5GS3nAtOXM577XE9VOe0LUU8.webp"
alt="nichijo.webp"
/>
<p class="caption">{t.friends.imgDesc}</p>
</div>
<h1>{t.friends.title}</h1>
<div class="content">
{t.friends.content.map((line: string) => <p set:html={line} />)}
</div>
<div class="friend-code-block">
<pre><code>---
name: 'CLoverta的博客'
description: "欢迎光临,请进门左转"
url: "https://blog.cloverta.top"
avatar: 'https://s2.loli.net/2025/11/22/tiDKuzdqycx1v9B.png'
---</code></pre>
</div>
<!-- <div class="player">
<iframe
data-testid="embed-iframe"
style="border-radius:2px"
src="https://open.spotify.com/embed/track/2vuSiplO7RnGkoyw2gH96M?utm_source=generator"
width="450px"
height="80"
frameborder="0"
allowfullscreen=""
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"></iframe>
</div> -->
</div>
<style>
.player {
margin-top: 1.5rem;
display: flex;
justify-content: center;
}
.content p {
line-height: 1.8;
margin: 1rem 0;
}
.description {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.friend-code-block {
background: #fbf5f2;
border: 1px solid #8fa1ad;
border-radius: 3px;
padding: 0rem;
overflow-x: auto;
}
.friend-code-block pre {
margin: 0;
}
.friend-code-block code {
font-family: monospace;
white-space: pre;
}
.image-wrap {
width: fit-content;
margin: 0 auto;
}
.image-wrap img {
width: calc((3.8em * 4) * 16 / 5);
height: calc(3.4em * 4);
aspect-ratio: 16 / 5;
object-fit: cover;
object-position: center;
display: block;
margin: 0 auto;
border: 2px dashed gray;
filter: sepia(0.3) saturate(1.08) hue-rotate(-12deg) brightness(0.97);
}
.image-wrap .caption {
margin-top: 0.3rem;
text-align: right;
color: #888;
font-style: italic;
font-size: 0.9rem;
}
@media (max-width: 600px) {
.description img {
height: calc(2.6em * 4);
}
}
</style>

View file

@ -0,0 +1,299 @@
---
const { lang = "zh" } = Astro.props;
const host = import.meta.env.PUBLIC_REMARK42_HOST;
const siteId = import.meta.env.PUBLIC_REMARK42_SITE_ID;
const max = 10;
const heading = lang === "zh" ? "最新评论" : "Latest comments";
---
<section
class="latest-comments"
data-lang={lang}
data-host={host}
data-site-id={siteId}
data-max={max}
>
<h2>{heading}</h2>
<div class="comments-loading" aria-hidden="true">
<div class="loading-card"></div>
<div class="loading-card"></div>
<div class="loading-card"></div>
</div>
<div class="latest-comments-list" aria-live="polite"></div>
</section>
<script is:inline data-astro-rerun>
function escapeHtml(str) {
return String(str ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function formatTime(input, lang) {
try {
const date = new Date(input);
return new Intl.DateTimeFormat(lang === "zh" ? "zh-CN" : "en", {
year: "numeric",
month: "short",
day: "numeric",
}).format(date);
} catch {
return "";
}
}
function rewritePostUrl(url, lang) {
if (!url) return "#";
if (url.startsWith("/posts/")) {
return `/${lang}${url}`;
}
try {
const u = new URL(url, window.location.origin);
if (u.pathname.startsWith("/posts/")) {
return `/${lang}${u.pathname}${u.search}${u.hash}`;
}
return u.pathname + u.search + u.hash;
} catch {
return url;
}
}
function getAvatar(item) {
return (
item?.user?.picture || item?.user?.photo || item?.user?.avatar || ""
);
}
function getUserName(item, lang) {
return item?.user?.name || (lang === "zh" ? "匿名用户" : "Anonymous");
}
function normalizePath(url) {
if (!url) return "";
try {
const u = new URL(url, window.location.origin);
let path = u.pathname;
if (!path.startsWith("/")) path = "/" + path;
if (!path.endsWith("/")) path += "/";
return path;
} catch {
let path = String(url);
path = path.replace(/^https?:\/\/[^/]+/i, "");
if (!path.startsWith("/")) path = "/" + path;
if (!path.endsWith("/")) path += "/";
return path;
}
}
function buildUrlCandidates(rawUrl) {
const path = normalizePath(rawUrl);
if (!path) return [];
const noSlash = path.endsWith("/") ? path.slice(0, -1) : path;
const candidates = [
path,
noSlash,
`/zh${path}`,
`/zh${noSlash}`,
`/en${path}`,
`/en${noSlash}`,
`${window.location.origin}${path}`,
`${window.location.origin}${noSlash}`,
];
return [...new Set(candidates.filter(Boolean))];
}
async function fetchPostComments(host, siteId, rawUrl) {
const candidates = buildUrlCandidates(rawUrl);
for (const candidate of candidates) {
try {
const res = await fetch(
`${host}/api/v1/find?site=${encodeURIComponent(siteId)}&url=${encodeURIComponent(candidate)}&format=plain`,
{ credentials: "omit" },
);
if (!res.ok) continue;
const data = await res.json();
const comments = Array.isArray(data)
? data
: Array.isArray(data?.comments)
? data.comments
: [];
if (comments.length > 0) {
return comments;
}
} catch {}
}
return [];
}
async function enrichReplyTargets(host, siteId, comments) {
const urls = [
...new Set(
comments.map((item) => item?.locator?.url).filter(Boolean),
),
];
const postMap = new Map();
await Promise.all(
urls.map(async (url) => {
const postComments = await fetchPostComments(host, siteId, url);
postMap.set(url, postComments);
}),
);
return comments.map((item) => {
if (!item?.pid) {
return { ...item, replyToName: "" };
}
const postComments = postMap.get(item?.locator?.url) || [];
const parent = postComments.find(
(c) => String(c?.id) === String(item?.pid),
);
return {
...item,
replyToName: parent?.user?.name || "",
};
});
}
function renderComments(root, comments, lang) {
const list = root.querySelector(".latest-comments-list");
const loading = root.querySelector(".comments-loading");
if (!list || !loading) return;
if (!Array.isArray(comments) || comments.length === 0) {
loading.style.display = "none";
list.innerHTML = `<p class="latest-comments-empty">${
lang === "zh" ? "还没有评论" : "No comments yet"
}</p>`;
return;
}
list.innerHTML = comments
.map((item) => {
const rawAuthor = getUserName(item, lang);
const author = escapeHtml(rawAuthor);
const fallbackInitial = escapeHtml(
rawAuthor.slice(0, 1).toUpperCase(),
);
const avatar = getAvatar(item);
const safeAvatar = escapeHtml(avatar || "");
const title = escapeHtml(
item?.title ||
(lang === "zh" ? "未命名文章" : "Untitled post"),
);
const href = rewritePostUrl(item?.locator?.url || "", lang);
// Remark42 返回的 text 是处理后的 HTML适合直接渲染
const html = item?.text || "";
const time = formatTime(item?.time, lang);
const replyTo = escapeHtml(item?.replyToName || "");
const authorLine = replyTo
? `<span class="comment-author">@${author}</span><span class="comment-reply-sep">${
lang === "zh" ? "回复" : "replied to"
}</span><span class="comment-reply-to">@${replyTo}</span>`
: `<span class="comment-author">@${author}</span>`;
const avatarHtml = avatar
? `<img class="comment-avatar-img" src="${safeAvatar}" alt="${author}" loading="lazy" referrerpolicy="no-referrer" />`
: `<div class="comment-avatar-fallback" aria-hidden="true">${fallbackInitial}</div>`;
return `
<article class="comment-card">
<div class="comment-card-body">
<div class="comment-card-info">
<div class="comment-avatar">
${avatarHtml}
</div>
<div class="comment-meta">
<div class="comment-author-row">
${authorLine}
${time ? `<span class="comment-time">${escapeHtml(time)}</span>` : ""}
</div>
</div>
</div>
<div class="comment-card-title">
<a class="comment-title-link" href="${href}">${title}</a>
</div>
<div class="comment-card-text">${html}</div>
</div>
</article>
`;
})
.join("");
loading.style.display = "none";
}
async function loadLatestComments(section) {
if (!section) return;
const host = section.dataset.host;
const siteId = section.dataset.siteId;
const max = section.dataset.max || "10";
const lang = section.dataset.lang || "zh";
const list = section.querySelector(".latest-comments-list");
const loading = section.querySelector(".comments-loading");
if (!host || !siteId || !list || !loading) return;
loading.style.display = "";
list.innerHTML = "";
const url = `${host}/api/v1/last/${encodeURIComponent(max)}?site=${encodeURIComponent(siteId)}`;
try {
const res = await fetch(url, { credentials: "omit" });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
const enriched = await enrichReplyTargets(
host,
siteId,
Array.isArray(data) ? data : [],
);
renderComments(section, enriched, lang);
} catch (err) {
loading.style.display = "none";
list.innerHTML = `<p class="latest-comments-empty">${
lang === "zh"
? "最新评论加载失败"
: "Failed to load latest comments"
}</p>`;
console.error("latest comments load failed:", err);
}
}
document.querySelectorAll(".latest-comments").forEach(loadLatestComments);
</script>

View file

@ -7,7 +7,7 @@ const t = getTranslations(lang);
<nav class="site-nav" aria-label="Site navigation"> <nav class="site-nav" aria-label="Site navigation">
<div class="site-nav-desktop"> <div class="site-nav-desktop">
<a href={`/${lang}/`} data-astro-reload>{t.nav.home}</a> <a href={`/${lang}/`}>{t.nav.home}</a>
<a href={`/${lang}/about/`}>{t.nav.about}</a> <a href={`/${lang}/about/`}>{t.nav.about}</a>
<a href={`/${lang}/tags/`}>{t.nav.tags}</a> <a href={`/${lang}/tags/`}>{t.nav.tags}</a>
<a href={`/${lang}/timeline/`}>{t.nav.timeline}</a> <a href={`/${lang}/timeline/`}>{t.nav.timeline}</a>
@ -17,7 +17,7 @@ const t = getTranslations(lang);
<details class="site-nav-mobile"> <details class="site-nav-mobile">
<summary>☰ {lang === "zh" ? "导航栏" : "Menu"}</summary> <summary>☰ {lang === "zh" ? "导航栏" : "Menu"}</summary>
<div class="dropdown-menu"> <div class="dropdown-menu">
<a href={`/${lang}/`} data-astro-reload>{t.nav.home}</a> <a href={`/${lang}/`}>{t.nav.home}</a>
<a href={`/${lang}/about/`}>{t.nav.about}</a> <a href={`/${lang}/about/`}>{t.nav.about}</a>
<a href={`/${lang}/tags/`}>{t.nav.tags}</a> <a href={`/${lang}/tags/`}>{t.nav.tags}</a>
<a href={`/${lang}/timeline/`}>{t.nav.timeline}</a> <a href={`/${lang}/timeline/`}>{t.nav.timeline}</a>
@ -26,6 +26,20 @@ const t = getTranslations(lang);
</details> </details>
</nav> </nav>
<script>
document.addEventListener("click", (event) => {
const nav = document.querySelector(".site-nav-mobile");
if (!nav) return;
const target = event.target as Node;
if (!nav.contains(target)) {
nav.removeAttribute("open");
}
});
</script>
<style> <style>
.site-nav { .site-nav {
margin: 1rem 0; margin: 1rem 0;
@ -42,9 +56,9 @@ const t = getTranslations(lang);
height: 12px; height: 12px;
background: repeating-linear-gradient( background: repeating-linear-gradient(
-45deg, -45deg,
#e96b6b 0 14px, var(--deep-red) 0 14px,
transparent 14px 28px, transparent 14px 28px,
#7da2ff 28px 42px, var(--deep-blue) 28px 42px,
transparent 42px 56px transparent 42px 56px
); );
pointer-events: none; pointer-events: none;
@ -143,7 +157,7 @@ const t = getTranslations(lang);
color: white; color: white;
} }
@media (max-width: 768px) { @media (max-width: 700px) {
.site-nav-desktop { .site-nav-desktop {
display: none; display: none;
} }

View file

@ -0,0 +1,118 @@
<dialog id="image-preview-dialog" class="image-preview-dialog">
<button class="image-preview-close" aria-label="关闭图片预览">×</button>
<img id="image-preview-img" alt="" />
<a
id="image-preview-download"
class="image-preview-download"
href="#"
aria-label="下载图片"
title="下载图片"
>
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M12 3v12m0 0 5-5m-5 5-5-5M5 21h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"></path>
</svg>
</a>
</dialog>
<script is:inline>
function initImagePreview() {
const dialog = document.getElementById("image-preview-dialog");
const previewImg = document.getElementById("image-preview-img");
const closeBtn = dialog?.querySelector(".image-preview-close");
const downloadBtn = document.getElementById("image-preview-download");
if (!dialog || !previewImg || !closeBtn || !downloadBtn) return;
let currentImageUrl = "";
function getFileNameFromUrl(url) {
try {
const pathname = new URL(url, window.location.href).pathname;
return pathname.split("/").pop() || "image";
} catch {
return "image";
}
}
async function downloadImage(url) {
const response = await fetch(url, {
mode: "cors",
credentials: "omit",
});
if (!response.ok) {
throw new Error("Failed to fetch image");
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = getFileNameFromUrl(url);
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(blobUrl);
}
document.querySelectorAll("article img").forEach((img) => {
if (img.dataset.previewBound === "true") return;
img.dataset.previewBound = "true";
img.addEventListener("click", () => {
const imageUrl = img.currentSrc || img.src;
currentImageUrl = imageUrl;
previewImg.src = imageUrl;
previewImg.alt = img.alt || "";
dialog.showModal();
});
});
downloadBtn.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
if (!currentImageUrl) return;
try {
await downloadImage(currentImageUrl);
} catch (error) {
// 如果 fetch 因为跨域失败,就退回到直接打开图片
window.open(currentImageUrl, "_blank", "noopener,noreferrer");
}
});
function closePreview() {
dialog.close();
previewImg.removeAttribute("src");
currentImageUrl = "";
}
closeBtn.addEventListener("click", closePreview);
dialog.addEventListener("click", (event) => {
if (event.target === dialog) {
closePreview();
}
});
}
document.addEventListener("DOMContentLoaded", initImagePreview);
document.addEventListener("astro:page-load", initImagePreview);
</script>

View file

@ -1,37 +1,123 @@
--- ---
import "@/styles/global.css"; import "@/styles/global.css";
import Remark42Count from "@/components/remark42-counter.svelte"; import Remark42Counter from "@/components/Remark42Counter.astro";
import { getLangFromUrl } from "@/i18n";
const lang = getLangFromUrl(Astro.url);
const data = Astro.props; const data = Astro.props;
const tags = data.tags;
--- ---
<li class="post-card"> <li class="post-card">
<a href={data.url} class="post-link" data-astro-reload> <div class="post-link">
<div class="post-text"> <div class="post-text">
<span class="post-title">{data.title}</span> <a href={data.url} class="post-title-link">
<span class="post-description">{data.description}</span> <h2 class="post-title" transition:name={`post-title-${data.postId}`}>
{data.title}
</h2>
</a>
<a href={data.url} class="post-description">
{data.description}
</a>
<div class="tags" transition:name={`post-tags-${data.postId}`}>
{
tags.map((tag: string) => (
<p class="tag">
<a href={`/${lang}/tags/${tag}`}>{tag}</a>
</p>
))
}
</div>
<div class="post-meta-row"> <div class="post-meta-row">
<span class="post-date">{data.date}</span> <span class="post-date">{data.date}</span>
<span class="post-stats"> <span class="post-stats">
<span class="post-stat" <span class="post-stat">
>💬 <Remark42Count url=`${data.postPath}` client:idle /></span 💬 <Remark42Counter url={data.postPath} />
> </span>
</span> </span>
</div> </div>
</div> </div>
<img src={data.img} alt={data.title} class="post-image" loading="lazy" /> <a href={data.url} class="post-image-link">
</a> <img
src={data.img}
alt={data.title}
class="post-image"
loading="lazy"
transition:name={`post-image-${data.postId}`}
/>
</a>
</div>
</li> </li>
<div class="section-divider"></div> <div class="section-divider"></div>
<style> <style>
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.tag {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
margin: 0;
padding: 0.22em 0.65em;
font-size: 1rem;
line-height: 1.2;
border: 1px dashed #8b6b4a;
border-radius: 0;
background-color: transparent;
}
.tag a {
color: #6f4e37;
text-decoration: none;
font-size: 0.8rem;
font-weight: 500;
}
.tag a:hover {
text-decoration: underline;
}
:global(.dark) .tag {
border-color: #d8c7a1;
}
:global(.dark) .tag a {
color: #e6d8b8;
}
:global(.dark) .tag {
border-color: #d8c7a1;
}
:global(.dark) .tag a {
color: #e6d8b8;
}
.post-card { .post-card {
list-style: none; list-style: none;
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
padding-left: 0; padding-left: 0;
padding-bottom: 0.9rem;
padding-top: 0.9rem;
} }
.post-link { .post-link {
@ -46,6 +132,7 @@ const data = Astro.props;
.post-text { .post-text {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
max-width: 40rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
@ -53,10 +140,26 @@ const data = Astro.props;
min-height: calc(1.6em * 4); min-height: calc(1.6em * 4);
} }
.post-title-link {
text-decoration: none;
display: block;
}
.post-title { .post-title {
font-family: "Maple Mono", "Maple Mono CN"; margin: 0;
color: inherit; font-family:
font-weight: 700; system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
font-weight: 500;
font-size: 1.34rem; font-size: 1.34rem;
line-height: 1.6; line-height: 1.6;
display: -webkit-box; display: -webkit-box;
@ -67,8 +170,10 @@ const data = Astro.props;
.post-description { .post-description {
color: black; color: black;
text-decoration: none;
font-weight: 400; font-weight: 400;
font-style: italic; font-size: 0.92rem;
/* font-style: italic; */
} }
.post-meta-row { .post-meta-row {
@ -102,12 +207,12 @@ const data = Astro.props;
} }
.post-image { .post-image {
width: calc((1.6em * 4) * 16 / 9); width: calc((1.6em * 4) * 16 / 10);
height: calc(1.6em * 4); height: calc(1.6em * 4);
aspect-ratio: 16 / 9; aspect-ratio: 16 / 10;
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
border-radius: 0.5rem; border: 1.5px #94a0ab dashed;
flex-shrink: 0; flex-shrink: 0;
display: block; display: block;
} }
@ -120,14 +225,61 @@ const data = Astro.props;
color: #aab7c4; color: #aab7c4;
} }
@media (max-width: 640px) { @media (max-width: 600px) {
.post-link { .post-link {
gap: 0.75rem; display: grid;
grid-template-columns: 1fr 145px;
grid-template-areas:
"title title"
"desc image"
"tags image"
"meta image";
column-gap: 0.75rem;
row-gap: 0.2rem;
align-items: start;
}
.post-text {
display: contents;
min-height: auto;
}
.post-title-link {
grid-area: title;
}
.post-title {
-webkit-line-clamp: 2;
}
.post-description {
grid-area: desc;
}
.post-meta-row {
grid-area: meta;
gap: 0.7rem;
} }
.post-image { .post-image {
width: 120px; /* border: none; */
height: calc(120px * 9 / 16); grid-area: image;
width: 100%;
height: calc(145px * 10 / 16);
align-self: center;
}
.post-image-link {
grid-area: image;
align-self: center;
display: block;
width: 145px;
text-decoration: none;
}
.post-card {
padding-bottom: 0.2rem;
padding-top: 0.2rem;
} }
} }
</style> </style>

View file

@ -16,7 +16,7 @@ const filteredPosts = sortedPosts.filter((post: any) => {
return postLang === lang; return postLang === lang;
}); });
const latestPosts = filteredPosts.slice(0, 5); const latestPosts = filteredPosts.slice(0, 7);
--- ---
<ul> <ul>
@ -37,6 +37,9 @@ const latestPosts = filteredPosts.slice(0, 5);
description={post.data.description} description={post.data.description}
date={formattedDate} date={formattedDate}
img={post.data.image.url} img={post.data.image.url}
tags={post.data.tags}
slug={slug}
postId={post.id}
/> />
); );
}) })

View file

@ -0,0 +1,403 @@
---
interface Heading {
depth: number;
slug: string;
text: string;
}
interface Props {
headings: Heading[];
}
const { headings = [] } = Astro.props;
const tocHeadings = headings.filter((h) => h.depth === 2 || h.depth === 3);
---
{
tocHeadings.length > 0 && (
<>
<button
class="post-menu-mobile-toggle"
type="button"
aria-expanded="false"
aria-controls="post-menu-panel"
aria-label="目录"
>
<span
class="post-menu-mobile-preview"
data-post-menu-preview
data-default-text={tocHeadings[0]?.text ?? "本文目录"}
>
{tocHeadings[0]?.text ?? "本文目录"}
</span>
<span class="post-menu-mobile-icon" aria-hidden="true">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 6.5H19M5 12H19M5 17.5H19"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
<circle cx="3.5" cy="6.5" r="1" fill="currentColor" />
<circle cx="3.5" cy="12" r="1" fill="currentColor" />
<circle cx="3.5" cy="17.5" r="1" fill="currentColor" />
</svg>
</span>
</button>
<aside class="post-menu" id="post-menu-panel">
<nav aria-label="Table of contents">
<p class="post-menu-title">目录</p>
<ul class="post-menu-list">
{tocHeadings.map((heading) => (
<li class={`post-menu-item depth-${heading.depth}`}>
<a
href={`#${heading.slug}`}
data-heading-link
data-heading-text={heading.text}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
</aside>
</>
)
}
<style is:global>
:where(h1, h2, h3, h4, h5, h6) {
scroll-margin-top: 4rem;
}
</style>
<style>
.post-menu {
box-sizing: border-box;
}
.post-menu-title {
margin: 0 0 0.7rem;
padding-top: 0.65rem;
background-image: linear-gradient(
to right,
rgba(65, 65, 65, 0.8) 0,
rgba(65, 65, 65, 0.8) 8px,
transparent 8px,
transparent 14px
);
background-repeat: repeat-x;
background-size: 14px 2px;
background-position: top left;
font-size: 1.1rem;
font-weight: 600;
opacity: 0.72;
}
.post-menu-list {
position: relative;
list-style: none;
margin: 0;
padding: 0 0 0 0.9rem;
font-size: 1rem;
}
.post-menu-list::before {
content: "";
position: absolute;
left: 0.15rem;
top: 0.25rem;
bottom: 0.25rem;
width: 1px;
background: rgba(128, 128, 128, 0.55);
}
.post-menu-item {
position: relative;
margin: 0.42rem 0;
line-height: 1.45;
}
.post-menu-item.depth-3 {
padding-left: 0.85rem;
opacity: 0.82;
font-size: 0.94em;
}
.post-menu a {
color: inherit;
text-decoration: none;
}
.post-menu a:hover {
text-decoration: underline;
}
.post-menu-mobile-toggle {
display: none;
}
@media (min-width: 1200px) {
.post-menu {
--content-width: 90ch;
--menu-width: clamp(
140px,
calc((100vw - 1200px) * 0.42 + 140px),
260px
);
--menu-gap: 0rem;
position: fixed;
top: 8.5rem;
width: var(--menu-width);
left: calc(
50vw - var(--content-width) / 2 - var(--menu-gap) -
var(--menu-width)
);
max-height: calc(100vh - 10rem);
overflow: auto;
padding: 0.2rem 0.2rem 0.2rem 0;
opacity: 0.9;
}
}
@media (max-width: 1200px) {
.post-menu-mobile-toggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
position: fixed;
top: 0.75rem;
right: 0.8rem;
z-index: 30;
max-width: min(78vw, 22rem);
padding: 0.6rem 0.75rem;
border: 1.5px solid var(--deep-blue);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(6px);
color: inherit;
font-size: 0.85rem;
cursor: pointer;
opacity: 0;
transform: translateY(calc(-100% - 0.6rem));
pointer-events: none;
transition:
transform 0.28s ease,
opacity 0.22s ease;
}
.post-menu-mobile-toggle.is-visible {
opacity: 1;
transform: translateY(calc(env(safe-area-inset-top) + 0.4rem));
pointer-events: auto;
}
.post-menu-mobile-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
opacity: 0.78;
}
.post-menu-mobile-preview {
display: inline-block;
width: 10rem;
/* height: 1rem; */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.post-menu {
display: none;
position: fixed;
top: calc(env(safe-area-inset-top) + 4.2rem);
right: 0.8rem;
z-index: 29;
width: min(88vw, 24rem);
max-height: min(65vh, 32rem);
overflow: auto;
padding: 0.9rem 1rem;
border: 1.5px solid var(--deep-blue);
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(8px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.08);
}
.post-menu-title {
padding-top: 0;
background-image: none;
}
.post-menu.is-open {
display: block;
}
:global(html.dark) .post-menu-mobile-toggle,
:global(html.dark) .post-menu {
background: var(--background-color-dark);
color: var(--text-color-dark);
}
}
</style>
<script is:inline>
(() => {
const setupPostMenu = () => {
const btn = document.querySelector(".post-menu-mobile-toggle");
const panel = document.querySelector("#post-menu-panel");
const preview = document.querySelector("[data-post-menu-preview]");
const headingLinks = Array.from(
document.querySelectorAll("[data-heading-link]"),
);
if (!btn || !panel || !preview || headingLinks.length === 0) return;
if (btn.dataset.bound === "true") return;
btn.dataset.bound = "true";
const defaultText =
preview.getAttribute("data-default-text") || "本文目录";
const getHeadingElements = () =>
headingLinks
.map((link) => {
const href = link.getAttribute("href");
if (!href || !href.startsWith("#")) return null;
const el = document.getElementById(href.slice(1));
if (!el) return null;
return {
link,
el,
text:
link.getAttribute("data-heading-text") ||
el.textContent ||
defaultText,
};
})
.filter(Boolean);
let headingItems = getHeadingElements();
const closeMenu = () => {
panel.classList.remove("is-open");
btn.setAttribute("aria-expanded", "false");
};
document.addEventListener("click", (e) => {
const target = e.target;
if (!(target instanceof Node)) return;
const clickedInsidePanel = panel.contains(target);
const clickedButton = btn.contains(target);
if (!clickedInsidePanel && !clickedButton) {
closeMenu();
}
});
const updateVisibility = () => {
if (window.innerWidth >= 1200) {
btn.classList.remove("is-visible");
closeMenu();
return;
}
const shouldShow = window.scrollY > 80;
btn.classList.toggle("is-visible", shouldShow);
if (!shouldShow) {
closeMenu();
}
};
const updateCurrentHeading = () => {
if (window.innerWidth >= 1200) return;
headingItems = getHeadingElements();
let current = null;
const triggerLine = 140;
for (const item of headingItems) {
const rect = item.el.getBoundingClientRect();
if (rect.top <= triggerLine) {
current = item;
} else {
break;
}
}
preview.textContent = current ? current.text : defaultText;
headingLinks.forEach((link) =>
link.classList.remove("is-current"),
);
if (current) current.link.classList.add("is-current");
};
const toggleMenu = () => {
const expanded = btn.getAttribute("aria-expanded") === "true";
btn.setAttribute("aria-expanded", String(!expanded));
panel.classList.toggle("is-open", !expanded);
};
btn.addEventListener("click", toggleMenu);
panel.addEventListener("click", (e) => {
const target = e.target;
if (!(target instanceof Element)) return;
const link = target.closest("a[data-heading-link]");
if (!link) return;
const href = link.getAttribute("href");
if (!href || !href.startsWith("#")) return;
const el = document.getElementById(href.slice(1));
if (!el) return;
e.preventDefault();
el.scrollIntoView({
behavior: "smooth",
block: "start",
});
history.replaceState(null, "", href);
closeMenu();
});
const onScroll = () => {
updateVisibility();
updateCurrentHeading();
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
updateVisibility();
updateCurrentHeading();
};
setupPostMenu();
document.addEventListener("astro:page-load", setupPostMenu);
})();
</script>

View file

@ -0,0 +1,130 @@
---
import { getCollection } from "astro:content";
import type { CollectionEntry } from "astro:content";
import type { Lang } from "@/i18n";
interface Props {
post: CollectionEntry<"blog">;
lang: Lang;
}
const { post, lang } = Astro.props;
const allPosts = await getCollection("blog");
// 只保留当前语言
const sameLangPosts = allPosts.filter((p) => {
const [postLang] = p.id.split("/");
return postLang === lang;
});
// 按日期倒序:越新越靠前
const sortedPosts = [...sameLangPosts].sort(
(a, b) =>
new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime(),
);
// 找当前文章位置
const currentIndex = sortedPosts.findIndex((p) => p.id === post.id);
// 更旧的一篇
const prevPost =
currentIndex < sortedPosts.length - 1
? sortedPosts[currentIndex + 1]
: null;
// 更新的一篇
const nextPost = currentIndex > 0 ? sortedPosts[currentIndex - 1] : null;
const getSlugFromId = (id: string) => id.split("/").slice(1).join("/");
const prevSlug = prevPost ? getSlugFromId(prevPost.id) : null;
const nextSlug = nextPost ? getSlugFromId(nextPost.id) : null;
---
<nav class="post-nav" aria-label="Post navigation">
<div class="post-nav-item post-nav-prev">
{
nextPost && nextSlug && (
<a href={`/${lang}/posts/${nextSlug}/`}>
<span class="post-nav-label">
{lang === "zh" ? "上一篇" : "Previous"}
</span>
<span class="post-nav-title">{nextPost.data.title}</span>
</a>
)
}
</div>
<div class="post-nav-item post-nav-next">
{
prevPost && prevSlug && (
<a href={`/${lang}/posts/${prevSlug}/`}>
<span class="post-nav-label">
{lang === "zh" ? "下一篇" : "Next"}
</span>
<span class="post-nav-title">{prevPost.data.title}</span>
</a>
)
}
</div>
</nav>
<style>
.post-nav {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 3rem;
padding-top: 1.5rem;
padding-bottom: 1.5rem;
border-top: 1px dashed rgba(128, 128, 128, 0.5);
border-bottom: 1px dashed rgba(128, 128, 128, 0.5);
}
.post-nav-item {
min-width: 0;
}
.post-nav-next {
text-align: right;
}
.post-nav-title {
display: inline-block;
transition: transform 0.18s ease;
}
.post-nav a:hover .post-nav-title {
transform: scale(1.04);
}
.post-nav a {
display: block;
text-decoration: none;
/* color: inherit; */
}
.post-nav-label {
display: block;
font-size: 0.9rem;
opacity: 0.65;
margin-bottom: 0.35rem;
color: black;
}
.post-nav-title {
display: block;
font-size: 1rem;
line-height: 1.5;
}
@media (max-width: 768px) {
.post-nav {
grid-template-columns: 1fr;
}
.post-nav-next {
text-align: right;
}
}
</style>

View file

@ -51,9 +51,11 @@ const groupedPosts = filteredPosts.reduce((acc: any[], post: any) => {
<a <a
href={`/${lang}/posts/${slug}/`} href={`/${lang}/posts/${slug}/`}
class="timeline-card" class="timeline-card"
data-astro-reload
> >
<h2 class="post-title"> <h2
class="post-title"
transition:name={`post-title-${post.id}`}
>
{post.data.title} {post.data.title}
</h2> </h2>
<p class="post-description"> <p class="post-description">
@ -145,7 +147,8 @@ const groupedPosts = filteredPosts.reduce((acc: any[], post: any) => {
margin: 0; margin: 0;
font-size: 0.88rem; font-size: 0.88rem;
color: #555; color: #555;
font-style: italic; font-weight: 400;
/* font-style: italic; */
line-height: 1.5; line-height: 1.5;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;

View file

@ -0,0 +1,66 @@
---
const { url } = Astro.props;
const host = import.meta.env.PUBLIC_REMARK42_HOST;
const siteId = import.meta.env.PUBLIC_REMARK42_SITE_ID;
---
<span
class="remark42__counter"
data-url={url}
data-host={host}
data-site-id={siteId}></span>
<script is:inline data-astro-rerun>
function getTheme() {
return document.documentElement.classList.contains("dark")
? "dark"
: "light";
}
function mountRemark42Counters() {
const counters = Array.from(
document.querySelectorAll(".remark42__counter[data-url]"),
);
if (!counters.length) return;
const first = counters[0];
const host = first.dataset.host;
const siteId = first.dataset.siteId;
if (!host || !siteId) return;
window.remark_config = {
host,
site_id: siteId,
components: ["counter"],
show_rss_subscription: false,
theme: getTheme(),
};
for (const el of counters) {
el.textContent = "";
}
document.getElementById("remark42-counter-loader")?.remove();
const script = document.createElement("script");
script.id = "remark42-counter-loader";
script.defer = true;
if ("noModule" in script) {
script.type = "module";
script.src = `${host}/web/counter.mjs?ts=${Date.now()}`;
} else {
script.async = true;
script.src = `${host}/web/counter.js?ts=${Date.now()}`;
}
document.head.appendChild(script);
}
clearTimeout(window.__remark42CounterMountTimer);
window.__remark42CounterMountTimer = setTimeout(() => {
mountRemark42Counters();
}, 0);
</script>

View file

@ -0,0 +1,108 @@
---
const { slug } = Astro.props;
const pagePath = `/posts/${slug}/`;
const host = import.meta.env.PUBLIC_REMARK42_HOST;
const siteId = import.meta.env.PUBLIC_REMARK42_SITE_ID;
---
<div id="remark42"></div>
<script define:vars={{ pagePath, host, siteId }} is:inline data-astro-rerun>
function getTheme() {
return document.documentElement.classList.contains("dark")
? "dark"
: "light";
}
function setRemarkConfig() {
window.remark_config = {
host,
site_id: siteId,
url: pagePath,
page_id: pagePath,
theme: getTheme(),
components: ["embed"],
show_rss_subscription: false,
};
}
function ensureScript() {
return new Promise((resolve, reject) => {
const existing = document.getElementById("remark42-loader");
if (existing) {
if (window.REMARK42) {
resolve(true);
return;
}
existing.addEventListener("load", () => resolve(true), {
once: true,
});
existing.addEventListener("error", reject, { once: true });
return;
}
const script = document.createElement("script");
script.id = "remark42-loader";
script.defer = true;
if ("noModule" in script) {
script.type = "module";
script.src = `${host}/web/embed.mjs`;
} else {
script.async = true;
script.src = `${host}/web/embed.js`;
}
script.onload = () => resolve(true);
script.onerror = reject;
document.head.appendChild(script);
});
}
async function mountRemark42() {
const node = document.getElementById("remark42");
if (!node) return;
setRemarkConfig();
await ensureScript();
if (!window.REMARK42) return;
if (typeof window.REMARK42.destroy === "function") {
window.REMARK42.destroy();
}
if (typeof window.REMARK42.createInstance === "function") {
node.innerHTML = "";
window.REMARK42.createInstance(window.remark_config);
}
}
function setupRemark42ThemeObserver() {
if (window.__remark42ThemeObserver) {
window.__remark42ThemeObserver.disconnect();
}
let lastTheme = getTheme();
const observer = new MutationObserver(() => {
const currentTheme = getTheme();
if (currentTheme === lastTheme) return;
lastTheme = currentTheme;
mountRemark42();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
window.__remark42ThemeObserver = observer;
}
mountRemark42();
setupRemark42ThemeObserver();
</script>

View file

@ -0,0 +1,91 @@
<div id="page-loading-indicator" aria-hidden="true" transition:persist>
<span class="page-loading-spinner"></span>
</div>
<script is:inline>
(() => {
let timer;
const show = () => {
clearTimeout(timer);
timer = setTimeout(() => {
const el = document.getElementById("page-loading-indicator");
if (el) el.classList.add("is-active");
}, 120);
};
const hide = () => {
clearTimeout(timer);
const el = document.getElementById("page-loading-indicator");
if (el) el.classList.remove("is-active");
};
document.addEventListener("astro:before-preparation", show);
document.addEventListener("astro:page-load", hide);
hide();
})();
</script>
<style is:global>
#page-loading-indicator {
position: fixed;
top: 1rem;
right: 1rem;
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
transform: scale(1.3) translateY(-70px);
pointer-events: none;
transition: transform 160ms linear;
z-index: 9999;
}
#page-loading-indicator.is-active {
transform: scale(1) translateY(0px);
}
.page-loading-spinner {
width: 1.4rem;
height: 1.4rem;
box-sizing: border-box;
border: 1.5px solid #7da2ff;
border-top-color: white;
border-radius: 50%;
background: transparent;
box-shadow:
0 0 0 1px #606060,
inset 0 0 0 1px #606060;
animation: page-loading-spin-rhythm 2.1s infinite;
transform-origin: center;
}
@keyframes page-loading-spin-rhythm {
0% {
transform: rotate(0deg);
animation-timing-function: cubic-bezier(0.25, 0.75, 0.35, 1);
}
52% {
transform: rotate(720deg); /* 前两圈,稍微慢一点 */
animation-timing-function: cubic-bezier(0.55, 0.08, 0.78, 0.22);
}
100% {
transform: rotate(1080deg); /* 最后一圈更慢 */
}
}
.dark .page-loading-spinner,
html.dark .page-loading-spinner {
border-color: #d8c7a1;
border-top-color: transparent;
}
@keyframes page-loading-spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -34,7 +34,7 @@ const switchHref = "/" + segments.join("/");
</svg> </svg>
</button> </button>
<a class="lang-switch" href={switchHref} data-astro-reload> <a class="lang-switch" href={switchHref}>
{switchLabel} {switchLabel}
</a> </a>
</div> </div>
@ -70,15 +70,15 @@ const switchHref = "/" + segments.join("/");
} }
.lang-switch { .lang-switch {
color: black; !important color: black;
font-weight: 700; !important font-weight: 700;
font-size: 0.95rem; !important font-size: 0.95rem;
text-decoration: none; !important text-decoration: none;
line-height: 1; !important line-height: 1;
} }
.lang-switch:hover { .lang-switch:hover {
text-decoration: underline; !important text-decoration: underline;
} }
.sun { .sun {
@ -104,37 +104,37 @@ const switchHref = "/" + segments.join("/");
<script is:inline> <script is:inline>
function applyTheme() { function applyTheme() {
const localStorageTheme = localStorage?.getItem("theme") ?? ""; const localStorageTheme = localStorage?.getItem("theme") ?? "";
let theme = "light"; let theme = "light";
if (["dark", "light"].includes(localStorageTheme)) { if (["dark", "light"].includes(localStorageTheme)) {
theme = localStorageTheme; theme = localStorageTheme;
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) { } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
theme = "dark"; theme = "dark";
}
document.documentElement.classList.toggle("dark", theme === "dark");
window.localStorage.setItem("theme", theme);
} }
document.documentElement.classList.toggle("dark", theme === "dark"); function bindThemeToggle() {
window.localStorage.setItem("theme", theme); const button = document.getElementById("themeToggle");
} if (!button) return;
function bindThemeToggle() { button.onclick = () => {
const button = document.getElementById("themeToggle"); const element = document.documentElement;
if (!button) return; element.classList.toggle("dark");
button.onclick = () => { const isDark = element.classList.contains("dark");
const element = document.documentElement; localStorage.setItem("theme", isDark ? "dark" : "light");
element.classList.toggle("dark"); };
}
const isDark = element.classList.contains("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
};
}
applyTheme();
bindThemeToggle();
document.addEventListener("astro:after-swap", () => {
applyTheme(); applyTheme();
bindThemeToggle(); bindThemeToggle();
});
document.addEventListener("astro:after-swap", () => {
applyTheme();
bindThemeToggle();
});
</script> </script>

View file

@ -1,38 +0,0 @@
<script>
import { onMount } from "svelte";
export let url;
const host = import.meta.env.PUBLIC_REMARK42_HOST;
const siteId = import.meta.env.PUBLIC_REMARK42_SITE_ID;
onMount(() => {
const remark_config = {
host,
site_id: siteId,
components: ["counter"],
show_rss_subscription: false,
theme: localStorage.getItem("color-theme") ?? "light",
};
window.remark_config = remark_config;
for (const name of remark_config.components || ["embed"]) {
const script = document.createElement("script");
let ext = ".js";
if ("noModule" in script) {
script.type = "module";
ext = ".mjs";
} else {
script.async = true;
}
script.defer = true;
script.src = `${remark_config.host}/web/${name}${ext}`;
document.head.appendChild(script);
}
});
</script>
<span class="remark42__counter" data-url={url}></span>

View file

@ -1,75 +0,0 @@
<script>
import { onMount } from "svelte";
export let slug;
let pagePath = `/posts/${slug}/`;
function getTheme() {
return document.documentElement.classList.contains("dark")
? "dark"
: "light";
}
function applyRemark42Theme() {
if (window.REMARK42 && typeof window.REMARK42.changeTheme === "function") {
window.REMARK42.changeTheme(getTheme());
return true;
}
return false;
}
onMount(() => {
const scriptId = "remark42-script";
window.remark_config = {
host: import.meta.env.PUBLIC_REMARK42_HOST,
site_id: import.meta.env.PUBLIC_REMARK42_SITE_ID,
components: ["embed"],
show_rss_subscription: false,
theme: getTheme(),
url: pagePath,
page_id: pagePath,
};
if (!document.getElementById(scriptId)) {
const script = document.createElement("script");
script.id = scriptId;
script.async = true;
script.innerHTML = `
!function(e,n){
for(var o=0;o<e.length;o++){
var r=n.createElement("script"),c=".js",d=n.head||n.body;
"noModule"in r?(r.type="module",c=".mjs"):r.async=!0;
r.defer=!0;
r.src=window.remark_config.host+"/web/"+e[o]+c;
d.appendChild(r);
}
}(window.remark_config.components||["embed"],document);
`;
document.body.appendChild(script);
}
const applyWhenReady = setInterval(() => {
if (applyRemark42Theme()) {
clearInterval(applyWhenReady);
}
}, 200);
const observer = new MutationObserver(() => {
applyRemark42Theme();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => {
clearInterval(applyWhenReady);
observer.disconnect();
};
});
</script>
<div id="remark42"></div>

View file

@ -1,252 +0,0 @@
<script>
import { onMount } from "svelte";
export let lang = "zh";
let loaded = false;
function rewriteLinks() {
const links = document.querySelectorAll(
".latest-comments .comment__title-link",
);
links.forEach((link) => {
const href = link.getAttribute("href");
if (!href) return;
if (href.startsWith("/posts/")) {
link.setAttribute("href", `/${lang}${href}`);
}
});
}
onMount(() => {
window.remark_config = {
host: import.meta.env.PUBLIC_REMARK42_HOST,
site_id: import.meta.env.PUBLIC_REMARK42_SITE_ID,
components: ["last-comments"],
theme: localStorage.getItem("color-theme") ?? "light",
max_last_comments: 10,
no_footer: true,
};
const loaderId = "remark42-last-comments-loader";
if (!document.getElementById(loaderId)) {
const init = document.createElement("script");
init.id = loaderId;
init.innerHTML = `
!function(e,n){
for(var o=0;o<e.length;o++){
var r=n.createElement("script"),c=".js",d=n.head||n.body;
"noModule"in r?(r.type="module",c=".mjs"):r.async=!0;
r.defer=!0;
r.src=window.remark_config.host+"/web/"+e[o]+c;
d.appendChild(r);
}
}(window.remark_config.components||["last-comments"],document);
`;
document.body.appendChild(init);
}
const timer = setInterval(() => {
const root = document.querySelector(".remark42__last-comments");
if (root && root.children.length > 0) {
rewriteLinks();
loaded = true;
clearInterval(timer);
}
}, 200);
return () => clearInterval(timer);
});
</script>
<section class="latest-comments">
<h2>最新评论</h2>
{#if !loaded}
<div class="comments-loading" aria-hidden="true">
<div class="loading-card"></div>
<div class="loading-card"></div>
<div class="loading-card"></div>
</div>
{/if}
<div class="remark42__last-comments" data-max="10"></div>
</section>
<style>
.loading-card {
height: 92px;
margin: 0 0 14px 0;
padding: 0.9rem 1rem;
border: 1px dashed #aeb8c2;
background: linear-gradient(
90deg,
rgba(160, 175, 190, 0.06) 25%,
rgba(160, 175, 190, 0.16) 50%,
rgba(160, 175, 190, 0.06) 75%
);
background-size: 200% 100%;
animation: shimmer 1.2s infinite linear;
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
.latest-comments {
margin-top: 1.5rem;
}
.latest-comments h2 {
margin-bottom: 0.8rem;
}
.latest-comments :global(.remark42__last-comments) {
display: block;
}
.latest-comments :global(article.comment.list-comments__item) {
display: block;
margin: 0 0 14px 0;
padding: 0.9rem 1rem;
border: 1px dashed #aeb8c2;
background: rgba(160, 175, 190, 0.06);
}
.latest-comments :global(article.comment.list-comments__item:last-child) {
margin-bottom: 0;
}
:global(.dark)
.latest-comments
:global(article.comment.list-comments__item) {
background: #2a3138;
border-color: #7f8c97;
}
.latest-comments :global(.comment__body) {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
grid-template-areas:
"info title"
"text title";
gap: 0.35rem 1rem;
align-items: start;
}
.latest-comments :global(.comment__title) {
grid-area: title;
text-align: right;
min-width: 0;
white-space: normal;
}
.latest-comments :global(.comment__title-link) {
color: #6f8090;
text-decoration: none;
font-size: 0.87rem;
font-weight: 700;
line-height: 1.45;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.latest-comments :global(.comment__title-link:hover) {
text-decoration: underline;
}
.latest-comments :global(.comment__info) {
grid-area: info;
font-weight: 700;
line-height: 1.3;
color: inherit;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
font-style: italic;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Noto Sans CJK SC",
"Source Han Sans SC",
sans-serif;
}
.latest-comments :global(.comment__info)::before {
content: "@";
margin-right: 0.08em;
opacity: 0.85;
}
.latest-comments :global(.comment__text) {
grid-area: text;
margin: 0;
color: #555;
line-height: 1.7;
font-size: 1.03rem;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
white-space: pre-wrap;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Noto Sans CJK SC",
"Source Han Sans SC",
sans-serif;
}
:global(.dark)
.latest-comments
:global(article.comment.list-comments__item) {
border-color: #7f8c97;
background: rgba(180, 190, 200, 0.06);
}
:global(.dark) .latest-comments :global(.comment__title-link) {
color: #c8d2dc;
}
:global(.dark) .latest-comments :global(.comment__text) {
color: #d3d7db;
}
@media (max-width: 640px) {
.latest-comments :global(.comment__body) {
grid-template-columns: 1fr;
grid-template-areas:
"info"
"title"
"text";
}
.latest-comments :global(.comment__title) {
text-align: left;
min-width: 0;
}
.latest-comments :global(.comment__title-link) {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>

View file

@ -15,6 +15,10 @@ export default {
writtenBy: "Written by", writtenBy: "Written by",
comments: "Comments", comments: "Comments",
publishedOn: "Published on", publishedOn: "Published on",
commentsDesc: [
"Feel free to leave your thoughts here. 💭💡",
"After signing in, you can click the “Subscribe by Email” button at the bottom right of the text box to receive notifications of new interactions via email."
]
}, },
theme: { theme: {
toggle: "Toggle theme", toggle: "Toggle theme",
@ -25,7 +29,8 @@ export default {
content: [ content: [
"These few lines speak what the heart would say; Ink and paper end, but thoughts still stay", "These few lines speak what the heart would say; Ink and paper end, but thoughts still stay",
"Welcome to Cloverta's blog.", "Welcome to Cloverta's blog.",
] ],
latestTitle: "Latest Posts"
}, },
about: { about: {
title: "About Me, and This Blog", title: "About Me, and This Blog",
@ -41,9 +46,10 @@ export default {
"The design of this blog was inspired by:", "The design of this blog was inspired by:",
'· <a href="https://ex-tasty.com/">極限風味</a>', '· <a href="https://ex-tasty.com/">極限風味</a>',
'· <a href="https://blog.cloudti.de/">Parsifal\'s Blog</a>', '· <a href="https://blog.cloudti.de/">Parsifal\'s Blog</a>',
'· <a href="https://www.kokosa.icu/">Kokosa\'s Notebook</a>',
"· And some other websites whose names I have unfortunately forgotten", "· And some other websites whose names I have unfortunately forgotten",
"Thank you for your ideas and passion!", "Thank you for your ideas and passion!",
"In addition, this blog is fully open source. You can find its source code through the link in the footer.", "In addition, this blog is open source under MIT License. You can find its source code through the link in the footer.",
] ]
}, },
tags: { tags: {
@ -51,7 +57,17 @@ export default {
description: "All tags that have appeared across the blog are collected here. Click a tag to jump to the corresponding list of posts." description: "All tags that have appeared across the blog are collected here. Click a tag to jump to the corresponding list of posts."
}, },
footer: { footer: {
githubIntro: 'See more on <a href="https://repo.cloverta.top/cloverta">Forgejo</a>!', githubIntro: 'See more on <a href="https://github.com/ClovertaTheTrilobita">Github</a>!',
repoIntro: 'This blog is fully open source at <a href="https://repo.cloverta.top/cloverta/SanYeCao-blog">ClovertaTheTrilobita/SanYeCao-blog</a>' repoIntro: 'This blog is fully open source at <a href="https://github.com/ClovertaTheTrilobita/SanYeCao-blog">ClovertaTheTrilobita/SanYeCao-blog</a>'
},
friends: {
title: "Friends",
content: [
"Thank you for making it this far.",
"These are my friends, and you're very welcome to exchange links with me too!",
'You can send your link information to <a href="mailto:cloverta@petalmail.com">my email</a>.',
"The format is as follows:"
],
imgDesc: "Our everyday lives may, in fact, be a series of miracles."
} }
}; };

View file

@ -15,6 +15,10 @@ export default {
writtenBy: "作者", writtenBy: "作者",
comments: "评论", comments: "评论",
publishedOn: "发布于", publishedOn: "发布于",
commentsDesc: [
"欢迎在这里留下你的想法。💭💡",
"你可以在登录评论区后点击文本框右下角的「Subscribe by Email」以通过邮件接收最新的互动通知。"
]
}, },
theme: { theme: {
toggle: "切换主题", toggle: "切换主题",
@ -25,7 +29,8 @@ export default {
content: [ content: [
"见字如晤,展信舒颜。楮墨有限,不尽欲言。", "见字如晤,展信舒颜。楮墨有限,不尽欲言。",
"欢迎来到三叶的博客。", "欢迎来到三叶的博客。",
] ],
latestTitle: "最新文章"
}, },
about: { about: {
title: "关于我,和这个博客", title: "关于我,和这个博客",
@ -41,9 +46,10 @@ export default {
"这个博客在设计理念上参考了:", "这个博客在设计理念上参考了:",
'· <a href="https://ex-tasty.com/">極限風味</a>', '· <a href="https://ex-tasty.com/">極限風味</a>',
'· <a href="https://blog.cloudti.de/">Parsifal\'s Blog</a>', '· <a href="https://blog.cloudti.de/">Parsifal\'s Blog</a>',
'· <a href="https://www.kokosa.icu/">Kokosa\'s Notebook</a>',
"· 还有一些已经忘记名字的网站", "· 还有一些已经忘记名字的网站",
"谢谢你们的想法和热情!", "谢谢你们的想法和热情!",
"此外,这个博客完全开源,你可以从页脚的链接处获取它的源代码。", "此外,这个博客的源码使用 MIT 协议开源,你可以从页脚的链接处获取它的源代码。",
] ]
}, },
tags: { tags: {
@ -51,7 +57,17 @@ export default {
description: "在这里收集着整篇博客出现过的标签,点击标签跳转对应的文章列表。", description: "在这里收集着整篇博客出现过的标签,点击标签跳转对应的文章列表。",
}, },
footer: { footer: {
githubIntro: '在 <a href="https://repo.cloverta.top/cloverta">Forgejo</a> 查看更多!', githubIntro: '在 <a href="https://github.com/ClovertaTheTrilobita">Github</a> 查看更多!',
repoIntro: '这个博客完全开源于 <a href="https://repo.cloverta.top/cloverta/SanYeCao-blog">ClovertaTheTrilobita/SanYeCao-blog</a>' repoIntro: '这个博客完全开源于 <a href="https://github.com/ClovertaTheTrilobita/SanYeCao-blog">ClovertaTheTrilobita/SanYeCao-blog</a>'
},
friends: {
title: "友链",
content: [
"感谢你能看到这里。",
"这里是我的朋友们,也非常欢迎你来一起交换友链!",
'你可以把友链信息发送到<a href="mailto:cloverta@petalmail.com">我的邮箱</a>。',
"友链的格式如下:"
],
imgDesc: "——我们日复一日度过的日常,也许就是接连发生的奇迹"
} }
}; };

View file

@ -2,7 +2,15 @@
import Footer from "@/components/Footer.astro"; import Footer from "@/components/Footer.astro";
import Header from "@/components/Header.astro"; import Header from "@/components/Header.astro";
import { ClientRouter } from "astro:transitions"; import { ClientRouter } from "astro:transitions";
import { fade } from "astro:transitions";
import { getLangFromUrl } from "@/i18n";
import "katex/dist/katex.min.css";
const lang = getLangFromUrl(Astro.url);
const htmlLang = lang === "zh" ? "zh-CN" : "en-US";
import SEO from "@/components/SEO.astro"; import SEO from "@/components/SEO.astro";
import FloatingActions from "@/components/FloatingActions.astro";
import Spinner from "@/components/Spinner.astro";
const { const {
pageTitle, pageTitle,
@ -12,19 +20,36 @@ const {
--- ---
<!doctype html> <!doctype html>
<html lang="en"> <html lang={htmlLang}>
<head> <head>
<ClientRouter />
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<SEO title={pageTitle} description={description} image={image} /> <SEO title={pageTitle} description={description} image={image} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title> <title>{pageTitle}</title>
</head> </head>
<FloatingActions />
<body> <body>
<Header /> <Header />
<main class="page-content"> <Spinner />
<main
class="page-content"
transition:animate={fade({ duration: "0.05s" })}
>
<slot /> <slot />
</main> </main>
<Footer /> <Footer />

View file

@ -1,10 +1,14 @@
--- ---
import BaseLayout from "./BaseLayout.astro"; import BaseLayout from "./BaseLayout.astro";
import Remark42Embed from "@/components/remark42-embed.svelte"; import Remark42Embed from "@/components/Remark42Embed.astro";
import { getLangFromUrl, getTranslations } from "@/i18n"; import PostNav from "@/components/Posts/PostNav.astro";
import PostMenu from "@/components/Posts/PostMenu.astro";
import Dialog from "@/components/Posts/Dialog.astro";
import { fade } from "astro:transitions";
import { getTranslations } from "@/i18n";
import "@/styles/global.css"; import "@/styles/global.css";
const { frontmatter, lang, postId } = Astro.props; const { post, frontmatter, lang, slug, postId, headings } = Astro.props;
const comments = lang === "zh" ? "评论区" : "comments"; const comments = lang === "zh" ? "评论区" : "comments";
const t = getTranslations(lang); const t = getTranslations(lang);
--- ---
@ -14,17 +18,46 @@ const t = getTranslations(lang);
description={frontmatter.description} description={frontmatter.description}
image={frontmatter.image?.url} image={frontmatter.image?.url}
> >
<div id="reading-progress" aria-hidden="true"></div>
<a
href="/"
class="back-button"
id="back-button"
aria-label="Go back"
title="返回"
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M15 18l-6-6 6-6"></path>
</svg>
</a>
<PostMenu headings={headings} />
<article class="post-article"> <article class="post-article">
<div class="post-header"> <div class="post-header">
<div class="post-meta"> <div class="post-meta">
<h1 class="post-title">{frontmatter.title}</h1> <h1 class="post-title" transition:name={`post-title-${postId}`}>
<p class="description"><em>{frontmatter.description}</em></p> {frontmatter.title}
</h1>
<p class="description">
<em>{frontmatter.description}</em>
</p>
<p class="meta-line"> <p class="meta-line">
{t.post.publishedOn}: {frontmatter.pubDate.toLocaleDateString()} {t.post.publishedOn}: {frontmatter.pubDate.toLocaleDateString()}
</p> </p>
<p class="meta-line">{t.post.writtenBy}: {frontmatter.author}</p> <p class="meta-line">{t.post.writtenBy}: {frontmatter.author}</p>
<div class="tags"> <div class="tags" transition:name={`post-tags-${postId}`}>
{ {
frontmatter.tags.map((tag: string) => ( frontmatter.tags.map((tag: string) => (
<p class="tag"> <p class="tag">
@ -39,17 +72,23 @@ const t = getTranslations(lang);
src={frontmatter.image.url} src={frontmatter.image.url}
alt={frontmatter.image.alt} alt={frontmatter.image.alt}
class="post-cover" class="post-cover"
transition:name={`post-image-${postId}`}
/> />
</div> </div>
<div class="post-divider"></div> <div class="post-divider"></div>
<div class="post-content"> <div class="post-content" transition:animate={fade({ duration: "0.2s" })}>
<slot /> <slot />
</div> </div>
<Dialog />
<PostNav post={post} lang={lang} />
<h2>{comments}</h2> <h2>{comments}</h2>
<Remark42Embed slug={postId} client:load /> {t.post.commentsDesc.map((line: string) => <p set:html={line} />)}
<Remark42Embed slug={slug} />
</article> </article>
</BaseLayout> </BaseLayout>
@ -81,7 +120,125 @@ const t = getTranslations(lang);
})} })}
/> />
<script>
const initFloatingActions = () => {
const backButton = document.getElementById("back-button");
if (backButton && backButton.dataset.bound !== "true") {
const toggleBackButton = () => {
if (window.scrollY > 300) {
backButton.classList.add("show");
} else {
backButton.classList.remove("show");
}
};
backButton.addEventListener("click", () => {
if (window.history.length > 1) {
window.history.back();
} else {
window.location.href = "/";
}
});
window.addEventListener("scroll", toggleBackButton, {
passive: true,
});
toggleBackButton();
backButton.dataset.bound = "true";
}
};
const updateProgress = () => {
const doc = document.documentElement;
const scrollTop = window.scrollY || doc.scrollTop;
const scrollHeight = doc.scrollHeight - window.innerHeight;
const progress = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0;
const bar = document.getElementById("reading-progress");
if (bar) {
bar.style.width = `${Math.min(progress, 100)}%`;
}
};
updateProgress();
window.addEventListener("scroll", updateProgress, { passive: true });
window.addEventListener("resize", updateProgress);
initFloatingActions();
document.addEventListener("astro:page-load", initFloatingActions);
</script>
<style> <style>
#reading-progress {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
width: 0;
height: 3px;
background: #5a89ff;
transition: width 0.08s linear;
pointer-events: none;
}
.back-button {
position: fixed;
top: 1.25rem;
left: 1.25rem;
width: 3rem;
height: 3rem;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
border: #536493 1.5px solid;
background: rgba(255, 255, 255, 0.92);
color: #222;
/* box-shadow: 0 4px 14px rgba(0, 0, 0, 0.16); */
backdrop-filter: blur(6px);
z-index: 1000;
visibility: hidden;
transform: translateY(-80px);
/* opacity: 0; */
transition:
opacity 0.2s ease,
transform 0.27s ease,
visibility 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease,
color 0.2s ease;
}
.back-button.show {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.back-button.show:hover {
transform: translateY(-2px);
}
.back-button:hover {
transform: translateY(-2px);
}
:global(.dark) .back-button {
background: rgba(34, 34, 34, 0.92);
color: #f5f5f5;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.32);
border: #2f406d 2px solid;
}
@media (max-width: 640px) {
.back-button {
top: 1rem;
left: 1rem;
width: 2.75rem;
height: 2.75rem;
}
}
.post-content { .post-content {
font-family: font-family:
system-ui, system-ui,
@ -94,6 +251,7 @@ const t = getTranslations(lang);
"Noto Sans CJK SC", "Noto Sans CJK SC",
"Source Han Sans SC", "Source Han Sans SC",
sans-serif; sans-serif;
font-weight: 400;
} }
.post-content :global(code), .post-content :global(code),
@ -144,10 +302,11 @@ const t = getTranslations(lang);
.post-cover { .post-cover {
width: 300px; width: 300px;
height: auto; height: auto;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 10;
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
border-radius: 0.4rem; /* border-radius: 0.4rem; */
border: 1.5px #94a0ab dashed;
display: block; display: block;
} }
@ -206,6 +365,91 @@ const t = getTranslations(lang);
.post-cover { .post-cover {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
aspect-ratio: 16 / 9;
} }
} }
details {
margin: 1.5rem 0;
overflow: hidden;
border-top: 2px solid #1e3a5f;
border-radius: 0 0 0.5rem 0.5rem;
background: rgba(128, 128, 128, 0.14);
}
details summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.8rem 0.9rem;
cursor: pointer;
font-weight: 600;
user-select: none;
list-style: none;
}
details summary::-webkit-details-marker {
display: none;
}
details summary::before {
content: "";
display: inline-block;
font-size: 1.25rem;
line-height: 1;
transition: transform 0.2s ease;
}
details[open] summary::before {
transform: rotate(90deg);
}
/* 展开内容 */
details > :not(summary) {
box-sizing: border-box;
margin-top: 0;
margin-bottom: 0;
padding-right: 1rem;
}
/* 为列表序号预留空间 */
details > ol,
details > ul {
padding: 0.6rem 1rem 1rem 3rem;
}
details li {
margin: 0.8rem 0;
}
blockquote {
margin: 1.25rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid var(--deep-red);
background: rgba(128, 128, 128, 0.1);
border-radius: 0 0.4rem 0.4rem 0;
}
blockquote p {
margin: 0;
}
blockquote p + p {
margin-top: 0.75rem;
}
/* 块级公式:超出屏幕时只滚动公式,不撑宽整篇文章 */
.katex-display {
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
padding: 0.25rem 0;
-webkit-overflow-scrolling: touch;
}
/* 避免 KaTeX 内部元素强行把父容器撑宽 */
.katex-display > .katex {
min-width: max-content;
}
</style> </style>

View file

@ -1,6 +1,7 @@
--- ---
import BaseLayout from "@/layouts/BaseLayout.astro"; import BaseLayout from "@/layouts/BaseLayout.astro";
import FriendlyLinkList from "@/components/FriendlyLinks/FriendlyLinkList.astro"; import FriendlyLinkList from "@/components/FriendlyLinks/FriendlyLinkList.astro";
import FriendsDescription from "@/components/FriendlyLinks/FriendsDescription.astro";
import { getLangFromUrl, getTranslations } from "@/i18n"; import { getLangFromUrl, getTranslations } from "@/i18n";
import "@/styles/global.css"; import "@/styles/global.css";
@ -14,5 +15,16 @@ const headerTitle = lang === "zh" ? "友情链接" : "Friends";
--- ---
<BaseLayout pageTitle=`${headerTitle} - ${t.banner.title}`> <BaseLayout pageTitle=`${headerTitle} - ${t.banner.title}`>
<FriendsDescription />
<div class="section-divider"></div>
<FriendlyLinkList /> <FriendlyLinkList />
</BaseLayout> </BaseLayout>
<style>
.section-divider {
height: 1px;
background: #cdd2d8;
margin: 1.5rem 0;
opacity: 0.8;
}
</style>

View file

@ -2,6 +2,7 @@
import BaseLayout from "@/layouts/BaseLayout.astro"; import BaseLayout from "@/layouts/BaseLayout.astro";
import PostList from "@/components/Posts/PostList.astro"; import PostList from "@/components/Posts/PostList.astro";
import Remark42LatestComments from "@/components/remark42-latest-comments.svelte"; import Remark42LatestComments from "@/components/remark42-latest-comments.svelte";
import LatestComments from "@/components/LatestComments.astro";
import { getLangFromUrl, getTranslations } from "@/i18n"; import { getLangFromUrl, getTranslations } from "@/i18n";
import "@/styles/global.css"; import "@/styles/global.css";
@ -13,13 +14,14 @@ const lang = getLangFromUrl(Astro.url);
const t = getTranslations(lang); const t = getTranslations(lang);
const headerTitle = lang === "zh" ? "Cloverta的博客" : "Cloverta's blog"; const headerTitle = lang === "zh" ? "Cloverta的博客" : "Cloverta's blog";
const pageTitle = t.home.title; const pageTitle = t.home.title;
const rssLink = lang === "zh" ? "/rss.xml" : "/en/rss.xml"
--- ---
<BaseLayout pageTitle=`${headerTitle} - ${t.banner.subtitle}`> <BaseLayout pageTitle=`${headerTitle} - ${t.banner.subtitle}`>
<h1 class="page-title"> <h1 class="page-title">
<span>{pageTitle}</span> <span>{pageTitle}</span>
<a <a
href={`/rss.xml`} href={rssLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="RSS Feed" aria-label="RSS Feed"
@ -32,7 +34,7 @@ const pageTitle = t.home.title;
width="33" width="33"
height="33" height="33"
aria-hidden="true" aria-hidden="true"
style="margin-left: -15px; margin-bottom: -5px; color:#3f50e5" style="margin-left: 0px; margin-bottom: -5px; color:#3f50e5"
> >
<path <path
d="M6.18 17.82a1.64 1.64 0 1 1 0 3.28 1.64 1.64 0 0 1 0-3.28ZM3 10.44v2.25c4.56 0 8.27 3.71 8.27 8.27h2.25C13.52 15.16 8.84 10.44 3 10.44Zm0-4.54v2.25c7.06 0 12.81 5.75 12.81 12.81h2.25C18.06 12.66 11.3 5.9 3 5.9Z" d="M6.18 17.82a1.64 1.64 0 1 1 0 3.28 1.64 1.64 0 0 1 0-3.28ZM3 10.44v2.25c4.56 0 8.27 3.71 8.27 8.27h2.25C13.52 15.16 8.84 10.44 3 10.44Zm0-4.54v2.25c7.06 0 12.81 5.75 12.81 12.81h2.25C18.06 12.66 11.3 5.9 3 5.9Z"
@ -46,11 +48,14 @@ const pageTitle = t.home.title;
<div class="section-divider"></div> <div class="section-divider"></div>
<h2>{t.home.latestTitle}</h2>
<PostList /> <PostList />
<div class="section-divider"></div> <div class="section-divider"></div>
<Remark42LatestComments lang={lang} client:load /> <!-- <Remark42LatestComments lang={lang} client:load /> -->
<LatestComments />
</BaseLayout> </BaseLayout>
<style> <style>
@ -64,4 +69,8 @@ const pageTitle = t.home.title;
:global(.dark) .section-divider { :global(.dark) .section-divider {
background: #7f8b97; background: #7f8b97;
} }
.page-title {
font-weight: 700;
}
</style> </style>

View file

@ -22,12 +22,19 @@ export async function getStaticPaths() {
} }
const { post, lang } = Astro.props; const { post, lang } = Astro.props;
const { Content } = await render(post); const { Content, headings } = await render(post);
const [postLang, ...slugParts] = post.id.split("/"); const [postLang, ...slugParts] = post.id.split("/");
const slug = slugParts.join("/"); const slug = slugParts.join("/");
--- ---
<MarkdownPostLayout frontmatter={post.data} lang={lang} postId={slug}> <MarkdownPostLayout
post={post}
frontmatter={post.data}
lang={lang}
slug={slug}
postId={post.id}
headings={headings}
>
<Content /> <Content />
</MarkdownPostLayout> </MarkdownPostLayout>

View file

@ -34,7 +34,7 @@ const t = getTranslations(lang);
<BaseLayout pageTitle={String(tag)}> <BaseLayout pageTitle={String(tag)}>
<p class="tag-heading"> <p class="tag-heading">
{lang === "zh" ? "带有标签" : "Posts tagged with"}{" "} {lang === "zh" ? "带有标签" : "Posts tagged with"}{" "}
<span class="tag-chip">{tag}</span> <span class="tag-chip" transition:name={`post-tags-${tag}`}>{tag}</span>
{lang === "zh" ? "的文章" : ""} {lang === "zh" ? "的文章" : ""}
</p> </p>
<ul> <ul>
@ -44,12 +44,17 @@ const t = getTranslations(lang);
.toISOString() .toISOString()
.split("T")[0]; .split("T")[0];
const [postLang, ...slugParts] = post.id.split("/");
const slug = slugParts.join("/");
return ( return (
<PostItem <PostItem
url={`/${lang}/posts/${post.id}/`} url={`/${lang}/posts/${slug}/`}
title={post.data.title} title={post.data.title}
date={formattedDate} date={formattedDate}
img={post.data.image.url} img={post.data.image.url}
tags={post.data.tags}
postId={post.id}
/> />
); );
}) })

View file

@ -15,14 +15,14 @@ const tags = [...new Set(allPosts.map((post: any) => post.data.tags).flat())];
const pageTitle = lang === "zh" ? "标签索引" : "Tag Index"; const pageTitle = lang === "zh" ? "标签索引" : "Tag Index";
--- ---
<BaseLayout pageTitle=`${pageTitle} - ${t.banner.title}` `> <BaseLayout pageTitle=`${pageTitle} - ${t.banner.title}`>
<h1>{t.tags.title}</h1> <h1>{t.tags.title}</h1>
<p>{t.tags.description}</p> <p>{t.tags.description}</p>
<div class="tags"> <div class="tags">
{ {
tags.map((tag) => ( tags.map((tag) => (
<p class="tag"> <p class="tag" transition:name={`post-tags-${tag}`}>
<a href={`/${lang}/tags/${tag}`}>{tag}</a> <a href={`/${lang}/tags/${tag}`}>{tag}</a>
</p> </p>
)) ))
@ -39,6 +39,19 @@ const pageTitle = lang === "zh" ? "标签索引" : "Tag Index";
} }
.tag { .tag {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
margin: 0; margin: 0;
padding: 0.22em 0.65em; padding: 0.22em 0.65em;
font-size: 1rem; font-size: 1rem;
@ -51,6 +64,7 @@ const pageTitle = lang === "zh" ? "标签索引" : "Tag Index";
.tag a { .tag a {
color: #6f4e37; color: #6f4e37;
text-decoration: none; text-decoration: none;
font-weight: 500;
} }
.tag a:hover { .tag a:hover {

35
src/pages/en/rss.xml.js Normal file
View file

@ -0,0 +1,35 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const posts = await getCollection('blog');
const enPosts = posts
.filter((post) => post.id.startsWith('en/'))
.sort(
(a, b) =>
new Date(b.data.pubDate).getTime() -
new Date(a.data.pubDate).getTime()
);
return rss({
title: "Cloverta's Blog",
description:
"Discover more here. Welcome to Cloverta's blog 🥳",
site: context.site,
items: enPosts.map((post) => {
const [, ...slugParts] = post.id.split('/');
const slug = slugParts.join('/');
return {
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `/en/posts/${slug}/`,
};
}),
customData: '<language>en-US</language>',
});
}

View file

@ -1,26 +1,34 @@
import rss from '@astrojs/rss'; import rss from '@astrojs/rss';
import { pagesGlobToRssItems } from '@astrojs/rss';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
export async function GET(context) { export async function GET(context) {
const posts = await getCollection("blog"); const posts = await getCollection('blog');
const zhPosts = posts
.filter((post) => post.id.startsWith('zh/'))
.sort(
(a, b) =>
new Date(b.data.pubDate).getTime() -
new Date(a.data.pubDate).getTime()
);
return rss({ return rss({
title: 'Cloverta的博客', title: 'Cloverta的博客',
description: '在这里,发现更多(雾)欢迎来到三叶的博客🥳', description: '在这里,发现更多(雾)欢迎来到三叶的博客🥳',
site: context.site, site: context.site,
items: await pagesGlobToRssItems(import.meta.glob('./**/*.md')),
items: posts.map((post) => {
const [postLang, ...slugParts] = post.id.split("/");
const slug = slugParts.join("/");
return ({ items: zhPosts.map((post) => {
const [, ...slugParts] = post.id.split('/');
const slug = slugParts.join('/');
return {
title: post.data.title, title: post.data.title,
pubDate: post.data.pubDate, pubDate: post.data.pubDate,
description: post.data.description, description: post.data.description,
link: `/${postLang}/posts/${slug}/`, link: `/zh/posts/${slug}/`,
}) };
}), }),
customData: `<language>en-us</language>`,
}) customData: '<language>zh-CN</language>',
});
} }

95
src/styles/dialog.css Normal file
View file

@ -0,0 +1,95 @@
.image-preview-dialog {
padding: 0;
border: none;
background: transparent;
max-width: min(96vw, 1200px);
max-height: 96vh;
}
.image-preview-dialog::backdrop {
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(6px);
}
.image-preview-dialog img {
display: block;
max-width: 96vw;
max-height: 90vh;
object-fit: contain;
box-shadow: 0 20px 80px rgba(0, 0, 0, 0.45);
}
.image-preview-close:focus {
outline: none;
}
.image-preview-close:focus-visible {
outline: 2px solid rgba(125, 162, 255, 0.9);
outline-offset: 4px;
}
.image-preview-close {
position: fixed;
top: 1.25rem;
right: 1.25rem;
z-index: 1;
width: 2.4rem;
height: 2.4rem;
border: none;
border-radius: 999px;
background: transparent;
color: var(--text-color-dark);
font-size: 2.6rem;
line-height: 1;
cursor: pointer;
}
/* .image-preview-close:hover {
background: rgba(0, 0, 0, 0.75);
} */
/* 给文章图片一个可点击提示 */
.prose img,
.markdown-body img,
article img {
cursor: zoom-in;
}
.image-preview-download {
position: fixed;
left: 50%;
bottom: max(1.5rem, env(safe-area-inset-bottom));
transform: translateX(-50%);
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
background: transparent;
border: none;
padding: 0.4rem;
cursor: pointer;
text-decoration: none;
opacity: 0.88;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.image-preview-download:hover {
opacity: 1;
transform: translateX(-50%) translateY(-2px);
}
.image-preview-download:focus {
outline: none;
}
.image-preview-download:focus-visible {
outline: 2px solid rgba(125, 162, 255, 0.9);
outline-offset: 4px;
border-radius: 8px;
}

View file

@ -1,30 +1,9 @@
@import url("https://unpkg.com/@fontsource/maple-mono@5.2.6/400.css"); @import url("https://unpkg.com/@fontsource/maple-mono@5.2.6/400.css");
@import url("https://unpkg.com/@fontsource/maple-mono@5.2.6/400-italic.css"); @import url("https://unpkg.com/@fontsource/maple-mono@5.2.6/400-italic.css");
@import url("https://unpkg.com/@fontsource/maple-mono@5.2.6/700.css"); @import url("https://unpkg.com/@fontsource/maple-mono@5.2.6/700.css");
@import "./latest-comments.css";
@font-face { @import "./variables.css";
font-family: "Maple Mono CN"; @import "./dialog.css";
src: url("/fonts/subset/MapleMono-CN-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Maple Mono CN";
src: url("/fonts/subset/MapleMono-CN-Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Maple Mono CN";
src: url("/fonts/subset/MapleMono-CN-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
pre { pre {
padding: 1rem; padding: 1rem;
@ -36,7 +15,7 @@ pre {
code { code {
font-family: "Maple Mono", monospace; font-family: "Maple Mono", monospace;
font-size: 0.92rem; font-size: 0.96rem;
} }
/* 行内代码 */ /* 行内代码 */
@ -49,10 +28,32 @@ td code {
background: rgba(127, 127, 127, 0.12); background: rgba(127, 127, 127, 0.12);
} }
article svg.flowchart,
article svg[class*="flowchart"],
article svg[id^="mermaid-"] {
display: block;
margin: 1.5rem auto;
}
html { html {
font-family: "Maple Mono", "Maple Mono CN", monospace; /* font-family: "Maple Mono", "Maple Mono CN", monospace; */
background-color: #ffffff; font-family:
color: #1f2328; "Noto Serif SC",
"Source Han Serif SC",
"Songti SC",
"STSong",
"SimSun",
serif;
font-weight: 500;
/* background-color: #ffffff; */
background-color: var(--background-color);
color: var(--text-color);
}
html {
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: #828282 transparent;
} }
body { body {
@ -61,6 +62,7 @@ body {
max-width: 90ch; max-width: 90ch;
padding: 1rem; padding: 1rem;
line-height: 1.7; line-height: 1.7;
font-size: 1.15rem;
} }
body::after { body::after {
@ -72,13 +74,14 @@ body::after {
/* 按需调整大小 */ /* 按需调整大小 */
height: 400px; height: 400px;
/* 按需调整大小 */ /* 按需调整大小 */
background-image: url("https://files.seeusercontent.com/2026/03/30/4Xfr/bc7e804dc2c8ecaf407c9d665414ff72.webp"); background-image: url("https://files.seeusercontent.com/2026/04/16/1sXb/touhou___kirisame_marisa__2__by_.webp");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right bottom; background-position: right bottom;
background-size: contain; background-size: contain;
opacity: 0.35; opacity: 0.35;
pointer-events: none; pointer-events: none;
z-index: -1; z-index: -1;
} }
html.dark body::after { html.dark body::after {
@ -87,10 +90,21 @@ html.dark body::after {
@media (max-width: 900px) { @media (max-width: 900px) {
body::after { body::after {
opacity: 0.06; opacity: 0;
} }
} }
@media (max-width: 700px) {
code {
font-size: 0.92rem;
}
body {
font-size: 1rem;
}
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -103,7 +117,7 @@ h1 {
a { a {
color: #416bd6; color: #3D74B6;
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
} }
@ -148,3 +162,111 @@ img {
height: auto; height: auto;
display: block; display: block;
} }
/* 避免 Markdown 内容撑宽正文 */
.post-article,
.post-content {
min-width: 0;
max-width: 100%;
}
/* 块级公式过长时独立横向滚动 */
.post-content .katex-display {
display: block;
width: 100%;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
box-sizing: border-box;
padding: 0.25rem 0;
-webkit-overflow-scrolling: touch;
}
.post-content .katex-display > .katex {
display: inline-block;
min-width: max-content;
white-space: nowrap;
}
/* =========================
Markdown details 下拉栏
========================= */
.post-content details {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
margin: 1.5rem 0;
overflow: hidden;
border-top: 2px solid #1e3a5f;
border-radius: 0 0 0.5rem 0.5rem;
background: rgba(128, 128, 128, 0.14);
}
.post-content details summary {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
padding: 0.8rem 0.9rem;
cursor: pointer;
font-weight: 600;
user-select: none;
list-style: none;
}
.post-content details summary::-webkit-details-marker {
display: none;
}
.post-content details summary::before {
content: "";
display: inline-block;
flex: 0 0 auto;
font-size: 1.25rem;
line-height: 1;
transition: transform 0.2s ease;
}
.post-content details[open] summary::before {
transform: rotate(90deg);
}
.post-content details > :not(summary) {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
margin-top: 0;
margin-bottom: 0;
padding-right: 1rem;
}
.post-content details > ol,
.post-content details > ul {
padding: 0.6rem 1rem 1rem 3rem;
}
.post-content details li {
margin: 0.8rem 0;
}
blockquote {
margin: 1.25rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid var(--deep-red);
background: rgba(128, 128, 128, 0.1);
border-radius: 0 0.4rem 0.4rem 0;
}
blockquote p {
margin: 0;
}
blockquote p + p {
margin-top: 0.75rem;
}

View file

@ -0,0 +1,263 @@
.latest-comments {
margin-top: 1.5rem;
}
.latest-comments h2 {
margin-bottom: 0.8rem;
}
.loading-card {
height: 92px;
margin: 0 0 14px 0;
padding: 0.9rem 1rem;
border: 1px dashed #aeb8c2;
background: linear-gradient(90deg,
rgba(160, 175, 190, 0.06) 25%,
rgba(160, 175, 190, 0.16) 50%,
rgba(160, 175, 190, 0.06) 75%);
background-size: 200% 100%;
animation: shimmer 1.2s infinite linear;
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
.latest-comments-list {
display: block;
}
.comment-card {
display: block;
margin: 0 0 14px 0;
padding: 0.6rem 1rem;
border: 2px dashed #aeb8c2;
background-color: #fbf5f2;
}
.comment-card:last-child {
margin-bottom: 0;
}
.comment-card-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
grid-template-areas:
"info title"
"text title";
gap: 0.2rem 1rem;
align-items: start;
}
.comment-card-info {
grid-area: info;
display: flex;
align-items: center;
gap: 0.7rem;
min-width: 0;
}
.comment-avatar {
width: 35px;
height: 35px;
flex-shrink: 0;
}
.comment-avatar-img,
.comment-avatar-fallback {
width: 35px;
height: 35px;
display: block;
border-radius: 50%;
object-fit: cover;
}
.comment-avatar-img {
border: 1px dashed #aeb8c2;
background: #f3f5f7;
}
.comment-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #aeb8c2;
background: rgba(160, 175, 190, 0.12);
color: #5e6b77;
font-weight: 700;
font-size: 1rem;
user-select: none;
}
.comment-meta {
min-width: 0;
flex: 1;
}
.comment-author-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.6rem;
min-width: 0;
line-height: 1.3;
font-style: italic;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Noto Sans CJK SC",
"Source Han Sans SC",
sans-serif;
}
.comment-author {
font-weight: 400;
font-size: 0.8rem;
color: gray;
overflow-wrap: anywhere;
word-break: break-word;
}
.comment-time {
color: #7a7a7a;
font-size: 0.7rem;
}
.comment-card-title {
grid-area: title;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
text-align: right;
min-width: 0;
}
.comment-title-link {
color: #6f8090;
text-decoration: none;
font-size: 1.1rem;
font-weight: 700;
line-height: 1.45;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.comment-title-link:hover {
text-decoration: underline;
}
.comment-card-text {
grid-area: text;
margin: 0;
color: #555;
line-height: 1.5;
font-size: 0.93rem;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Noto Sans CJK SC",
"Source Han Sans SC",
sans-serif;
}
.comment-card-text :global(p),
.comment-card-text p {
margin: 0;
}
.comment-card-text :global(img),
.comment-card-text img {
max-width: 100%;
height: auto;
}
.latest-comments-empty {
color: #777;
font-style: italic;
}
.comment-reply-sep {
font-size: 0.8rem;
opacity: 0.7;
}
.latest-comments .comment-reply-to {
font-weight: 400;
font-size: 0.8rem;
color: gray;
opacity: 0.9;
overflow-wrap: anywhere;
word-break: break-word;
}
html.dark .latest-comments .comment-card,
.dark .latest-comments .comment-card {
border-color: #7f8c97;
background-color: #252525;
}
html.dark .comment-title-link {
color: #c8d2dc;
}
html.dark .comment-card-text {
color: #d3d7db;
}
html.dark .comment-time {
color: #a8b0b7;
}
html.dark .latest-comments .comment-avatar-fallback,
.dark .latest-comments .comment-avatar-fallback {
border-color: #7f8c97;
background-color: #3a444d;
color: #dbe3ea;
}
:global(.dark) .comment-avatar-fallback {
background: rgba(180, 190, 200, 0.12);
color: #dbe3ea;
}
@media (max-width: 640px) {
.comment-card-body {
grid-template-columns: 1fr;
grid-template-areas:
"title"
"info"
"text";
}
.comment-card-title {
text-align: left;
margin-bottom: 0.2rem;
}
.comment-title-link {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}

9
src/styles/variables.css Normal file
View file

@ -0,0 +1,9 @@
:root {
--deep-blue: #536493;
--deep-red: #ef5a6f;
--background-color: #f9f2ed;
--text-color: #0E2F56;
--background-color-dark: #1e1e1e;
--text-color-dark: #e6e6e6;
}