First commit

This commit is contained in:
KynixInHK 2024-11-19 21:42:37 +08:00
commit 13350a6396
79 changed files with 10423 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# build output
dist/
# generated types
.astro/
.vscode/
.vercel/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Adrian Chen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

160
README.md Normal file
View File

@ -0,0 +1,160 @@
# Astro Blog Theme Curve
本倉庫是基於 [Astro](https://astro.build/) 構建的部落格主題。名為主題,實際是一個完整的、開箱即用的部落格模板。美學基礎來自 `imsyy` 大神釋出的基於 VitePress 的部落格主題 [Curve](https://github.com/imsyy/vitepress-theme-curve)。
## 為什麼要做這個主題?
大神的主題似乎有出現小 bug即在生產模式下當某個 category 或 tag 有超過一頁的列表項時,切換分頁會導致頁面無法正常渲染。由於原主題在開發模式下運行良好,因此我無法確定問題的根源(才疏學淺)。因此一不做二不休,我決定僅基於原主題的美學基礎,自行在 Astro 上復現一個部落格主題。然而,由於大神的水平遠超於我,因此有一些功能復現及其失敗,有一些甚至無法復現,敬請諒解。
## 技術群
- Astro
- React
- TypeScript
- Tailwind CSS
- SCSS
- Vercel Serverless Function
## 已經實現的功能和提上日程的功能
### 已經實現的功能
- [x] 文章列表
- [x] 文章分類
- [x] 文章標籤
- [x] 基本動畫和過渡效果
- [x] 文章目錄
- [x] 集成 [Dewvine](https://merak.axiomatrix.org/Axiomatrix_Org/Dewvine)
- [x] 集成 Vercel Serverless Function 部署
### 尚未實現的功能
- [ ] 文章搜索
- [ ] 基於 Twikoo 的文章評論系統
- [ ] 文章分享功能
- [ ] 檔案鎖
- [ ] 其他尚未想到的點子
## 如何使用
1. Clone 本倉庫
```bash
git clone https://merak.axiomatrix.org/Axiomatrix_Org/astro-blog-theme-curve.git
```
2. 安裝依賴(推薦使用 pnpm
```bash
cd astro-blog-theme-curve
pnpm install
```
3. 配置檔:整個部落格的配置檔位於 `src/config/blog.config.ts` 檔案中,具體的配置條列將在下方列出。
4. 開始寫作:本專案已集成對 `markdown``mdx` 的支援,以及現代化的公式 `katex` 支援。所有文章必須放置在 `src/content/articles` 目錄下,並且以 `.md``.mdx` 為副檔名。Front Matter 部分也有具體的要求,詳情請參見下方的 Front Matter 規範。
5. 預覽:在開發模式下,執行 `pnpm dev` 即可在本地啟動一個開發伺服器,並且在 `http://localhost:4321` 上預覽部落格。
6. 部署到 Vercel目前部落格僅支援 Vercel Serverless Function 部署。或者你也可以參見 [Astro 官方文檔](https://docs.astro.build/en/guides/deploy/) 進行部署到其他靜態網站伺服站。要將部落格部署到 Vercel只需要將本倉庫 push 至 GitHub 或其他支援 Vercel 的 Git 服務器,然後在 Vercel 上選擇部署即可。注意,以 Vercel Serverless Function 方式 build 後,**輸出目錄將不再是預設的 `dist`,而是 `.vercel/output`**,請在 Vercel 上設置正確的部署路徑。
## 配置檔中的配置條列
```typescript
type BlogConfigType = {
// 部落格標題
title: string,
// 作者名稱,將顯示在部落格底部的版權聲明中,除非你在文章的 Front Matter 中指定了其他作者
author: string,
// 部落格描述
description: string,
// 部落格首次部署的日期,格式為 YYYY-MM-DD
birth: string,
// 部落格部署後的基座 url
base: string,
// 部落格頂部的導航選單
nav: {
// 一級導航選單項的名字
title: string,
// 一級導航選單項的 icon使用 [tabler Icons](https://tabler.io/icons) 的 icon 名稱
icon?: string,
// 一級導航選單項的連結
link?: string,
// 二級導航選單項
children?: {
title: string,
icon?: string,
link: string
}[]
}[],
// Dewvine 配置,詳情參考 Dewvine 官方文檔
dewvine: {
url: string,
type: string,
lang: string,
length: number,
number: number
},
// 部落格首頁側欄的個人資料
profile: {
// 你的名字
name: string,
// 你的大頭貼 url
avatar: string,
// 你的座右銘
motto: string,
// 你的社交連結,建議最多兩個
social: {
icon: string,
link: string
}[]
},
// 部落格首頁的重大節日計時器
timer: {
// 重大節日的名稱
name: string,
// 重大節日的日期,格式為 YYYY-MM-DD
time: string
},
// about 中的聯絡方式
social: {
icon: string,
link: string
}[],
// 致謝列表
thanks: {
// 致謝人員或網站的大頭貼 url
iconSrc: string,
// 致謝人員或網站的名字
name: string,
// 致謝人員或網站的描述
description: string,
// 致謝人員或網站的連結
url: string
}[],
// 友情鏈接
links: {
// 友鏈分組名稱
title: string,
// 友鏈分組描述
description: string,
// 分組的友鏈列表
links: {
// 友鏈的大頭貼 url
iconSrc: string,
// 友鏈的名字
name: string,
// 友鏈的描述
description: string,
// 友鏈的連結
url: string
}[]
}[]
}
```
## Front Matter 規範
```yaml
title: 文章標題string必填
description: 文章描述string可選
date: 文章發布日期,格式為 YYYY-MM-DDstring必填
tags: 文章標籤string[],可選
categories: 文章分類string[],可選
cover: 文章封面圖片 urlstring可選暫未實現
author: 文章作者string可選
pin: 是否釘住文章boolean可選
gpt: FakeGPT 的生成內容string可選
```
## LICENSE
- MIT

34
astro.config.mjs Normal file
View File

@ -0,0 +1,34 @@
// @ts-check
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import vercel from '@astrojs/vercel/serverless';
import remarkDirective from "remark-directive";
import astroStarlightRemarkAsides from "./plugins/astro-starlight-remark-aside/index.js";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import expressiveCode from "astro-expressive-code";
import {pluginLineNumbers} from "@expressive-code/plugin-line-numbers";
// https://astro.build/config
export default defineConfig({
integrations: [react(), tailwind(), expressiveCode({
plugins: [pluginLineNumbers()],
}), mdx()],
output: 'server',
adapter: vercel(),
prefetch: {
defaultStrategy: "viewport"
},
markdown: {
remarkPlugins: [remarkDirective, astroStarlightRemarkAsides, remarkMath],
rehypePlugins: [rehypeKatex],
shikiConfig: {
theme: "catppuccin-macchiato"
}
}
});

47
package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "kynix-blog",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"i18n:extract": "astro-i18n extract",
"i18n:generate:pages": "astro-i18n generate:pages --purge",
"i18n:generate:types": "astro-i18n generate:types",
"i18n:sync": "npm run i18n:generate:pages && npm run i18n:generate:types"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^3.1.9",
"@astrojs/react": "^3.6.2",
"@astrojs/tailwind": "^5.1.2",
"@astrojs/vercel": "^7.8.2",
"@expressive-code/plugin-line-numbers": "^0.38.3",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@tabler/icons-webfont": "^3.21.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-transition-group": "^4.4.11",
"astro": "^4.16.10",
"astro-expressive-code": "^0.38.3",
"hastscript": "^9.0.0",
"katex": "^0.16.11",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.3.0",
"react-spinners": "^0.14.1",
"react-transition-group": "^4.4.5",
"rehype-katex": "^7.0.1",
"remark-directive": "^3.0.0",
"remark-math": "^6.0.0",
"sass": "^1.80.6",
"shiki": "^1.23.0",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"unist-util-visit": "^5.0.0"
}
}

View File

@ -0,0 +1,120 @@
/* based on https://github.com/Microflash/remark-callout-directives */
/* inspired by https://github.com/withastro/starlight/blob/main/packages/starlight/integrations/asides.ts */
import { visit } from "unist-util-visit";
import { h } from "hastscript";
/**
* adds callouts for asides just like starlight
* you must add the respective .css file to the pages you use this on
*
* ```
* :::tip[Tip Example]
* example tip
* :::
* ```
* supported callotus: note, tip, caution, danger and success
* @see {@link callouts} to edit icons
*/
export default function astroStarlightRemarkAsides() {
return (tree) => {
visit(tree, (node) => {
if (node.type === "containerDirective") {
if (!callouts[node.name]) {
return;
}
const callout = callouts[node.name];
const data = node.data || (node.data = {});
const { ...attributes } = node.attributes;
// logic to support :::tip{title="title"} syntax and default to Tip for :::tip
let title = node.attributes.title || callout.title;
node.attributes = {
...attributes,
class:
"class" in attributes
? `callout callout-${node.name} ${attributes.class}`
: `callout callout-${node.name}`,
};
// logic to support :::tip[title] syntax
// remark-directive converts a containers “label” to a paragraph at children[0] with the `directiveLabel` property set to true
if (node.children[0].data?.directiveLabel) {
title = node.children[0].children[0].value;
node.children.shift();
}
node.children = generate(title, node.children, callout.icon);
// TO DO: maybe get rid of h just make it set the classes in hProperties without needing hast
const hast = h("aside", node.attributes);
data.hName = hast.tagName;
data.hProperties = hast.properties;
}
});
};
}
const callouts = {
note: {
title: "Note",
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true"><path d="M12 8h.01M12 12v4"/><circle cx="12" cy="12" r="10"/></svg>`,
},
success: {
title: "Success",
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true"><path d="m8 12 2.7 2.7L16 9.3"/><circle cx="12" cy="12" r="10"/></svg>`,
},
caution: {
title: "Caution",
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true"><path d="M12 9v4m0 4h.01M8.681 4.082C9.351 2.797 10.621 2 12 2s2.649.797 3.319 2.082l6.203 11.904a4.28 4.28 0 0 1-.046 4.019C20.793 21.241 19.549 22 18.203 22H5.797c-1.346 0-2.59-.759-3.273-1.995a4.28 4.28 0 0 1-.046-4.019L8.681 4.082Z"/></svg>`,
},
danger: {
title: "Danger",
icon: `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" aria-hidden="true"><path d="M12 7C11.7348 7 11.4804 7.10536 11.2929 7.29289C11.1054 7.48043 11 7.73478 11 8V12C11 12.2652 11.1054 12.5196 11.2929 12.7071C11.4804 12.8946 11.7348 13 12 13C12.2652 13 12.5196 12.8946 12.7071 12.7071C12.8946 12.5196 13 12.2652 13 12V8C13 7.73478 12.8946 7.48043 12.7071 7.29289C12.5196 7.10536 12.2652 7 12 7ZM12 15C11.8022 15 11.6089 15.0586 11.4444 15.1685C11.28 15.2784 11.1518 15.4346 11.0761 15.6173C11.0004 15.8 10.9806 16.0011 11.0192 16.1951C11.0578 16.3891 11.153 16.5673 11.2929 16.7071C11.4327 16.847 11.6109 16.9422 11.8049 16.9808C11.9989 17.0194 12.2 16.9996 12.3827 16.9239C12.5654 16.8482 12.7216 16.72 12.8315 16.5556C12.9414 16.3911 13 16.1978 13 16C13 15.7348 12.8946 15.4804 12.7071 15.2929C12.5196 15.1054 12.2652 15 12 15ZM21.71 7.56L16.44 2.29C16.2484 2.10727 15.9948 2.00368 15.73 2H8.27C8.00523 2.00368 7.75163 2.10727 7.56 2.29L2.29 7.56C2.10727 7.75163 2.00368 8.00523 2 8.27V15.73C2.00368 15.9948 2.10727 16.2484 2.29 16.44L7.56 21.71C7.75163 21.8927 8.00523 21.9963 8.27 22H15.73C15.9948 21.9963 16.2484 21.8927 16.44 21.71L21.71 16.44C21.8927 16.2484 21.9963 15.9948 22 15.73V8.27C21.9963 8.00523 21.8927 7.75163 21.71 7.56ZM20 15.31L15.31 20H8.69L4 15.31V8.69L8.69 4H15.31L20 8.69V15.31Z"></path></svg>`,
},
tip: {
title: "Tip",
icon: `<svg viewBox="0 0 16 16" width="24" height="24" aria-hidden="true" fill="currentColor"><path d="M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z"></path></svg>`,
},
};
function generate(title, children, icon) {
const iconNode = {
type: "html",
value: icon,
};
const titleNode = {
type: "paragraph",
children: [
{
type: "text",
value: title,
},
],
data: {
hName: "span",
hProperties: { className: ["callout-title"] },
},
};
return [
{
type: "paragraph",
data: {
hName: "div",
hProperties: { className: ["callout-indicator"] },
},
children: [iconNode, titleNode],
},
{
type: "paragraph",
data: {
hName: "div",
hProperties: { className: ["callout-content"] },
},
children,
},
];
}

View File

@ -0,0 +1,16 @@
declare module "astro-starlight-remark-asides" {
/**
* you must add the respective .css file to the pages you use this on
*
* ```
* :::tip[Tip Example]
* example tip
* :::
* ```
*
* supported callotus: `note`, `tip`, `caution`, `danger` and `success`
*
* dark mode styles are based on `:root[data-theme="dark"]`
*/
export default function astroStarlightRemarkAsides(): (tree: any) => void;
}

6238
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

9
public/favicon.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

BIN
src/assets/Designer.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

1
src/assets/enfp.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 50 KiB

BIN
src/assets/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='192' height='192' viewBox='0 0 192 192'><path fill='#fcfcfc' fill-opacity='0.08' d='M192 15v2a11 11 0 0 0-11 11c0 1.94 1.16 4.75 2.53 6.11l2.36 2.36a6.93 6.93 0 0 1 1.22 7.56l-.43.84a8.08 8.08 0 0 1-6.66 4.13H145v35.02a6.1 6.1 0 0 0 3.03 4.87l.84.43c1.58.79 4 .4 5.24-.85l2.36-2.36a12.04 12.04 0 0 1 7.51-3.11 13 13 0 1 1 .02 26 12 12 0 0 1-7.53-3.11l-2.36-2.36a4.93 4.93 0 0 0-5.24-.85l-.84.43a6.1 6.1 0 0 0-3.03 4.87V143h35.02a8.08 8.08 0 0 1 6.66 4.13l.43.84a6.91 6.91 0 0 1-1.22 7.56l-2.36 2.36A10.06 10.06 0 0 0 181 164a11 11 0 0 0 11 11v2a13 13 0 0 1-13-13 12 12 0 0 1 3.11-7.53l2.36-2.36a4.93 4.93 0 0 0 .85-5.24l-.43-.84a6.1 6.1 0 0 0-4.87-3.03H145v35.02a8.08 8.08 0 0 1-4.13 6.66l-.84.43a6.91 6.91 0 0 1-7.56-1.22l-2.36-2.36A10.06 10.06 0 0 0 124 181a11 11 0 0 0-11 11h-2a13 13 0 0 1 13-13c2.47 0 5.79 1.37 7.53 3.11l2.36 2.36a4.94 4.94 0 0 0 5.24.85l.84-.43a6.1 6.1 0 0 0 3.03-4.87V145h-35.02a8.08 8.08 0 0 1-6.66-4.13l-.43-.84a6.91 6.91 0 0 1 1.22-7.56l2.36-2.36A10.06 10.06 0 0 0 107 124a11 11 0 0 0-22 0c0 1.94 1.16 4.75 2.53 6.11l2.36 2.36a6.93 6.93 0 0 1 1.22 7.56l-.43.84a8.08 8.08 0 0 1-6.66 4.13H49v35.02a6.1 6.1 0 0 0 3.03 4.87l.84.43c1.58.79 4 .4 5.24-.85l2.36-2.36a12.04 12.04 0 0 1 7.51-3.11A13 13 0 0 1 81 192h-2a11 11 0 0 0-11-11c-1.94 0-4.75 1.16-6.11 2.53l-2.36 2.36a6.93 6.93 0 0 1-7.56 1.22l-.84-.43a8.08 8.08 0 0 1-4.13-6.66V145H11.98a6.1 6.1 0 0 0-4.87 3.03l-.43.84c-.79 1.58-.4 4 .85 5.24l2.36 2.36a12.04 12.04 0 0 1 3.11 7.51A13 13 0 0 1 0 177v-2a11 11 0 0 0 11-11c0-1.94-1.16-4.75-2.53-6.11l-2.36-2.36a6.93 6.93 0 0 1-1.22-7.56l.43-.84a8.08 8.08 0 0 1 6.66-4.13H47v-35.02a6.1 6.1 0 0 0-3.03-4.87l-.84-.43c-1.59-.8-4-.4-5.24.85l-2.36 2.36A12 12 0 0 1 28 109a13 13 0 1 1 0-26c2.47 0 5.79 1.37 7.53 3.11l2.36 2.36a4.94 4.94 0 0 0 5.24.85l.84-.43A6.1 6.1 0 0 0 47 84.02V49H11.98a8.08 8.08 0 0 1-6.66-4.13l-.43-.84a6.91 6.91 0 0 1 1.22-7.56l2.36-2.36A10.06 10.06 0 0 0 11 28 11 11 0 0 0 0 17v-2a13 13 0 0 1 13 13c0 2.47-1.37 5.79-3.11 7.53l-2.36 2.36a4.94 4.94 0 0 0-.85 5.24l.43.84A6.1 6.1 0 0 0 11.98 47H47V11.98a8.08 8.08 0 0 1 4.13-6.66l.84-.43a6.91 6.91 0 0 1 7.56 1.22l2.36 2.36A10.06 10.06 0 0 0 68 11 11 11 0 0 0 79 0h2a13 13 0 0 1-13 13 12 12 0 0 1-7.53-3.11l-2.36-2.36a4.93 4.93 0 0 0-5.24-.85l-.84.43A6.1 6.1 0 0 0 49 11.98V47h35.02a8.08 8.08 0 0 1 6.66 4.13l.43.84a6.91 6.91 0 0 1-1.22 7.56l-2.36 2.36A10.06 10.06 0 0 0 85 68a11 11 0 0 0 22 0c0-1.94-1.16-4.75-2.53-6.11l-2.36-2.36a6.93 6.93 0 0 1-1.22-7.56l.43-.84a8.08 8.08 0 0 1 6.66-4.13H143V11.98a6.1 6.1 0 0 0-3.03-4.87l-.84-.43c-1.59-.8-4-.4-5.24.85l-2.36 2.36A12 12 0 0 1 124 13a13 13 0 0 1-13-13h2a11 11 0 0 0 11 11c1.94 0 4.75-1.16 6.11-2.53l2.36-2.36a6.93 6.93 0 0 1 7.56-1.22l.84.43a8.08 8.08 0 0 1 4.13 6.66V47h35.02a6.1 6.1 0 0 0 4.87-3.03l.43-.84c.8-1.59.4-4-.85-5.24l-2.36-2.36A12 12 0 0 1 179 28a13 13 0 0 1 13-13zM84.02 143a6.1 6.1 0 0 0 4.87-3.03l.43-.84c.8-1.59.4-4-.85-5.24l-2.36-2.36A12 12 0 0 1 83 124a13 13 0 1 1 26 0c0 2.47-1.37 5.79-3.11 7.53l-2.36 2.36a4.94 4.94 0 0 0-.85 5.24l.43.84a6.1 6.1 0 0 0 4.87 3.03H143v-35.02a8.08 8.08 0 0 1 4.13-6.66l.84-.43a6.91 6.91 0 0 1 7.56 1.22l2.36 2.36A10.06 10.06 0 0 0 164 107a11 11 0 0 0 0-22c-1.94 0-4.75 1.16-6.11 2.53l-2.36 2.36a6.93 6.93 0 0 1-7.56 1.22l-.84-.43a8.08 8.08 0 0 1-4.13-6.66V49h-35.02a6.1 6.1 0 0 0-4.87 3.03l-.43.84c-.79 1.58-.4 4 .85 5.24l2.36 2.36a12.04 12.04 0 0 1 3.11 7.51A13 13 0 1 1 83 68a12 12 0 0 1 3.11-7.53l2.36-2.36a4.93 4.93 0 0 0 .85-5.24l-.43-.84A6.1 6.1 0 0 0 84.02 49H49v35.02a8.08 8.08 0 0 1-4.13 6.66l-.84.43a6.91 6.91 0 0 1-7.56-1.22l-2.36-2.36A10.06 10.06 0 0 0 28 85a11 11 0 0 0 0 22c1.94 0 4.75-1.16 6.11-2.53l2.36-2.36a6.93 6.93 0 0 1 7.56-1.22l.84.43a8.08 8.08 0 0 1 4.13 6.66V143h35.02z'></path></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

1
src/assets/pattern.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns='http://www.w3.org/2000/svg' width='192' height='192' viewBox='0 0 192 192'><path fill='#494849' fill-opacity='0.08' d='M192 15v2a11 11 0 0 0-11 11c0 1.94 1.16 4.75 2.53 6.11l2.36 2.36a6.93 6.93 0 0 1 1.22 7.56l-.43.84a8.08 8.08 0 0 1-6.66 4.13H145v35.02a6.1 6.1 0 0 0 3.03 4.87l.84.43c1.58.79 4 .4 5.24-.85l2.36-2.36a12.04 12.04 0 0 1 7.51-3.11 13 13 0 1 1 .02 26 12 12 0 0 1-7.53-3.11l-2.36-2.36a4.93 4.93 0 0 0-5.24-.85l-.84.43a6.1 6.1 0 0 0-3.03 4.87V143h35.02a8.08 8.08 0 0 1 6.66 4.13l.43.84a6.91 6.91 0 0 1-1.22 7.56l-2.36 2.36A10.06 10.06 0 0 0 181 164a11 11 0 0 0 11 11v2a13 13 0 0 1-13-13 12 12 0 0 1 3.11-7.53l2.36-2.36a4.93 4.93 0 0 0 .85-5.24l-.43-.84a6.1 6.1 0 0 0-4.87-3.03H145v35.02a8.08 8.08 0 0 1-4.13 6.66l-.84.43a6.91 6.91 0 0 1-7.56-1.22l-2.36-2.36A10.06 10.06 0 0 0 124 181a11 11 0 0 0-11 11h-2a13 13 0 0 1 13-13c2.47 0 5.79 1.37 7.53 3.11l2.36 2.36a4.94 4.94 0 0 0 5.24.85l.84-.43a6.1 6.1 0 0 0 3.03-4.87V145h-35.02a8.08 8.08 0 0 1-6.66-4.13l-.43-.84a6.91 6.91 0 0 1 1.22-7.56l2.36-2.36A10.06 10.06 0 0 0 107 124a11 11 0 0 0-22 0c0 1.94 1.16 4.75 2.53 6.11l2.36 2.36a6.93 6.93 0 0 1 1.22 7.56l-.43.84a8.08 8.08 0 0 1-6.66 4.13H49v35.02a6.1 6.1 0 0 0 3.03 4.87l.84.43c1.58.79 4 .4 5.24-.85l2.36-2.36a12.04 12.04 0 0 1 7.51-3.11A13 13 0 0 1 81 192h-2a11 11 0 0 0-11-11c-1.94 0-4.75 1.16-6.11 2.53l-2.36 2.36a6.93 6.93 0 0 1-7.56 1.22l-.84-.43a8.08 8.08 0 0 1-4.13-6.66V145H11.98a6.1 6.1 0 0 0-4.87 3.03l-.43.84c-.79 1.58-.4 4 .85 5.24l2.36 2.36a12.04 12.04 0 0 1 3.11 7.51A13 13 0 0 1 0 177v-2a11 11 0 0 0 11-11c0-1.94-1.16-4.75-2.53-6.11l-2.36-2.36a6.93 6.93 0 0 1-1.22-7.56l.43-.84a8.08 8.08 0 0 1 6.66-4.13H47v-35.02a6.1 6.1 0 0 0-3.03-4.87l-.84-.43c-1.59-.8-4-.4-5.24.85l-2.36 2.36A12 12 0 0 1 28 109a13 13 0 1 1 0-26c2.47 0 5.79 1.37 7.53 3.11l2.36 2.36a4.94 4.94 0 0 0 5.24.85l.84-.43A6.1 6.1 0 0 0 47 84.02V49H11.98a8.08 8.08 0 0 1-6.66-4.13l-.43-.84a6.91 6.91 0 0 1 1.22-7.56l2.36-2.36A10.06 10.06 0 0 0 11 28 11 11 0 0 0 0 17v-2a13 13 0 0 1 13 13c0 2.47-1.37 5.79-3.11 7.53l-2.36 2.36a4.94 4.94 0 0 0-.85 5.24l.43.84A6.1 6.1 0 0 0 11.98 47H47V11.98a8.08 8.08 0 0 1 4.13-6.66l.84-.43a6.91 6.91 0 0 1 7.56 1.22l2.36 2.36A10.06 10.06 0 0 0 68 11 11 11 0 0 0 79 0h2a13 13 0 0 1-13 13 12 12 0 0 1-7.53-3.11l-2.36-2.36a4.93 4.93 0 0 0-5.24-.85l-.84.43A6.1 6.1 0 0 0 49 11.98V47h35.02a8.08 8.08 0 0 1 6.66 4.13l.43.84a6.91 6.91 0 0 1-1.22 7.56l-2.36 2.36A10.06 10.06 0 0 0 85 68a11 11 0 0 0 22 0c0-1.94-1.16-4.75-2.53-6.11l-2.36-2.36a6.93 6.93 0 0 1-1.22-7.56l.43-.84a8.08 8.08 0 0 1 6.66-4.13H143V11.98a6.1 6.1 0 0 0-3.03-4.87l-.84-.43c-1.59-.8-4-.4-5.24.85l-2.36 2.36A12 12 0 0 1 124 13a13 13 0 0 1-13-13h2a11 11 0 0 0 11 11c1.94 0 4.75-1.16 6.11-2.53l2.36-2.36a6.93 6.93 0 0 1 7.56-1.22l.84.43a8.08 8.08 0 0 1 4.13 6.66V47h35.02a6.1 6.1 0 0 0 4.87-3.03l.43-.84c.8-1.59.4-4-.85-5.24l-2.36-2.36A12 12 0 0 1 179 28a13 13 0 0 1 13-13zM84.02 143a6.1 6.1 0 0 0 4.87-3.03l.43-.84c.8-1.59.4-4-.85-5.24l-2.36-2.36A12 12 0 0 1 83 124a13 13 0 1 1 26 0c0 2.47-1.37 5.79-3.11 7.53l-2.36 2.36a4.94 4.94 0 0 0-.85 5.24l.43.84a6.1 6.1 0 0 0 4.87 3.03H143v-35.02a8.08 8.08 0 0 1 4.13-6.66l.84-.43a6.91 6.91 0 0 1 7.56 1.22l2.36 2.36A10.06 10.06 0 0 0 164 107a11 11 0 0 0 0-22c-1.94 0-4.75 1.16-6.11 2.53l-2.36 2.36a6.93 6.93 0 0 1-7.56 1.22l-.84-.43a8.08 8.08 0 0 1-4.13-6.66V49h-35.02a6.1 6.1 0 0 0-4.87 3.03l-.43.84c-.79 1.58-.4 4 .85 5.24l2.36 2.36a12.04 12.04 0 0 1 3.11 7.51A13 13 0 1 1 83 68a12 12 0 0 1 3.11-7.53l2.36-2.36a4.93 4.93 0 0 0 .85-5.24l-.43-.84A6.1 6.1 0 0 0 84.02 49H49v35.02a8.08 8.08 0 0 1-4.13 6.66l-.84.43a6.91 6.91 0 0 1-7.56-1.22l-2.36-2.36A10.06 10.06 0 0 0 28 85a11 11 0 0 0 0 22c1.94 0 4.75-1.16 6.11-2.53l2.36-2.36a6.93 6.93 0 0 1 7.56-1.22l.84.43a8.08 8.08 0 0 1 4.13 6.66V143h35.02z'></path></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -0,0 +1,33 @@
type AnalysisCardProps = {
articleNumber: number
startTime: number
}
export default function AnalysisCard({articleNumber, startTime}: AnalysisCardProps) {
return (
<div className={"w-80 bg-[--main-bg] relative z-20 mt-4 p-8 shadow-xl border rounded-3xl"}>
<div className={"text-gray-400 flex items-center"}>
<i className={"ti ti-device-desktop-analytics text-xl"}/>
<div className={"ml-4"}></div>
</div>
<div className={"mt-4"}>
<div className={"flex items-center justify-start ml-2 mt-4 font-bold"}>
<i className={"ti ti-article text-xl"}></i>
<div className={"flex items-center justify-between w-full"}>
<div className={"ml-2"}></div>
<div className={"text-gray-400"}>{articleNumber} </div>
</div>
</div>
<div className={"flex items-center justify-start ml-2 mt-4 font-bold"}>
<i className={"ti ti-calendar-clock text-xl"}></i>
<div className={"flex items-center justify-between w-full"}>
<div className={"ml-2"}></div>
<div className={"text-gray-400"}>{startTime} </div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,71 @@
import "./styles/ArticleTag.scss"
type ArticleTagProps = {
data: {
title: string,
description?: string,
date: object,
tags: string[],
categories: string[],
pin?: boolean,
cover?: string,
author?: string,
gpt?: string,
slug?: string
},
delay: number
}
export default function ArticleTag({data, delay}: ArticleTagProps) {
const date = new Date(data.date.toString())
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const formattedDate = `${day} / ${month} / ${year}`
return (
<a href={`/posts/${data.slug}`}>
<div
className={"article-tag border relative z-10 bg-[--secondary-bg] mb-4 p-4 pl-8 pr-8 rounded-xl flex flex-col justify-center items-start shadow cursor-pointer transition-all hover:border-[--main-color] fadeInUp"}
style={{animationDelay: `${delay}ms`}}>
<div className={"flex items-center line-clamp-1"}>
<div className={"flex items-center"}>
{data.categories.map((category, index) => (
<div key={index} className={"flex items-center text-gray-400"}>
<i className={"ti ti-folder"}></i>
<div className={"ml-2 line-clamp-1"}>{category}</div>
</div>
))}
</div>
{data.pin === true &&
<div className={"flex items-center text-[--main-color] ml-2"}>
<i className={"ti ti-pin"}></i>
<div className={"ml-2 line-clamp-1"}></div>
</div>
}
</div>
<div className={"article-tag-title text-2xl leading-loose mt-2 mb-2 font-bold"}>{data.title}</div>
<div className={"leading-loose line-clamp-2"}>{data.description}</div>
<div className={"flex items-end justify-between w-full text-gray-400 line-clamp-1"}>
<div className={"flex items-center line-clamp-1"}>
{data.tags.map((tag, index) => (
<div key={index}
className={"flex items-center mr-4 mt-4 cursor-pointer transition-all hover:text-[--main-color] line-clamp-1"}>
<i className={"ti ti-tag"}></i>
<div className={"ml-2 line-clamp-1"}>{tag}</div>
</div>
))}
</div>
<div className={"line-clamp-1"}>
{formattedDate}
</div>
</div>
</div>
</a>
)
}

View File

@ -0,0 +1,38 @@
type CCBoardProps = {
title: string,
url: string,
author: string,
createDate: string,
cc: string,
ccStr: string
}
export default function CCBoard({title, url, author, createDate, cc, ccStr}: CCBoardProps) {
return (
<div className={"mt-8 p-4 border bg-[--secondary-bg] rounded-xl relative overflow-hidden select-none"}>
<div className={"text-lg leading-loose"}>{title}</div>
<div className={"text-gray-400"}>{url}</div>
<div className={"flex items-center justify-start mt-4 mb-4 relative z-10"}>
<div>
<div></div>
<div className={"text-gray-400"}>{author}</div>
</div>
<div className={"ml-4"}>
<div></div>
<div className={"text-gray-400"}>{createDate}</div>
</div>
<div className={"ml-4"}>
<div></div>
<div className={"text-gray-400"}>{cc}</div>
</div>
</div>
<div className={"relative z-10"}>{ccStr}</div>
<div className={"absolute -right-12 -bottom-12 -rotate-45 z-0"}>
<i className={"ti ti-copyright text-gray-400 opacity-20"} style={{fontSize: "15rem"}}></i>
</div>
</div>
)
}

View File

@ -0,0 +1,31 @@
import {type ReactNode, useEffect, useState} from "react";
import "./styles/CateOrTag.scss"
type CateOrTagProps = {
type: "category" | "tag",
children: ReactNode,
count: number,
delay: number,
url: string
}
export default function CateOrTag({type, children, count, delay, url}: CateOrTagProps) {
const [show, setShow] = useState(false)
useEffect(() => {
setTimeout(() => {
setShow(true)
}, delay)
}, []);
return (
<div className={`inline-block w-fit select-none cursor-pointer custom-transition ${show ? "show" : "hide"}`} onClick={() => window.location.href = url}>
<div
className={"flex items-center font-bold p-4 bg-[--main-bg] relative z-10 w-fit min-w-28 border m-2 rounded-xl hover:bg-[--main-color] hover:text-white hover:scale-105 transition-all"}>
<i className={`ti ti-${type === "category" ? "category-2" : "tag"} text-xl`}></i>
<div className={"text-xl ml-1"}>{children}</div>
<div className={"ml-2 bg-[--third-bg] w-6 h-6 rounded"}>{count}</div>
</div>
</div>
)
}

View File

@ -0,0 +1,21 @@
import {type JSX} from "astro/jsx-runtime";
import type {ButtonHTMLAttributes, CSSProperties, ReactNode} from "react";
interface FilledButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode,
onClick?: () => void,
className?: string,
}
export default function FilledButton({
children,
onClick,
className,
...properties
}: FilledButtonProps): JSX.Element {
return (
<button
className={`flex justify-center items-center p-1 transition-all hover:bg-[--main-color] hover:text-white cursor-pointer rounded-full disabled:hover:bg-transparent disabled:text-gray-400 disabled:hover:text-gray-400] ${className}`}
onClick={onClick} {...properties}>{children}</button>
)
}

21
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,21 @@
import "./styles/Footer.scss"
import {FaCreativeCommons, FaCreativeCommonsBy, FaCreativeCommonsNc, FaCreativeCommonsSa} from "react-icons/fa6";
export default function Footer() {
return (
<div className={"footer w-full h-20 bg-[--secondary-bg] relative z-20 border-t flex items-center justify-between pl-6 pr-6"}>
<div className={"font-bold"}>© {new Date().getFullYear()} <a href={"https://www.kynix.tw/"} target={"_blank"}>Adrian Chen</a></div>
<div className={"flex items-center"}>
<div>Powered by <a href={"https://docs.astro.build/en/getting-started/"} className={"font-bold"}>Astro</a></div>
<a className={"flex"} href={"https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh-hant"} target={"_blank"}>
<FaCreativeCommons/>
<FaCreativeCommonsBy className={"ml-1"}/>
<FaCreativeCommonsNc className={"ml-1"}/>
<FaCreativeCommonsSa className={"ml-1"}/>
</a>
</div>
</div>
)
}

50
src/components/GPT.tsx Normal file
View File

@ -0,0 +1,50 @@
import {useEffect, useState} from "react";
import "./styles/GPT.scss"
type GPTProps = {
gpt: string
}
export default function GPT({gpt}: GPTProps) {
const [gptContent, setGPTContent] = useState("加載中...");
const [showGPT, setShowGPT] = useState(true);
function processString(str: string): void {
let index = 0; // 当前字符的索引
let temp = ""
const intervalId = setInterval(() => {
if (index < str.length) {
temp = temp + str[index]
setGPTContent(temp)
index++;
} else {
setShowGPT(false)
clearInterval(intervalId); // 清除定时器
}
}, 150); // 每 1 秒执行一次
}
useEffect(() => {
setTimeout(() => {
processString(gpt)
}, 2000)
}, []);
return (
<div className={"bg-[--gray-bg] rounded-xl p-4 border"}>
<div className={"flex items-center justify-between"}>
<div className={"flex items-center text-lg text-[--main-color]"}>
<i className={"ti ti-brand-github-copilot"}></i>
<div className={"ml-2 font-bold"}></div>
</div>
<div className={`pt-1 pb-1 pl-4 pr-4 bg-[--main-color] text-white rounded-full font-bold ${showGPT ? "shrinking" : ""}`} style={{fontSize: "0.7rem"}}>FakeGPT</div>
</div>
<div className={"p-4 bg-[--secondary-bg] mt-2 mb-2 rounded-xl border"}>{gptContent}</div>
<div className={"text-gray-400"} style={{fontSize: "0.7rem"}}></div>
</div>
)
}

128
src/components/Header.tsx Normal file
View File

@ -0,0 +1,128 @@
import {type JSX} from "astro/jsx-runtime";
import FilledButton from "./FilledButton.tsx";
import {AiFillProduct} from "react-icons/ai";
import NavItem from "./NavItem.tsx";
import {MdDarkMode, MdLightMode} from "react-icons/md";
import {CgDarkMode} from "react-icons/cg";
import {useEffect, useState} from "react";
import "./styles/Header.scss";
import SideBar from "./SideBar.tsx";
import Loading from "./Loading.tsx";
type HeaderProps = {
title: string,
themeCookie: "light" | "dark" | "auto",
links: {
title: string,
icon?: string,
link?: string,
children?: {
title: string,
icon?: string,
link: string
}[]
}[],
}
export default function Header({title, links, themeCookie}: HeaderProps) : JSX.Element {
let [theme, setTheme] = useState(themeCookie)
let [loading, setLoading] = useState(false)
const changeTheme = (e: "light" | "dark" | "auto"): void => {
document.cookie = `theme=${e};path=/`
setTheme(e)
document.documentElement.className = e
}
const toggleTheme = (): void => {
switch (theme) {
case "auto": changeTheme("dark"); break
case "dark": changeTheme("light"); break
case "light": changeTheme("auto"); break
}
}
useEffect(() => {
document.addEventListener('astro:before-preparation', (event) => {
setLoading(true)
});
document.addEventListener('astro:page-load', (event) => {
setLoading(false)
});
}, []);
return (
<>
<div
className={"blog_header flex items-center justify-between pl-10 pr-10 shadow fixed left-0 top-0 w-full h-16 backdrop-blur z-30"}
id={"head"}>
<div className={"blog_header_left flex items-center w-44"}>
<FilledButton>
<AiFillProduct size={20}/>
</FilledButton>
<a className={"font-bold text-lg ml-4 select-none cursor-pointer"} href={"/"}>{title}</a>
</div>
<div className={"blog_header_middle flex items-center h-full"}>
{
links.map((link, index) => {
return (
<NavItem
key={index}
title={link.title}
link={link.link}
children={link.children}/>
)
})
}
</div>
<div className={"blog_header_right w-44 flex items-center justify-end"}>
<FilledButton onClick={toggleTheme} className={"w-8 h-8"}>
{theme === "dark" && <MdDarkMode size={20}/>}
{theme === "light" && <MdLightMode size={20}/>}
{theme === "auto" && <CgDarkMode size={20}/>}
</FilledButton>
</div>
<div className={"blog_header_mobile"}>
<SideBar icon={"menu-deep"}>
{links.map((link, index) => {
return (
<div key={index} className={"text-gray-400 p-2 pl-8 pt-6"}>
<div>{link.title}</div>
<div className={"flex items-center"}>
{link.children?.map((child, index) => (
<a
href={child.link}
key={index}
className={"p-2 m-2 border text-[--main-text] active:bg-[--main-color] active:text-white rounded"}
>{child.title}</a>
))}
{link.link && <a href={link.link}
className={"p-2 m-2 border text-[--main-text] active:bg-[--main-color] active:text-white rounded"}>{link.title}</a>}
</div>
</div>
)
})}
<div className={"text-gray-400 p-2 pl-8 pt-6"}>
<div></div>
<div className={"flex justify-start items-center mt-4"}>
<button onClick={toggleTheme}
className={"border p-4 rounded-full transition-all active:bg-[--main-color] active:text-[--main-text] text-[--main-text]"}>
{theme === "dark" && <MdDarkMode size={40}/>}
{theme === "light" && <MdLightMode size={40}/>}
{theme === "auto" && <CgDarkMode size={40}/>}
</button>
</div>
</div>
</SideBar>
</div>
</div>
<Loading show={loading}/>
</>
)
}

View File

@ -0,0 +1,28 @@
import "./styles/IndexCard.scss";
import {useEffect, useState} from "react";
type IndexCardProps = {
title: string,
dewvine: string
}
export default function IndexCard({title, dewvine}: IndexCardProps) {
const [showTitle, setShowTitle] = useState(false);
const [showDewvine, setShowDewvine] = useState(false);
useEffect(() => {
setShowTitle(true)
setTimeout(() => {
setShowDewvine(true)
}, 100)
}, []);
return (
<div className={"h-96 flex flex-col justify-center items-center relative"} id={"index-card"}>
<h1 className={`${showTitle ? "show" : "hide"} custom-transition text-4xl font-bold text-center leading-normal`}>{title}</h1>
<div className={`${showDewvine ? "show" : "hide"} custom-transition pt-10 leading-normal text-xl`}>{dewvine === "" ? "蔓露加載失敗" : dewvine}</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
import HashLoader from "react-spinners/HashLoader";
type LoadingProps = {
show: boolean
}
export default function Loading({show}: LoadingProps) {
return (
<>
{show && (
<div
className={"w-screen h-screen bg-[--main-bg] fixed left-0 top-0 text-4xl font-bold flex justify-center items-center z-40"}>
<HashLoader loading={true} size={70} color={"gray"}/>
</div>
)}
</>
)
}

View File

@ -0,0 +1,90 @@
import React, {useEffect, useRef, useState} from "react";
import ProfileCard from "./ProfileCard.tsx";
import Timer from "./Timer.tsx";
import AnalysisCard from "./AnalysisCard.tsx";
import "./styles/MainSide.scss"
type MainSideProps = {
profile: {
name: string,
avatar: string,
motto: string,
social: {
icon: string,
link: string
}[];
}
timer: {
name: string,
time: string
}
article_count: number,
birthday: string
}
export default function MainSide({profile, timer, article_count, birthday}: MainSideProps) {
function daysSince(dateString: string): number {
const pastDate = new Date(dateString);
const currentDate = new Date();
// 将时间差转换为天数
const timeDiff = currentDate.getTime() - pastDate.getTime();
const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
return daysDiff;
}
const nodeRef = useRef<HTMLDivElement>(null);
const [scrollY, setScrollY] = useState(window.scrollY);
const [sticky, setSticky] = useState(false);
const [stop, setStop] = useState(false)
const [show, setShow] = useState(false);
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
}
window.addEventListener("scroll", handleScroll);
setTimeout(() => {
setShow(true)
}, 300)
return () => {
window.removeEventListener("scroll", handleScroll)
}
}, []);
useEffect(() => {
const headerHeight = document.querySelector("#head")?.clientHeight || 0;
const cardHeight = document.querySelector("#index-card")?.clientHeight || 0;
const sidebarHeight = nodeRef.current?.clientHeight || 0;
const viewportHeight = window.innerHeight;
const contentHeight = document.querySelector("#content")?.clientHeight || 0;
const sidebarStyle = window.getComputedStyle(nodeRef.current!, null);
const sidebarPadding = parseInt(sidebarStyle.paddingBottom);
if (scrollY + viewportHeight > headerHeight + cardHeight + sidebarHeight) {
setSticky(true);
} else {
setSticky(false)
}
if (scrollY + viewportHeight > headerHeight + cardHeight + contentHeight + sidebarPadding) {
setStop(true);
setSticky(false)
} else {
setStop(false)
}
}, [scrollY]);
return (
<div className={`${show ? "show" : "hide"} custom-transition pb-8 ${sticky ? "fixed right-8 bottom-0" : stop ? "absolute -bottom-8" : ""}`} ref={nodeRef}>
<ProfileCard profile={profile}/>
<Timer timer={timer}/>
<AnalysisCard articleNumber={article_count} startTime={daysSince(birthday)}/>
</div>
)
}

View File

@ -0,0 +1,52 @@
import React, {useRef, useState} from "react";
import {CSSTransition} from "react-transition-group";
import "./styles/NavItem.scss";
type NavItemProps = {
title: string,
icon?: string,
link?: string,
children?: {
title: string,
icon?: string,
link: string
}[]
}
export default function NavItem({title, icon, link, children}: NavItemProps) {
const [hovered, setHovered] = useState(false);
const dropdownRef = useRef(null)
return (
<div className={"relative font-bold flex items-center"} onMouseEnter={() => setHovered(!hovered)} onMouseLeave={() => setHovered(!hovered)}>
<a href={link ? link : ""}
className={(hovered ? "bg-[--main-color] text-white " : "") + "ml-2 mr-2 pl-3 pr-3 pt-2 pb-2 rounded-full transition-all"}>{title}</a>
{
children ? (
<CSSTransition
timeout={500}
in={hovered}
classNames={"scale"}
nodeRef={dropdownRef}
unmountOnExit
>
<div className={"shizidakaikou absolute top-8"} ref={dropdownRef}>
<div className={"flex items-center mt-6 border border-[--main-border] p-2 rounded-full bg-[--main-bg]"}>
{
children!.map((child, index) => (
<a href={child.link} key={index}
className={"whitespace-nowrap pl-6 pr-6 pt-2 pb-2 transition-all hover:bg-[--main-color] hover:text-white rounded-full flex items-center"}>
<i className={"text-xl ti ti-" + (child.icon as string)}></i>
<div className={"pl-2"}>{child.title}</div>
</a>
))
}
</div>
</div>
</CSSTransition>
) : null
}
</div>
)
}

View File

@ -0,0 +1,123 @@
import "./styles/Pagination.scss"
import FilledButton from "./FilledButton.tsx";
import {type FormEventHandler, useState} from "react";
type PaginationProps = {
total: number,
current: number,
delay: number,
type?: "all" | "category" | "tag"
base?: string
}
export default function Pagination({total, current, delay, base, type="all"}: PaginationProps) {
const maxDisplay = 5
let paginationNumber: number[] = []
if (total > maxDisplay) {
for (let i = 0; i < maxDisplay; i++) {
paginationNumber.push(i + 1)
}
} else {
for (let i = 0; i < total; i++) {
paginationNumber.push(i + 1)
}
}
function isNumeric(str: string) {
return /^\d+$/.test(str); // 匹配全数字字符串
}
const [input, setInput] = useState("")
const handleInput: FormEventHandler<HTMLInputElement> = (e) => {
let target = e.currentTarget.value
if (!isNumeric(target)) {
target = target.slice(0, -1)
}
if (parseInt(target) > total) {
target = total.toString()
}
setInput(target)
}
return (
<div className={"flex items-center justify-between relative z-10 fadeInUp"}
style={{animationDelay: `${delay}ms`}}>
{current !== 1 &&
<a href={type === "all" ? `/all/${current - 1}` : `/${base}?page=${current - 1}`}>
<div
className={"flex items-center border pt-2 pb-2 pl-4 pr-4 bg-[--secondary-bg] rounded cursor-pointer hover:text-[--main-color] hover:border-[--main-color] transition-all shadow h-10"}>
<i className={"ti ti-chevron-left text-lg"}></i>
<div className={"pagination-indicator ml-1 text-sm"}></div>
</div>
</a>
}
<div className={"flex items-center relative z-10"}>
<div className={"flex items-center"}>
{total <= maxDisplay &&
paginationNumber.map((number, index) => (
<a href={type === "all" ? `/all/${number}` : `/${base}?page=${number}`} key={index}>
<div
className={`p-2 border w-10 h-10 flex justify-center items-center ml-1 mr-1 rounded cursor-pointer hover:border-[--main-color] transition-all ${current === number ? "bg-[--main-color] text-white" : "bg-[--secondary-bg] hover:text-[--main-color]"}`}>
{number}
</div>
</a>
))
}
{total > maxDisplay &&
<>
{/*page 1*/}
<a href={type === "all" ? "/" : `/${base}?page=1`}>
<div
className={`p-2 border w-10 h-10 flex justify-center items-center ml-1 mr-1 rounded cursor-pointer hover:border-[--main-color] transition-all ${current === 1 ? "bg-[--main-color] text-white" : "bg-[--secondary-bg] hover:text-[--main-color]"}`}>1
</div>
</a>
{/*page 2*/}
<a href={type === "all" ? (current !== 1 && current !== 2 && current !== total && current !== total - 1 ? "" : "/all/2") : (current !== 1 && current !== 2 && current !== total && current !== total - 1 ? "" : `/${base}?page=2`)}>
<div
className={`p-2 border w-10 h-10 flex justify-center items-center ml-1 mr-1 rounded cursor-pointer hover:border-[--main-color] transition-all ${current === 2 ? "bg-[--main-color] text-white" : "bg-[--secondary-bg] hover:text-[--main-color]"}`}>{current !== 1 && current !== 2 && current !== total && current !== total - 1 ? "⋯" : "2"}</div>
</a>
{/*page 3*/}
<a href={type === "all" ? (current !== 1 && current !== 2 && current !== total && current !== total - 1 ? `/all/${current}` : "") : (current !== 1 && current !== 2 && current !== total && current !== total - 1 ? `/${base}?page=${current}` : "")}>
<div
className={`p-2 border w-10 h-10 flex justify-center items-center ml-1 mr-1 rounded cursor-pointer hover:border-[--main-color] transition-all ${current === 3 ? "bg-[--main-color] text-white" : "bg-[--secondary-bg] hover:text-[--main-color]"}`}>{current !== 1 && current !== 2 && current !== total && current !== total - 1 ? current : "⋯"}</div>
</a>
{/*page 4*/}
<a href={type === "all" ? (current !== 1 && current !== 2 && current !== total && current !== total - 1 ? "" : `/all/${total - 1}`) : (current !== 1 && current !== 2 && current !== total && current !== total - 1 ? "" : `/${base}?page=${total - 1}`)}>
<div
className={`p-2 border w-10 h-10 flex justify-center items-center ml-1 mr-1 rounded cursor-pointer hover:border-[--main-color] transition-all ${current === total - 1 ? "bg-[--main-color] text-white" : "bg-[--secondary-bg] hover:text-[--main-color]"}`}>{current !== 1 && current !== 2 && current !== total && current !== total - 1 ? "⋯" : total - 1}</div>
</a>
{/*page 5*/}
<a href={type === "all" ? `/all/${total}` : `/${base}?page=${total}`}>
<div
className={`p-2 border w-10 h-10 flex justify-center items-center ml-1 mr-1 rounded cursor-pointer hover:border-[--main-color] transition-all ${current === total ? "bg-[--main-color] text-white" : "bg-[--secondary-bg] hover:text-[--main-color]"}`}>{total}</div>
</a>
</>
}
<div
className={`p-2 border h-10 flex justify-center items-center ml-1 mr-1 rounded cursor-pointer hover:border-[--main-color] transition-all bg-[--secondary-bg] hover:text-[--main-color] relative z-10 flex items-center input-btn`}>
<input className={"border-none outline-none mr-2 rounded"} value={input} onInput={handleInput}/>
<FilledButton disabled={input === ""} onClick={() => window.location.href = type === "all" ? `/all/${input}` : `/${base}?page=${input}`}>
<i className={"ti ti-chevrons-right"}></i>
</FilledButton>
</div>
</div>
</div>
{current !== total &&
<a href={type === "all" ? `/all/${current + 1}` : `/${base}?page=${current + 1}`}>
<div
className={"flex items-center border pt-2 pb-2 pl-4 pr-4 bg-[--secondary-bg] rounded cursor-pointer hover:text-[--main-color] hover:border-[--main-color] transition-all shadow h-10"}>
<div className={"mr-1 text-sm pagination-indicator"}></div>
<i className={"ti ti-chevron-right text-lg"}></i>
</div>
</a>
}
</div>
)
}

View File

@ -0,0 +1,20 @@
import TableOfContents from "./TableOfContents.tsx";
import {useEffect, useState} from "react";
export default function PostSide() {
const [showPanel, setShowPanel] = useState(true);
useEffect(() => {
if (window.innerWidth < 1075) {
setShowPanel(false);
}
}, []);
return (
<>
{showPanel && (
<div className={"post_side w-72 h-full shadow relative z-20"}>
<TableOfContents scrollContainerId={"post-main-body"} headingSelector={"h2, h3"}/>
</div>
)}
</>
)
}

View File

@ -0,0 +1,31 @@
import "./styles/Processor.scss"
import {CSSTransition, SwitchTransition} from "react-transition-group";
import {useRef, useState} from "react";
import {Simulate} from "react-dom/test-utils";
import change = Simulate.change;
type ProcessorProps = {
title: string;
process: string;
rest: string;
unit: string;
}
export default function Processor({title, process, rest, unit}: ProcessorProps) {
const [showRest, setShowRest] = useState(false)
const changeRef = useRef(null)
return (
<div className={"w-full h-8 flex items-center justify-between ml-2 mr-2 mt-1 mb-1"} onMouseEnter={() => setShowRest(true)} onMouseLeave={() => setShowRest(false)}>
<div className={"text-sm whitespace-nowrap"}>{title}</div>
<div className={"h-full bg-[--main-color-bg] w-full ml-2 rounded-xl"}>
<div style={{width: process}} className={`h-full bg-[--main-color] rounded-xl flex justify-start items-center pl-2 whitespace-nowrap text-sm transition-all ${parseFloat(process)/100 < 0.5 ? "opacity-40" : "text-white"}`}>
<SwitchTransition mode={"out-in"}>
<CSSTransition timeout={300} classNames={"fade"} key={showRest ? "showRest" : "hideRest"} nodeRef={changeRef}>
<span ref={changeRef}>{!showRest? process: `餘下 ${rest} ${unit}`}</span>
</CSSTransition>
</SwitchTransition>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,63 @@
import Skeleton from "./Skeleton.tsx";
import Designer from "../assets/Designer.jpeg";
type ProfileCardProps = {
profile: {
name: string,
avatar: string,
motto: string,
social: {
icon: string,
link: string
}[]
}
}
export default function ProfileCard({profile}: ProfileCardProps) {
const date = new Date();
const hour = date.getHours();
let greeting = ""
if (hour < 5) {
greeting = "夜深了,注意休息"
} else if (hour < 8) {
greeting = "早上好,新的一天開始了"
} else if (hour < 11) {
greeting = "上午好,加油做事喔"
} else if (hour < 14) {
greeting = "中午小睡,下午精神百倍"
} else if (hour < 17) {
greeting = "下午好,工作再忙也要微笑"
} else if (hour < 19) {
greeting = "傍晚好,去散散心吧"
} else if (hour < 22) {
greeting = "晚上好,今天也辛苦了"
} else if (hour < 24) {
greeting = "夜深了,注意休息"
}
return (
<div
className={"w-80 h-96 bg-[--main-color] relative z-20 rounded-3xl shadow-xl flex flex-col justify-center items-center select-none"}>
<div
className={"bg-[--secondary-color] text-[--secondary-text] p-4 pt-2 pb-2 rounded-full shadow cursor-pointer hover:scale-105 hover:bg-white hover:text-black transition-all"}>{greeting}</div>
<div className={"m-6"}>
{/*<Skeleton width={"10rem"} height={"10rem"} circle={true} className={"m-4 shadow"}/>*/}
<img src={Designer.src} className={"w-40 h-40 rounded-full shadow-xl"}/>
</div>
<div className={"w-full flex items-center justify-around"}>
<div>
<h1 className={"text-2xl font-bold text-[--secondary-text]"}>{profile.name}</h1>
<p className={"text-[--secondary-text] text-sm"}>{profile.motto}</p>
</div>
<div className={"flex"}>
{profile.social.map((social, index) => (
<a key={index} href={social.link} target={"_blank"} className={"w-10 h-10 bg-[--secondary-color] m-1 flex justify-center items-center rounded-full text-[--secondary-text] hover:scale-105 hover:bg-white hover:text-black transition-all"}>
<i className={`ti ti-${social.icon} text-2xl`}></i>
</a>
))}
</div>
</div>
</div>
)
}

13
src/components/Resume.tsx Normal file
View File

@ -0,0 +1,13 @@
type ResumeProps = {
dot: string,
title: string
}
export default function Resume({ dot, title }: ResumeProps) {
return (
<div className={"flex items-center m-4"}>
<div className={"w-4 h-4 rounded-full shadow-xl"} style={{backgroundColor: dot}}></div>
<div className={"ml-4"}>{title}</div>
</div>
)
}

View File

@ -0,0 +1,24 @@
import {type ReactNode, useState} from "react";
import FilledButton from "./FilledButton.tsx";
import "./styles/SideBar.scss";
type SideBarProps = {
children: ReactNode,
icon: string
}
export default function SideBar({icon, children}: SideBarProps) {
const [open, setOpen] = useState(false)
return (
<>
<FilledButton onClick={() => setOpen(!open)}>
<i className={"text-xl ti ti-" + icon}/>
</FilledButton>
<div className={(open ? "sidebar_bar_open" : "sidebar_bar_hide") + " sidebar_bar"}>
{children}
</div>
</>
)
}

View File

@ -0,0 +1,25 @@
import type {CSSProperties, ReactNode} from "react";
import "./styles/SimpleCard.scss"
type SimpleCardProps = {
children: ReactNode,
className?: string,
style?: CSSProperties,
bgi?: string,
flow?: boolean
}
export default function SimpleCard({children, className, style, bgi, flow=false}: SimpleCardProps) {
return (
<div
className={`border relative z-10 p-4 rounded-xl ${flow && "card_flow"} ${className}`}
style={{
...(style || {}),
...(bgi
? { backgroundImage: `url(${bgi})`, backgroundPosition: "center", backgroundSize: "cover" }
: { backgroundColor: "var(--secondary-bg)" }),
}}>
{children}
</div>
)
}

View File

@ -0,0 +1,22 @@
type SkeletonProps = {
width: string;
height: string;
className?: string;
style?: React.CSSProperties;
circle?: boolean;
}
export default function Skeleton({width, height, className, style, circle}: SkeletonProps) {
return (
<div
className={`animate-pulse bg-gray-300 ${className}`}
style={{
width: width,
height: height,
borderRadius: circle ? "50%" : "0",
...style
}}
/>
)
}

View File

@ -0,0 +1,19 @@
type SkillTagProps = {
skill: {
title: string,
icon: string,
color: string,
}
}
export default function SkillTag({skill}: SkillTagProps) {
return (
<div className={"flex items-center bg-[--main-bg] w-fit border p-2 rounded-full m-2"}>
<div className={`flex justify-center items-center p-2 rounded-full`} style={{backgroundColor: skill.color}}>
<i className={`text-lg ti text-white ti-${skill.icon}`}></i>
</div>
<div className={"ml-2 font-bold"}>{skill.title}</div>
</div>
)
}

View File

@ -0,0 +1,14 @@
type SocialLinkProps = {
social: {
icon: string,
link: string
}
}
export default function SocialLink({social}: SocialLinkProps) {
return (
<a href={social.link} className={"w-fit bg-[--main-bg] border flex justify-center items-center p-4 rounded-full m-2 shadow-xl hover:scale-90 transition-all"}>
<i className={`text-2xl ti ti-${social.icon}`}></i>
</a>
)
}

View File

@ -0,0 +1,137 @@
import React, { useEffect, useState } from "react";
import "./styles/TableOfContents.scss";
type TOCItem = {
id: string;
text: string;
level: number;
}
interface TableOfContentsProps {
scrollContainerId: string; // 滾動容器的 ID
headingSelector?: string; // 標題選擇器,例如 "h1, h2, h3"
}
const TableOfContents: React.FC<TableOfContentsProps> = ({scrollContainerId, headingSelector}) => {
const [tocData, setTocData] = useState<TOCItem[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [scrollY, setScrollY] = useState(window.scrollY);
const [sticky, setSticky] = useState(false);
const [stop, setStop] = useState(false);
useEffect(() => {
const scrollContainer = document.querySelector(`#${scrollContainerId}`);
if (!scrollContainer) return;
const handleScroll = () => {
setScrollY(window.scrollY);
const headings = Array.from(
scrollContainer.querySelectorAll(headingSelector || "h1, h2, h3, h4, h5, h6")
) as HTMLElement[];
headings.forEach((heading, index) => {
const rect = heading.getBoundingClientRect();
const next_rect = headings[index + 1]?.getBoundingClientRect();
if (rect.top <= 1 && (next_rect ? next_rect.top : Infinity) > 0) {
setActiveId(heading.id);
// break; // 只更新第一个符合条件的元素
}
});
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
useEffect(() => {
const headerHeight = document.querySelector("#head")?.clientHeight || 0;
const postHeaderCardHeight = document.querySelector("#post_header_card")?.clientHeight || 0;
const mainBody = document.querySelector("#post-main-body");
const mainBodyStyle = window.getComputedStyle(mainBody as Element, null);
const mainBodyPaddingTop = parseInt(mainBodyStyle.getPropertyValue("padding").replace("px", ""));
const viewPostHeight = window.innerHeight;
const contentPostHeight = document.querySelector("#content_post")?.clientHeight || 0;
const contentPostStyle = window.getComputedStyle(document.querySelector("#content_post") as Element, null);
const contentPostPaddingBottom = parseInt(contentPostStyle.getPropertyValue("padding").replace("px", ""));
const menuHeight = document.querySelector(".toc")?.clientHeight || 0;
const blockBottom = viewPostHeight - headerHeight - 20 - menuHeight;
if (scrollY > postHeaderCardHeight + mainBodyPaddingTop - 20) {
setSticky(true);
} else {
setSticky(false)
}
if (scrollY + viewPostHeight > headerHeight + postHeaderCardHeight + contentPostHeight + contentPostPaddingBottom + blockBottom) {
setStop(true);
setSticky(false)
} else {
setStop(false)
}
}, [scrollY]);
// 提取標題生成目錄
useEffect(() => {
const scrollContainer = document.querySelector(`#${scrollContainerId}`);
if (!scrollContainer) return;
const headings = Array.from(scrollContainer.querySelectorAll("h2, h3, h4, h5, h6")) as HTMLElement[];
const tocItems: TOCItem[] = headings.map((heading) => ({
id: heading.id,
text: heading.innerText,
level: parseInt(heading.tagName[1]),
}));
setTocData(tocItems);
}, [scrollContainerId, headingSelector]);
useEffect(() => {
// 每当 activeId 变化时,滚动目录面板
const activeItem = document.querySelector(`.toc a[href="#${activeId}"]`);
activeItem?.scrollIntoView({
behavior: "smooth", // 平滑滚动
block: "center", // 将元素滚动到容器中央
});
}, [activeId]);
// 點擊目錄滾動到相應標題
const handleClick = (id: string) => {
const scrollContainer = document.querySelector(`#${scrollContainerId}`);
const target = document.getElementById(id);
if (scrollContainer && target) {
target.scrollIntoView({ behavior: "smooth" });
}
};
return (
<div className={`toc bg-[--secondary-bg] border w-full p-4 rounded-xl shadow ${sticky ? "sticky" : stop ? "stop" : ""}`}>
<div className={"flex items-center text-lg font-bold"}>
<i className={"ti ti-category"}></i>
<div className={"ml-2"}></div>
</div>
<ul className={"h-96 overflow-auto"}>
{tocData.map((item) => (
<li key={item.id}>
<a
href={`#${item.id}`}
onClick={(e) => {
e.preventDefault();
handleClick(item.id);
}}
className={`ml-${2 * (item.level - 2)} mt-2 mb-2 pl-2 pr-2 text-nowrap line-clamp-1 overflow-ellipsis transition-all ${activeId === item.id ? "active" : ""}`}
style={{ lineHeight: "3" }}
>
{item.text}
</a>
</li>
))}
</ul>
</div>
);
};
export default TableOfContents;

View File

@ -0,0 +1,66 @@
import "./styles/ThanksList.scss"
import {useEffect, useState} from "react";
import Skeleton from "./Skeleton.tsx";
type ThanksListProps = {
title: string,
description?: string,
thanks: {
iconSrc: string,
name: string,
description: string,
url: string
}[],
delay: number,
}
function ThanksTag({thanks}: {
thanks: {
iconSrc: string,
name: string,
description: string,
url: string
}
}) {
const [isLoaded, setIsLoaded] = useState(false)
const handleImageLoad = () => {
setIsLoaded(true)
}
return (
<a href={thanks.url} className={"thanks_list_a flex items-center justify-between w-72 h-24 p-2 bg-[--main-bg] border m-2 rounded-xl"}>
<div className={"thanks_list_icon w-12 h-12 flex justify-center items-center bg-[#f7f9fe] rounded-full overflow-hidden relative"}>
{!isLoaded && <Skeleton width={"2.5rem"} height={"2.5rem"} circle={true} className={"absolute"}/> }
<img src={thanks.iconSrc} className={"w-10"} onLoad={handleImageLoad} style={{opacity: isLoaded ? 1: 0, transition: "opacity 0.3s ease-in-out"}}/>
</div>
<div className={"thanks_list_desc w-52"}>
<div className={"text-xl font-bold"}>{thanks.name}</div>
<div className={"thanks_list_description line-clamp-2 text-gray-400"}>{thanks.description}</div>
</div>
</a>
)
}
export default function ThanksList({title, description, thanks, delay}: ThanksListProps) {
const [show, setShow] = useState(false)
useEffect(() => {
setTimeout(() => {
setShow(true)
}, delay)
}, []);
return (
<div className={`relative z-10 mt-4 custom-transition ${show ? "show" : "hide"}`}>
<div className={"text-2xl font-bold leading-loose"}>{title} ({thanks.length})</div>
<div className={"text-gray-400 leading-loose"}>{description}</div>
<div className={"flex flex-wrap mt-4"}>
{thanks.map((thank, index) => (
<div key={index}>
<ThanksTag thanks={thank}/>
</div>
))}
</div>
</div>
)
}

99
src/components/Timer.tsx Normal file
View File

@ -0,0 +1,99 @@
import Processor from "./Processor.tsx";
import {useState} from "react";
type TimerProps = {
timer: {
name: string,
time: string
}
}
export default function Timer({timer}: TimerProps) {
function daysUntil(targetDate: string) {
// 1. 计算当天到指定日期的间隔天数
const now = new Date();
const target = new Date(targetDate);
const timeDiff = target.getTime() - now.getTime();
return Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
}
function hoursLeftToday() {
// 2. 求当前时刻到今天结束的小时数,及今天已过百分比
const now = new Date();
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59);
const totalHours = 24;
const hoursPassed = now.getHours() + now.getMinutes() / 60;
const hoursRemaining = totalHours - hoursPassed;
const percentagePassed = (hoursPassed / totalHours) * 100;
return {
hoursRemaining: hoursRemaining.toFixed(0),
percentagePassed: percentagePassed.toFixed(2) + '%',
};
}
function daysLeftThisWeek() {
// 3. 求本周还剩多少天,及已过百分比
const now = new Date();
const dayOfWeek = now.getDay(); // 周日为0周一为1...周六为6
const daysPassed = dayOfWeek; // 本周已过的天数
const daysRemaining = 6 - dayOfWeek; // 0-6剩余天数
const percentagePassed = (daysPassed / 7) * 100;
return {
daysRemaining: daysRemaining + 1,
percentagePassed: percentagePassed.toFixed(2) + '%',
};
}
function daysLeftThisMonth() {
// 4. 求本月还剩多少天,及已过百分比
const now = new Date();
const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const daysInMonth = (startOfNextMonth.getTime() - new Date(now.getFullYear(), now.getMonth(), 1).getTime()) / (1000 * 60 * 60 * 24);
const daysPassed = now.getDate();
const daysRemaining = daysInMonth - daysPassed;
const percentagePassed = (daysPassed / daysInMonth) * 100;
return {
daysRemaining: daysRemaining + 1,
percentagePassed: percentagePassed.toFixed(2) + '%',
};
}
function daysLeftThisYear() {
// 5. 求本年还剩多少天,及已过百分比
const now = new Date();
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
const daysInYear = (startOfNextYear.getTime() - new Date(now.getFullYear(), 0, 1).getTime()) / (1000 * 60 * 60 * 24);
const daysPassed = Math.floor((now.getTime() - new Date(now.getFullYear(), 0, 1).getTime()) / (1000 * 60 * 60 * 24));
const daysRemaining = daysInYear - daysPassed;
const percentagePassed = (daysPassed / daysInYear) * 100;
return {
daysRemaining,
percentagePassed: percentagePassed.toFixed(2) + '%',
};
}
return (
<div className={"w-80 h-52 p-8 bg-[--main-bg] relative z-20 mt-4 rounded-3xl shadow-xl border flex justify-around items-center select-none"}>
<div className={"flex items-center"}>
<div className={"flex flex-col items-center"}>
<div className={"text-sm mb-1"}></div>
<div className={"text-xl font-bold"}>{timer.name}</div>
<div className={"text-4xl font-bold m-2 text-[--main-color]"}>{daysUntil(timer.time)}</div>
<div className={"text-sm text-gray-400 whitespace-nowrap"}>{timer.time}</div>
</div>
<div className={"w-0.5 h-24 ml-4 bg-gray-300"}></div>
</div>
<div className={"w-full"}>
<Processor title={"今日"} process={hoursLeftToday().percentagePassed} rest={hoursLeftToday().hoursRemaining} unit={"小時"}/>
<Processor title={"本週"} process={daysLeftThisWeek().percentagePassed} rest={daysLeftThisWeek().daysRemaining.toString()} unit={"天"}/>
<Processor title={"本月"} process={daysLeftThisMonth().percentagePassed} rest={daysLeftThisMonth().daysRemaining.toString()} unit={"天"}/>
<Processor title={"今年"} process={daysLeftThisYear().percentagePassed} rest={daysLeftThisYear().daysRemaining.toString()} unit={"天"}/>
</div>
</div>
)
}

View File

@ -0,0 +1,23 @@
.article-tag {
.article-tag-title {
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
&:hover {
.article-tag-title {
color: var(--main-color);
}
}
}
.fadeInUp {
opacity: 0;
transform: translateY(20px); /* 初始状态从底部偏移 */
animation: fadeInUp 0.8s ease-out forwards;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -0,0 +1,13 @@
.hide {
opacity: 0;
transform: translateY(20px);
}
.show {
opacity: 1;
transform: translateY(0);
}
.custom-transition {
transition: all 0.3s ease;
}

View File

@ -0,0 +1,23 @@
.footer {
a {
padding: 0.5rem;
border-radius: 0.5rem;
transition: all 0.3s linear;
&:hover {
background-color: var(--main-color-opacity);
color: var(--main-color);
}
}
}
@media (screen and max-width: 470px) {
.footer {
font-size: 0.8rem;
}
}
@media (screen and max-width: 400px) {
.footer {
font-size: 0.6rem;
}
}

View File

@ -0,0 +1,15 @@
@keyframes shrink {
0% {
background-color: var(--main-color);
}
50% {
background-color: var(--main-color-opacity);
}
100% {
background-color: var(--main-color);
}
}
.shrinking {
animation: shrink 1s linear infinite;
}

View File

@ -0,0 +1,15 @@
.blog_header_mobile {
display: none;
}
@media (screen and max-width: 785px) {
.blog_header_middle {
display: none;
}
.blog_header_right {
display: none;
}
.blog_header_mobile {
display: block;
}
}

View File

@ -0,0 +1,13 @@
.hide {
opacity: 0;
transform: translateY(20px);
}
.show {
opacity: 1;
transform: translateY(0);
}
.custom-transition {
transition: all 0.3s ease;
}

View File

@ -0,0 +1,13 @@
.hide {
opacity: 0;
transform: translateY(20px);
}
.show {
opacity: 1;
transform: translateY(0);
}
.custom-transition {
transition: all 0.3s ease;
}

View File

@ -0,0 +1,31 @@
/* 隐藏状态样式 */
.scale-enter {
transform: scale(0);
opacity: 0;
}
/* 进入动画样式 */
.scale-enter-active {
transform: scale(1);
opacity: 1;
transition: transform 0.3s ease, opacity 0.3s ease;
}
/* 退出动画样式 */
.scale-exit {
transform: scale(1);
opacity: 1;
}
/* 退出动画结束状态 */
.scale-exit-active {
transform: scale(0);
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.shizidakaikou {
padding-left: 1000rem;
padding-right: 1000rem;
left: calc(-1000rem - 100%);
}

View File

@ -0,0 +1,36 @@
.fadeInUp {
opacity: 0;
transform: translateY(20px); /* 初始状态从底部偏移 */
animation: fadeInUp 0.8s ease-out forwards;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
.input-btn {
&:hover {
input {
width: 3rem;
border: var(--main-color) 2px solid;
}
}
input {
text-align: center;
width: 0;
transition: all 0.3s ease;
&:focus {
border: var(--main-color) 2px solid;
}
}
}
@media (screen and max-width: 974px) {
.pagination-indicator {
display: none;
}
}

View File

@ -0,0 +1,19 @@
.fade-enter {
opacity: 0;
transform: translateX(10px); /* 从右方淡入 */
}
.fade-enter-active {
opacity: 1;
transform: translateX(0);
transition: opacity 300ms, transform 300ms;
}
.fade-exit {
opacity: 1;
transform: translateX(0);
}
.fade-exit-active {
opacity: 0;
transform: translateX(-10px); /* 向左淡出 */
transition: opacity 300ms, transform 300ms;
}

View File

@ -0,0 +1,16 @@
.sidebar_bar {
height: calc(100vh - 4rem);
transition: all 500ms ease;
background-color: var(--main-bg);
position: fixed;
top: 4rem;
width: 100vw;
}
.sidebar_bar_hide {
left: 120%;
}
.sidebar_bar_open {
left: 0;
}

View File

@ -0,0 +1,26 @@
@keyframes GradientFlow {
0%, 100% {
background-position: 0 50%;
}
50% {
background-position: 100% 50%;
}
}
.card_flow {
animation: GradientFlow 6s ease infinite;
}
.map {
div {
transform: translateY(0);
transition: all 0.5s ease;
}
&:hover {
background-size: 120% 120%;
div {
transform: translateY(100%);
}
}
}

View File

@ -0,0 +1,20 @@
.active {
font-weight: bold;
background-color: var(--main-color-opacity);
color: var(--main-color);
border-radius: 0.7rem;
}
.sticky {
width: 18rem;
position: fixed;
right: 4rem;
top: calc(4rem + 20px);
}
.stop {
position: absolute;
width: 18rem;
right: 0;
bottom: 0;
}

View File

@ -0,0 +1,44 @@
.thanks_list_a {
transition: all 0.5s ease;
position: relative;
.thanks_list_icon {
transform: scale(100%);
transition: all 0.5s ease;
}
.thanks_list_desc {
transition: all 0.5s ease;
position: absolute;
right: 0.5rem;
}
&:hover {
background-color: var(--main-color);
.thanks_list_icon {
transform: scale(0);
}
.thanks_list_desc {
width: calc(100% - 2rem);
color: white;
}
.thanks_list_description {
color: white !important;
}
}
}
.hide {
opacity: 0;
transform: translateY(20px);
}
.show {
opacity: 1;
transform: translateY(0);
}
.custom-transition {
transition: all 0.3s ease;
}

166
src/config/blog.config.ts Normal file
View File

@ -0,0 +1,166 @@
type BlogConfigType = {
title: string,
author: string,
description: string,
birth: string,
base: string,
nav: {
title: string,
icon?: string,
link?: string,
children?: {
title: string,
icon?: string,
link: string
}[]
}[],
dewvine: {
url: string,
type: string,
lang: string,
length: number,
number: number
},
profile: {
name: string,
avatar: string,
motto: string,
social: {
icon: string,
link: string
}[]
},
timer: {
name: string,
time: string
},
social: {
icon: string,
link: string
}[],
thanks: {
iconSrc: string,
name: string,
description: string,
url: string
}[],
links: {
title: string,
description: string,
links: {
iconSrc: string,
name: string,
description: string,
url: string
}[]
}[]
}
const BlogConfig = () : BlogConfigType => {
return {
title: "Demo", // Your blog title
author: "Blog Demo",
description: "", // Your blog description
birth: "2024-01-01", // The birthday of the site
base: "https://demo.example.com", // The base URL of the site
nav: [ // Your navigation links
{
title: "文章",
children: [
{
title: "全部分類",
link: "/articles/categories",
icon: "folder"
},
{
title: "全部標籤",
link: "/articles/tags",
icon: "hash"
}
]
},
{
title: "友情鏈接",
link: "/links",
},
{
title: "協議",
children: [
{
title: "隱私政策",
link: "/protocol/privacy",
icon: "shield"
},
{
title: "授權協議",
link: "/protocol/license",
icon: "accessible"
}
]
},
{
title: "關於本站",
children: [
{
title: "致謝列表",
link: "/thanks",
icon: "heart"
},
{
title: "關於我",
link: "/about",
icon: "user-check"
}
]
}
],
dewvine: { // https://merak.axiomatrix.org/Axiomatrix_Org/Dewvine
url: "https://dewvine.example.com",
type: "internet,literature,philosophy,poem",
lang: "zh-TW",
length: 20,
number: 1
},
profile: {
name: "Demo Blog",
avatar: "https://avatars.githubusercontent.com/u/79146431?v=4",
motto: "人間可愛,生活精彩。",
social: [
{ icon: "brand-github", link: "https://github.com/XXX/" },
{ icon: "mail", link: "mailto:demo@example.com" }
]
},
timer: {
name: "春節",
time: "2025-01-29",
},
social: [
{ icon: "brand-github", link: "https://github.com/XXX/" },
{ icon: "mail", link: "mailto:demo@example.com" },
{ icon: "brand-x", link: "https://x.com/xxx"},
{ icon: "brand-facebook", link: "https://www.facebook.com/profile.php?id=100084xxxxxxxxx" },
{ icon: "brand-instagram", link: "https://www.instagram.com/xxx/" }
],
thanks: [
{iconSrc: "https://pictures.axiomatrix.org/astro-black.png", name: "Astro", description: "The web framework for content-driven websites.", url: "https://astro.build/"},
{iconSrc: "https://pictures.axiomatrix.org/blog-config/wuming.webp", name: "無名小栈", description: "本站美學之基點。", url: "https://blog.imsyy.top/"},
{iconSrc: "https://pictures.axiomatrix.org/blog-config/vercel.png", name: "Vercel", description: "為本站提供建置服務。", url: "https://vercel.com/"}
],
links: [
{
title: "推薦",
description: "大神圈,推薦追蹤。",
links: [
{iconSrc: "https://pictures.axiomatrix.org/blog-config/ruan-yi-feng.ico", name: "阮一峯", description: "阮老師,知名閣主,大神中的大神。", url: "https://www.ruanyifeng.com/blog/"},
{iconSrc: "https://pictures.axiomatrix.org/blog-config/144.webp", name: "張洪 Heo", description: "產品設計師,獨立開發者,設計與科技分享。", url: "https://blog.zhheo.com/"},
{iconSrc: "https://pictures.axiomatrix.org/blog-config/du-lao-shi-shuo.ico", name: "杜老師說", description: "資深網路工程師,網站技術營運總監,系統維運、架構設計以及最佳化專家。", url: "https://dusays.com/"},
{iconSrc: "https://pictures.axiomatrix.org/blog-config/XAOUU.ico", name: "XAOXUU", description: "Hexo Stellar、Volantis 主題作者。", url: "https://xaoxuu.com/"},
{iconSrc: "https://pictures.axiomatrix.org/blog-config/feng-ji-xing-chen.png!webp", name: "風記星辰", description: "非常優秀設計與交互的部落格。", url: "https://www.thyuu.com/"},
{iconSrc: "https://pictures.axiomatrix.org/blog-config/diygod.png", name: "DIYgod", description: "寫代碼是熱愛,寫到世界充滿愛!", url: "https://diygod.cc/"},
]
}
]
}
}
export default BlogConfig;

43
src/content/config.ts Normal file
View File

@ -0,0 +1,43 @@
// 1. 从 `astro:content` 导入
import { z, defineCollection } from 'astro:content';
// 2. 定义集合
const articlesCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string().optional(),
date: z.date(),
tags: z.array(z.string()),
categories: z.array(z.string()),
cover: z.string().optional(),
author: z.string().optional(),
pin: z.boolean().optional(),
gpt: z.string().optional()
})
});
const protocolsCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string().optional(),
date: z.date(),
author: z.string().optional()
})
})
const friendsCollection = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string().optional(),
date: z.date(),
author: z.string().optional()
})
})
// 3. 导出一个 `collections` 对象来注册集合
export const collections = {
'articles': articlesCollection,
'protocols': protocolsCollection,
'friends': friendsCollection
};

View File

@ -0,0 +1,37 @@
---
title: 友情鏈接申請
date: 2024-11-16
---
# 友情鏈接申請
很高興能和各位優秀的朋友們交流,本站友鏈目前採用**人工增加**之方式,如果你想加入友鏈,可以以任意聯絡方式聯絡我。
### 友鏈相關須知
- 為了友鏈相關頁面和組件的統一性和美觀性,可能會對你的部分資訊進行縮短處理,例如包含 `部落格`、`XX的XX` 等內容或形式**將被簡化**。
- 為了圖片載入速度和內容安全性考慮,大頭貼或其他資訊中的圖片均使用本站圖床,**所以無法收到貴站自己的大頭貼更新**,如果有迫切的更改信息需求,**請單獨聯絡**。
### 我的友鏈資訊
- 名稱:`陳叔叔的部落格`
- 描述:`人間可愛,生活精彩`
- 位址:`https://blog.kynix.tw/`
- 大頭貼:`https://pictures.axiomatrix.org/logo.svg`
```yml
name: 陳叔叔的部落格
desc: 人間可愛,生活精彩
url: https://blog.kynix.tw/
avatar: https://pictures.axiomatrix.org/logo.svg
```
### 申請友鏈
- 我已增加 [陳叔叔的部落格](https://blog.kynix.tw/) 之友情鏈接。
- 本站不接受 **採集站**、**搬運站**、**論壇** 等非 **個人部落格** 類型的站點之友鏈申請。
- 網站內容符合 **中華人民共和國(中國大陸)和中國台灣之法律規定**
:::caution[警告]
若申請時或日後有違反上述規定的站點,主人有權**自行刪除**,並恕不另行通知。
:::

View File

@ -0,0 +1,54 @@
---
title: 授權協議
date: 2024-11-15
---
為了確保文章質量,並保持網路開放共享之精神,綜合考慮下本站的所有原創文章均採用 cc 協議中比較嚴苛的 [姓名標示─非商業性─禁止改作4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/deed.zh) 國際標準。這篇文章主要想能夠更清楚明白的介紹本站的協議標準和要求。方便你合理的使用本站的文章。
本站無廣告嵌入及商業行為。違反協議的行為不僅會損害原作者的創作熱情,也會影響整個版權環境。強烈呼籲你能夠在轉載時遵守協議。遵守協議的行為幾乎不會對你的目標產生負面影響,鼓勵創作環境是每個創作者的期望。
## 哪些文章適用於本協議?
- 所有原創內容均在文章標題頂部,以及文章結尾的版權說明部分展示。
- 原創內容的非商用轉載必須為完整轉載且標註出處的 `帶有完整url連結``訪問原文` 之類字樣的超連結。
- 作為參考資料的情況可以無需完整轉載,摘錄所需的部分內容即可,但需標註出處。
## 你可以做什麼?
只要你遵守本頁的許可,你可以自由地分享文章的內容 — 在任何媒介以任何形式複製、發行本作品。並且無需通知作者。
## 你需要遵守什麼樣的許可?
### 姓名標示
你必須標註內容的來源,你需要在文章開頭部分(或明顯位置)標註原文章連結(建議使用超連結提升閱讀體驗)。
### 禁止商用
本站內容免費向網路所有使用者提供,分享本站文章時禁止商業性使用、禁止在轉載頁面中插入廣告(例如 Google 廣告、百度廣告)、禁止閱讀的攔截行為(例如追蹤公眾號、下載 App 後觀看文章)。
### 禁止改作
- 作為參考資料截取部分內容
> 作為參考資料的情況可以無需完整轉載,摘錄所需的部分內容即可,但需標註出處。
- 分享全部內容(無修改)
> 你需要在文章開頭部分(或明顯位置)標註原文章連結(建議使用超連結)
- 分享部分截取內容或衍生創作
> 目前本站全部原創文章的衍生品禁止公開分享和散佈。
## 什麼內容會被版權保護?
包括但不限於:
- 文章封面圖片
- 文章標題和正文
## 例外情況
本著友好互相進步的原則,被本站友鏈收錄的部落格允許部落格文章內容的衍生品之分享和分發,但仍需標註出處。
## 網站原始碼協議
根據自由軟體之要求,本部落格將採用 MIT 協議開放原始碼。

View File

@ -0,0 +1,58 @@
---
title: 隱私政策
date: 2024-11-15
---
> 更新日期2024 年 11 月 15 日
>
> 生效日期2024 年 01 月 25 日
歡迎來到 [陳叔叔的部落格](https://blog.kynix.tw/) (以下簡稱“本站”)。本站非常重視您的隱私和個人資訊保護。您在使用網站時,可能會收集和使用您的相關資訊。透過本頁面向您說明在您造訪本站時,本站是如何收集、使用、存儲、分享和轉讓這些資訊。
## 資訊收集和使用
### 在您訪問時
在訪問時,收集訪問資訊的服務會收集不限於以下資訊:
- **網路身分識別資訊** (瀏覽器 UA、IP 位址等)
- **裝置資訊** (裝置型號、裝置作業系統等)
- **瀏覽過程** (操作方式、瀏覽方式與時長、效能與網頁載入情況等)
:::note
本站預設已關閉所有第三方資訊獲取方,請放心使用。
:::
### 在您評論時
評論使用的是無登入的匿名評論系統,您可以自願填寫真實的、或虛假的資訊作為您評論的展示資訊。鼓勵您使用不易被人惡意識別的暱稱進行評論,但是建議您填寫真實的電郵以便收到回复(**你的電郵將不會被公開**)。
當您評論時,會額外收集您的詳細個人與裝置資訊進行存儲,用於鑑別惡意使用者。
### Cookies 和 Local Storage
本站為實現無帳戶評論、深色模式切換,客製化配置等功能,會在您的瀏覽器中進行本地存儲,您可以隨時清除瀏覽器中保存的所有`cookies`和`LocalStorage`。這將不會影響您的正常使用。
本部落格中的以下業務會在您的電腦上主動儲存資料:
- 評論系統
- 友鏈列表
詳情參考 [在 Chrome 中删除、允許與管理 Cookie](https://support.google.com/chrome/answer/95647?co=GENIE.Platform=Desktop&hl=zh-Hant)。
## 資訊分享和揭露
本站不會出售、交易或出租您的個人識別資訊給外部的公司或個人,除非得到您的許可、需要遵從法律要求、保護本站的權利和財產或在緊急情況下保護個人安全。
## 第三方網站
本站可能包含連結到其他網站。本站不對這些第三方網站的隱私權政策或內容負責,請您在離開本站造訪其他網站時閱讀其隱私權政策。
## 安全性
本站採取適當的安全措施,以保護儲存在本站系統中的個人資訊不受未經授權的存取或洩漏。
## 附屬協議
當本站監測到存在惡意存取、惡意請求、惡意攻擊、惡意評論的行為時為了防止增大受害範圍可能會臨時將您的IP 位址及存取資訊短期內添加到黑名單,短期內禁止訪問。
此黑名單會被公開,並可能共享給其他網站( 主體並非本人 使用包括但不限於IP 位址、裝置資訊、地理位置等。
## 隱私政策之變更
本站保留隨時更新或更改本隱私權政策的權利。本隱私權政策最新的修訂日期將會在本頁頂部顯示。
## 聯絡本站
如果您對本隱私權政策有任何疑問,請透過 <a href="/about">關於我</a> 中所示的聯絡方式與本站聯絡。

1
src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

42
src/layouts/Layout.astro Normal file
View File

@ -0,0 +1,42 @@
---
import Header from "../components/Header";
import Footer from "../components/Footer";
import BlogConfig from "../config/blog.config";
import {ViewTransitions} from "astro:transitions";
let theme: "light" | "dark" | "auto" = (Astro.cookies.get("theme")?.value || "auto") as "light" | "dark" | "auto";
const config = BlogConfig()
// 網站標題
const title = config.title
// 網站導航
const nav = config.nav
---
<!doctype html>
<html lang="en" class={theme}>
<head>
<meta charset="UTF-8"/>
<meta name="description" content="Astro description"/>
<meta name="viewport" content="width=device-width"/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
<meta name="generator" content={Astro.generator}/>
<title>{title}</title>
<ViewTransitions/>
</head>
<body>
<div class={theme + " background-pattern fixed left-0 top-0 w-screen h-screen"}></div>
<Header
title={title}
links={nav}
themeCookie={theme}
client:load
/>
<div class="h-16"></div>
<slot/>
<Footer/>
</body>
</html>
<style is:global lang="scss">
@use "../styles/global";
</style>

252
src/pages/about.astro Normal file
View File

@ -0,0 +1,252 @@
---
import SimpleCard from '../components/SimpleCard'
import SkillTag from "../components/SkillTag";
import Resume from "../components/Resume";
import SocialLink from "../components/SocialLink";
import Layout from "../layouts/Layout.astro";
import ENFP from "../assets/enfp.svg";
import Tech from "../assets/pexels-luis-gomes-166706-546819.jpg";
import Music from "../assets/pexels-ellis-1389429.jpg";
import Stars from "../assets/pexels-instawally-169789.jpg";
import Map from "../assets/map.png"
import BlogConfig from "../config/blog.config";
let skills = [
{
title: "JavaScript",
icon: "brand-javascript",
color: "#f1e05abd"
},
{
title: "TypeScript",
icon: "brand-typescript",
color: "#2496f2"
},
{
title: "HTML5",
icon: "brand-html5",
color: "#e34f26"
},
{
title: "CSS3",
icon: "brand-css3",
color: "#563d7c"
},
{
title: "React",
icon: "brand-react",
color: "#149ECA"
},
{
title: "Vue",
icon: "brand-vue",
color: "#41b883"
},
{
title: "Node.js",
icon: "brand-nodejs",
color: "#026E00"
},
{
title: "Python",
icon: "brand-python",
color: "#3776AB"
},
{
title: "Docker",
icon: "brand-docker",
color: "#2496f2"
},
{
title: "Git",
icon: "brand-git",
color: "#F05032"
},
{
title: "Go",
icon: "brand-golang",
color: "#4AA181"
}
]
const config = BlogConfig()
const socialLinks = config.social
---
<Layout>
<div style="height: 8rem" class={`fadeInUp flex justify-center items-center text-4xl font-bold custom-transition`}>
關於本站
</div>
<div class="fadeInUp container-main grid p-8 pt-4" style="grid-template-columns: 60% auto; gap: 1rem;">
<SimpleCard className="flex flex-col justify-center pl-8" style={{
backgroundImage: "linear-gradient(120deg, #5b27ff, #00d4ff)",
backgroundSize: "200% 200%",
color: 'var(--secondary-text)'
}} flow={true}>
<p class="leading-loose">你好,很高興認識你👋</p>
<p class="text-4xl leading-loose">這裡是 Demo</p>
<p class="leading-loose">一名全端開發工程師</p>
</SimpleCard>
<SimpleCard>
<div class="text-gray-400 mb-4">理想</div>
<div class="text-4xl leading-normal font-bold">和平的世界 與</div>
<div class="text-4xl leading-normal font-bold text-[#fa7671]">無罪的人間</div>
</SimpleCard>
</div>
<div class="fadeInUp container-main grid p-8" style="grid-template-columns: 40% auto; gap: 1rem">
<SimpleCard>
<div class="text-gray-400 mb-4 ml-4">技能</div>
<div class="text-4xl font-bold leading-normal ml-4">開啟創造力</div>
<div class="grid grid-cols-2 mt-3">
{skills.map((skill, index) => (
<SkillTag skill={skill}/>
))}
</div>
</SimpleCard>
<SimpleCard>
<div class="text-gray-400 mb-4 ml-4">生涯</div>
<div class="text-4xl font-bold leading-normal ml-4">無限進步</div>
<div>
<Resume dot="#357ef5" title="2021 · 山東大學 · 醫科學士在讀"/>
<Resume dot="#eb372a" title="2026 · 山東大學 · 醫科(普外科)碩士候讀"/>
</div>
</SimpleCard>
</div>
<div class="fadeInUp container-main grid p-8" style="grid-template-columns: 60% auto; gap: 1rem;">
<SimpleCard className="relative overflow-hidden">
<div class="text-gray-400 mb-4 ml-4">性格</div>
<div>
<div class="text-4xl font-bold leading-normal ml-4">競選者</div>
<div class="text-4xl font-bold leading-normal ml-4 text-[#33A474]">ENFP-A</div>
</div>
<div class="ml-4 mt-6 text-gray-400 text-sm max-w-72 w-3/5">在 <a href="https://www.16personalities.com/tw/">16personalities</a>
瞭解更多關於 <a href="https://www.16personalities.com/tw/enfp-%E6%80%A7%E6%A0%BC">競選者</a></div>
<img src={ENFP.src} class="enfp absolute right-2 top-8 w-1/2"/>
</SimpleCard>
<SimpleCard>
<div class="text-gray-400 mb-4 ml-4">格言</div>
<div>
<div class="text-4xl font-bold leading-normal ml-4 text-gray-400">人間可愛</div>
<div class="text-4xl font-bold leading-normal ml-4">生活精彩</div>
</div>
</SimpleCard>
</div>
<div class="fadeInUp container-main grid grid-cols-2 p-8 gap-4 text-white">
<SimpleCard className="h-96 relative" bgi={Tech.src}>
<div class="text-gray-400 mb-4 ml-4 relative z-20">追蹤偏好</div>
<div class="text-4xl font-bold leading-normal ml-4 relative z-20">數碼科技</div>
<div class="absolute bottom-4 ml-4 z-20">手機、電腦及軟硬體</div>
<div class="absolute left-0 top-0 w-full h-full z-10 rounded-xl"
style="background: radial-gradient(circle, rgba(128,128,128,0), transparent 50%, rgb(89,89,89));"></div>
</SimpleCard>
<SimpleCard bgi={Music.src} className="h-96">
<div class="text-gray-400 mb-4 ml-4 relative z-20">音樂</div>
<div class="text-4xl font-bold leading-normal ml-4 relative z-20">華語流行、華語經典、純音樂</div>
<div class="absolute bottom-4 ml-4 z-20">一起聆聽 ♫</div>
<div class="absolute left-0 top-0 w-full h-full z-10 rounded-xl"
style="background: radial-gradient(circle, rgba(128,128,128,0), transparent 50%, rgb(108,0,0));"></div>
</SimpleCard>
</div>
<div class="fadeInUp container-main grid p-8 gap-4" style="grid-template-columns: 40% auto">
<SimpleCard className="h-80 relative text-white" bgi={Stars.src}>
<div class="text-gray-400 mb-4 ml-4 relative z-20">語言</div>
<div class="text-4xl font-bold leading-normal ml-4 relative z-20">繁體中文、简体中文、English</div>
<div class="absolute bottom-4 ml-4 z-20">溝通無國界</div>
</SimpleCard>
<div class="grid grid-rows-2 gap-4">
<SimpleCard bgi={Map.src} className="map relative overflow-hidden">
<div class="bg-gray-400 absolute bottom-0 left-0 w-full rounded-b-xl h-1/2 flex items-center p-4 text-white text-xl font-bold transform-none">
我來自 中國台灣 · 桃園市
</div>
</SimpleCard>
<SimpleCard className="flex items-center">
<div class="flex flex-col items-start mr-8">
<div class="leading-loose text-gray-400">生於</div>
<div class="info text-4xl leading-normal font-bold text-[#43a6c6]">2002</div>
</div>
<div class="flex flex-col items-start ml-4">
<div class="leading-loose text-gray-400">政治立場</div>
<div class="info text-4xl leading-normal font-bold text-[#dfac46]">XX 主義</div>
</div>
</SimpleCard>
</div>
</div>
<SimpleCard className="m-8">
<div class="text-gray-400 mb-4 ml-4 relative z-20">聯絡我</div>
<div class="text-4xl font-bold leading-normal ml-4 relative z-20">這些是我的社交媒體,歡迎互追喔~</div>
<div class="socials ml-4 mt-6 flex items-center">
{ socialLinks.map((link, index) => (
<SocialLink social={link}/>
)) }
</div>
</SimpleCard>
</Layout>
<style lang="scss">
a {
&:hover {
color: var(--main-color);
}
}
.enfp {
transition: all 1s ease;
&:hover {
transform: scale(1.2);
transform-origin: left top;
}
}
@media (screen and max-width: 915px) {
.container-main {
grid-template-rows: auto auto;
grid-template-columns: auto !important;
}
}
@media (screen and max-width: 500px) {
.socials {
display: grid;
grid-template-columns: repeat(3, auto);
}
}
@media (screen and max-width: 430px) {
.info {
font-size: 1.7rem;
}
}
.fadeInUp {
opacity: 0;
transform: translateY(20px); /* 初始状态从底部偏移 */
animation: fadeInUp 0.8s ease-out forwards;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -0,0 +1,141 @@
---
import Layout from '../../layouts/Layout.astro';
import IndexCard from "../../components/IndexCard";
import MainSide from "../../components/MainSide";
import BlogConfig from "../../config/blog.config";
import ArticleTag from "../../components/ArticleTag";
import Pagination from "../../components/Pagination";
import {getCollection} from "astro:content";
//*******************************
// 开始整活
// 获取文章
const posts = await getCollection("articles");
// 每页的文章数
const postsPerPage = 8;
// 计算总页数
const totalPages = Math.ceil(posts.length / postsPerPage);
// 获取当前页码
const {slug} = Astro.params
// 如果是第一页,直接跳到首页
if (slug === "1") {
return Astro.redirect("/")
}
// 如果 slug 是 undefined跳到404
if (!slug) {
return Astro.redirect("/404")
}
// 如果不是总页数之内的数字跳到404
if (parseInt(slug as string) > totalPages || parseInt(slug as string) < 1) {
return Astro.redirect("/404")
}
// 如果甚至不是数字跳到404
if (isNaN(parseInt(slug as string))) {
return Astro.redirect("/404")
}
// 排序pin置顶非pin按照时间排列
posts.sort((a, b) => {
const pinA = a.data.pin || false
const pinB = b.data.pin || false
if (pinA !== pinB) {
return pinA ? -1 : 1
}
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
})
// 计算分页数据
const start = (parseInt(slug as string) - 1) * postsPerPage
const end = start + postsPerPage
const pagePosts = posts.slice(start, end)
let realData : {
title: string,
description?: string,
date: object,
tags: string[],
categories: string[],
pin?: boolean,
cover?: string,
author?: string,
gpt?: string,
slug?: string
}[] = []
pagePosts.map((post) => {
let newData: {
title: string,
description?: string,
date: object,
tags: string[],
categories: string[],
pin?: boolean,
cover?: string,
author?: string,
gpt?: string,
slug?: string
} = {
slug: post.slug,
...post.data
}
realData.push(newData)
})
//*******************************
const config = BlogConfig()
// 網站標題
const title = `你好,歡迎來到${config.title}`
// Dewvine
const dewvine = config.dewvine
const result = await ((await fetch(dewvine.url + "?type=" + dewvine.type + "&lang=" + dewvine.lang + "&length=" + dewvine.length + "&number=" + dewvine.number)).json())
// Profile
const profile = config.profile
// Timer
const timer = config.timer
// Analysis
const birthday = config.birth
---
<Layout>
<IndexCard
title={title}
dewvine={result[0].sentence || ""}
client:load
/>
<main class="mb-8 flex pl-8 pr-8">
<div style="flex: 3" id="content" class="pr-4">
{realData!.map((data, index) => (
<ArticleTag data={data} delay={300 * (index + 1)}/>
))}
<Pagination total={totalPages} current={parseInt(slug as string)} delay={2700} client:load/>
</div>
<div style="width: 20rem" class="relative index-right">
<MainSide
profile={profile}
timer={timer}
article_count={posts.length}
birthday={birthday}
client:only
/>
</div>
</main>
</Layout>
<style lang="scss">
@media (screen and max-width: 775px) {
.index-right {
display: none;
}
}
@media (screen and max-width: 520px) {
main {
padding: 0 !important;
}
#content {
padding-left: 1rem;
}
}
</style>

View File

@ -0,0 +1,65 @@
---
import Layout from "../../layouts/Layout.astro";
import {getCollection} from "astro:content";
import CateOrTag from "../../components/CateOrTag";
const pages = ["categories", "tags"]
const {slug} = Astro.params
const page = pages.find((page) => page === slug)
if (!page) return Astro.redirect("/404");
function unionAndCount<T>(...arrays: T[][]): Map<T, number> {
const elementCounts = new Map<T, number>();
arrays.flat().forEach((element) => {
elementCounts.set(element, (elementCounts.get(element) || 0) + 1);
});
return elementCounts;
}
const posts = await getCollection("articles")
const categories = unionAndCount(...posts.map((post) => post.data.categories))
const tags = unionAndCount(...posts.map((post) => post.data.tags))
let categoriesArr = Array.from(categories)
let tagsArr = Array.from(tags)
---
<Layout>
<div style="min-height: calc(100vh - 9rem)">
<div style="height: 8rem" class={`fadeInUp flex flex-col justify-center items-center custom-transition pt-8`}>
<div class="text-4xl font-bold">{slug === "categories" ? "全部分類" : "全部標籤"}</div>
<div class="text-xl text-gray-400 mt-6">共有 {slug === "categories" ? categories.size : tags.size} 個{slug === "categories" ? "分類" : "標籤"}</div>
</div>
<div class="text-center p-4">
{ slug === "categories" ? categoriesArr.map((item, index) => (
<CateOrTag count={item[1]} type={"category"} delay={200} client:load url={`/categories/${item[0]}?page=1`}>
{item[0]}
</CateOrTag>
)) : tagsArr.map((item, index) => (
<CateOrTag count={item[1]} type={"tag"} delay={200} client:load url={`/tags/${item[0]}?page=1`}>
{item[0]}
</CateOrTag>
)) }
</div>
</div>
</Layout>
<style>
.fadeInUp {
opacity: 0;
transform: translateY(20px); /* 初始状态从底部偏移 */
animation: fadeInUp 0.8s ease-out forwards;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -0,0 +1,161 @@
---
import Layout from '../../layouts/Layout.astro';
import IndexCard from "../../components/IndexCard";
import MainSide from "../../components/MainSide";
import BlogConfig from "../../config/blog.config";
import ArticleTag from "../../components/ArticleTag";
import Pagination from "../../components/Pagination";
import {getCollection} from "astro:content";
//*******************************
// 开始整活
function unionAndCount<T>(...arrays: T[][]): Map<T, number> {
const elementCounts = new Map<T, number>();
arrays.flat().forEach((element) => {
elementCounts.set(element, (elementCounts.get(element) || 0) + 1);
});
return elementCounts;
}
// 獲取 slug
const {slug} = Astro.params
// 获取文章
let posts = await getCollection("articles");
const categories = unionAndCount(...posts.map((post) => post.data.categories))
let categoriesArr = Array.from(categories)
// 重定向
if (!categoriesArr.find((item) => item[0] === slug)) {
return Astro.redirect("/404")
}
posts = posts.filter((post) => {
return post.data.categories.includes(slug as string)
})
// 每页的文章数
const postsPerPage = 8;
// 计算总页数
const totalPages = Math.ceil(posts.length / postsPerPage);
// 获取当前頁碼
const url = new URL(Astro.request.url)
const page = url.searchParams.get("page")
// 如果 page 是 undefined跳到404
if (!page) {
return Astro.redirect("/404")
}
// 如果不是总页数之内的数字跳到404
if (parseInt(page as string) > totalPages || parseInt(page as string) < 1) {
return Astro.redirect("/404")
}
// 如果甚至不是数字跳到404
if (isNaN(parseInt(page as string))) {
return Astro.redirect("/404")
}
// 排序pin置顶非pin按照时间排列
posts.sort((a, b) => {
const pinA = a.data.pin || false
const pinB = b.data.pin || false
if (pinA !== pinB) {
return pinA ? -1 : 1
}
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
})
// 计算分页数据
const start = (parseInt(page as string) - 1) * postsPerPage
const end = start + postsPerPage
const pagePosts = posts.slice(start, end)
let realData : {
title: string,
description?: string,
date: object,
tags: string[],
categories: string[],
pin?: boolean,
cover?: string,
author?: string,
gpt?: string,
slug?: string
}[] = []
pagePosts.map((post) => {
let newData: {
title: string,
description?: string,
date: object,
tags: string[],
categories: string[],
pin?: boolean,
cover?: string,
author?: string,
gpt?: string,
slug?: string
} = {
slug: post.slug,
...post.data
}
realData.push(newData)
})
//*******************************
const config = BlogConfig()
// Dewvine
const dewvine = config.dewvine
const result = await ((await fetch(dewvine.url + "?type=" + dewvine.type + "&lang=" + dewvine.lang + "&length=" + dewvine.length + "&number=" + dewvine.number)).json())
// Profile
const profile = config.profile
// Timer
const timer = config.timer
// Analysis
const birthday = config.birth
---
<Layout>
<IndexCard
title={slug as string}
dewvine={"分類"}
client:load
/>
<main class="mb-8 flex pl-8 pr-8">
<div style="flex: 3" id="content" class="pr-4">
{realData.map((post, index) => (
<ArticleTag data={post} delay={300 * (index + 1)}/>
))}
<Pagination total={totalPages} current={parseInt(page as string)} delay={2700} type="category" base={`categories/${slug}`} client:load/>
</div>
<div style="width: 20rem" class="relative index-right">
<MainSide
profile={profile}
timer={timer}
article_count={posts.length}
birthday={birthday}
client:only
/>
</div>
</main>
</Layout>
<style lang="scss">
@media (screen and max-width: 775px) {
.index-right {
display: none;
}
}
@media (screen and max-width: 520px) {
main {
padding: 0 !important;
}
#content {
padding-left: 1rem;
}
}
</style>

167
src/pages/index.astro Normal file
View File

@ -0,0 +1,167 @@
---
import Layout from '../layouts/Layout.astro';
import IndexCard from "../components/IndexCard";
import MainSide from "../components/MainSide";
import BlogConfig from "../config/blog.config";
import ArticleTag from "../components/ArticleTag";
import {getCollection} from "astro:content";
import Pagination from "../components/Pagination";
//*******************************
// 开始整活
// 获取文章
const posts = await getCollection("articles");
// 每页的文章数
const postsPerPage = 8;
// 计算总页数
const totalPages = Math.ceil(posts.length / postsPerPage);
// 获取当前页码
const slug = "1"
// 排序pin置顶非pin按照时间排列
posts.sort((a, b) => {
const pinA = a.data.pin || false
const pinB = b.data.pin || false
if (pinA !== pinB) {
return pinA ? -1 : 1
}
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
})
// 计算分页数据
const start = (parseInt(slug as string) - 1) * postsPerPage
const end = start + postsPerPage
const pagePosts = posts.slice(start, end)
let realData : {
title: string,
description?: string,
date: object,
tags: string[],
categories: string[],
pin?: boolean,
cover?: string,
author?: string,
gpt?: string,
slug?: string
}[] = []
pagePosts.map((post) => {
let newData: {
title: string,
description?: string,
date: object,
tags: string[],
categories: string[],
pin?: boolean,
cover?: string,
author?: string,
gpt?: string,
slug?: string
} = {
slug: post.slug,
...post.data
}
realData.push(newData)
})
//*******************************
const config = BlogConfig()
// 網站標題
const title = `你好,歡迎來到${config.title}`
// Dewvine
const mockResponse = (data: unknown): Response => {
return {
ok: true, // 模拟成功状态
status: 200,
statusText: "OK",
headers: new Headers(),
url: "",
redirected: false,
type: "basic",
clone() {
return mockResponse(data);
},
body: null,
bodyUsed: false,
async json() {
return data; // 返回传入的数据
},
async text() {
return JSON.stringify(data); // 如果需要 .text() 方法
},
arrayBuffer() {
return Promise.reject(new Error("Not implemented"));
},
blob() {
return Promise.reject(new Error("Not implemented"));
},
formData() {
return Promise.reject(new Error("Not implemented"));
},
};
};
let result;
try {
const dewvine = config.dewvine
let response = await fetch(dewvine.url + "?type=" + dewvine.type + "&lang=" + dewvine.lang + "&length=" + dewvine.length + "&number=" + dewvine.number)
if (!response.ok) {
response = mockResponse([{sentence: "蔓露載入失敗,請稍後重試。"}])
}
result = await response.json()
} catch (err) {
result = [{sentence: "蔓露載入失敗,請稍後重試。"}]
}
// Profile
const profile = config.profile
// Timer
const timer = config.timer
// Analysis
const birthday = config.birth
---
<Layout>
<IndexCard
title={title}
dewvine={result[0].sentence || ""}
client:load
/>
<main class="mb-8 flex pl-8 pr-8">
<div style="flex: 3" id="content" class="pr-4">
{realData!.map((data, index) => (
<ArticleTag data={data} delay={300 * (index + 1)}/>
))}
<Pagination total={totalPages} current={1} delay={2700} client:load/>
</div>
<div style="width: 20rem" class="index-right relative">
<MainSide
profile={profile}
timer={timer}
article_count={posts.length}
birthday={birthday}
client:only
/>
</div>
</main>
</Layout>
<style lang="scss">
@media (screen and max-width: 775px) {
.index-right {
display: none;
}
}
@media (screen and max-width: 520px) {
main {
padding: 0 !important;
}
#content {
padding-left: 1rem;
}
}
</style>

83
src/pages/links.astro Normal file
View File

@ -0,0 +1,83 @@
---
import Layout from "../layouts/Layout.astro";
import SimpleCard from "../components/SimpleCard";
import ThanksList from "../components/ThanksList";
import BlogConfig from "../config/blog.config";
import {getCollection} from "astro:content";
import "../styles/styles.css"
import "katex/dist/katex.min.css"
type Links = {
title: string,
description?: string,
thanks: {
iconSrc: string,
name: string,
description: string,
url: string
}[],
delay: number,
}
const config = BlogConfig()
const links = config.links
let linksLocal: Links[] = []
links.map((link, index) => {
let l: Links = {
title: link.title,
description: link.description,
thanks: link.links,
delay: 200 * (index + 1)
}
linksLocal.push(l)
})
const friends = await getCollection("friends")
const {Content, headings} = await friends![0].render()
---
<Layout>
<SimpleCard className={"m-8 flex items-center justify-between"}>
<div class="min-h-36">
<div class="ml-4 text-sm text-gray-400">友情鏈接</div>
<div class="ml-4 text-4xl mt-4 font-bold leading-normal">互相交流,共同進步</div>
</div>
<div class="mr-4">
<a id="apply_for_friends" class="flex items-center bg-[--main-text] text-[--main-bg] p-2 pl-4 pr-4 rounded shadow-xl transition-all hover:bg-[--main-color] hover:text-white cursor-pointer">
<i class="ti ti-circle-arrow-right-filled text-xl"></i>
<button class="text-xl ml-2">申請友鏈</button>
</a>
</div>
</SimpleCard>
<div class="m-8">
{linksLocal.map((link, index) => (
<ThanksList title={link.title} description={link.description} thanks={link.thanks} delay={link.delay} client:load/>
))}
</div>
<div class="post m-8 relative z-10">
<Content/>
</div>
</Layout>
<style lang="scss">
@media (screen and max-width: 620px) {
#apply_for_friends {
display: none;
}
}
</style>
<script>
let btn = document.querySelector("#apply_for_friends")
btn!.addEventListener("click", () => {
const targetElement = document.querySelector("#友情鏈接申請")
targetElement!.scrollIntoView({behavior: "smooth", block: "start", inline: "nearest"})
})
</script>

View File

@ -0,0 +1,119 @@
---
import Layout from "../../layouts/Layout.astro";
import GPT from "../../components/GPT";
import PostSide from "../../components/PostSide";
import CCBoard from "../../components/CCBoard";
import "../../styles/styles.css"
import "katex/dist/katex.min.css"
import {getCollection} from "astro:content";
import BlogConfig from "../../config/blog.config";
// 獲取文章列表及slug列表
const posts = await getCollection("articles");
const slugs = posts.map((post) => post.slug);
// 獲取slug
const {slug} = Astro.params;
// 判斷slug是否在slugs中
if (!slugs.find((item) => item === slug)) {
return Astro.redirect("/404")
}
const post = posts.find((post) => post.slug === slug);
const {Content, headings} = await post!.render()
const config = BlogConfig()
---
<Layout>
<div style="min-height: calc(100vh - 9rem)" class="p-8 pl-16 pr-16 w-full" id="post-main-body">
<div class="min-h-60 relative z-10 flex flex-col justify-center" id="post_header_card">
<div class="flex items-center flex-wrap">
<div class="flex items-center flex-wrap">
{post!.data.categories.map((category, index) => (
<a href={`/categories/${category}?page=1`}>
<div class="flex flex-nowrap items-center bg-[--opacity-bg] pt-2 pb-2 pl-4 pr-4 rounded text-sm font-bold cursor-pointer transition-all hover:bg-[--main-color-opacity] hover:text-[--main-color]">
<i class="ti ti-folder"></i>
<div class="ml-2 line-clamp-1">{category}</div>
</div>
</a>
))}
</div>
<div class="flex items-center flex-wrap">
{post!.data.tags.map((tag, index) => (
<a href={`/tags/${tag}?page=1`}>
<div class="flex flex-nowrap items-center pt-2 pb-2 pl-4 pr-4 text-sm font-bold cursor-pointer transition-all hover:text-[--main-color]">
<i class="ti ti-hash"></i>
<div class="ml-2 line-clamp-1">{tag}</div>
</div>
</a>
))}
</div>
</div>
<h1 class="text-4xl mt-6 mb-6 font-bold leading-normal">{post!.data.title}</h1>
<div>
<div class="flex items-center text-gray-400">
<i class="ti ti-calendar-month"></i>
<div class="ml-2">{`${new Date(post!.data.date).getFullYear()} 年 ${new Date(post!.data.date).getMonth() + 1} 月 ${new Date(post!.data.date).getDate()} 日`}</div>
</div>
</div>
</div>
<main class="relative z-10 grid w-full gap-4 pping" style="grid-template-columns: auto 18rem">
<div class="content_post bg-[--secondary-bg] relative z-10 rounded-xl border p-8 overflow-hidden" id="content_post">
<GPT gpt={post!.data.gpt || "文章無摘要生成。"} client:load/>
<div class="post">
<Content/>
</div>
<CCBoard
title={post!.data.title}
url={`${config.base}/posts/${post!.slug}/`}
author={post!.data.author || config.author}
createDate={`${new Date(post!.data.date).getFullYear()} 年 ${new Date(post!.data.date).getMonth() + 1} 月 ${new Date(post!.data.date).getDate()} 日`},
cc="BY-NC-SA 4.0"
ccStr="姓名標示-非商業性-相同方式分享 4.0 國際"
/>
</div>
<div class="w-fit">
<PostSide client:only/>
</div>
</main>
</div>
</Layout>
<style lang="scss">
@media (screen and max-width: 1075px) {
.pping {
grid-template-columns: auto !important;
}
}
@media (screen and max-width: 565px) {
#post-main-body {
padding-left: 2rem;
padding-right: 2rem;
}
.content_post {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
}
@media (screen and max-width: 400px) {
#post-main-body {
padding-left: 0.7rem;
padding-right: 0.7rem;
}
}
</style>
<script lang="ts">
let expressive_codes = document.getElementsByClassName("frame");
for (let i = 0; i < expressive_codes.length; i ++) {
expressive_codes[i].classList.remove("is-terminal")
expressive_codes[i].classList.remove("has-title")
}
</script>

View File

@ -0,0 +1,50 @@
---
import Layout from "../../layouts/Layout.astro";
import {getCollection} from "astro:content";
import "../../styles/styles.css"
import "katex/dist/katex.min.css"
const pages = ["privacy", "license"]
const {slug} = Astro.params
const page = pages.find((page) => page === slug)
if (!page) return Astro.redirect("/404");
const protocols = await getCollection("protocols")
const content = protocols.find((protocol: { slug: string }) => protocol.slug === page)
const {Content, headings} = await content!.render()
---
<Layout>
<div class="fadeInUp protocol_container m-8 p-4 bg-[--secondary-bg] relative z-10 border pl-12 pr-12"
style="margin-left: 4rem; margin-right: 4rem; border-radius: 1rem">
<h1 class="font-bold text-4xl text-center m-6">{content?.data.title}</h1>
<div class="post">
<Content/>
</div>
</div>
</Layout>
<style lang="scss">
@media (screen and max-width: 600px) {
.protocol_container {
margin-left: 1rem !important;
margin-right: 1rem !important;
}
}
.fadeInUp {
opacity: 0;
transform: translateY(20px); /* 初始状态从底部偏移 */
animation: fadeInUp 0.8s ease-out forwards;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -0,0 +1,158 @@
---
import Layout from '../../layouts/Layout.astro';
import IndexCard from "../../components/IndexCard";
import MainSide from "../../components/MainSide";
import BlogConfig from "../../config/blog.config";
import ArticleTag from "../../components/ArticleTag";
import Pagination from "../../components/Pagination";
import {getCollection} from "astro:content";
//*******************************
// 开始整活
function unionAndCount<T>(...arrays: T[][]): Map<T, number> {
const elementCounts = new Map<T, number>();
arrays.flat().forEach((element) => {
elementCounts.set(element, (elementCounts.get(element) || 0) + 1);
});
return elementCounts;
}
// 獲取 slug
const {slug} = Astro.params
// 获取文章
let posts = await getCollection("articles");
const tags = unionAndCount(...posts.map((post) => post.data.tags))
let tagsArr = Array.from(tags)
// 重定向
if (!tagsArr.find((item) => item[0] === slug)) {
return Astro.redirect("/404")
}
posts = posts.filter((post) => {
return post.data.tags.includes(slug as string)
})
// 每页的文章数
const postsPerPage = 8;
// 计算总页数
const totalPages = Math.ceil(posts.length / postsPerPage);
// 获取当前頁碼
const url = new URL(Astro.request.url)
const page = url.searchParams.get("page")
// 如果 page 是 undefined跳到404
if (!page) {
return Astro.redirect("/404")
}
// 如果不是总页数之内的数字跳到404
if (parseInt(page as string) > totalPages || parseInt(page as string) < 1) {
return Astro.redirect("/404")
}
// 如果甚至不是数字跳到404
if (isNaN(parseInt(page as string))) {
return Astro.redirect("/404")
}
// 排序pin置顶非pin按照时间排列
posts.sort((a, b) => {
const pinA = a.data.pin || false
const pinB = b.data.pin || false
if (pinA !== pinB) {
return pinA ? -1 : 1
}
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
})
// 计算分页数据
const start = (parseInt(page as string) - 1) * postsPerPage
const end = start + postsPerPage
const pagePosts = posts.slice(start, end)
let realData : {
title: string,
description?: string,
date: object,
tags: string[],
categories: string[],
pin?: boolean,
cover?: string,
author?: string,
gpt?: string,
slug?: string
}[] = []
pagePosts.map((post) => {
let newData: {
title: string,
description?: string,
date: object,
tags: string[],
categories: string[],
pin?: boolean,
cover?: string,
author?: string,
gpt?: string,
slug?: string
} = {
slug: post.slug,
...post.data
}
realData.push(newData)
})
//*******************************
const config = BlogConfig()
// Profile
const profile = config.profile
// Timer
const timer = config.timer
// Analysis
const birthday = config.birth
---
<Layout>
<IndexCard
title={slug as string}
dewvine={"標籤"}
client:load
/>
<main class="mb-8 flex pl-8 pr-8">
<div style="flex: 3" id="content" class="pr-4">
{realData!.map((post, index) => (
<ArticleTag data={post} delay={300 * (index + 1)}/>
))}
<Pagination total={totalPages} current={parseInt(page as string)} delay={2700} type="tag" base={`tags/${slug}`} client:load/>
</div>
<div style="width: 20rem" class="relative index-right">
<MainSide
profile={profile}
timer={timer}
article_count={posts.length}
birthday={birthday}
client:only
/>
</div>
</main>
</Layout>
<style lang="scss">
@media (screen and max-width: 775px) {
.index-right {
display: none;
}
}
@media (screen and max-width: 520px) {
main {
padding: 0 !important;
}
#content {
padding-left: 1rem;
}
}
</style>

34
src/pages/thanks.astro Normal file
View File

@ -0,0 +1,34 @@
---
import Layout from "../layouts/Layout.astro";
import ThanksList from "../components/ThanksList";
import BlogConfig from "../config/blog.config";
const config = BlogConfig()
const thanks = config.thanks
---
<Layout>
<div style="min-height: calc(100vh - 9rem)" class="pl-16 pr-16">
<div style="height: 8rem" class={`fadeInUp flex justify-center items-center text-4xl font-bold custom-transition fadeInUp`}>
致謝列表
</div>
<ThanksList title="本站基座" thanks={thanks} client:load delay={200}/>
</div>
</Layout>
<style>
.fadeInUp {
opacity: 0;
transform: translateY(20px); /* 初始状态从底部偏移 */
animation: fadeInUp 0.8s ease-out forwards;
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

10
src/scripts/loading.ts Normal file
View File

@ -0,0 +1,10 @@
const showLoading = () => {
console.log('show loading');
}
const hideLoading = () => {
console.log('hide loading');
}
window.addEventListener('astro:before-navigate', showLoading);
window.addEventListener('astro:after-navigate', hideLoading);

436
src/styles/global.scss Normal file
View File

@ -0,0 +1,436 @@
@use "@tabler/icons-webfont/dist/tabler-icons.min.css";
:root {
font-family: system-ui, sans-serif;
transition: all 0.3s linear;
}
@mixin light-theme {
--main-color: #60c7ff;
--main-color-decending: #d2eeff;
--main-color-opacity: rgba(96, 199, 255, 0.3);
--secondary-color: rgba(255, 255, 255, 0.15);
--main-bg: #f7f9fe;
--main-code-bg: #f7f9fe;
--secondary-bg: #fff;
--third-bg: #cce9ff;
--gray-bg: #f7f7f9;
--main-text: #333;
--secondary-text: #ffffff;
--main-border: #e1e1e1;
--main-scrollbar-bar: #e1e1e1;
--main-bg-blur: rgba(247, 249, 254, 0.4);
--main-color-bg: #4259ef0d;
--secondary-table: #ffffff;
--main-bg-pattern: url("../assets/pattern.svg");
--opacity-bg: rgba(0, 0, 0, .08);
--callout-headingFontColor-note: hsl(235, 76%, 29%);
--callout-bg-note: hsl(236, 77%, 90%);
--callout-border-note: hsl(235, 82%, 59%);
--callout-headingFontColor-success: hsl(137, 72%, 24%);
--callout-bg-success: hsl(96, 79%, 91%);
--callout-border-success: hsl(137, 66%, 30%);
--callout-headingFontColor-tip: hsl(277, 76%, 31%);
--callout-bg-tip: hsl(276, 70%, 90%);
--callout-border-tip: hsl(277, 81%, 60%);
--callout-headingFontColor-caution: hsl(41, 57%, 27%);
--callout-bg-caution: hsl(43, 80%, 88%);
--callout-border-caution: hsl(41, 80%, 63%);
--callout-headingFontColor-danger: hsl(342, 62%, 31%);
--callout-bg-danger: hsl(338, 57%, 89%);
--callout-border-danger: hsl(341, 72%, 60%);
}
@mixin dark-theme {
--main-color: #007bff;
--main-color-decending: #a5ceff;
--main-color-opacity: rgba(0, 123, 255, 0.3);
--secondary-color: rgba(0, 0, 0, 0.15);
--main-bg: #333;
--main-code-bg: #dddddd;
--secondary-bg: #333;
--third-bg: #333;
--gray-bg: #21232a;
--main-text: #efefef;
--secondary-text: #ffffff;
--main-border: #444;
--main-scrollbar-bar: #444;
--main-bg-blur: rgba(51, 51, 51, 0.4);
--main-color-bg: #f2b94b23;
--secondary-table: #333;
--main-bg-pattern: url("../assets/pattern.dark.svg");
--opacity-bg: rgba(255, 255, 255, .08);
--callout-headingFontColor-note: hsl(235, 86%, 86%);
--callout-bg-note: hsl(235, 50%, 20%);
--callout-border-note: hsl(235, 90%, 59%);
--callout-headingFontColor-success: hsl(96, 77%, 81%);
--callout-bg-success: hsl(95, 33%, 23%);
--callout-border-success: hsl(96, 77%, 81%);
--callout-headingFontColor-tip: hsl(276, 72%, 89%);
--callout-bg-tip: hsl(276, 36%, 22%);
--callout-border-tip: hsl(277, 81%, 60%);
--callout-headingFontColor-caution: hsl(42, 72%, 87%);
--callout-bg-caution: hsl(43, 33%, 22%);
--callout-border-caution: hsl(42, 72%, 65%);
--callout-headingFontColor-danger: hsl(338, 61%, 86%);
--callout-bg-danger: hsl(339, 32%, 21%);
--callout-border-danger: hsl(340, 64%, 62%);
}
@media (prefers-color-scheme: light) {
html.auto {
@include light-theme;
}
}
@media (prefers-color-scheme: dark) {
html.auto {
@include dark-theme
}
}
html.light {
@include light-theme;
}
html.dark {
@include dark-theme;
}
html {
background: var(--main-bg);
color: var(--main-text);
}
.background-pattern {
background-image: var(--main-bg-pattern);
}
// 滾動條
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: var(--main-scrollbar-bar);
border-radius: 8px;
cursor: pointer;
&:hover {
background-color: var(--main-color);
}
}
// 選中文字
::selection {
background-color: var(--main-color);
color: #efefef;
}
// markdown 渲染效果
.post {
@mixin h {
cursor: pointer;
position: relative;
margin-top: 1rem;
margin-bottom: 1rem;
width: fit-content;
&::before {
content: "#";
color: var(--main-color);
position: absolute;
transition: all 0.3s linear;
}
}
//padding: 2rem;
h1 {
font-size: 2.25rem;
font-weight: 700;
margin-left: 2rem;
&::before {
left: -2rem;
}
@include h;
}
h2 {
font-size: 1.75rem;
font-weight: 700;
border-bottom: var(--main-border) 1.5px solid;
margin-left: 1.7rem;
&::before {
left: -1.7rem;
}
@include h;
}
h3 {
font-size: 1.25rem;
font-weight: 700;
margin-left: 1.3rem;
&::before {
left: -1.3rem;
}
&::after {
content: "";
display: block;
width: 100%;
height: 1rem;
position: absolute;
left: 0;
bottom: 0;
border-radius: 3px;
background: var(--main-border);
margin-top: 0.5rem;
z-index: -1;
}
@include h;
}
h4 {
font-size: 1rem;
font-weight: 700;
margin-left: 1rem;
&::before {
left: -1rem;
}
@include h;
}
h5 {
font-size: 0.875rem;
font-weight: 700;
margin-left: 0.9rem;
&::before {
left: -0.9rem;
}
@include h;
}
h6 {
font-size: 0.75rem;
font-weight: 700;
margin-left: 0.8rem;
&::before {
left: -0.8rem;
}
@include h;
}
strong {
color: var(--main-color);
}
img {
max-width: 100%;
padding: 2rem;
margin-top: 1rem;
margin-bottom: 1rem;
height: auto;
border-radius: 0.5rem;
box-shadow: 2px 2px 10px var(--main-border);
}
blockquote {
border-left: 0.25em solid var(--main-border);
padding: 0.5rem 1rem;
color: inherit;
background-color: var(--gray-bg);
border-radius: 0.125rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
ol {
list-style-type: decimal;
margin: 0;
padding-left: 20px;
li::marker {
color: var(--main-color);
font-weight: bold;
}
ol {
//padding-left: 40px;
}
}
ul {
list-style-type: none;
margin: 0;
padding-left: 20px;
li::marker {
content: "";
color: var(--main-color);
font-weight: bold;
}
ul {
li::marker {
content: "";
}
ul {
li::marker {
content: "";
}
}
}
}
table {
min-width: 100%;
border-collapse: collapse;
text-align: left;
margin-top: 1rem;
margin-bottom: 1rem;
box-shadow: 2px 2px 10px var(--main-border);
}
thead {
background-color: var(--main-color);
color: white;
th {
padding: 8px;
border: 1px solid var(--main-border);
}
}
tbody {
tr:nth-child(odd) {
background-color: var(--main-bg);
}
tr:nth-child(even) {
background-color: var(--secondary-table);
}
}
td {
padding: 8px;
border: 1px solid var(--main-border);
}
:not(pre) {
code {
background-color: var(--main-code-bg);
padding: 0.25rem;
border-radius: 0.25rem;
font-size: 0.875rem;
color: var(--main-color);
}
}
.katex-display {
box-shadow: 2px 2px 10px var(--main-border);
padding-top: 1rem;
padding-bottom: 1rem;
position: relative;
margin-top: 4rem;
margin-bottom: 1rem;
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
&::before {
box-shadow: 0px -2px 10px var(--main-border);
height: 3rem;
content: "LaTeX";
color: var(--main-text);
background-color: var(--main-bg);
position: absolute;
width: 100%;
left: 0;
top: -4rem;
text-align: left;
display: flex;
align-items: center;
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
padding-left: 1rem;
margin-top: 1rem;
font-weight: bold;
}
}
.expressive-code {
width: 100%;
.frame {
box-shadow: none !important;
}
}
pre {
border-top-left-radius: 0 !important;
border-top-right-radius: 0 !important;
margin-top: 4rem !important;
box-shadow: var(--ec-frm-frameBoxShdCssVal);
&::before {
height: 3rem;
content: attr(data-language);
color: var(--main-text);
position: absolute;
width: 100%;
left: 0;
top: -4rem;
text-align: left;
display: flex;
align-items: center;
border-top-left-radius: calc(var(--ec-brdRad) + var(--ec-brdWd));
border-top-right-radius: calc(var(--ec-brdRad) + var(--ec-brdWd));
padding-left: 1rem;
margin-top: 1rem;
border-top: var(--ec-brdWd) solid var(--ec-brdCol);
border-left: var(--ec-brdWd) solid var(--ec-brdCol);
border-right: var(--ec-brdWd) solid var(--ec-brdCol);
z-index: 0;
//
background: var(--code-background);
}
code {
}
}
a {
color: var(--main-color);
position: relative;
&::after {
content: "";
display: block;
width: 100%;
height: 2px;
background-color: var(--main-color-opacity);
transition: all 0.3s ease;
position: absolute;
bottom: 0;
left: 0;
z-index: -1;
}
&:hover {
&::after {
height: 100%;
}
}
}
p {
line-height: 2;
}
li {
line-height: 2;
}
}

105
src/styles/styles.css Normal file
View File

@ -0,0 +1,105 @@
/* This is taken from @microflash/remark-callout-directives/themes/github/index.css and modified. Modified to work with tailwind darkMode, and match starlights <Asides/> */
/*:root {*/
/* --callout-headingFontColor-note: hsl(235, 76%, 29%);*/
/* --callout-bg-note: hsl(236, 77%, 90%);*/
/* --callout-border-note: hsl(235, 82%, 59%);*/
/* --callout-headingFontColor-success: hsl(137, 72%, 24%);*/
/* --callout-bg-success: hsl(96, 79%, 91%);*/
/* --callout-border-success: hsl(137, 66%, 30%);*/
/* --callout-headingFontColor-tip: hsl(277, 76%, 31%);*/
/* --callout-bg-tip: hsl(276, 70%, 90%);*/
/* --callout-border-tip: hsl(277, 81%, 60%);*/
/* --callout-headingFontColor-caution: hsl(41, 57%, 27%);*/
/* --callout-bg-caution: hsl(43, 80%, 88%);*/
/* --callout-border-caution: hsl(41, 80%, 63%);*/
/* --callout-headingFontColor-danger: hsl(342, 62%, 31%);*/
/* --callout-bg-danger: hsl(338, 57%, 89%);*/
/* --callout-border-danger: hsl(341, 72%, 60%);*/
/*}*/
/*:root[data-theme="dark"] {*/
/* --callout-headingFontColor-note: hsl(235, 86%, 86%);*/
/* --callout-bg-note: hsl(235, 50%, 20%);*/
/* --callout-border-note: hsl(235, 90%, 59%);*/
/* --callout-headingFontColor-success: hsl(96, 77%, 81%);*/
/* --callout-bg-success: hsl(95, 33%, 23%);*/
/* --callout-border-success: hsl(96, 77%, 81%);*/
/* --callout-headingFontColor-tip: hsl(276, 72%, 89%);*/
/* --callout-bg-tip: hsl(276, 36%, 22%);*/
/* --callout-border-tip: hsl(277, 81%, 60%);*/
/* --callout-headingFontColor-caution: hsl(42, 72%, 87%);*/
/* --callout-bg-caution: hsl(43, 33%, 22%);*/
/* --callout-border-caution: hsl(42, 72%, 65%);*/
/* --callout-headingFontColor-danger: hsl(338, 61%, 86%);*/
/* --callout-bg-danger: hsl(339, 32%, 21%);*/
/* --callout-border-danger: hsl(340, 64%, 62%);*/
/*}*/
.callout {
--calloutheadingFontColor: var(--callout-headingFontColor);
--callout-bg: var(--callout-bg);
--callout-border: var(--callout-border);
border-left: 0.25em solid var(--callout-border);
padding: 0.5rem 1rem;
color: inherit;
background-color: var(--callout-bg);
border-radius: 0.125rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.callout-indicator {
display: flex;
align-items: center;
line-height: 1;
margin-bottom: 16px;
color: var(--calloutheadingFontColor);
}
.callout-title {
font-weight: 600;
font-size: 1.25rem;
letter-spacing: 0.025em;
}
.callout-indicator > svg:first-of-type {
margin-right: 0.5rem;
}
.callout-content:first-child,
.callout-content:only-child {
margin-block-start: 0;
}
.callout-content:last-child,
.callout-content:only-child {
margin-block-end: 0;
}
.callout-note {
--callout-headingFontColor: var(--callout-headingFontColor-note);
--callout-bg: var(--callout-bg-note);
--callout-border: var(--callout-border-note);
}
.callout-success {
--callout-headingFontColor: var(--callout-headingFontColor-success);
--callout-bg: var(--callout-bg-success);
--callout-border: var(--callout-border-success);
}
.callout-caution {
--callout-headingFontColor: var(--callout-headingFontColor-caution);
--callout-bg: var(--callout-bg-caution);
--callout-border: var(--callout-border-caution);
}
.callout-danger {
--callout-headingFontColor: var(--callout-headingFontColor-danger);
--callout-bg: var(--callout-bg-danger);
--callout-border: var(--callout-border-danger);
}
.callout-tip {
--callout-headingFontColor: var(--callout-headingFontColor-tip);
--callout-bg: var(--callout-bg-tip);
--callout-border: var(--callout-border-tip);
}

8
tailwind.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}

7
vercel.json Normal file
View File

@ -0,0 +1,7 @@
{
"build": {
"env": {
"NODE_ENV": "production"
}
}
}