Initial commit
23
.gitignore
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# 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
|
||||
|
||||
.vercel
|
3
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["biomejs.biome", "astro-build.astro-vscode"]
|
||||
}
|
22
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"quickfix.biome": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
},
|
||||
"frontMatter.dashboard.openOnStart": false
|
||||
}
|
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 saicaca
|
||||
|
||||
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.
|
55
README.ja-JP.md
Normal file
|
@ -0,0 +1,55 @@
|
|||
# 🍥Fuwari
|
||||
|
||||
[Astro](https://astro.build)で構築された静的ブログテンプレート
|
||||
|
||||
[**🖥️ライブデモ (Vercel)**](https://fuwari.vercel.app) / [**🌏中文**](https://github.com/saicaca/fuwari/blob/main/README.zh-CN.md) / [**🌏日本語**](https://github.com/saicaca/fuwari/blob/main/README.ja-JP.md) / [**📦旧Hexoバージョン**](https://github.com/saicaca/hexo-theme-vivia)
|
||||
|
||||
![Preview Image](https://raw.githubusercontent.com/saicaca/resource/main/fuwari/home.png)
|
||||
|
||||
## ✨ 特徴
|
||||
|
||||
- [x] [Astro](https://astro.build)及び [Tailwind CSS](https://tailwindcss.com)で構築
|
||||
- [x] スムーズなアニメーションとページ遷移
|
||||
- [x] ライト/ダークテーマ対応
|
||||
- [x] カスタマイズ可能なテーマカラーとバナー
|
||||
- [x] レスポンシブデザイン
|
||||
- [ ] コメント機能
|
||||
- [x] 検索機能
|
||||
- [ ] 目次
|
||||
|
||||
## 🚀 使用方法
|
||||
|
||||
1. [テンプレート](https://github.com/saicaca/fuwari/generate)から新しいリポジトリを作成するかCloneをします。
|
||||
2. ブログをローカルで編集するには、リポジトリをクローンした後、`pnpm install` と `pnpm add sharp` を実行して依存関係をインストールします。
|
||||
- [pnpm](https://pnpm.io)がインストールされていない場合は `npm install -g pnpm` で導入可能です。
|
||||
3. `src/config.ts`ファイルを編集する事でブログを自分好みにカスタマイズ出来ます。
|
||||
4. `pnpm new-post <filename>`で新しい記事を作成し、`src/content/posts/`.フォルダ内で編集します。
|
||||
5. 作成したブログをVercel、Netlify、GitHub Pagesなどにデプロイするには[ガイド](https://docs.astro.build/ja/guides/deploy/)に従って下さい。加えて、別途デプロイを行う前に`astro.config.mjs`を編集してサイト構成を変更する必要があります。
|
||||
|
||||
## ⚙️ 記事のフロントマター
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My First Blog Post
|
||||
published: 2023-09-09
|
||||
description: This is the first post of my new Astro blog.
|
||||
image: /images/cover.jpg
|
||||
tags: [Foo, Bar]
|
||||
category: Front-end
|
||||
draft: false
|
||||
---
|
||||
```
|
||||
|
||||
## 🧞 コマンド
|
||||
|
||||
すべてのコマンドは、ターミナルでプロジェクトのルートから実行する必要があります:
|
||||
|
||||
| Command | Action |
|
||||
|:------------------------------------|:-------------------------------------------------|
|
||||
| `pnpm install` AND `pnpm add sharp` | 依存関係のインストール |
|
||||
| `pnpm dev` | `localhost:4321`で開発用ローカルサーバーを起動 |
|
||||
| `pnpm build` | `./dist/`にビルド内容を出力 |
|
||||
| `pnpm preview` | デプロイ前の内容をローカルでプレビュー |
|
||||
| `pnpm new-post <filename>` | 新しい投稿を作成 |
|
||||
| `pnpm astro ...` | `astro add`, `astro check`の様なコマンドを実行する際に使用 |
|
||||
| `pnpm astro --help` | Astro CLIのヘルプを表示 |
|
55
README.md
Normal file
|
@ -0,0 +1,55 @@
|
|||
# 🍥Fuwari
|
||||
|
||||
A static blog template built with [Astro](https://astro.build).
|
||||
|
||||
[**🖥️Live Demo (Vercel)**](https://fuwari.vercel.app) / [**🌏中文 README**](https://github.com/saicaca/fuwari/blob/main/README.zh-CN.md) / [**🌏日本語 README**](https://github.com/saicaca/fuwari/blob/main/README.ja-JP.md) / [**📦Old Hexo Version**](https://github.com/saicaca/hexo-theme-vivia)
|
||||
|
||||
![Preview Image](https://raw.githubusercontent.com/saicaca/resource/main/fuwari/home.png)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- [x] Built with [Astro](https://astro.build) and [Tailwind CSS](https://tailwindcss.com)
|
||||
- [x] Smooth animations and page transitions
|
||||
- [x] Light / dark mode
|
||||
- [x] Customizable theme colors & banner
|
||||
- [x] Responsive design
|
||||
- [ ] Comments
|
||||
- [x] Search
|
||||
- [ ] TOC
|
||||
|
||||
## 🚀 How to Use
|
||||
|
||||
1. [Generate a new repository](https://github.com/saicaca/fuwari/generate) from this template or fork this repository.
|
||||
2. To edit your blog locally, clone your repository, run `pnpm install` AND `pnpm add sharp` to install dependencies.
|
||||
- Install [pnpm](https://pnpm.io) `npm install -g pnpm` if you haven't.
|
||||
3. Edit the config file `src/config.ts` to customize your blog.
|
||||
4. Run `pnpm new-post <filename>` to create a new post and edit it in `src/content/posts/`.
|
||||
5. Deploy your blog to Vercel, Netlify, GitHub Pages, etc. following [the guides](https://docs.astro.build/en/guides/deploy/). You need to edit the site configuration in `astro.config.mjs` before deployment.
|
||||
|
||||
## ⚙️ Frontmatter of Posts
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My First Blog Post
|
||||
published: 2023-09-09
|
||||
description: This is the first post of my new Astro blog.
|
||||
image: /images/cover.jpg
|
||||
tags: [Foo, Bar]
|
||||
category: Front-end
|
||||
draft: false
|
||||
---
|
||||
```
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
|:------------------------------------|:-------------------------------------------------|
|
||||
| `pnpm install` AND `pnpm add sharp` | Installs dependencies |
|
||||
| `pnpm dev` | Starts local dev server at `localhost:4321` |
|
||||
| `pnpm build` | Build your production site to `./dist/` |
|
||||
| `pnpm preview` | Preview your build locally, before deploying |
|
||||
| `pnpm new-post <filename>` | Create a new post |
|
||||
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `pnpm astro --help` | Get help using the Astro CLI |
|
55
README.zh-CN.md
Normal file
|
@ -0,0 +1,55 @@
|
|||
# 🍥Fuwari
|
||||
|
||||
基于 [Astro](https://astro.build) 开发的静态博客模板。
|
||||
|
||||
[**🖥️在线预览(Vercel)**](https://fuwari.vercel.app) / [**🌏English README**](https://github.com/saicaca/fuwari) / [**🌏日本語 README**](https://github.com/saicaca/fuwari/blob/main/README.ja-JP.md) / [**📦旧 Hexo 版本**](https://github.com/saicaca/hexo-theme-vivia)
|
||||
|
||||
![Preview Image](https://raw.githubusercontent.com/saicaca/resource/main/fuwari/home.png)
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- [x] 基于 Astro 和 Tailwind CSS 开发
|
||||
- [x] 流畅的动画和页面过渡
|
||||
- [x] 亮色 / 暗色模式
|
||||
- [x] 自定义主题色和横幅图片
|
||||
- [x] 响应式设计
|
||||
- [ ] 评论
|
||||
- [x] 搜索
|
||||
- [ ] 文内目录
|
||||
|
||||
## 🚀 使用方法
|
||||
|
||||
1. 使用此模板[生成新仓库](https://github.com/saicaca/fuwari/generate)或 Fork 此仓库
|
||||
2. 进行本地开发,Clone 新的仓库,执行 `pnpm install` 和 `pnpm add sharp` 以安装依赖
|
||||
- 若未安装 [pnpm](https://pnpm.io),执行 `npm install -g pnpm`
|
||||
3. 通过配置文件 `src/config.ts` 自定义博客
|
||||
4. 执行 `pnpm new-post <filename>` 创建新文章,并在 `src/content/posts/` 目录中编辑
|
||||
5. 参考[官方指南](https://docs.astro.build/zh-cn/guides/deploy/)将博客部署至 Vercel, Netlify, GitHub Pages 等;部署前需编辑 `astro.config.mjs` 中的站点设置。
|
||||
|
||||
## ⚙️ 文章 Frontmatter
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My First Blog Post
|
||||
published: 2023-09-09
|
||||
description: This is the first post of my new Astro blog.
|
||||
image: /images/cover.jpg
|
||||
tags: [Foo, Bar]
|
||||
category: Front-end
|
||||
draft: false
|
||||
---
|
||||
```
|
||||
|
||||
## 🧞 指令
|
||||
|
||||
下列指令均需要在项目根目录执行:
|
||||
|
||||
| Command | Action |
|
||||
|:----------------------------------|:----------------------------------|
|
||||
| `pnpm install` 并 `pnpm add sharp` | 安装依赖 |
|
||||
| `pnpm dev` | 在 `localhost:4321` 启动本地开发服务器 |
|
||||
| `pnpm build` | 构建网站至 `./dist/` |
|
||||
| `pnpm preview` | 本地预览已构建的网站 |
|
||||
| `pnpm new-post <filename>` | 创建新文章 |
|
||||
| `pnpm astro ...` | 执行 `astro add`, `astro check` 等指令 |
|
||||
| `pnpm astro --help` | 显示 Astro CLI 帮助 |
|
124
astro.config.mjs
Normal file
|
@ -0,0 +1,124 @@
|
|||
import tailwind from "@astrojs/tailwind"
|
||||
import Compress from "astro-compress"
|
||||
import icon from "astro-icon"
|
||||
import { defineConfig } from "astro/config"
|
||||
import Color from "colorjs.io"
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings"
|
||||
import rehypeKatex from "rehype-katex"
|
||||
import rehypeSlug from "rehype-slug"
|
||||
import remarkMath from "remark-math"
|
||||
import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs"
|
||||
import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs"
|
||||
import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.mjs"
|
||||
import remarkDirective from "remark-directive" /* Handle directives */
|
||||
import remarkGithubAdmonitionsToDirectives from "remark-github-admonitions-to-directives";
|
||||
import rehypeComponents from "rehype-components"; /* Render the custom directive content */
|
||||
import svelte from "@astrojs/svelte"
|
||||
import swup from '@swup/astro';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import {parseDirectiveNode} from "./src/plugins/remark-directive-rehype.js";
|
||||
|
||||
const oklchToHex = (str) => {
|
||||
const DEFAULT_HUE = 250
|
||||
const regex = /-?\d+(\.\d+)?/g
|
||||
const matches = str.string.match(regex)
|
||||
const lch = [matches[0], matches[1], DEFAULT_HUE]
|
||||
return new Color("oklch", lch).to("srgb").toString({
|
||||
format: "hex",
|
||||
})
|
||||
}
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: "https://fuwari.vercel.app/",
|
||||
base: "/",
|
||||
trailingSlash: "always",
|
||||
integrations: [
|
||||
tailwind(),
|
||||
swup({
|
||||
theme: false,
|
||||
animationClass: 'transition-',
|
||||
containers: ['main'],
|
||||
smoothScrolling: true,
|
||||
cache: true,
|
||||
preload: true,
|
||||
accessibility: true,
|
||||
globalInstance: true,
|
||||
}),
|
||||
icon({
|
||||
include: {
|
||||
"material-symbols": ["*"],
|
||||
"fa6-brands": ["*"],
|
||||
"fa6-regular": ["*"],
|
||||
"fa6-solid": ["*"],
|
||||
},
|
||||
}),
|
||||
Compress({
|
||||
Image: false,
|
||||
}),
|
||||
svelte(),
|
||||
sitemap(),
|
||||
],
|
||||
markdown: {
|
||||
remarkPlugins: [remarkMath, remarkReadingTime, remarkGithubAdmonitionsToDirectives, remarkDirective, parseDirectiveNode],
|
||||
rehypePlugins: [
|
||||
rehypeKatex,
|
||||
rehypeSlug,
|
||||
[rehypeComponents, {
|
||||
components: {
|
||||
github: GithubCardComponent,
|
||||
note: (x, y) => AdmonitionComponent(x, y, "note"),
|
||||
tip: (x, y) => AdmonitionComponent(x, y, "tip"),
|
||||
important: (x, y) => AdmonitionComponent(x, y, "important"),
|
||||
caution: (x, y) => AdmonitionComponent(x, y, "caution"),
|
||||
warning: (x, y) => AdmonitionComponent(x, y, "warning"),
|
||||
},
|
||||
}],
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
behavior: "append",
|
||||
properties: {
|
||||
className: ["anchor"],
|
||||
},
|
||||
content: {
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
className: ["anchor-icon"],
|
||||
'data-pagefind-ignore': true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
vite: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
onwarn(warning, warn) {
|
||||
// temporarily suppress this warning
|
||||
if (warning.message.includes("is dynamically imported by") && warning.message.includes("but also statically imported by")) {
|
||||
return;
|
||||
}
|
||||
warn(warning);
|
||||
}
|
||||
}
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
stylus: {
|
||||
define: {
|
||||
oklchToHex: oklchToHex,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
66
biome.json
Normal file
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.4.1/schema.json",
|
||||
"extends": [],
|
||||
"files": { "ignoreUnknown": true },
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"ignore": [],
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80
|
||||
},
|
||||
"javascript": {
|
||||
"parser": {
|
||||
"unsafeParameterDecoratorsEnabled": true
|
||||
},
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "single",
|
||||
"trailingComma": "all",
|
||||
"semicolons": "asNeeded",
|
||||
"arrowParentheses": "asNeeded"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"parser": { "allowComments": true },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"ignore": [],
|
||||
"rules": {
|
||||
"a11y": {
|
||||
"recommended": true
|
||||
},
|
||||
"complexity": {
|
||||
"recommended": true
|
||||
},
|
||||
"correctness": {
|
||||
"recommended": true
|
||||
},
|
||||
"performance": {
|
||||
"recommended": true
|
||||
},
|
||||
"security": {
|
||||
"recommended": true
|
||||
},
|
||||
"style": {
|
||||
"recommended": true
|
||||
},
|
||||
"suspicious": {
|
||||
"recommended": true
|
||||
},
|
||||
"nursery": {
|
||||
"recommended": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
62
frontmatter.json
Normal file
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"$schema": "https://frontmatter.codes/frontmatter.schema.json",
|
||||
"frontMatter.framework.id": "astro",
|
||||
"frontMatter.preview.host": "http://localhost:4321",
|
||||
"frontMatter.content.publicFolder": "public",
|
||||
"frontMatter.content.pageFolders": [
|
||||
{
|
||||
"title": "posts",
|
||||
"path": "[[workspace]]/src/content/posts"
|
||||
}
|
||||
],
|
||||
"frontMatter.taxonomy.contentTypes": [
|
||||
{
|
||||
"name": "default",
|
||||
"pageBundle": true,
|
||||
"previewPath": "'blog'",
|
||||
"filePrefix": null,
|
||||
"clearEmpty": true,
|
||||
"fields": [
|
||||
{
|
||||
"title": "title",
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"single": true
|
||||
},
|
||||
{
|
||||
"title": "description",
|
||||
"name": "description",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "published",
|
||||
"name": "published",
|
||||
"type": "datetime",
|
||||
"default": "{{now}}",
|
||||
"isPublishDate": true
|
||||
},
|
||||
{
|
||||
"title": "preview",
|
||||
"name": "image",
|
||||
"type": "image",
|
||||
"isPreviewImage": true
|
||||
},
|
||||
{
|
||||
"title": "tags",
|
||||
"name": "tags",
|
||||
"type": "list"
|
||||
},
|
||||
{
|
||||
"title": "category",
|
||||
"name": "category",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "draft",
|
||||
"name": "draft",
|
||||
"type": "boolean"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
71
package.json
Normal file
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"name": "fuwari",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build && pagefind --site dist",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"new-post": "node scripts/new-post.js",
|
||||
"format": "biome format --write ./src",
|
||||
"lint": "biome check --apply ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.7.0",
|
||||
"@astrojs/rss": "^4.0.6",
|
||||
"@astrojs/sitemap": "^3.1.6",
|
||||
"@astrojs/svelte": "^5.6.0",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.0.21",
|
||||
"@fontsource/roboto": "^5.0.13",
|
||||
"@swup/astro": "^1.4.1",
|
||||
"astro": "^4.11.0",
|
||||
"astro-compress": "^2.2.28",
|
||||
"astro-icon": "1.1.0",
|
||||
"colorjs.io": "^0.5.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"overlayscrollbars": "^2.8.3",
|
||||
"pagefind": "^1.1.0",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-components": "^0.3.0",
|
||||
"rehype-katex": "^7.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-directive": "^3.0.0",
|
||||
"remark-directive-rehype": "^0.4.2",
|
||||
"remark-math": "^6.0.0",
|
||||
"sanitize-html": "^2.13.0",
|
||||
"sharp": "^0.33.4",
|
||||
"svelte": "^4.2.18",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.5.2",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/ts-plugin": "^1.8.0",
|
||||
"@biomejs/biome": "1.8.2",
|
||||
"@iconify-json/fa6-brands": "^1.1.19",
|
||||
"@iconify-json/fa6-regular": "^1.1.19",
|
||||
"@iconify-json/fa6-solid": "^1.1.21",
|
||||
"@iconify-json/material-symbols": "^1.1.82",
|
||||
"@iconify/svelte": "^4.0.2",
|
||||
"@rollup/plugin-yaml": "^4.1.2",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/markdown-it": "^14.1.1",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/sanitize-html": "^2.11.0",
|
||||
"remark-github-admonitions-to-directives": "^1.0.5",
|
||||
"sass": "^1.77.6",
|
||||
"stylus": "^0.63.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite-imagetools": "^6.2.7",
|
||||
"sharp": "^0.33.0"
|
||||
}
|
||||
}
|
||||
}
|
10250
pnpm-lock.yaml
Normal file
BIN
public/favicon/favicon-dark-128.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
public/favicon/favicon-dark-180.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
public/favicon/favicon-dark-192.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
public/favicon/favicon-dark-32.png
Normal file
After Width: | Height: | Size: 426 B |
BIN
public/favicon/favicon-light-128.png
Normal file
After Width: | Height: | Size: 2 KiB |
BIN
public/favicon/favicon-light-180.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
public/favicon/favicon-light-192.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
public/favicon/favicon-light-32.png
Normal file
After Width: | Height: | Size: 554 B |
52
scripts/new-post.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
/* This is a script to create a new post markdown file with front-matter */
|
||||
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
function getDate() {
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(today.getDate()).padStart(2, "0")
|
||||
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error(`Error: No filename argument provided
|
||||
Usage: npm run new-post -- <filename>`)
|
||||
process.exit(1) // Terminate the script and return error code 1
|
||||
}
|
||||
|
||||
let fileName = args[0]
|
||||
|
||||
// Add .md extension if not present
|
||||
const fileExtensionRegex = /\.(md|mdx)$/i
|
||||
if (!fileExtensionRegex.test(fileName)) {
|
||||
fileName += ".md"
|
||||
}
|
||||
|
||||
const targetDir = "./src/content/posts/"
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
|
||||
if (fs.existsSync(fullPath)) {
|
||||
console.error(`Error:File ${fullPath} already exists `)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const content = `---
|
||||
title: ${args[0]}
|
||||
published: ${getDate()}
|
||||
description: ''
|
||||
image: ''
|
||||
tags: []
|
||||
category: ''
|
||||
draft: false
|
||||
---
|
||||
`
|
||||
|
||||
fs.writeFileSync(path.join(targetDir, fileName), content)
|
||||
|
||||
console.log(`Post ${fullPath} created`)
|
BIN
src/assets/images/demo-avatar.png
Normal file
After Width: | Height: | Size: 406 KiB |
BIN
src/assets/images/demo-banner.png
Normal file
After Width: | Height: | Size: 877 KiB |
128
src/components/ArchivePanel.astro
Normal file
|
@ -0,0 +1,128 @@
|
|||
---
|
||||
import {getSortedPosts} from "../utils/content-utils";
|
||||
import {getPostUrlBySlug} from "../utils/url-utils";
|
||||
import {i18n} from "../i18n/translation";
|
||||
import I18nKey from "../i18n/i18nKey";
|
||||
import {UNCATEGORIZED} from "@constants/constants";
|
||||
|
||||
interface Props {
|
||||
keyword: string;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
}
|
||||
const { keyword, tags, categories} = Astro.props;
|
||||
|
||||
let posts = await getSortedPosts()
|
||||
|
||||
if (Array.isArray(tags) && tags.length > 0) {
|
||||
posts = posts.filter(post =>
|
||||
Array.isArray(post.data.tags) && post.data.tags.some(tag => tags.includes(tag))
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(categories) && categories.length > 0) {
|
||||
posts = posts.filter(post =>
|
||||
(post.data.category && categories.includes(post.data.category)) ||
|
||||
(!post.data.category && categories.includes(UNCATEGORIZED))
|
||||
);
|
||||
}
|
||||
|
||||
const groups = function () {
|
||||
const groupedPosts = posts.reduce((grouped, post) => {
|
||||
const year = post.data.published.getFullYear()
|
||||
if (!grouped[year]) {
|
||||
grouped[year] = []
|
||||
}
|
||||
grouped[year].push(post)
|
||||
return grouped
|
||||
}, {})
|
||||
|
||||
// convert the object to an array
|
||||
const groupedPostsArray = Object.keys(groupedPosts).map(key => ({
|
||||
year: key,
|
||||
posts: groupedPosts[key]
|
||||
}))
|
||||
|
||||
// sort years by latest first
|
||||
groupedPostsArray.sort((a, b) => b.year - a.year)
|
||||
return groupedPostsArray;
|
||||
}();
|
||||
|
||||
function formatDate(date: Date) {
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatTag(tag: string[]) {
|
||||
return tag.map(t => `#${t}`).join(' ');
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
<div class="card-base px-8 py-6">
|
||||
{
|
||||
groups.map(group => (
|
||||
<div>
|
||||
<div class="flex flex-row w-full items-center h-[3.75rem]">
|
||||
<div class="w-[15%] md:w-[10%] transition text-2xl font-bold text-right text-75">{group.year}</div>
|
||||
<div class="w-[15%] md:w-[10%]">
|
||||
<div class="h-3 w-3 bg-none rounded-full outline outline-[var(--primary)] mx-auto -outline-offset-[2px] z-50 outline-3"></div>
|
||||
</div>
|
||||
<div class="w-[70%] md:w-[80%] transition text-left text-50">{group.posts.length} {i18n(I18nKey.postsCount)}</div>
|
||||
</div>
|
||||
{group.posts.map(post => (
|
||||
<a href={getPostUrlBySlug(post.slug)}
|
||||
aria-label={post.data.title}
|
||||
class="group btn-plain block h-10 w-full rounded-lg hover:text-[initial]"
|
||||
>
|
||||
<div class="flex flex-row justify-start items-center h-full">
|
||||
<!-- date -->
|
||||
<div class="w-[15%] md:w-[10%] transition text-sm text-right text-50">
|
||||
{formatDate(post.data.published)}
|
||||
</div>
|
||||
<!-- dot and line -->
|
||||
<div class="w-[15%] md:w-[10%] relative dash-line h-full flex items-center">
|
||||
<div class="transition-all mx-auto w-1 h-1 rounded group-hover:h-5
|
||||
bg-[oklch(0.5_0.05_var(--hue))] group-hover:bg-[var(--primary)]
|
||||
outline outline-4 z-50
|
||||
outline-[var(--card-bg)]
|
||||
group-hover:outline-[var(--btn-plain-bg-hover)]
|
||||
group-active:outline-[var(--btn-plain-bg-active)]
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
<!-- post title -->
|
||||
<div class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
|
||||
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
|
||||
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
|
||||
>
|
||||
{post.data.title}
|
||||
</div>
|
||||
<!-- tag list -->
|
||||
<div class="hidden md:block md:w-[15%] text-left text-sm transition
|
||||
whitespace-nowrap overflow-ellipsis overflow-hidden
|
||||
text-30"
|
||||
>{formatTag(post.data.tags)}</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.dash-line {
|
||||
}
|
||||
.dash-line::before {
|
||||
content: "";
|
||||
@apply w-[10%] h-full absolute -top-1/2 left-[calc(50%_-_1px)] -top-[50%] border-l-[2px]
|
||||
border-dashed pointer-events-none border-[var(--line-color)] transition
|
||||
}
|
||||
}
|
||||
</style>
|
8
src/components/ConfigCarrier.astro
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
|
||||
import {siteConfig} from "../config";
|
||||
|
||||
---
|
||||
|
||||
<div id="config-carrier" data-hue={siteConfig.themeColor.hue}>
|
||||
</div>
|
13
src/components/Footer.astro
Normal file
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
|
||||
import {profileConfig} from "../config";
|
||||
|
||||
---
|
||||
|
||||
<div class="card-base max-w-[var(--page-width)] min-h-[4.5rem] rounded-b-none mx-auto flex items-center px-6">
|
||||
<div class="transition text-50 text-sm">
|
||||
© 2024 {profileConfig.name}. All Rights Reserved.
|
||||
<br>
|
||||
Powered by <a class="link text-[var(--primary)] font-medium" target="_blank" href="https://github.com/saicaca/fuwari">Fuwari</a>
|
||||
</div>
|
||||
</div>
|
298
src/components/GlobalStyles.astro
Normal file
|
@ -0,0 +1,298 @@
|
|||
---
|
||||
|
||||
---
|
||||
|
||||
<div>
|
||||
<slot/>
|
||||
</div>
|
||||
|
||||
<style is:global lang="stylus">
|
||||
|
||||
/* utils */
|
||||
white(a)
|
||||
rgba(255, 255, 255, a)
|
||||
|
||||
black(a)
|
||||
rgba(0, 0, 0, a)
|
||||
|
||||
isOklch(c)
|
||||
return substr(c, 0, 5) == 'oklch'
|
||||
|
||||
oklch_fallback(c)
|
||||
str = '' + c // convert color value to string
|
||||
if isOklch(str)
|
||||
return convert(oklchToHex(str))
|
||||
return c
|
||||
|
||||
color_set(colors)
|
||||
@supports (color: oklch(0 0 0))
|
||||
:root
|
||||
for key, value in colors
|
||||
{key}: value[0]
|
||||
:root.dark
|
||||
for key, value in colors
|
||||
if length(value) > 1
|
||||
{key}: value[1]
|
||||
/* provide fallback color for oklch */
|
||||
@supports not (color: oklch(0 0 0))
|
||||
:root
|
||||
for key, value in colors
|
||||
{key}: oklch_fallback(value[0])
|
||||
:root.dark
|
||||
for key, value in colors
|
||||
if length(value) > 1
|
||||
{key}: oklch_fallback(value[1])
|
||||
|
||||
rainbow-light = linear-gradient(to right, oklch(0.80 0.10 0), oklch(0.80 0.10 30), oklch(0.80 0.10 60), oklch(0.80 0.10 90), oklch(0.80 0.10 120), oklch(0.80 0.10 150), oklch(0.80 0.10 180), oklch(0.80 0.10 210), oklch(0.80 0.10 240), oklch(0.80 0.10 270), oklch(0.80 0.10 300), oklch(0.80 0.10 330), oklch(0.80 0.10 360))
|
||||
rainbow-dark = linear-gradient(to right, oklch(0.70 0.10 0), oklch(0.70 0.10 30), oklch(0.70 0.10 60), oklch(0.70 0.10 90), oklch(0.70 0.10 120), oklch(0.70 0.10 150), oklch(0.70 0.10 180), oklch(0.70 0.10 210), oklch(0.70 0.10 240), oklch(0.70 0.10 270), oklch(0.70 0.10 300), oklch(0.70 0.10 330), oklch(0.70 0.10 360))
|
||||
|
||||
:root
|
||||
--radius-large 1rem
|
||||
|
||||
--banner-height-home 60vh
|
||||
--banner-height 40vh
|
||||
|
||||
--content-delay 150ms
|
||||
|
||||
color_set({
|
||||
--primary: oklch(0.70 0.14 var(--hue)) oklch(0.75 0.14 var(--hue))
|
||||
--page-bg: oklch(0.95 0.01 var(--hue)) oklch(0.16 0.014 var(--hue))
|
||||
--card-bg: white oklch(0.23 0.015 var(--hue))
|
||||
|
||||
--btn-content: oklch(0.55 0.12 var(--hue)) oklch(0.75 0.1 var(--hue))
|
||||
|
||||
--btn-regular-bg: oklch(0.95 0.025 var(--hue)) oklch(0.33 0.035 var(--hue))
|
||||
--btn-regular-bg-hover: oklch(0.9 0.05 var(--hue)) oklch(0.38 0.04 var(--hue))
|
||||
--btn-regular-bg-active: oklch(0.85 0.08 var(--hue)) oklch(0.43 0.045 var(--hue))
|
||||
|
||||
--btn-plain-bg-hover: oklch(0.95 0.025 var(--hue)) oklch(0.30 0.035 var(--hue))
|
||||
--btn-plain-bg-active: oklch(0.98 0.01 var(--hue)) oklch(0.27 0.025 var(--hue))
|
||||
|
||||
--btn-card-bg-hover: oklch(0.98 0.005 var(--hue)) oklch(0.3 0.03 var(--hue))
|
||||
--btn-card-bg-active: oklch(0.9 0.03 var(--hue)) oklch(0.35 0.035 var(--hue))
|
||||
|
||||
--enter-btn-bg: var(--btn-regular-bg)
|
||||
--enter-btn-bg-hover: var(--btn-regular-bg-hover)
|
||||
--enter-btn-bg-active: var(--btn-regular-bg-active)
|
||||
|
||||
--deep-text: oklch(0.25 0.02 var(--hue))
|
||||
|
||||
--title-active: oklch(0.6 0.1 var(--hue))
|
||||
|
||||
--line-divider: black(0.08) white(0.08)
|
||||
|
||||
--line-color: black(0.1) white(0.1)
|
||||
--meta-divider: black(0.2) white(0.2)
|
||||
|
||||
--inline-code-bg: var(--btn-regular-bg)
|
||||
--inline-code-color: var(--btn-content)
|
||||
--selection-bg: oklch(0.90 0.05 var(--hue)) oklch(0.40 0.08 var(--hue))
|
||||
--codeblock-selection: oklch(0.40 0.08 var(--hue))
|
||||
--codeblock-bg: oklch(0.2 0.015 var(--hue)) oklch(0.17 0.015 var(--hue))
|
||||
|
||||
--license-block-bg: black(0.03) var(--codeblock-bg)
|
||||
|
||||
--link-underline: oklch(0.93 0.04 var(--hue)) oklch(0.40 0.08 var(--hue))
|
||||
--link-hover: oklch(0.95 0.025 var(--hue)) oklch(0.40 0.08 var(--hue))
|
||||
--link-active: oklch(0.90 0.05 var(--hue)) oklch(0.35 0.07 var(--hue))
|
||||
|
||||
--float-panel-bg: white oklch(0.19 0.015 var(--hue))
|
||||
|
||||
--scrollbar-bg-light: black(0.4)
|
||||
--scrollbar-bg-hover-light: black(0.5)
|
||||
--scrollbar-bg-active-light: black(0.6)
|
||||
|
||||
--scrollbar-bg-dark: white(0.4)
|
||||
--scrollbar-bg-hover-dark: white(0.5)
|
||||
--scrollbar-bg-active-dark: white(0.6)
|
||||
|
||||
--scrollbar-bg: var(--scrollbar-bg-light) var(--scrollbar-bg-dark)
|
||||
--scrollbar-bg-hover: var(--scrollbar-bg-hover-light) var(--scrollbar-bg-hover-dark)
|
||||
--scrollbar-bg-active: var(--scrollbar-bg-active-light) var(--scrollbar-bg-active-dark)
|
||||
|
||||
--color-selection-bar: rainbow-light rainbow-dark
|
||||
|
||||
--display-light-icon: 1 0
|
||||
--display-dark-icon: 0 1
|
||||
|
||||
--admonitions-color-tip: oklch(0.7 0.14 180) oklch(0.75 0.14 180)
|
||||
--admonitions-color-note: oklch(0.7 0.14 250) oklch(0.75 0.14 250)
|
||||
--admonitions-color-important: oklch(0.7 0.14 310) oklch(0.75 0.14 310)
|
||||
--admonitions-color-warning: oklch(0.7 0.14 60) oklch(0.75 0.14 60)
|
||||
--admonitions-color-caution: oklch(0.6 0.2 25) oklch(0.65 0.2 25)
|
||||
})
|
||||
|
||||
|
||||
/* some global styles */
|
||||
::selection
|
||||
background-color: var(--selection-bg)
|
||||
|
||||
.scrollbar-base.os-scrollbar
|
||||
transition: width 0.15s ease-in-out, height 0.15s ease-in-out, opacity 0.15s, visibility 0.15s, top 0.15s, right 0.15s, bottom 0.15s, left 0.15s;
|
||||
pointer-events: unset;
|
||||
&.os-scrollbar-horizontal
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
height: 16px;
|
||||
.os-scrollbar-track .os-scrollbar-handle
|
||||
height: 4px;
|
||||
border-radius: 4px;
|
||||
&:hover
|
||||
.os-scrollbar-track .os-scrollbar-handle
|
||||
height: 8px;
|
||||
&.px-2
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
&.os-scrollbar-vertical
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
width: 16px;
|
||||
.os-scrollbar-track .os-scrollbar-handle
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
&:hover
|
||||
.os-scrollbar-track .os-scrollbar-handle
|
||||
width: 8px;
|
||||
&.py-1
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
|
||||
.scrollbar-auto
|
||||
&.os-scrollbar
|
||||
--os-handle-bg: var(--scrollbar-bg);
|
||||
--os-handle-bg-hover: var(--scrollbar-bg-hover);
|
||||
--os-handle-bg-active: var(--scrollbar-bg-active);
|
||||
|
||||
.scrollbar-dark
|
||||
&.os-scrollbar
|
||||
--os-handle-bg: var(--scrollbar-bg-dark);
|
||||
--os-handle-bg-hover: var(--scrollbar-bg-hover-dark);
|
||||
--os-handle-bg-active: var(--scrollbar-bg-active-dark);
|
||||
|
||||
.scrollbar-light
|
||||
&.os-scrollbar
|
||||
--os-handle-bg: var(--scrollbar-bg-light);
|
||||
--os-handle-bg-hover: var(--scrollbar-bg-hover-light);
|
||||
--os-handle-bg-active: var(--scrollbar-bg-active-light);
|
||||
|
||||
|
||||
</style>
|
||||
<style is:global lang="scss">
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.card-base {
|
||||
@apply rounded-[var(--radius-large)] overflow-hidden bg-[var(--card-bg)] transition;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6, p, a, span, li, ul, ol, blockquote, code, pre, table, th, td, strong {
|
||||
@apply transition;
|
||||
}
|
||||
.card-shadow {
|
||||
@apply drop-shadow-[0_2px_4px_rgba(0,0,0,0.005)]
|
||||
}
|
||||
.expand-animation {
|
||||
@apply relative before:ease-out before:transition active:bg-none hover:before:bg-[var(--btn-plain-bg-hover)] active:before:bg-[var(--btn-plain-bg-active)] z-0
|
||||
before:absolute before:rounded-[inherit] before:inset-0 before:scale-[0.85] hover:before:scale-100 before:-z-10
|
||||
}
|
||||
.link {
|
||||
@apply transition rounded-md p-1 -m-1 expand-animation;
|
||||
}
|
||||
.link-lg {
|
||||
@apply transition rounded-md p-1.5 -m-1.5 expand-animation;
|
||||
}
|
||||
.float-panel {
|
||||
@apply top-[5.25rem] rounded-[var(--radius-large)] overflow-hidden bg-[var(--float-panel-bg)] transition shadow-xl dark:shadow-none
|
||||
}
|
||||
.float-panel-closed {
|
||||
@apply -translate-y-1 opacity-0 pointer-events-none
|
||||
}
|
||||
.search-panel mark {
|
||||
@apply bg-transparent text-[var(--primary)]
|
||||
}
|
||||
|
||||
.btn-card {
|
||||
@apply transition flex items-center justify-center bg-[var(--card-bg)] hover:bg-[var(--btn-card-bg-hover)]
|
||||
active:bg-[var(--btn-card-bg-active)]
|
||||
}
|
||||
.btn-card.disabled {
|
||||
@apply pointer-events-none text-black/10 dark:text-white/10
|
||||
}
|
||||
.btn-plain {
|
||||
@apply transition relative flex items-center justify-center bg-none
|
||||
text-black/75 hover:text-[var(--primary)] dark:text-white/75 dark:hover:text-[var(--primary)];
|
||||
&:not(.scale-animation) {
|
||||
@apply hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)]
|
||||
}
|
||||
&.scale-animation {
|
||||
@apply expand-animation;
|
||||
&.current-theme-btn {
|
||||
@apply before:scale-100 before:opacity-100 before:bg-[var(--btn-plain-bg-hover)] text-[var(--primary)]
|
||||
}
|
||||
}
|
||||
}
|
||||
.btn-regular {
|
||||
@apply transition flex items-center justify-center bg-[var(--btn-regular-bg)] hover:bg-[var(--btn-regular-bg-hover)] active:bg-[var(--btn-regular-bg-active)]
|
||||
text-[var(--btn-content)] dark:text-white/75
|
||||
}
|
||||
|
||||
.link-underline {
|
||||
@apply transition underline decoration-2 decoration-dashed decoration-[var(--link-underline)]
|
||||
hover:decoration-[var(--link-hover)] active:decoration-[var(--link-active)] underline-offset-[0.25rem]
|
||||
}
|
||||
|
||||
.text-90 {
|
||||
@apply text-black/90 dark:text-white/90
|
||||
}
|
||||
.text-75 {
|
||||
@apply text-black/75 dark:text-white/75
|
||||
}
|
||||
.text-50 {
|
||||
@apply text-black/50 dark:text-white/50
|
||||
}
|
||||
.text-30 {
|
||||
@apply text-black/30 dark:text-white/30
|
||||
}
|
||||
.text-25 {
|
||||
@apply text-black/25 dark:text-white/25
|
||||
}
|
||||
|
||||
html.is-changing .transition-fade {
|
||||
@apply transition-all duration-200
|
||||
}
|
||||
html.is-animating .transition-fade {
|
||||
@apply opacity-0 translate-y-4
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
0% {
|
||||
transform: translateY(2rem);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.onload-animation {
|
||||
opacity: 0;
|
||||
animation: 300ms fade-in-up;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
#top-row {
|
||||
animation-delay: 0ms
|
||||
}
|
||||
#sidebar {
|
||||
animation-delay: 100ms
|
||||
}
|
||||
#content-wrapper {
|
||||
animation-delay: var(--content-delay);
|
||||
}
|
||||
#footer {
|
||||
animation-delay: 400ms;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
120
src/components/LightDarkSwitch.svelte
Normal file
|
@ -0,0 +1,120 @@
|
|||
<script lang="ts">
|
||||
import type { LIGHT_DARK_MODE } from '@/types/config.ts'
|
||||
import {
|
||||
AUTO_MODE,
|
||||
DARK_MODE,
|
||||
LIGHT_MODE,
|
||||
} from '@constants/constants.ts'
|
||||
import I18nKey from '@i18n/i18nKey'
|
||||
import { i18n } from '@i18n/translation'
|
||||
import Icon from '@iconify/svelte'
|
||||
import {
|
||||
applyThemeToDocument,
|
||||
getStoredTheme,
|
||||
setTheme,
|
||||
} from '@utils/setting-utils.ts'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
const seq: LIGHT_DARK_MODE[] = [
|
||||
LIGHT_MODE,
|
||||
DARK_MODE,
|
||||
AUTO_MODE,
|
||||
]
|
||||
let mode: LIGHT_DARK_MODE = AUTO_MODE
|
||||
|
||||
onMount(() => {
|
||||
mode = getStoredTheme()
|
||||
const darkModePreference = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)',
|
||||
)
|
||||
const changeThemeWhenSchemeChanged: Parameters<
|
||||
typeof darkModePreference.addEventListener<'change'>
|
||||
>[1] = e => {
|
||||
applyThemeToDocument(mode)
|
||||
}
|
||||
darkModePreference.addEventListener(
|
||||
'change',
|
||||
changeThemeWhenSchemeChanged,
|
||||
)
|
||||
return () => {
|
||||
darkModePreference.removeEventListener(
|
||||
'change',
|
||||
changeThemeWhenSchemeChanged,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function switchScheme(newMode: LIGHT_DARK_MODE) {
|
||||
mode = newMode
|
||||
setTheme(newMode)
|
||||
}
|
||||
|
||||
function toggleScheme() {
|
||||
let i = 0
|
||||
for (; i < seq.length; i++) {
|
||||
if (seq[i] === mode) {
|
||||
break
|
||||
}
|
||||
}
|
||||
switchScheme(seq[(i + 1) % seq.length])
|
||||
}
|
||||
|
||||
function showPanel() {
|
||||
const panel = document.querySelector('#light-dark-panel')
|
||||
panel.classList.remove('float-panel-closed')
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
const panel = document.querySelector('#light-dark-panel')
|
||||
panel.classList.add('float-panel-closed')
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<!-- z-50 make the panel higher than other float panels -->
|
||||
<div class="relative z-50" role="menu" tabindex="-1" on:mouseleave={hidePanel}>
|
||||
<button aria-label="Light/Dark Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="scheme-switch" on:click={toggleScheme} on:mouseenter={showPanel}>
|
||||
<div class="absolute" class:opacity-0={mode !== LIGHT_MODE}>
|
||||
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem]"></Icon>
|
||||
</div>
|
||||
<div class="absolute" class:opacity-0={mode !== DARK_MODE}>
|
||||
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem]"></Icon>
|
||||
</div>
|
||||
<div class="absolute" class:opacity-0={mode !== AUTO_MODE}>
|
||||
<Icon icon="material-symbols:radio-button-partial-outline" class="text-[1.25rem]"></Icon>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div id="light-dark-panel" class="hidden lg:block absolute transition float-panel-closed top-11 -right-2 pt-5" >
|
||||
<div class="card-base float-panel p-2">
|
||||
<button class="flex transition whitespace-nowrap items-center justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5"
|
||||
class:current-theme-btn={mode === LIGHT_MODE}
|
||||
on:click={() => switchScheme(LIGHT_MODE)}
|
||||
>
|
||||
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
|
||||
{i18n(I18nKey.lightMode)}
|
||||
</button>
|
||||
<button class="flex transition whitespace-nowrap items-center justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5"
|
||||
class:current-theme-btn={mode === DARK_MODE}
|
||||
on:click={() => switchScheme(DARK_MODE)}
|
||||
>
|
||||
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
|
||||
{i18n(I18nKey.darkMode)}
|
||||
</button>
|
||||
<button class="flex transition whitespace-nowrap items-center justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95"
|
||||
class:current-theme-btn={mode === AUTO_MODE}
|
||||
on:click={() => switchScheme(AUTO_MODE)}
|
||||
>
|
||||
<Icon icon="material-symbols:radio-button-partial-outline" class="text-[1.25rem] mr-3"></Icon>
|
||||
{i18n(I18nKey.systemMode)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="css">
|
||||
.current-setting {
|
||||
background: var(--btn-plain-bg-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
120
src/components/Navbar.astro
Normal file
|
@ -0,0 +1,120 @@
|
|||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import DisplaySettings from "./widget/DisplaySettings.svelte";
|
||||
import {LinkPreset, NavBarLink} from "../types/config";
|
||||
import {navBarConfig, siteConfig} from "../config";
|
||||
import NavMenuPanel from "./widget/NavMenuPanel.astro";
|
||||
import Search from "./Search.svelte";
|
||||
import {LinkPresets} from "../constants/link-presets";
|
||||
import LightDarkSwitch from "./LightDarkSwitch.svelte";
|
||||
import {url} from "../utils/url-utils";
|
||||
const className = Astro.props.class;
|
||||
|
||||
let links: NavBarLink[] = navBarConfig.links.map((item: NavBarLink | LinkPreset): NavBarLink => {
|
||||
if (typeof item === "number") {
|
||||
return LinkPresets[item]
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
---
|
||||
<div class:list={[
|
||||
className,
|
||||
"card-base sticky top-0 overflow-visible max-w-[var(--page-width)] h-[4.5rem] rounded-t-none mx-auto flex items-center justify-between px-4"]}>
|
||||
<a href={url('/')} class="btn-plain scale-animation rounded-lg h-[3.25rem] px-5 font-bold active:scale-95">
|
||||
<div class="flex flex-row text-[var(--primary)] items-center text-md">
|
||||
<Icon name="material-symbols:home-outline-rounded" size={"1.75rem"} class="mb-1 mr-2" />
|
||||
{siteConfig.title}
|
||||
</div>
|
||||
</a>
|
||||
<div class="hidden md:flex">
|
||||
{links.map((l) => {
|
||||
return <a aria-label={l.name} href={l.external ? l.url : url(l.url)} target={l.external ? "_blank" : null}
|
||||
class="btn-plain scale-animation rounded-lg h-11 font-bold px-5 active:scale-95"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
{l.name}
|
||||
{l.external && <Icon size="14" name="fa6-solid:arrow-up-right-from-square" class="transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]"></Icon>}
|
||||
</div>
|
||||
</a>;
|
||||
})}
|
||||
</div>
|
||||
<div class="flex">
|
||||
<!--<SearchPanel client:load>-->
|
||||
<Search client:load>
|
||||
<Icon slot="search-icon" name="material-symbols:search" size={"1.25rem"} class="absolute pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
|
||||
<!--<Icon slot="arrow-icon" name="material-symbols:chevron-right-rounded" size={"1.25rem"} class="transition my-auto text-[var(--primary)]"></Icon>-->
|
||||
<Icon slot="arrow-icon" name="fa6-solid:chevron-right" size={"0.75rem"} class="transition translate-x-0.5 my-auto text-[var(--primary)]"></Icon>
|
||||
<Icon slot="search-switch" name="material-symbols:search" size={"1.25rem"}></Icon>
|
||||
</Search>
|
||||
{!siteConfig.themeColor.fixed && (
|
||||
<button aria-label="Display Settings" class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="display-settings-switch">
|
||||
<Icon name="material-symbols:palette-outline" size={"1.25rem"}></Icon>
|
||||
</button>
|
||||
)}
|
||||
<LightDarkSwitch client:load></LightDarkSwitch>
|
||||
<button aria-label="Menu" name="Nav Menu" class="btn-plain scale-animation rounded-lg w-11 h-11 active:scale-90 md:hidden" id="nav-menu-switch">
|
||||
<Icon name="material-symbols:menu-rounded" size={"1.25rem"}></Icon>
|
||||
</button>
|
||||
</div>
|
||||
<NavMenuPanel links={links}></NavMenuPanel>
|
||||
<DisplaySettings client:only="svelte">
|
||||
<Icon slot="restore-icon" name="fa6-solid:arrow-rotate-left" size={"0.875rem"} class=""></Icon>
|
||||
</DisplaySettings>
|
||||
</div>
|
||||
|
||||
<style lang="stylus">
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
function switchTheme() {
|
||||
if (localStorage.theme === 'dark') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.theme = 'light';
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.theme = 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
function loadButtonScript() {
|
||||
let switchBtn = document.getElementById("scheme-switch");
|
||||
switchBtn.addEventListener("click", function () {
|
||||
switchTheme()
|
||||
});
|
||||
|
||||
let settingBtn = document.getElementById("display-settings-switch");
|
||||
if (settingBtn) {
|
||||
settingBtn.addEventListener("click", function () {
|
||||
let settingPanel = document.getElementById("display-setting");
|
||||
settingPanel.classList.toggle("float-panel-closed");
|
||||
});
|
||||
}
|
||||
|
||||
let menuBtn = document.getElementById("nav-menu-switch");
|
||||
menuBtn.addEventListener("click", function () {
|
||||
let menuPanel = document.getElementById("nav-menu-panel");
|
||||
menuPanel.classList.toggle("float-panel-closed");
|
||||
});
|
||||
}
|
||||
|
||||
loadButtonScript();
|
||||
|
||||
document.addEventListener('astro:after-swap', () => {
|
||||
loadButtonScript();
|
||||
}, { once: false });
|
||||
</script>
|
||||
|
||||
{import.meta.env.PROD && <script is:inline define:vars={{scriptUrl: url('/pagefind/pagefind.js')}}>
|
||||
async function loadPagefind() {
|
||||
const pagefind = await import(scriptUrl)
|
||||
await pagefind.options({
|
||||
'excerptLength': 20
|
||||
})
|
||||
pagefind.init()
|
||||
window.pagefind = pagefind
|
||||
pagefind.search('') // speed up the first search
|
||||
}
|
||||
loadPagefind()
|
||||
</script>}
|
95
src/components/PostCard.astro
Normal file
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
import path from "path";
|
||||
import PostMetadata from "./PostMeta.astro";
|
||||
import ImageWrapper from "./misc/ImageWrapper.astro";
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import {i18n} from "../i18n/translation";
|
||||
import I18nKey from "../i18n/i18nKey";
|
||||
import {getDir} from "../utils/url-utils";
|
||||
|
||||
interface Props {
|
||||
class: string;
|
||||
entry: any;
|
||||
title: string;
|
||||
url: string;
|
||||
published: Date;
|
||||
tags: string[];
|
||||
category: string;
|
||||
image: string;
|
||||
description: string;
|
||||
words: number;
|
||||
draft: boolean;
|
||||
style: string;
|
||||
}
|
||||
const { entry, title, url, published, tags, category, image, description, words, style } = Astro.props;
|
||||
const className = Astro.props.class;
|
||||
|
||||
const hasCover = image !== undefined && image !== null && image !== '';
|
||||
|
||||
const coverWidth = "28%";
|
||||
|
||||
const { remarkPluginFrontmatter } = await entry.render();
|
||||
|
||||
---
|
||||
<div class:list={["card-base flex flex-col-reverse md:flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative", className]} style={style}>
|
||||
<div class:list={["pl-6 md:pl-9 pr-6 md:pr-2 pt-6 md:pt-7 pb-6 relative", {"w-full md:w-[calc(100%_-_52px_-_12px)]": !hasCover, "w-full md:w-[calc(100%_-_var(--coverWidth)_-_12px)]": hasCover}]}>
|
||||
<a href={url}
|
||||
class="transition group w-full block font-bold mb-3 text-3xl text-90
|
||||
hover:text-[var(--primary)] dark:hover:text-[var(--primary)]
|
||||
active:text-[var(--title-active)] dark:active:text-[var(--title-active)]
|
||||
before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
|
||||
before:absolute before:top-[35px] before:left-[18px] before:hidden md:before:block
|
||||
">
|
||||
{title}
|
||||
<Icon class="inline text-[var(--primary)] md:hidden translate-y-0.5 absolute" name="material-symbols:chevron-right-rounded" size="2rem" ></Icon>
|
||||
<Icon class="text-[var(--primary)] transition hidden md:inline absolute translate-y-0.5 opacity-0 group-hover:opacity-100 -translate-x-1 group-hover:translate-x-0" name="material-symbols:chevron-right-rounded" size="2rem" ></Icon>
|
||||
</a>
|
||||
|
||||
<!-- metadata -->
|
||||
<PostMetadata published={published} tags={tags} category={category} hideTagsForMobile={true} class="mb-4"></PostMetadata>
|
||||
|
||||
<!-- description -->
|
||||
<div class="transition text-75 mb-3.5 pr-4">
|
||||
{ description }
|
||||
</div>
|
||||
|
||||
<!-- word count and read time -->
|
||||
<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition">
|
||||
<div>{remarkPluginFrontmatter.words} {" " + i18n(I18nKey.wordsCount)}</div>
|
||||
<div>|</div>
|
||||
<div>{remarkPluginFrontmatter.minutes} {" " + i18n(I18nKey.minutesCount)}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{hasCover && <a href={url} aria-label={title}
|
||||
class:list={["group",
|
||||
"max-h-[20vh] md:max-h-none mx-4 mt-4 -mb-2 md:mb-0 md:mx-0 md:mt-0",
|
||||
"md:w-[var(--coverWidth)] relative md:absolute md:top-3 md:bottom-3 md:right-3 rounded-xl overflow-hidden active:scale-95"
|
||||
]} >
|
||||
<div class="absolute pointer-events-none z-10 w-full h-full group-hover:bg-black/30 group-active:bg-black/50 transition"></div>
|
||||
<div class="absolute pointer-events-none z-20 w-full h-full flex items-center justify-center ">
|
||||
<Icon name="material-symbols:chevron-right-rounded"
|
||||
class="transition opacity-0 group-hover:opacity-100 scale-50 group-hover:scale-100 text-white text-5xl">
|
||||
</Icon>
|
||||
</div>
|
||||
<ImageWrapper src={image} basePath={path.join("content/posts/", getDir(entry.id))} alt="Cover Image of the Post"
|
||||
class="w-full h-full">
|
||||
</ImageWrapper>
|
||||
</a>}
|
||||
|
||||
{!hasCover &&
|
||||
<a href={url} aria-label={title} class="hidden md:flex btn-regular w-[3.25rem]
|
||||
absolute right-3 top-3 bottom-3 rounded-xl bg-[var(--enter-btn-bg)]
|
||||
hover:bg-[var(--enter-btn-bg-hover)] active:bg-[var(--enter-btn-bg-active)] active:scale-95
|
||||
">
|
||||
<Icon name="material-symbols:chevron-right-rounded"
|
||||
class="transition text-[var(--primary)] text-4xl mx-auto">
|
||||
</Icon>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="transition border-t-[1px] border-dashed mx-6 border-black/10 dark:border-white/[0.15] last:border-t-0 md:hidden"></div>
|
||||
|
||||
<style lang="stylus" define:vars={{coverWidth}}>
|
||||
</style>
|
77
src/components/PostMeta.astro
Normal file
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
import {formatDateToYYYYMMDD} from "../utils/date-utils";
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import {i18n} from "../i18n/translation";
|
||||
import I18nKey from "../i18n/i18nKey";
|
||||
import {url} from "../utils/url-utils";
|
||||
|
||||
interface Props {
|
||||
class: string;
|
||||
published: Date;
|
||||
tags: string[];
|
||||
category: string;
|
||||
hideTagsForMobile: boolean;
|
||||
}
|
||||
const {published, tags, category, hideTagsForMobile} = Astro.props;
|
||||
const className = Astro.props.class;
|
||||
---
|
||||
|
||||
<div class:list={["flex flex-wrap text-neutral-500 dark:text-neutral-400 items-center gap-4 gap-x-4 gap-y-2", className]}>
|
||||
<!-- publish date -->
|
||||
<div class="flex items-center">
|
||||
<div class="meta-icon"
|
||||
>
|
||||
<Icon name="material-symbols:calendar-today-outline-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(published)}</span>
|
||||
</div>
|
||||
|
||||
<!-- categories -->
|
||||
<div class="flex items-center">
|
||||
<div class="meta-icon"
|
||||
>
|
||||
<Icon name="material-symbols:menu-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<div class="flex flex-row flex-nowrap items-center">
|
||||
<a href={url(`/archive/category/${category || 'uncategorized'}/`)} aria-label=`View all posts in the ${category} category`
|
||||
class="link-lg transition text-50 text-sm font-medium
|
||||
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
|
||||
{category || i18n(I18nKey.uncategorized)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- tags -->
|
||||
<div class:list={["items-center", {"flex": !hideTagsForMobile, "hidden md:flex": hideTagsForMobile}]}>
|
||||
<div class="meta-icon"
|
||||
>
|
||||
<Icon name="material-symbols:tag-rounded" class="text-xl"></Icon>
|
||||
</div>
|
||||
<div class="flex flex-row flex-nowrap items-center">
|
||||
{(tags && tags.length > 0) && tags.map((tag, i) => (
|
||||
<div class:list={[{"hidden": i == 0}, "mx-1.5 text-[var(--meta-divider)] text-sm"]}>/</div>
|
||||
<a href={url(`/archive/tag/${tag}/`)} aria-label=`View all posts with the ${tag} tag`
|
||||
class="link-lg transition text-50 text-sm font-medium
|
||||
hover:text-[var(--primary)] dark:hover:text-[var(--primary)] whitespace-nowrap">
|
||||
{tag}
|
||||
</a>
|
||||
))}
|
||||
{!(tags && tags.length > 0) && <div class="transition text-50 text-sm font-medium">{i18n(I18nKey.noTags)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.meta-icon {
|
||||
@apply w-8 h-8 transition rounded-md flex items-center justify-center bg-[var(--btn-regular-bg)]
|
||||
text-[var(--btn-content)] mr-2
|
||||
}
|
||||
.with-divider {
|
||||
@apply before:content-['/'] before:ml-1.5 before:mr-1.5 before:text-[var(--meta-divider)] before:text-sm
|
||||
before:font-medium before:first-of-type:hidden before:transition
|
||||
}
|
||||
}
|
||||
</style>
|
28
src/components/PostPage.astro
Normal file
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
import {getPostUrlBySlug} from "@utils/url-utils";
|
||||
import PostCard from "./PostCard.astro";
|
||||
|
||||
const {page} = Astro.props;
|
||||
|
||||
let delay = 0
|
||||
const interval = 50
|
||||
---
|
||||
<div class="transition flex flex-col rounded-[var(--radius-large)] bg-[var(--card-bg)] py-1 md:py-0 md:bg-transparent md:gap-4 mb-4">
|
||||
{page.data.map((entry: { data: { draft: boolean; title: string; tags: string[]; category: string; published: Date; image: string; description: string; }; slug: string; }) => {
|
||||
return (
|
||||
<PostCard
|
||||
entry={entry}
|
||||
title={entry.data.title}
|
||||
tags={entry.data.tags}
|
||||
category={entry.data.category}
|
||||
published={entry.data.published}
|
||||
url={getPostUrlBySlug(entry.slug)}
|
||||
image={entry.data.image}
|
||||
description={entry.data.description}
|
||||
draft={entry.data.draft}
|
||||
class:list="onload-animation"
|
||||
style={`animation-delay: calc(var(--content-delay) + ${delay++ * interval}ms);`}
|
||||
></PostCard>
|
||||
);
|
||||
})}
|
||||
</div>
|
116
src/components/Search.svelte
Normal file
|
@ -0,0 +1,116 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import {url} from "@utils/url-utils.ts"
|
||||
import { i18n } from '@i18n/translation';
|
||||
import I18nKey from '@i18n/i18nKey';
|
||||
let keywordDesktop = ''
|
||||
let keywordMobile = ''
|
||||
let result = []
|
||||
const fakeResult = [{
|
||||
url: url('/'),
|
||||
meta: {
|
||||
title: 'This Is a Fake Search Result'
|
||||
},
|
||||
excerpt: 'Because the search cannot work in the <mark>dev</mark> environment.'
|
||||
}, {
|
||||
url: url('/'),
|
||||
meta: {
|
||||
title: 'If You Want to Test the Search'
|
||||
},
|
||||
excerpt: 'Try running <mark>npm build && npm preview</mark> instead.'
|
||||
}]
|
||||
|
||||
let search = (keyword: string, isDesktop: boolean) => {}
|
||||
|
||||
onMount(() => {
|
||||
search = async (keyword: string, isDesktop: boolean) => {
|
||||
let panel = document.getElementById('search-panel')
|
||||
if (!panel)
|
||||
return
|
||||
|
||||
if (!keyword && isDesktop) {
|
||||
panel.classList.add("float-panel-closed")
|
||||
return
|
||||
}
|
||||
|
||||
let arr = [];
|
||||
if (import.meta.env.PROD) {
|
||||
const ret = await pagefind.search(keyword)
|
||||
for (const item of ret.results) {
|
||||
arr.push(await item.data())
|
||||
}
|
||||
} else {
|
||||
// Mock data for non-production environment
|
||||
// arr = JSON.parse('[{"url":"/","content":"Simple Guides for Fuwari. Cover image source: Source. This blog template is built with Astro. For the things that are not mentioned in this guide, you may find the answers in the Astro Docs. Front-matter of Posts. --- title: My First Blog Post published: 2023-09-09 description: This is the first post of my new Astro blog. image: ./cover.jpg tags: [Foo, Bar] category: Front-end draft: false ---AttributeDescription title. The title of the post. published. The date the post was published. description. A short description of the post. Displayed on index page. image. The cover image path of the post. 1. Start with http:// or https://: Use web image 2. Start with /: For image in public dir 3. With none of the prefixes: Relative to the markdown file. tags. The tags of the post. category. The category of the post. draft. If this post is still a draft, which won’t be displayed. Where to Place the Post Files. Your post files should be placed in src/content/posts/ directory. You can also create sub-directories to better organize your posts and assets. src/content/posts/ ├── post-1.md └── post-2/ ├── cover.png └── index.md.","word_count":187,"filters":{},"meta":{"title":"This Is a Fake Search Result"},"anchors":[{"element":"h2","id":"front-matter-of-posts","text":"Front-matter of Posts","location":34},{"element":"h2","id":"where-to-place-the-post-files","text":"Where to Place the Post Files","location":151}],"weighted_locations":[{"weight":10,"balanced_score":57600,"location":3}],"locations":[3],"raw_content":"Simple Guides for Fuwari. Cover image source: Source. This blog template is built with Astro. For the things that are not mentioned in this guide, you may find the answers in the Astro Docs. Front-matter of Posts. --- title: My First Blog Post published: 2023-09-09 description: This is the first post of my new Astro blog. image: ./cover.jpg tags: [Foo, Bar] category: Front-end draft: false ---AttributeDescription title. The title of the post. published. The date the post was published. description. A short description of the post. Displayed on index page. image. The cover image path of the post. 1. Start with http:// or https://: Use web image 2. Start with /: For image in public dir 3. With none of the prefixes: Relative to the markdown file. tags. The tags of the post. category. The category of the post. draft. If this post is still a draft, which won’t be displayed. Where to Place the Post Files. Your post files should be placed in src/content/posts/ directory. You can also create sub-directories to better organize your posts and assets. src/content/posts/ ├── post-1.md └── post-2/ ├── cover.png └── index.md.","raw_url":"/posts/guide/","excerpt":"Because the search cannot work in the <mark>dev</mark> environment.","sub_results":[{"title":"Simple Guides for Fuwari - Fuwari","url":"/posts/guide/","weighted_locations":[{"weight":10,"balanced_score":57600,"location":3}],"locations":[3],"excerpt":"Simple Guides for <mark>Fuwari.</mark> Cover image source: Source. This blog template is built with Astro. For the things that are not mentioned in this guide, you may find the answers"}]},{"url":"/","content":"About. This is the demo site for Fuwari. Sources of images used in this site. Unsplash. 星と少女 by Stella. Rabbit - v1.4 Showcase by Rabbit_YourMajesty.","word_count":25,"filters":{},"meta":{"title":"If You Want to Test the Search"},"anchors":[{"element":"h1","id":"about","text":"About","location":0},{"element":"h3","id":"sources-of-images-used-in-this-site","text":"Sources of images used in this site","location":8}],"weighted_locations":[{"weight":1,"balanced_score":576,"location":7}],"locations":[7],"raw_content":"About. This is the demo site for Fuwari. Sources of images used in this site. Unsplash. 星と少女 by Stella. Rabbit - v1.4 Showcase by Rabbit_YourMajesty.","raw_url":"/about/","excerpt":"Try running <mark>npm build && npm preview</mark> instead.","sub_results":[{"title":"About","url":"/about/#about","anchor":{"element":"h1","id":"about","text":"About","location":0},"weighted_locations":[{"weight":1,"balanced_score":576,"location":7}],"locations":[7],"excerpt":"About. This is the demo site for <mark>Fuwari.</mark>"}]}]')
|
||||
arr = fakeResult
|
||||
}
|
||||
|
||||
if (!arr.length && isDesktop) {
|
||||
panel.classList.add("float-panel-closed")
|
||||
return
|
||||
}
|
||||
|
||||
if (isDesktop) {
|
||||
panel.classList.remove("float-panel-closed")
|
||||
}
|
||||
result = arr
|
||||
}
|
||||
})
|
||||
|
||||
const togglePanel = () => {
|
||||
let panel = document.getElementById('search-panel')
|
||||
panel?.classList.toggle("float-panel-closed")
|
||||
}
|
||||
|
||||
$: search(keywordDesktop, true)
|
||||
$: search(keywordMobile, false)
|
||||
</script>
|
||||
|
||||
<!-- search bar for desktop view -->
|
||||
<div id="search-bar" class="hidden lg:flex transition-all items-center h-11 mr-2 rounded-lg
|
||||
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
|
||||
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
|
||||
">
|
||||
<slot name="search-icon"></slot>
|
||||
<input placeholder="{i18n(I18nKey.search)}" bind:value={keywordDesktop} on:focus={() => search(keywordDesktop, true)}
|
||||
class="transition-all pl-10 text-sm bg-transparent outline-0
|
||||
h-full w-40 active:w-60 focus:w-60 text-black/50 dark:text-white/50"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- toggle btn for phone/tablet view -->
|
||||
<button on:click={togglePanel} aria-label="Search Panel" id="search-switch"
|
||||
class="btn-plain scale-animation lg:hidden rounded-lg w-11 h-11 active:scale-90">
|
||||
<slot name="search-switch"></slot>
|
||||
</button>
|
||||
|
||||
<!-- search panel -->
|
||||
<div id="search-panel" class="float-panel float-panel-closed search-panel absolute md:w-[30rem]
|
||||
top-20 left-4 md:left-[unset] right-4 shadow-2xl rounded-2xl p-2">
|
||||
|
||||
<!-- search bar inside panel for phone/tablet -->
|
||||
<div id="search-bar-inside" class="flex relative lg:hidden transition-all items-center h-11 rounded-xl
|
||||
bg-black/[0.04] hover:bg-black/[0.06] focus-within:bg-black/[0.06]
|
||||
dark:bg-white/5 dark:hover:bg-white/10 dark:focus-within:bg-white/10
|
||||
">
|
||||
<slot name="search-icon"></slot>
|
||||
<input placeholder="Search" bind:value={keywordMobile}
|
||||
class="pl-10 absolute inset-0 text-sm bg-transparent outline-0
|
||||
focus:w-60 text-black/50 dark:text-white/50"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- search results -->
|
||||
{#each result as item}
|
||||
<a href={item.url}
|
||||
class="transition first-of-type:mt-2 lg:first-of-type:mt-0 group block
|
||||
rounded-xl text-lg px-3 py-2 hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)]">
|
||||
<div class="transition text-90 inline-flex font-bold group-hover:text-[var(--primary)]">
|
||||
{item.meta.title}<slot name="arrow-icon"></slot>
|
||||
</div>
|
||||
<div class="transition text-sm text-50">
|
||||
{@html item.excerpt}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
57
src/components/control/BackToTop.astro
Normal file
|
@ -0,0 +1,57 @@
|
|||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
---
|
||||
|
||||
<!-- There can't be a filter on parent element, or it will break `fixed` -->
|
||||
<div class="back-to-top-wrapper hidden lg:block">
|
||||
<div id="back-to-top-btn" class="back-to-top-btn hide flex items-center rounded-2xl overflow-hidden transition" onclick="backToTop()">
|
||||
<button aria-label="Back to Top" class="btn-card h-[3.75rem] w-[3.75rem]">
|
||||
<Icon name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto"></Icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="stylus">
|
||||
.back-to-top-wrapper
|
||||
width: 3.75rem
|
||||
height: 3.75rem
|
||||
position: absolute
|
||||
right: 0
|
||||
top: 0
|
||||
|
||||
.back-to-top-btn
|
||||
color: var(--primary)
|
||||
font-size: 2.25rem
|
||||
font-weight: bold
|
||||
border: none
|
||||
position: fixed
|
||||
bottom: 15rem
|
||||
opacity: 1
|
||||
cursor: pointer
|
||||
transform: translateX(5rem)
|
||||
i
|
||||
font-size: 1.75rem
|
||||
&.hide
|
||||
transform: translateX(5rem) scale(0.9)
|
||||
opacity: 0
|
||||
pointer-events: none
|
||||
&:active
|
||||
transform: translateX(5rem) scale(0.9)
|
||||
|
||||
</style>
|
||||
|
||||
<script is:raw>
|
||||
function backToTop() {
|
||||
window.scroll({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function scrollFunction() {
|
||||
let btn = document.getElementById('back-to-top-btn');
|
||||
if (document.body.scrollTop > 600 || document.documentElement.scrollTop > 600) {
|
||||
btn.classList.remove('hide')
|
||||
} else {
|
||||
btn.classList.add('hide')
|
||||
}
|
||||
}
|
||||
window.onscroll = scrollFunction
|
||||
</script>
|
43
src/components/control/ButtonLink.astro
Normal file
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
interface Props {
|
||||
badge?: string
|
||||
url?: string
|
||||
label?: string
|
||||
}
|
||||
const { badge, url, name } = Astro.props
|
||||
---
|
||||
<a href={url} aria-label={name}>
|
||||
<button
|
||||
class:list={`
|
||||
w-full
|
||||
h-10
|
||||
rounded-lg
|
||||
bg-none
|
||||
hover:bg-[var(--btn-plain-bg-hover)]
|
||||
active:bg-[var(--btn-plain-bg-active)]
|
||||
transition-all
|
||||
pl-2
|
||||
hover:pl-3
|
||||
|
||||
text-neutral-700
|
||||
hover:text-[var(--primary)]
|
||||
dark:text-neutral-300
|
||||
dark:hover:text-[var(--primary)]
|
||||
`
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between relative mr-2">
|
||||
<div class="overflow-hidden text-left whitespace-nowrap overflow-ellipsis ">
|
||||
<slot></slot>
|
||||
</div>
|
||||
{ badge !== undefined && badge !== null && badge !== '' &&
|
||||
<div class="transition h-7 ml-4 min-w-[2rem] rounded-lg text-sm font-bold
|
||||
text-[var(--btn-content)] dark:text-[var(--deep-text)]
|
||||
bg-[oklch(0.95_0.025_var(--hue))] dark:bg-[var(--primary)]
|
||||
flex items-center justify-center">
|
||||
{ badge }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
</a>
|
13
src/components/control/ButtonTag.astro
Normal file
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
interface Props {
|
||||
size?: string;
|
||||
dot?: boolean;
|
||||
href?: string;
|
||||
label?: string;
|
||||
}
|
||||
const { size, dot, href, label }: Props = Astro.props;
|
||||
---
|
||||
<a href={href} aria-label={label} class="btn-regular h-8 text-sm px-3 rounded-lg">
|
||||
{dot && <div class="h-1 w-1 bg-[var(--btn-content)] dark:bg-[var(--card-bg)] transition rounded-md mr-2"></div>}
|
||||
<slot></slot>
|
||||
</a>
|
91
src/components/control/Pagination.astro
Normal file
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
import type { Page } from "astro";
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import {url} from "../../utils/url-utils";
|
||||
interface Props {
|
||||
page: Page;
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
const {page, style} = Astro.props;
|
||||
|
||||
const HIDDEN = -1;
|
||||
|
||||
const className = Astro.props.class;
|
||||
|
||||
const ADJ_DIST = 2;
|
||||
const VISIBLE = ADJ_DIST * 2 + 1;
|
||||
|
||||
// for test
|
||||
let count = 1;
|
||||
let l = page.currentPage, r = page.currentPage;
|
||||
while (0 < l - 1 && r + 1 <= page.lastPage && count + 2 <= VISIBLE) {
|
||||
count += 2;
|
||||
l--;
|
||||
r++;
|
||||
}
|
||||
while (0 < l - 1 && count < VISIBLE) {
|
||||
count++;
|
||||
l--;
|
||||
}
|
||||
while (r + 1 <= page.lastPage && count < VISIBLE) {
|
||||
count++;
|
||||
r++;
|
||||
}
|
||||
|
||||
let pages: number[] = [];
|
||||
if (l > 1)
|
||||
pages.push(1);
|
||||
if (l == 3)
|
||||
pages.push(2);
|
||||
if (l > 3)
|
||||
pages.push(HIDDEN);
|
||||
for (let i = l; i <= r; i++)
|
||||
pages.push(i);
|
||||
if (r < page.lastPage - 2)
|
||||
pages.push(HIDDEN);
|
||||
if (r == page.lastPage - 2)
|
||||
pages.push(page.lastPage - 1);
|
||||
if (r < page.lastPage)
|
||||
pages.push(page.lastPage);
|
||||
|
||||
const getPageUrl = (p: number) => {
|
||||
if (p == 1)
|
||||
return '/';
|
||||
return `/${p}/`;
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
<div class:list={[className, "flex flex-row gap-3 justify-center"]} style={style}>
|
||||
<a href={url(page.url.prev)} aria-label={page.url.prev ? "Previous Page" : null}
|
||||
class:list={["btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
|
||||
{"disabled": page.url.prev == undefined}
|
||||
]}
|
||||
>
|
||||
<Icon name="material-symbols:chevron-left-rounded" size="1.75rem"></Icon>
|
||||
</a>
|
||||
<div class="bg-[var(--card-bg)] flex flex-row rounded-lg items-center text-neutral-700 dark:text-neutral-300 font-bold">
|
||||
{pages.map((p) => {
|
||||
if (p == HIDDEN)
|
||||
return <Icon name="material-symbols:more-horiz" class="mx-1"/>;
|
||||
if (p == page.currentPage)
|
||||
return <div class="h-11 w-11 rounded-lg bg-[var(--primary)] flex items-center justify-center
|
||||
font-bold text-white dark:text-black/70"
|
||||
>
|
||||
{p}
|
||||
</div>
|
||||
return <a href={url(getPageUrl(p))} aria-label=`Page ${p}`
|
||||
class="btn-card w-11 h-11 rounded-lg overflow-hidden active:scale-[0.85]"
|
||||
>{p}</a>
|
||||
})}
|
||||
</div>
|
||||
<a href={url(page.url.next)} aria-label={page.url.next ? "Next Page" : null}
|
||||
class:list={["btn-card overflow-hidden rounded-lg text-[var(--primary)] w-11 h-11",
|
||||
{"disabled": page.url.next == undefined}
|
||||
]}
|
||||
>
|
||||
<Icon name="material-symbols:chevron-right-rounded" size="1.75rem"></Icon>
|
||||
</a>
|
||||
</div>
|
37
src/components/misc/ImageWrapper.astro
Normal file
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
import path from "path";
|
||||
interface Props {
|
||||
id?: string
|
||||
src: string;
|
||||
class?: string;
|
||||
alt?: string
|
||||
position?: string;
|
||||
basePath?: string
|
||||
}
|
||||
import { Image } from 'astro:assets';
|
||||
import { url } from "../../utils/url-utils";
|
||||
|
||||
const {id, src, alt, position = 'center', basePath = '/'} = Astro.props;
|
||||
const className = Astro.props.class;
|
||||
|
||||
const isLocal = !(src.startsWith('/') || src.startsWith('http') || src.startsWith('https') || src.startsWith('data:'));
|
||||
const isPublic = src.startsWith('/');
|
||||
|
||||
// TODO temporary workaround for images dynamic import
|
||||
// https://github.com/withastro/astro/issues/3373
|
||||
let img;
|
||||
if (isLocal) {
|
||||
const files = import.meta.glob<ImageMetadata>("../../**", { import: 'default' });
|
||||
let normalizedPath = path.normalize(path.join("../../", basePath, src)).replace(/\\/g, "/");
|
||||
img = await (files[normalizedPath])();
|
||||
}
|
||||
|
||||
const imageClass = 'w-full h-full object-cover';
|
||||
const imageStyle = `object-position: ${position}`
|
||||
---
|
||||
<div class:list={[className, 'overflow-hidden relative']}>
|
||||
<div class="transition absolute inset-0 dark:bg-black/10 bg-opacity-50 pointer-events-none"></div>
|
||||
{isLocal && <Image src={img} alt={alt || ""} class={imageClass} style={imageStyle}/>}
|
||||
{!isLocal && <img src={isPublic ? url(src) : src} alt={alt || ""} class={imageClass} style={imageStyle}/>}
|
||||
</div>
|
||||
|
44
src/components/misc/License.astro
Normal file
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
import {formatDateToYYYYMMDD} from "../../utils/date-utils";
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import {licenseConfig, profileConfig} from "../../config";
|
||||
import {i18n} from "../../i18n/translation";
|
||||
import I18nKey from "../../i18n/i18nKey";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
slug: string;
|
||||
pubDate: Date;
|
||||
class: string;
|
||||
}
|
||||
|
||||
const { title, slug, pubDate } = Astro.props;
|
||||
const className = Astro.props.class;
|
||||
const profileConf = profileConfig;
|
||||
const licenseConf = licenseConfig;
|
||||
const postUrl = decodeURIComponent(Astro.url.toString());
|
||||
|
||||
---
|
||||
<div class=`relative transition overflow-hidden bg-[var(--license-block-bg)] py-5 px-6 ${className}`>
|
||||
<div class="transition font-bold text-black/75 dark:text-white/75">
|
||||
{title}
|
||||
</div>
|
||||
<a href={postUrl} class="link text-[var(--primary)]">
|
||||
{postUrl}
|
||||
</a>
|
||||
<div class="flex gap-6 mt-2">
|
||||
<div>
|
||||
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.author)}</div>
|
||||
<div class="transition text-black/75 dark:text-white/75 whitespace-nowrap">{profileConf.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.publishedAt)}</div>
|
||||
<div class="transition text-black/75 dark:text-white/75 whitespace-nowrap">{formatDateToYYYYMMDD(pubDate)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="transition text-black/30 dark:text-white/30 text-sm">{i18n(I18nKey.license)}</div>
|
||||
<a href={licenseConf.url} target="_blank" class="link text-[var(--primary)] whitespace-nowrap">{licenseConf.name}</a>
|
||||
</div>
|
||||
</div>
|
||||
<Icon name="fa6-brands:creative-commons" class="transition absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5" size="240"></Icon>
|
||||
</div>
|
491
src/components/misc/Markdown.astro
Normal file
|
@ -0,0 +1,491 @@
|
|||
---
|
||||
import '@fontsource-variable/jetbrains-mono';
|
||||
import '@fontsource-variable/jetbrains-mono/wght-italic.css';
|
||||
|
||||
interface Props {
|
||||
class: string;
|
||||
}
|
||||
const className = Astro.props.class;
|
||||
---
|
||||
<div data-pagefind-body class=`prose dark:prose-invert prose-base max-w-none custom-md ${className}`>
|
||||
<!--<div class="prose dark:prose-invert max-w-none custom-md">-->
|
||||
<!--<div class="max-w-none custom-md">-->
|
||||
<slot/>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const observer = new MutationObserver(addPreCopyButton);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
document.addEventListener("DOMContentLoaded", addPreCopyButton);
|
||||
|
||||
function addPreCopyButton() {
|
||||
observer.disconnect();
|
||||
|
||||
let codeBlocks = Array.from(document.querySelectorAll("pre"));
|
||||
|
||||
for (let codeBlock of codeBlocks) {
|
||||
if (codeBlock.parentElement?.nodeName === "DIV" && codeBlock.parentElement?.classList.contains("code-block")) continue
|
||||
|
||||
let wrapper = document.createElement("div");
|
||||
wrapper.className = "relative code-block";
|
||||
|
||||
let copyButton = document.createElement("button");
|
||||
copyButton.className = "copy-btn btn-regular-dark absolute active:scale-90 h-8 w-8 top-2 right-2 opacity-75 text-sm p-1.5 rounded-lg transition-all ease-in-out";
|
||||
|
||||
codeBlock.setAttribute("tabindex", "0");
|
||||
if (codeBlock.parentNode) {
|
||||
codeBlock.parentNode.insertBefore(wrapper, codeBlock);
|
||||
}
|
||||
|
||||
let copyIcon = `<svg class="copy-btn-icon copy-icon" xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px"><path d="M368.37-237.37q-34.48 0-58.74-24.26-24.26-24.26-24.26-58.74v-474.26q0-34.48 24.26-58.74 24.26-24.26 58.74-24.26h378.26q34.48 0 58.74 24.26 24.26 24.26 24.26 58.74v474.26q0 34.48-24.26 58.74-24.26 24.26-58.74 24.26H368.37Zm0-83h378.26v-474.26H368.37v474.26Zm-155 238q-34.48 0-58.74-24.26-24.26-24.26-24.26-58.74v-515.76q0-17.45 11.96-29.48 11.97-12.02 29.33-12.02t29.54 12.02q12.17 12.03 12.17 29.48v515.76h419.76q17.45 0 29.48 11.96 12.02 11.97 12.02 29.33t-12.02 29.54q-12.03 12.17-29.48 12.17H213.37Zm155-238v-474.26 474.26Z"/></svg>`
|
||||
let successIcon = `<svg class="copy-btn-icon success-icon" xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px"><path d="m389-377.13 294.7-294.7q12.58-12.67 29.52-12.67 16.93 0 29.61 12.67 12.67 12.68 12.67 29.53 0 16.86-12.28 29.14L419.07-288.41q-12.59 12.67-29.52 12.67-16.94 0-29.62-12.67L217.41-430.93q-12.67-12.68-12.79-29.45-.12-16.77 12.55-29.45 12.68-12.67 29.62-12.67 16.93 0 29.28 12.67L389-377.13Z"/></svg>`
|
||||
copyButton.innerHTML = `<div>${copyIcon} ${successIcon}</div>
|
||||
`
|
||||
|
||||
wrapper.appendChild(codeBlock);
|
||||
wrapper.appendChild(copyButton);
|
||||
|
||||
let timeout;
|
||||
copyButton.addEventListener("click", async () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
let text = codeBlock?.querySelector("code")?.innerText;
|
||||
await navigator.clipboard.writeText(text);
|
||||
copyButton.classList.add("success");
|
||||
timeout = setTimeout(() => {
|
||||
copyButton.classList.remove("success");
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Styles for copy-code-button -->
|
||||
<style lang="css" is:global>
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.btn-regular-dark {
|
||||
@apply flex items-center justify-center
|
||||
bg-[oklch(0.45_0.01_var(--hue))] hover:bg-[oklch(0.50_0.01_var(--hue))] active:bg-[oklch(0.55_0.01_var(--hue))]
|
||||
dark:bg-[oklch(0.30_0.02_var(--hue))] dark:hover:bg-[oklch(0.35_0.03_var(--hue))] dark:active:bg-[oklch(0.40_0.03_var(--hue))]
|
||||
}
|
||||
.btn-regular-dark.success {
|
||||
@apply bg-[oklch(0.75_0.14_var(--hue))] dark:bg-[oklch(0.75_0.14_var(--hue))]
|
||||
}
|
||||
|
||||
.copy-btn-icon {
|
||||
@apply absolute top-1/2 left-1/2 transition -translate-x-1/2 -translate-y-1/2
|
||||
}
|
||||
.copy-btn .copy-icon {
|
||||
@apply opacity-100 fill-white dark:fill-white/75
|
||||
}
|
||||
.copy-btn.success .copy-icon {
|
||||
@apply opacity-0 fill-[var(--deep-text)]
|
||||
}
|
||||
.copy-btn .success-icon {
|
||||
@apply opacity-0
|
||||
}
|
||||
.copy-btn.success .success-icon {
|
||||
@apply opacity-100
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="stylus" is:global>
|
||||
.custom-md
|
||||
h1, h2, h3, h4, h5, h6
|
||||
.anchor
|
||||
margin: -0.125rem !important
|
||||
margin-left: 0.2ch !important
|
||||
padding: 0.125rem !important
|
||||
user-select: none !important
|
||||
opacity: 0 !important
|
||||
text-decoration: none !important
|
||||
transition: opacity 0.15s ease-in-out, background 0.15s ease-in-out !important
|
||||
|
||||
.anchor-icon
|
||||
margin-left: 0.45ch !important
|
||||
margin-right: 0.45ch !important
|
||||
|
||||
&:hover
|
||||
.anchor
|
||||
opacity: 1 !important
|
||||
|
||||
a:not(.no-styling)
|
||||
position: relative
|
||||
background: none
|
||||
margin: -0.25rem
|
||||
padding: 0.25rem
|
||||
border-radius: 0.375rem
|
||||
font-weight: 500
|
||||
color: var(--primary)
|
||||
text-decoration-line: underline
|
||||
text-decoration-color: var(--link-underline)
|
||||
text-decoration-thickness: 0.125rem
|
||||
text-decoration-style: dashed
|
||||
text-underline-offset: 0.25rem
|
||||
/*&:after*/
|
||||
/* content: ''*/
|
||||
/* position: absolute*/
|
||||
/* left: 2px*/
|
||||
/* right: 2px*/
|
||||
/* bottom: 4px*/
|
||||
/* height: 6px*/
|
||||
/* border-radius: 3px*/
|
||||
/* background: var(--link-hover)*/
|
||||
/* transition: background 0.15s ease-in-out;*/
|
||||
/* z-index: -1;*/
|
||||
|
||||
&:hover
|
||||
background: var(--link-hover)
|
||||
text-decoration-color: var(--link-hover)
|
||||
|
||||
&:active
|
||||
background: var(--link-active)
|
||||
text-decoration-color: var(--link-active)
|
||||
|
||||
code
|
||||
font-family: 'JetBrains Mono Variable', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace
|
||||
background: var(--inline-code-bg)
|
||||
color: var(--inline-code-color)
|
||||
padding: 0.125rem 0.25rem
|
||||
border-radius: 0.25rem
|
||||
overflow: hidden
|
||||
counter-reset: line
|
||||
|
||||
&:before
|
||||
content: none
|
||||
|
||||
&:after
|
||||
content: none
|
||||
|
||||
span.line
|
||||
&:before
|
||||
content: counter(line)
|
||||
counter-increment: line
|
||||
direction: rtl
|
||||
display: inline-block
|
||||
margin-right: 1rem
|
||||
width: 1rem
|
||||
color: rgba(255, 255, 255, 0.25)
|
||||
&:last-child:empty, &:last-child:has(> span:empty:only-child)
|
||||
display: none
|
||||
|
||||
pre
|
||||
background: var(--codeblock-bg) !important
|
||||
border-radius: 0.75rem
|
||||
padding-left: 1.25rem
|
||||
padding-right: 1.25rem
|
||||
|
||||
code
|
||||
color: unset
|
||||
font-size: 0.875rem
|
||||
padding: 0
|
||||
background: none
|
||||
|
||||
::selection
|
||||
background: var(--codeblock-selection)
|
||||
|
||||
span.br::selection
|
||||
background: var(--codeblock-selection)
|
||||
|
||||
ul
|
||||
li
|
||||
&::marker
|
||||
color: var(--primary)
|
||||
|
||||
ol
|
||||
li
|
||||
&::marker
|
||||
color: var(--primary)
|
||||
|
||||
blockquote
|
||||
font-style: normal
|
||||
font-weight: inherit
|
||||
border-left-color: rgba(0, 0, 0, 0)
|
||||
position: relative;
|
||||
|
||||
&:before
|
||||
content: ''
|
||||
position: absolute
|
||||
left: -0.25rem
|
||||
display: block
|
||||
transition: background 0.15s ease-in-out;
|
||||
background: var(--btn-regular-bg)
|
||||
height: 100%
|
||||
width: 0.25rem
|
||||
border-radius: 1rem
|
||||
|
||||
p
|
||||
&:before
|
||||
content: none
|
||||
|
||||
&:after
|
||||
content: none
|
||||
|
||||
blockquote.admonition
|
||||
.bdm-title
|
||||
display: flex
|
||||
align-items: center
|
||||
margin-bottom: -.9rem
|
||||
font-weight: bold
|
||||
|
||||
&:before
|
||||
content: ' '
|
||||
display: inline-block
|
||||
font-size: inherit
|
||||
overflow: visible
|
||||
margin-right: .6rem
|
||||
height: 1em
|
||||
width: 1em
|
||||
vertical-align: -.126em
|
||||
mask-size: contain
|
||||
mask-position: center
|
||||
mask-repeat: no-repeat
|
||||
transform: translateY(-0.0625rem)
|
||||
&.bdm-tip
|
||||
.bdm-title
|
||||
color: var(--admonitions-color-tip)
|
||||
|
||||
&:before
|
||||
background: var(--admonitions-color-tip)
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath 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'%3E%3C/path%3E%3C/svg%3E")
|
||||
|
||||
&:before
|
||||
background: var(--admonitions-color-tip)
|
||||
&.bdm-note
|
||||
.bdm-title
|
||||
color: var(--admonitions-color-note)
|
||||
|
||||
&:before
|
||||
background: var(--admonitions-color-note)
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath fill='var(--admonitions-color-tip)' d='M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'%3E%3C/path%3E%3C/svg%3E")
|
||||
|
||||
&:before
|
||||
background: var(--admonitions-color-note)
|
||||
&.bdm-important
|
||||
.bdm-title
|
||||
color: var(--admonitions-color-important)
|
||||
|
||||
&:before
|
||||
background: var(--admonitions-color-important)
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z'%3E%3C/path%3E%3C/svg%3E")
|
||||
|
||||
&:before
|
||||
background: var(--admonitions-color-important)
|
||||
&.bdm-warning
|
||||
.bdm-title
|
||||
color: var(--admonitions-color-warning)
|
||||
|
||||
&:before
|
||||
background: var(--admonitions-color-warning)
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z'%3E%3C/path%3E%3C/svg%3E")
|
||||
|
||||
&:before
|
||||
background: var(--admonitions-color-warning)
|
||||
&.bdm-caution
|
||||
.bdm-title
|
||||
color: var(--admonitions-color-caution)
|
||||
|
||||
&:before
|
||||
background: var(--admonitions-color-caution)
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'%3E%3Cpath d='M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'%3E%3C/path%3E%3C/svg%3E")
|
||||
|
||||
&:before
|
||||
background: var(--admonitions-color-caution)
|
||||
|
||||
img
|
||||
border-radius: 0.75rem
|
||||
|
||||
hr
|
||||
border-color: var(--line-divider)
|
||||
border-style: dashed
|
||||
|
||||
iframe
|
||||
border-radius: 0.75rem
|
||||
margin-left: auto
|
||||
margin-right: auto
|
||||
max-width: 100%
|
||||
|
||||
a.card-github
|
||||
display: block
|
||||
background: var(--license-block-bg)
|
||||
position: relative
|
||||
margin: 0.5rem 0
|
||||
padding: 1.1rem 1.5rem 1.1rem 1.5rem
|
||||
color: var(--tw-prose-body)
|
||||
border-radius: var(--radius-large)
|
||||
text-decoration-thickness: 0px
|
||||
text-decoration-line: none
|
||||
|
||||
&:hover
|
||||
background-color: var(--btn-regular-bg-hover)
|
||||
|
||||
.gc-titlebar
|
||||
color: var(--btn-content)
|
||||
|
||||
.gc-stars, .gc-forks, .gc-license, .gc-description
|
||||
color: var(--tw-prose-headings)
|
||||
|
||||
&:before
|
||||
background-color: var(--tw-prose-headings)
|
||||
|
||||
&:active
|
||||
scale: .98
|
||||
background-color: var(--btn-regular-bg-active);
|
||||
|
||||
.gc-titlebar
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
margin-bottom: 0.5rem
|
||||
color: var(--tw-prose-headings)
|
||||
font-size: 1.25rem
|
||||
font-weight: 500
|
||||
|
||||
.gc-titlebar-left
|
||||
display: flex
|
||||
flex-flow: row nowrap
|
||||
gap: 0.5rem
|
||||
|
||||
.gc-repo
|
||||
font-weight: bold
|
||||
|
||||
.gc-owner
|
||||
font-weight: 300
|
||||
position: relative
|
||||
display: flex
|
||||
flex-flow: row nowrap
|
||||
gap: 0.5rem
|
||||
align-items: center
|
||||
|
||||
.gc-avatar
|
||||
display: block
|
||||
overflow: hidden
|
||||
width: 1.5rem
|
||||
height: 1.5rem
|
||||
margin-top: -0.1rem
|
||||
background-color: var(--primary)
|
||||
background-size: cover
|
||||
border-radius: 50%
|
||||
|
||||
.gc-description
|
||||
margin-bottom: 0.7rem
|
||||
font-size: 1rem
|
||||
font-weight: 300
|
||||
line-height: 1.5rem
|
||||
color: var(--tw-prose-body)
|
||||
|
||||
.gc-infobar
|
||||
display: flex
|
||||
flex-flow: row nowrap
|
||||
gap: 1.5rem
|
||||
color: var(--tw-prose-body)
|
||||
width: fit-content
|
||||
|
||||
.gc-language
|
||||
display: none
|
||||
|
||||
.gc-stars, .gc-forks, .gc-license, .github-logo
|
||||
font-weight: 500
|
||||
font-size: 0.875rem
|
||||
opacity: 0.9;
|
||||
|
||||
&:before
|
||||
content: ' '
|
||||
display: inline-block
|
||||
height: 1.3em
|
||||
width: 1.3em
|
||||
margin-right: .4rem
|
||||
vertical-align: -.24em
|
||||
font-size: inherit
|
||||
background-color: var(--tw-prose-body)
|
||||
overflow: visible
|
||||
mask-size: contain
|
||||
mask-position: center
|
||||
mask-repeat: no-repeat
|
||||
transition-property: background-color, background;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1)
|
||||
transition-duration: 0.15s
|
||||
|
||||
.gc-stars
|
||||
&:before
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16'%3E%3Cpath d='M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Zm0 2.445L6.615 5.5a.75.75 0 0 1-.564.41l-3.097.45 2.24 2.184a.75.75 0 0 1 .216.664l-.528 3.084 2.769-1.456a.75.75 0 0 1 .698 0l2.77 1.456-.53-3.084a.75.75 0 0 1 .216-.664l2.24-2.183-3.096-.45a.75.75 0 0 1-.564-.41L8 2.694Z'%3E%3C/path%3E%3C/svg%3E")
|
||||
|
||||
.gc-license
|
||||
&:before
|
||||
margin-right: .5rem
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16'%3E%3Cpath d='M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z'%3E%3C/path%3E%3C/svg%3E")
|
||||
|
||||
.gc-forks
|
||||
&:before
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16'%3E%3Cpath d='M5 5.372v.878c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.878a2.25 2.25 0 1 1 1.5 0v.878a2.25 2.25 0 0 1-2.25 2.25h-1.5v2.128a2.251 2.251 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.878a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z'%3E%3C/path%3E%3C/svg%3E")
|
||||
|
||||
.github-logo
|
||||
font-size: 1.25rem
|
||||
|
||||
&:before
|
||||
background-color: var(--tw-prose-headings)
|
||||
margin-right: 0
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='31' height='32' viewBox='0 0 496 512'%3E%3Cpath fill='%23a1f7cb' d='M165.9 397.4c0 2-2.3 3.6-5.2 3.6c-3.3.3-5.6-1.3-5.6-3.6c0-2 2.3-3.6 5.2-3.6c3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9c2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9c.3 2 2.9 3.3 5.9 2.6c2.9-.7 4.9-2.6 4.6-4.6c-.3-1.9-3-3.2-5.9-2.9M244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2c12.8 2.3 17.3-5.6 17.3-12.1c0-6.2-.3-40.4-.3-61.4c0 0-70 15-84.7-29.8c0 0-11.4-29.1-27.8-36.6c0 0-22.9-15.7 1.6-15.4c0 0 24.9 2 38.6 25.8c21.9 38.6 58.6 27.5 72.9 20.9c2.3-16 8.8-27.1 16-33.7c-55.9-6.2-112.3-14.3-112.3-110.5c0-27.5 7.6-41.3 23.6-58.9c-2.6-6.5-11.1-33.3 2.6-67.9c20.9-6.5 69 27 69 27c20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27c13.7 34.7 5.2 61.4 2.6 67.9c16 17.7 25.8 31.5 25.8 58.9c0 96.5-58.9 104.2-114.8 110.5c9.2 7.9 17 22.9 17 46.4c0 33.7-.3 75.4-.3 83.6c0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252C496 113.3 383.5 8 244.8 8M97.2 352.9c-1.3 1-1 3.3.7 5.2c1.6 1.6 3.9 2.3 5.2 1c1.3-1 1-3.3-.7-5.2c-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9c1.6 1 3.6.7 4.3-.7c.7-1.3-.3-2.9-2.3-3.9c-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2c2.3 2.3 5.2 2.6 6.5 1c1.3-1.3.7-4.3-1.3-6.2c-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9c1.6 2.3 4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2c-1.4-2.3-4-3.3-5.6-2'/%3E%3C/svg%3E")
|
||||
|
||||
a.card-github.fetch-waiting
|
||||
pointer-events: none
|
||||
opacity: 0.7
|
||||
transition: opacity 0.15s ease-in-out
|
||||
|
||||
.gc-description, .gc-infobar
|
||||
background-color: var(--tw-prose-body)
|
||||
color: transparent
|
||||
opacity: 0.5;
|
||||
border-radius: 0.5rem
|
||||
animation: pulsate 2s infinite linear
|
||||
user-select: none
|
||||
|
||||
&:before
|
||||
background-color: transparent
|
||||
|
||||
.gc-avatar
|
||||
display: none
|
||||
|
||||
.gc-repo
|
||||
margin-left: -0.1rem
|
||||
|
||||
a.card-github.fetch-error
|
||||
pointer-events: all
|
||||
opacity: 1
|
||||
|
||||
@keyframes pulsate
|
||||
0%
|
||||
opacity: 0.15
|
||||
50%
|
||||
opacity: 0.25
|
||||
100%
|
||||
opacity: 0.15
|
||||
|
||||
.card-github, .gc-description, .gc-titlebar, .gc-stars, .gc-forks, .gc-license, .gc-avatar, github-logo
|
||||
transition-property: all
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1)
|
||||
transition-duration: 0.15s
|
||||
|
||||
</style>
|
||||
|
||||
<style lang="css" is:global>
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
.custom-md h1 {
|
||||
@apply text-3xl
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
38
src/components/widget/Categories.astro
Normal file
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
import WidgetLayout from "./WidgetLayout.astro";
|
||||
|
||||
import {i18n} from "../../i18n/translation";
|
||||
import I18nKey from "../../i18n/i18nKey";
|
||||
import {Category, getCategoryList} from "../../utils/content-utils";
|
||||
import {getCategoryUrl} from "../../utils/url-utils";
|
||||
import ButtonLink from "../control/ButtonLink.astro";
|
||||
|
||||
const categories = await getCategoryList();
|
||||
|
||||
const COLLAPSED_HEIGHT = "7.5rem";
|
||||
const COLLAPSE_THRESHOLD = 5;
|
||||
|
||||
const isCollapsed = categories.length >= COLLAPSE_THRESHOLD;
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const className = Astro.props.class
|
||||
const style = Astro.props.style
|
||||
|
||||
---
|
||||
|
||||
<WidgetLayout name={i18n(I18nKey.categories)} id="categories" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT}
|
||||
class={className} style={style}
|
||||
>
|
||||
{categories.map((c) =>
|
||||
<ButtonLink
|
||||
url={getCategoryUrl(c.name)}
|
||||
badge={c.count}
|
||||
label=`View all posts in the ${c.name} category`
|
||||
>
|
||||
{c.name}
|
||||
</ButtonLink>
|
||||
)}
|
||||
</WidgetLayout>
|
91
src/components/widget/DisplaySettings.svelte
Normal file
|
@ -0,0 +1,91 @@
|
|||
<script lang="ts">
|
||||
import {i18n} from '@i18n/translation';
|
||||
import I18nKey from '@i18n/i18nKey';
|
||||
import {getDefaultHue, getHue, setHue} from '@utils/setting-utils';
|
||||
|
||||
let hue = getHue()
|
||||
const defaultHue = getDefaultHue()
|
||||
|
||||
function resetHue() {
|
||||
hue = getDefaultHue()
|
||||
}
|
||||
|
||||
$: if (hue || hue === 0) {
|
||||
setHue(hue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="display-setting" class="float-panel float-panel-closed absolute transition-all w-80 right-4 px-4 py-4">
|
||||
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
|
||||
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
|
||||
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||
before:absolute before:-left-3 before:top-[0.33rem]"
|
||||
>
|
||||
{i18n(I18nKey.themeColor)}
|
||||
<button aria-label="Reset to Default" class="btn-regular w-7 h-7 rounded-md active:scale-90"
|
||||
class:opacity-0={hue === defaultHue} class:pointer-events-none={hue === defaultHue} on:click={resetHue}>
|
||||
<div class="text-[var(--btn-content)]">
|
||||
<slot name="restore-icon"></slot>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<div id="hueValue" class="transition bg-[var(--btn-regular-bg)] w-10 h-7 rounded-md flex justify-center
|
||||
font-bold text-sm items-center text-[var(--btn-content)]">
|
||||
{hue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-6 px-1 bg-[oklch(0.80_0.10_0)] dark:bg-[oklch(0.70_0.10_0)] rounded select-none">
|
||||
<input aria-label={i18n(I18nKey.themeColor)} type="range" min="0" max="360" bind:value={hue}
|
||||
class="slider" id="colorSlider" step="5" style="width: 100%;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<style lang="stylus">
|
||||
#display-setting
|
||||
input[type="range"]
|
||||
-webkit-appearance: none;
|
||||
height: 1.5rem;
|
||||
background-image: var(--color-selection-bar)
|
||||
transition: background-image 0.15s ease-in-out
|
||||
|
||||
/* Input Thumb */
|
||||
::-webkit-slider-thumb
|
||||
-webkit-appearance: none;
|
||||
height: 1rem;
|
||||
width: 0.5rem;
|
||||
border-radius: 0.125rem;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
box-shadow: none;
|
||||
&:hover
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
&:active
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
|
||||
::-moz-range-thumb
|
||||
-webkit-appearance: none;
|
||||
height: 1rem;
|
||||
width: 0.5rem;
|
||||
border-radius: 0.125rem;
|
||||
border-width: 0
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
box-shadow: none;
|
||||
&:hover
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
&:active
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
|
||||
&::-ms-thumb
|
||||
-webkit-appearance: none;
|
||||
height: 1rem;
|
||||
width: 0.5rem;
|
||||
border-radius: 0.125rem;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
box-shadow: none;
|
||||
&:hover
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
&:active
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
</style>
|
32
src/components/widget/NavMenuPanel.astro
Normal file
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
import {NavBarLink} from "../../types/config";
|
||||
import {Icon} from "astro-icon/components";
|
||||
import {url} from "../../utils/url-utils";
|
||||
|
||||
interface Props {
|
||||
links: NavBarLink[],
|
||||
}
|
||||
|
||||
const links = Astro.props.links;
|
||||
---
|
||||
<div id="nav-menu-panel" class:list={["float-panel float-panel-closed absolute transition-all fixed right-4 px-2 py-2"]}>
|
||||
{links.map((link) => (
|
||||
<a href={link.external ? link.url : url(link.url)} class="group flex justify-between items-center py-2 pl-3 pr-1 rounded-lg gap-8
|
||||
hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition
|
||||
"
|
||||
target={link.external ? "_blank" : null}
|
||||
>
|
||||
<div class="transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
|
||||
{link.name}
|
||||
</div>
|
||||
{!link.external && <Icon name="material-symbols:chevron-right-rounded"
|
||||
class="transition text-[var(--primary)]" size="20"
|
||||
>
|
||||
</Icon>}
|
||||
{link.external && <Icon name="fa6-solid:arrow-up-right-from-square"
|
||||
class="transition text-black/25 dark:text-white/25 -translate-x-1" size="12"
|
||||
>
|
||||
</Icon>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
39
src/components/widget/Profile.astro
Normal file
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
import ImageWrapper from "../misc/ImageWrapper.astro";
|
||||
import {Icon} from "astro-icon/components";
|
||||
import {profileConfig} from "../../config";
|
||||
import {url} from "../../utils/url-utils";
|
||||
|
||||
const config = profileConfig;
|
||||
---
|
||||
<div class="card-base p-3">
|
||||
<a aria-label="Go to About Page" href={url('/about/')}
|
||||
class="group block relative mx-auto mt-1 lg:mx-0 lg:mt-0 mb-3
|
||||
max-w-[240px] lg:max-w-none overflow-hidden rounded-xl active:scale-95">
|
||||
<div class="absolute transition pointer-events-none group-hover:bg-black/30 group-active:bg-black/50
|
||||
w-full h-full z-50 flex items-center justify-center">
|
||||
<Icon name="fa6-regular:address-card"
|
||||
class="transition opacity-0 scale-90 group-hover:scale-100 group-hover:opacity-100 text-white text-5xl">
|
||||
</Icon>
|
||||
</div>
|
||||
<ImageWrapper src={config.avatar} alt="Profile Image of the Author" class="mx-auto lg:w-full h-full lg:mt-0 "></ImageWrapper>
|
||||
</a>
|
||||
<div class="px-2">
|
||||
<div class="font-bold text-xl text-center mb-1 dark:text-neutral-50 transition">{config.name}</div>
|
||||
<div class="h-1 w-5 bg-[var(--primary)] mx-auto rounded-full mb-2 transition"></div>
|
||||
<div class="text-center text-neutral-400 mb-2.5 transition">{config.bio}</div>
|
||||
<div class="flex gap-2 justify-center mb-1">
|
||||
{config.links.length > 1 && config.links.map(item =>
|
||||
<a rel="me" aria-label={item.name} href={item.url} target="_blank" class="btn-regular rounded-lg h-10 w-10 active:scale-90">
|
||||
<Icon name={item.icon} size="1.5rem"></Icon>
|
||||
</a>
|
||||
)}
|
||||
{config.links.length == 1 && <a rel="me" aria-label={config.links[0].name} href={config.links[0].url} target="_blank"
|
||||
class="btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95">
|
||||
<Icon name={config.links[0].icon} size="1.5rem"></Icon>
|
||||
{config.links[0].name}
|
||||
</a>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
16
src/components/widget/SideBar.astro
Normal file
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
import Profile from "./Profile.astro";
|
||||
import Tag from "./Tags.astro";
|
||||
import Categories from "./Categories.astro";
|
||||
|
||||
const className = Astro.props.class;
|
||||
---
|
||||
<div id="sidebar" class:list={[className, "w-full"]}>
|
||||
<div class="flex flex-col w-full gap-4 mb-4">
|
||||
<Profile></Profile>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-4 top-4 sticky top-4">
|
||||
<Categories class="onload-animation" style="animation-delay: 150ms"></Categories>
|
||||
<Tag class="onload-animation" style="animation-delay: 200ms"></Tag>
|
||||
</div>
|
||||
</div>
|
32
src/components/widget/Tags.astro
Normal file
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
|
||||
import WidgetLayout from "./WidgetLayout.astro";
|
||||
import ButtonTag from "../control/ButtonTag.astro";
|
||||
import {getTagList} from "../../utils/content-utils";
|
||||
import {i18n} from "../../i18n/translation";
|
||||
import I18nKey from "../../i18n/i18nKey";
|
||||
import {url} from "../../utils/url-utils";
|
||||
|
||||
const tags = await getTagList();
|
||||
|
||||
const COLLAPSED_HEIGHT = "7.5rem";
|
||||
|
||||
const isCollapsed = tags.length >= 20;
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const className = Astro.props.class
|
||||
const style = Astro.props.style
|
||||
|
||||
---
|
||||
<WidgetLayout name={i18n(I18nKey.tags)} id="tags" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT} class={className} style={style}>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{tags.map(t => (
|
||||
<ButtonTag href={url(`/archive/tag/${t.name}/`)} label={`View all posts with the ${t.name} tag`}>
|
||||
{t.name}
|
||||
</ButtonTag>
|
||||
))}
|
||||
</div>
|
||||
</WidgetLayout>
|
65
src/components/widget/WidgetLayout.astro
Normal file
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import {i18n} from "../../i18n/translation";
|
||||
import I18nKey from "../../i18n/i18nKey";
|
||||
interface Props {
|
||||
id: string;
|
||||
name?: string;
|
||||
isCollapsed?: boolean;
|
||||
collapsedHeight?: string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
}
|
||||
const props = Astro.props;
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
isCollapsed,
|
||||
collapsedHeight,
|
||||
style,
|
||||
} = Astro.props
|
||||
const className = Astro.props.class
|
||||
|
||||
---
|
||||
<widget-layout data-id={id} data-is-collapsed={isCollapsed} class={"pb-4 card-base " + className} style={style}>
|
||||
<div class="font-bold transition text-lg text-neutral-900 dark:text-neutral-100 relative ml-8 mt-4 mb-2
|
||||
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
|
||||
before:absolute before:left-[-16px] before:top-[5.5px]">{name}</div>
|
||||
<div id={id} class:list={["collapse-wrapper px-4 overflow-hidden", {"collapsed": isCollapsed}]}>
|
||||
<slot></slot>
|
||||
</div>
|
||||
{isCollapsed && <div class="expand-btn px-4 -mb-2">
|
||||
<button class="btn-plain rounded-lg w-full h-9">
|
||||
<div class="text-[var(--primary)] flex items-center justify-center gap-2 -translate-x-2">
|
||||
<Icon name="material-symbols:more-horiz" size={28}></Icon> {i18n(I18nKey.more)}
|
||||
</div>
|
||||
</button>
|
||||
</div>}
|
||||
</widget-layout>
|
||||
|
||||
<style define:vars={{ collapsedHeight }}>
|
||||
.collapsed {
|
||||
height: var(--collapsedHeight);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
class WidgetLayout extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (this.dataset.isCollapsed === undefined || this.dataset.isCollapsed === false)
|
||||
return;
|
||||
|
||||
const id = this.dataset.id;
|
||||
const btn = this.querySelector('.expand-btn');
|
||||
const wrapper = this.querySelector(`#${id}`)
|
||||
btn.addEventListener('click', () => {
|
||||
wrapper.classList.remove('collapsed');
|
||||
btn.classList.add('hidden');
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('widget-layout', WidgetLayout);
|
||||
</script>
|
73
src/config.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import type {
|
||||
LicenseConfig,
|
||||
NavBarConfig,
|
||||
ProfileConfig,
|
||||
SiteConfig,
|
||||
} from './types/config'
|
||||
import { LinkPreset } from './types/config'
|
||||
|
||||
export const siteConfig: SiteConfig = {
|
||||
title: 'Fuwari',
|
||||
subtitle: 'Demo Site',
|
||||
lang: 'en', // 'en', 'zh_CN', 'zh_TW', 'ja'
|
||||
themeColor: {
|
||||
hue: 250, // Default hue for the theme color, from 0 to 360. e.g. red: 0, teal: 200, cyan: 250, pink: 345
|
||||
fixed: false, // Hide the theme color picker for visitors
|
||||
},
|
||||
banner: {
|
||||
enable: false,
|
||||
src: 'assets/images/demo-banner.png', // Relative to the /src directory. Relative to the /public directory if it starts with '/'
|
||||
position: 'center', // Equivalent to object-position, defaults center
|
||||
},
|
||||
favicon: [ // Leave this array empty to use the default favicon
|
||||
// {
|
||||
// src: '/favicon/icon.png', // Path of the favicon, relative to the /public directory
|
||||
// theme: 'light', // (Optional) Either 'light' or 'dark', set only if you have different favicons for light and dark mode
|
||||
// sizes: '32x32', // (Optional) Size of the favicon, set only if you have favicons of different sizes
|
||||
// }
|
||||
]
|
||||
}
|
||||
|
||||
export const navBarConfig: NavBarConfig = {
|
||||
links: [
|
||||
LinkPreset.Home,
|
||||
LinkPreset.Archive,
|
||||
LinkPreset.About,
|
||||
{
|
||||
name: 'GitHub',
|
||||
url: 'https://github.com/saicaca/fuwari', // Internal links should not include the base path, as it is automatically added
|
||||
external: true, // Show an external link icon and will open in a new tab
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const profileConfig: ProfileConfig = {
|
||||
avatar: 'assets/images/demo-avatar.png', // Relative to the /src directory. Relative to the /public directory if it starts with '/'
|
||||
name: 'Lorem Ipsum',
|
||||
bio: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
|
||||
links: [
|
||||
{
|
||||
name: 'Twitter',
|
||||
icon: 'fa6-brands:twitter', // Visit https://icones.js.org/ for icon codes
|
||||
// You will need to install the corresponding icon set if it's not already included
|
||||
// `pnpm add @iconify-json/<icon-set-name>`
|
||||
url: 'https://twitter.com',
|
||||
},
|
||||
{
|
||||
name: 'Steam',
|
||||
icon: 'fa6-brands:steam',
|
||||
url: 'https://store.steampowered.com',
|
||||
},
|
||||
{
|
||||
name: 'GitHub',
|
||||
icon: 'fa6-brands:github',
|
||||
url: 'https://github.com/saicaca/fuwari',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const licenseConfig: LicenseConfig = {
|
||||
enable: true,
|
||||
name: 'CC BY-NC-SA 4.0',
|
||||
url: 'https://creativecommons.org/licenses/by-nc-sa/4.0/',
|
||||
}
|
6
src/constants/constants.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export const UNCATEGORIZED = '__uncategorized__'
|
||||
|
||||
export const PAGE_SIZE = 8
|
||||
|
||||
export const LIGHT_MODE = 'light', DARK_MODE = 'dark', AUTO_MODE = 'auto'
|
||||
export const DEFAULT_THEME = AUTO_MODE
|
37
src/constants/icon.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import type {Favicon} from "@/types/config.ts";
|
||||
|
||||
export const defaultFavicons: Favicon[] = [
|
||||
{
|
||||
src: '/favicon/favicon-light-32.png',
|
||||
theme: 'light',
|
||||
sizes: '32x32',
|
||||
}, {
|
||||
src: '/favicon/favicon-light-128.png',
|
||||
theme: 'light',
|
||||
sizes: '128x128',
|
||||
}, {
|
||||
src: '/favicon/favicon-light-180.png',
|
||||
theme: 'light',
|
||||
sizes: '180x180',
|
||||
}, {
|
||||
src: '/favicon/favicon-light-192.png',
|
||||
theme: 'light',
|
||||
sizes: '192x192',
|
||||
}, {
|
||||
src: '/favicon/favicon-dark-32.png',
|
||||
theme: 'dark',
|
||||
sizes: '32x32',
|
||||
}, {
|
||||
src: '/favicon/favicon-dark-128.png',
|
||||
theme: 'dark',
|
||||
sizes: '128x128',
|
||||
}, {
|
||||
src: '/favicon/favicon-dark-180.png',
|
||||
theme: 'dark',
|
||||
sizes: '180x180',
|
||||
}, {
|
||||
src: '/favicon/favicon-dark-192.png',
|
||||
theme: 'dark',
|
||||
sizes: '192x192',
|
||||
}
|
||||
]
|
18
src/constants/link-presets.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { LinkPreset, type NavBarLink } from '@/types/config'
|
||||
import I18nKey from '@i18n/i18nKey'
|
||||
import { i18n } from '@i18n/translation'
|
||||
|
||||
export const LinkPresets: { [key in LinkPreset]: NavBarLink } = {
|
||||
[LinkPreset.Home]: {
|
||||
name: i18n(I18nKey.home),
|
||||
url: '/',
|
||||
},
|
||||
[LinkPreset.About]: {
|
||||
name: i18n(I18nKey.about),
|
||||
url: '/about/',
|
||||
},
|
||||
[LinkPreset.Archive]: {
|
||||
name: i18n(I18nKey.archive),
|
||||
url: '/archive/',
|
||||
},
|
||||
}
|
16
src/content/config.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { defineCollection, z } from 'astro:content'
|
||||
|
||||
const postsCollection = defineCollection({
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
published: z.date(),
|
||||
draft: z.boolean().optional(),
|
||||
description: z.string().optional(),
|
||||
image: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
category: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
export const collections = {
|
||||
posts: postsCollection,
|
||||
}
|
22
src/content/posts/draft.md
Normal file
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
title: Draft Example
|
||||
published: 2022-07-01
|
||||
tags: [Markdown, Blogging, Demo]
|
||||
category: Examples
|
||||
draft: true
|
||||
---
|
||||
|
||||
# This Article is a Draft
|
||||
|
||||
This article is currently in a draft state and is not published. Therefore, it will not be visible to the general audience. The content is still a work in progress and may require further editing and review.
|
||||
|
||||
When the article is ready for publication, you can update the "draft" field to "false" in the Frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: Draft Example
|
||||
published: 2024-01-11T04:40:26.381Z
|
||||
tags: [Markdown, Blogging, Demo]
|
||||
category: Examples
|
||||
draft: false
|
||||
---
|
BIN
src/content/posts/guide/cover.jpeg
Normal file
After Width: | Height: | Size: 218 KiB |
51
src/content/posts/guide/index.md
Normal file
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
title: Simple Guides for Fuwari
|
||||
published: 2024-04-01
|
||||
description: "How to use this blog template."
|
||||
image: "./cover.jpeg"
|
||||
tags: ["Fuwari", "Blogging", "Customization"]
|
||||
category: Guides
|
||||
draft: false
|
||||
---
|
||||
|
||||
> Cover image source: [Source](https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/208fc754-890d-4adb-9753-2c963332675d/width=2048/01651-1456859105-(colour_1.5),girl,_Blue,yellow,green,cyan,purple,red,pink,_best,8k,UHD,masterpiece,male%20focus,%201boy,gloves,%20ponytail,%20long%20hair,.jpeg)
|
||||
|
||||
This blog template is built with [Astro](https://astro.build/). For the things that are not mentioned in this guide, you may find the answers in the [Astro Docs](https://docs.astro.build/).
|
||||
|
||||
## Front-matter of Posts
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My First Blog Post
|
||||
published: 2023-09-09
|
||||
description: This is the first post of my new Astro blog.
|
||||
image: ./cover.jpg
|
||||
tags: [Foo, Bar]
|
||||
category: Front-end
|
||||
draft: false
|
||||
---
|
||||
```
|
||||
|
||||
| Attribute | Description |
|
||||
|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `title` | The title of the post. |
|
||||
| `published` | The date the post was published. |
|
||||
| `description` | A short description of the post. Displayed on index page. |
|
||||
| `image` | The cover image path of the post.<br/>1. Start with `http://` or `https://`: Use web image<br/>2. Start with `/`: For image in `public` dir<br/>3. With none of the prefixes: Relative to the markdown file |
|
||||
| `tags` | The tags of the post. |
|
||||
| `category` | The category of the post. |
|
||||
| `draft` | If this post is still a draft, which won't be displayed. |
|
||||
|
||||
## Where to Place the Post Files
|
||||
|
||||
|
||||
|
||||
Your post files should be placed in `src/content/posts/` directory. You can also create sub-directories to better organize your posts and assets.
|
||||
|
||||
```
|
||||
src/content/posts/
|
||||
├── post-1.md
|
||||
└── post-2/
|
||||
├── cover.png
|
||||
└── index.md
|
||||
```
|
77
src/content/posts/markdown-extended.md
Normal file
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
title: Markdown Extended Features
|
||||
published: 2024-05-01
|
||||
description: 'Read more about Markdown features in Fuwari'
|
||||
image: ''
|
||||
tags: [Demo, Example, Markdown, Fuwari]
|
||||
category: 'Examples'
|
||||
draft: false
|
||||
---
|
||||
|
||||
## GitHub repository cards
|
||||
You can add dynamic cards that link to GitHub repositories, on page load, the repository information is pulled from the GitHub API.
|
||||
|
||||
::github{repo="Fabrizz/MMM-OnSpotify"}
|
||||
|
||||
Create a GitHub repository card with the code `::github{repo="<owner>/<repo>"}`.
|
||||
|
||||
```markdown
|
||||
::github{repo="saicaca/fuwari"}
|
||||
```
|
||||
|
||||
## Admonitions
|
||||
|
||||
Following types of admonitions are supported: `note` `tip` `important` `warning` `caution`
|
||||
|
||||
:::note
|
||||
Highlights information that users should take into account, even when skimming.
|
||||
:::
|
||||
|
||||
:::tip
|
||||
Optional information to help a user be more successful.
|
||||
:::
|
||||
|
||||
:::important
|
||||
Crucial information necessary for users to succeed.
|
||||
:::
|
||||
|
||||
:::warning
|
||||
Critical content demanding immediate user attention due to potential risks.
|
||||
:::
|
||||
|
||||
:::caution
|
||||
Negative potential consequences of an action.
|
||||
:::
|
||||
|
||||
```markdown
|
||||
:::note
|
||||
Highlights information that users should take into account, even when skimming.
|
||||
:::
|
||||
|
||||
:::tip
|
||||
Optional information to help a user be more successful.
|
||||
:::
|
||||
```
|
||||
|
||||
The title of the admonition can be customized.
|
||||
|
||||
:::note[MY CUSTOM TITLE]
|
||||
This is a note with a custom title.
|
||||
:::
|
||||
|
||||
```markdown
|
||||
:::note[MY CUSTOM TITLE]
|
||||
This is a note with a custom title.
|
||||
:::
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> [The GitHub syntax](https://github.com/orgs/community/discussions/16925) is also supported.
|
||||
|
||||
```
|
||||
> [!NOTE]
|
||||
> The GitHub syntax is also supported.
|
||||
|
||||
> [!TIP]
|
||||
> The GitHub syntax is also supported.
|
||||
```
|
166
src/content/posts/markdown.md
Normal file
|
@ -0,0 +1,166 @@
|
|||
---
|
||||
title: Markdown Example
|
||||
published: 2023-10-01
|
||||
description: A simple example of a Markdown blog post.
|
||||
tags: [Markdown, Blogging, Demo]
|
||||
category: Examples
|
||||
draft: false
|
||||
---
|
||||
|
||||
# An h1 header
|
||||
|
||||
Paragraphs are separated by a blank line.
|
||||
|
||||
2nd paragraph. _Italic_, **bold**, and `monospace`. Itemized lists
|
||||
look like:
|
||||
|
||||
- this one
|
||||
- that one
|
||||
- the other one
|
||||
|
||||
Note that --- not considering the asterisk --- the actual text
|
||||
content starts at 4-columns in.
|
||||
|
||||
> Block quotes are
|
||||
> written like so.
|
||||
>
|
||||
> They can span multiple paragraphs,
|
||||
> if you like.
|
||||
|
||||
Use 3 dashes for an em-dash. Use 2 dashes for ranges (ex., "it's all
|
||||
in chapters 12--14"). Three dots ... will be converted to an ellipsis.
|
||||
Unicode is supported. ☺
|
||||
|
||||
## An h2 header
|
||||
|
||||
Here's a numbered list:
|
||||
|
||||
1. first item
|
||||
2. second item
|
||||
3. third item
|
||||
|
||||
Note again how the actual text starts at 4 columns in (4 characters
|
||||
from the left side). Here's a code sample:
|
||||
|
||||
# Let me re-iterate ...
|
||||
for i in 1 .. 10 { do-something(i) }
|
||||
|
||||
As you probably guessed, indented 4 spaces. By the way, instead of
|
||||
indenting the block, you can use delimited blocks, if you like:
|
||||
|
||||
```
|
||||
define foobar() {
|
||||
print "Welcome to flavor country!";
|
||||
}
|
||||
```
|
||||
|
||||
(which makes copying & pasting easier). You can optionally mark the
|
||||
delimited block for Pandoc to syntax highlight it:
|
||||
|
||||
```python
|
||||
import time
|
||||
# Quick, count to ten!
|
||||
for i in range(10):
|
||||
# (but not *too* quick)
|
||||
time.sleep(0.5)
|
||||
print i
|
||||
```
|
||||
|
||||
### An h3 header
|
||||
|
||||
Now a nested list:
|
||||
|
||||
1. First, get these ingredients:
|
||||
|
||||
- carrots
|
||||
- celery
|
||||
- lentils
|
||||
|
||||
2. Boil some water.
|
||||
|
||||
3. Dump everything in the pot and follow
|
||||
this algorithm:
|
||||
|
||||
find wooden spoon
|
||||
uncover pot
|
||||
stir
|
||||
cover pot
|
||||
balance wooden spoon precariously on pot handle
|
||||
wait 10 minutes
|
||||
goto first step (or shut off burner when done)
|
||||
|
||||
Do not bump wooden spoon or it will fall.
|
||||
|
||||
Notice again how text always lines up on 4-space indents (including
|
||||
that last line which continues item 3 above).
|
||||
|
||||
Here's a link to [a website](http://foo.bar), to a [local
|
||||
doc](local-doc.html), and to a [section heading in the current
|
||||
doc](#an-h2-header). Here's a footnote [^1].
|
||||
|
||||
[^1]: Footnote text goes here.
|
||||
|
||||
Tables can look like this:
|
||||
|
||||
size material color
|
||||
|
||||
---
|
||||
|
||||
9 leather brown
|
||||
10 hemp canvas natural
|
||||
11 glass transparent
|
||||
|
||||
Table: Shoes, their sizes, and what they're made of
|
||||
|
||||
(The above is the caption for the table.) Pandoc also supports
|
||||
multi-line tables:
|
||||
|
||||
---
|
||||
|
||||
keyword text
|
||||
|
||||
---
|
||||
|
||||
red Sunsets, apples, and
|
||||
other red or reddish
|
||||
things.
|
||||
|
||||
green Leaves, grass, frogs
|
||||
and other things it's
|
||||
not easy being.
|
||||
|
||||
---
|
||||
|
||||
A horizontal rule follows.
|
||||
|
||||
---
|
||||
|
||||
Here's a definition list:
|
||||
|
||||
apples
|
||||
: Good for making applesauce.
|
||||
oranges
|
||||
: Citrus!
|
||||
tomatoes
|
||||
: There's no "e" in tomatoe.
|
||||
|
||||
Again, text is indented 4 spaces. (Put a blank line between each
|
||||
term/definition pair to spread things out more.)
|
||||
|
||||
Here's a "line block":
|
||||
|
||||
| Line one
|
||||
| Line too
|
||||
| Line tree
|
||||
|
||||
and images can be specified like so:
|
||||
|
||||
[//]: # (![example image](./demo-banner.png "An exemplary image"))
|
||||
|
||||
Inline math equations go in like so: $\omega = d\phi / dt$. Display
|
||||
math should get its own line and be put in in double-dollarsigns:
|
||||
|
||||
$$I = \int \rho R^{2} dV$$
|
||||
|
||||
And note that you can backslash-escape any punctuation characters
|
||||
which you wish to be displayed literally, ex.: \`foo\`, \*bar\*, etc.
|
28
src/content/posts/video.md
Normal file
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
title: Include Video in the Posts
|
||||
published: 2023-08-01
|
||||
description: This post demonstrates how to include embedded video in a blog post.
|
||||
tags: [Example, Video]
|
||||
category: Examples
|
||||
draft: false
|
||||
---
|
||||
|
||||
Just copy the embed code from YouTube or other platforms, and paste it in the markdown file.
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Include Video in the Post
|
||||
published: 2023-10-19
|
||||
// ...
|
||||
---
|
||||
|
||||
<iframe width="100%" height="468" src="https://www.youtube.com/embed/5gIf0_xpFPI?si=N1WTorLKL0uwLsU_" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
|
||||
```
|
||||
|
||||
## YouTube
|
||||
|
||||
<iframe width="100%" height="468" src="https://www.youtube.com/embed/5gIf0_xpFPI?si=N1WTorLKL0uwLsU_" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
|
||||
|
||||
## Bilibili
|
||||
|
||||
<iframe width="100%" height="468" src="//player.bilibili.com/player.html?bvid=BV1fK4y1s7Qf&p=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"> </iframe>
|
9
src/content/spec/about.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
# About
|
||||
This is the demo site for [Fuwari](https://github.com/saicaca/fuwari).
|
||||
|
||||
::github{repo="saicaca/fuwari"}
|
||||
|
||||
> ### Sources of images used in this site
|
||||
> - [Unsplash](https://unsplash.com/)
|
||||
> - [星と少女](https://www.pixiv.net/artworks/108916539) by [Stella](https://www.pixiv.net/users/93273965)
|
||||
> - [Rabbit - v1.4 Showcase](https://civitai.com/posts/586908) by [Rabbit_YourMajesty](https://civitai.com/user/Rabbit_YourMajesty)
|
2
src/env.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="astro/client" />
|
||||
/// <reference path="../.astro/types.d.ts" />
|
37
src/i18n/i18nKey.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
enum I18nKey {
|
||||
home = 'home',
|
||||
about = 'about',
|
||||
archive = 'archive',
|
||||
search = 'search',
|
||||
|
||||
tags = 'tags',
|
||||
categories = 'categories',
|
||||
recentPosts = 'recentPosts',
|
||||
|
||||
comments = 'comments',
|
||||
|
||||
untitled = 'untitled',
|
||||
uncategorized = 'uncategorized',
|
||||
noTags = 'noTags',
|
||||
|
||||
wordCount = 'wordCount',
|
||||
wordsCount = 'wordsCount',
|
||||
minuteCount = 'minuteCount',
|
||||
minutesCount = 'minutesCount',
|
||||
postCount = 'postCount',
|
||||
postsCount = 'postsCount',
|
||||
|
||||
themeColor = 'themeColor',
|
||||
|
||||
lightMode = 'lightMode',
|
||||
darkMode = 'darkMode',
|
||||
systemMode = 'systemMode',
|
||||
|
||||
more = 'more',
|
||||
|
||||
author = 'author',
|
||||
publishedAt = 'publishedAt',
|
||||
license = 'license',
|
||||
}
|
||||
|
||||
export default I18nKey
|
38
src/i18n/languages/en.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import Key from '../i18nKey'
|
||||
import type { Translation } from '../translation'
|
||||
|
||||
export const en: Translation = {
|
||||
[Key.home]: 'Home',
|
||||
[Key.about]: 'About',
|
||||
[Key.archive]: 'Archive',
|
||||
[Key.search]: 'Search',
|
||||
|
||||
[Key.tags]: 'Tags',
|
||||
[Key.categories]: 'Categories',
|
||||
[Key.recentPosts]: 'Recent Posts',
|
||||
|
||||
[Key.comments]: 'Comments',
|
||||
|
||||
[Key.untitled]: 'Untitled',
|
||||
[Key.uncategorized]: 'Uncategorized',
|
||||
[Key.noTags]: 'No Tags',
|
||||
|
||||
[Key.wordCount]: 'word',
|
||||
[Key.wordsCount]: 'words',
|
||||
[Key.minuteCount]: 'minute',
|
||||
[Key.minutesCount]: 'minutes',
|
||||
[Key.postCount]: 'post',
|
||||
[Key.postsCount]: 'posts',
|
||||
|
||||
[Key.themeColor]: 'Theme Color',
|
||||
|
||||
[Key.lightMode]: 'Light',
|
||||
[Key.darkMode]: 'Dark',
|
||||
[Key.systemMode]: 'System',
|
||||
|
||||
[Key.more]: 'More',
|
||||
|
||||
[Key.author]: 'Author',
|
||||
[Key.publishedAt]: 'Published at',
|
||||
[Key.license]: 'License',
|
||||
}
|
38
src/i18n/languages/ja.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import Key from '../i18nKey'
|
||||
import type { Translation } from '../translation'
|
||||
|
||||
export const ja: Translation = {
|
||||
[Key.home]: 'Home',
|
||||
[Key.about]: 'About',
|
||||
[Key.archive]: 'Archive',
|
||||
[Key.search]: '検索',
|
||||
|
||||
[Key.tags]: 'タグ',
|
||||
[Key.categories]: 'カテゴリ',
|
||||
[Key.recentPosts]: '最近の投稿',
|
||||
|
||||
[Key.comments]: 'コメント',
|
||||
|
||||
[Key.untitled]: 'タイトルなし',
|
||||
[Key.uncategorized]: 'カテゴリなし',
|
||||
[Key.noTags]: 'タグなし',
|
||||
|
||||
[Key.wordCount]: '文字',
|
||||
[Key.wordsCount]: '文字',
|
||||
[Key.minuteCount]: '分',
|
||||
[Key.minutesCount]: '分',
|
||||
[Key.postCount]: '件の投稿',
|
||||
[Key.postsCount]: '件の投稿',
|
||||
|
||||
[Key.themeColor]: 'テーマカラー',
|
||||
|
||||
[Key.lightMode]: 'ライト',
|
||||
[Key.darkMode]: 'ダーク',
|
||||
[Key.systemMode]: 'システム',
|
||||
|
||||
[Key.more]: 'もっと',
|
||||
|
||||
[Key.author]: '作者',
|
||||
[Key.publishedAt]: '公開日',
|
||||
[Key.license]: 'ライセンス',
|
||||
}
|
38
src/i18n/languages/zh_CN.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import Key from '../i18nKey'
|
||||
import type { Translation } from '../translation'
|
||||
|
||||
export const zh_CN: Translation = {
|
||||
[Key.home]: '主页',
|
||||
[Key.about]: '关于',
|
||||
[Key.archive]: '归档',
|
||||
[Key.search]: '搜索',
|
||||
|
||||
[Key.tags]: '标签',
|
||||
[Key.categories]: '分类',
|
||||
[Key.recentPosts]: '最新文章',
|
||||
|
||||
[Key.comments]: '评论',
|
||||
|
||||
[Key.untitled]: '无标题',
|
||||
[Key.uncategorized]: '未分类',
|
||||
[Key.noTags]: '无标签',
|
||||
|
||||
[Key.wordCount]: '字',
|
||||
[Key.wordsCount]: '字',
|
||||
[Key.minuteCount]: '分钟',
|
||||
[Key.minutesCount]: '分钟',
|
||||
[Key.postCount]: '篇文章',
|
||||
[Key.postsCount]: '篇文章',
|
||||
|
||||
[Key.themeColor]: '主题色',
|
||||
|
||||
[Key.lightMode]: '亮色',
|
||||
[Key.darkMode]: '暗色',
|
||||
[Key.systemMode]: '跟随系统',
|
||||
|
||||
[Key.more]: '更多',
|
||||
|
||||
[Key.author]: '作者',
|
||||
[Key.publishedAt]: '发布于',
|
||||
[Key.license]: '许可协议',
|
||||
}
|
38
src/i18n/languages/zh_TW.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import Key from '../i18nKey'
|
||||
import type { Translation } from '../translation'
|
||||
|
||||
export const zh_TW: Translation = {
|
||||
[Key.home]: '首頁',
|
||||
[Key.about]: '關於',
|
||||
[Key.archive]: '彙整',
|
||||
[Key.search]: '搜尋',
|
||||
|
||||
[Key.tags]: '標籤',
|
||||
[Key.categories]: '分類',
|
||||
[Key.recentPosts]: '最新文章',
|
||||
|
||||
[Key.comments]: '評論',
|
||||
|
||||
[Key.untitled]: '無標題',
|
||||
[Key.uncategorized]: '未分類',
|
||||
[Key.noTags]: '無標籤',
|
||||
|
||||
[Key.wordCount]: '字',
|
||||
[Key.wordsCount]: '字',
|
||||
[Key.minuteCount]: '分鐘',
|
||||
[Key.minutesCount]: '分鐘',
|
||||
[Key.postCount]: '篇文章',
|
||||
[Key.postsCount]: '篇文章',
|
||||
|
||||
[Key.themeColor]: '主題色',
|
||||
|
||||
[Key.lightMode]: '亮色',
|
||||
[Key.darkMode]: '暗色',
|
||||
[Key.systemMode]: '跟隨系統',
|
||||
|
||||
[Key.more]: '更多',
|
||||
|
||||
[Key.author]: '作者',
|
||||
[Key.publishedAt]: '發佈於',
|
||||
[Key.license]: '許可協議',
|
||||
}
|
32
src/i18n/translation.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { siteConfig } from '../config'
|
||||
import type I18nKey from './i18nKey'
|
||||
import { en } from './languages/en'
|
||||
import { ja } from './languages/ja'
|
||||
import { zh_CN } from './languages/zh_CN'
|
||||
import { zh_TW } from './languages/zh_TW'
|
||||
|
||||
export type Translation = {
|
||||
[K in I18nKey]: string
|
||||
}
|
||||
|
||||
const defaultTranslation = en
|
||||
|
||||
const map: { [key: string]: Translation } = {
|
||||
en: en,
|
||||
en_us: en,
|
||||
en_gb: en,
|
||||
en_au: en,
|
||||
zh_cn: zh_CN,
|
||||
zh_tw: zh_TW,
|
||||
ja: ja,
|
||||
ja_jp: ja,
|
||||
}
|
||||
|
||||
export function getTranslation(lang: string): Translation {
|
||||
return map[lang.toLowerCase()] || defaultTranslation
|
||||
}
|
||||
|
||||
export function i18n(key: I18nKey): string {
|
||||
const lang = siteConfig.lang || 'en'
|
||||
return getTranslation(lang)[key]
|
||||
}
|
348
src/layouts/Layout.astro
Normal file
|
@ -0,0 +1,348 @@
|
|||
---
|
||||
import GlobalStyles from "@components/GlobalStyles.astro";
|
||||
import '@fontsource/roboto/400.css';
|
||||
import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
import ImageWrapper from "@components/misc/ImageWrapper.astro";
|
||||
|
||||
import {pathsEqual} from "@utils/url-utils";
|
||||
import ConfigCarrier from "@components/ConfigCarrier.astro";
|
||||
import {profileConfig, siteConfig} from "@/config";
|
||||
import {Favicon} from "../types/config";
|
||||
import {defaultFavicons} from "../constants/icon";
|
||||
import {LIGHT_MODE, DARK_MODE, AUTO_MODE, DEFAULT_THEME} from "../constants/constants";
|
||||
import {url} from "../utils/url-utils";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
banner: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
let { title, banner, description } = Astro.props;
|
||||
|
||||
const isHomePage = pathsEqual(Astro.url.pathname, '/');
|
||||
|
||||
const testPathName = Astro.url.pathname;
|
||||
|
||||
const anim = {
|
||||
old: {
|
||||
name: 'fadeIn',
|
||||
duration: '4s',
|
||||
easing: 'linear',
|
||||
fillMode: 'forwards',
|
||||
mixBlendMode: 'normal',
|
||||
},
|
||||
new: {
|
||||
name: 'fadeOut',
|
||||
duration: '4s',
|
||||
easing: 'linear',
|
||||
fillMode: 'backwards',
|
||||
mixBlendMode: 'normal',
|
||||
}
|
||||
};
|
||||
|
||||
const myFade = {
|
||||
forwards: anim,
|
||||
backwards: anim,
|
||||
};
|
||||
|
||||
// defines global css variables
|
||||
// why doing this in Layout instead of GlobalStyles: https://github.com/withastro/astro/issues/6728#issuecomment-1502203757
|
||||
const configHue = siteConfig.themeColor.hue;
|
||||
if (!banner || typeof banner !== 'string' || banner.trim() === '') {
|
||||
banner = siteConfig.banner.src;
|
||||
}
|
||||
|
||||
// TODO don't use post cover as banner for now
|
||||
banner = siteConfig.banner.src;
|
||||
|
||||
const enableBanner = siteConfig.banner.enable;
|
||||
|
||||
let pageTitle;
|
||||
if (title) {
|
||||
pageTitle = `${title} - ${siteConfig.title}`;
|
||||
} else {
|
||||
pageTitle = `${siteConfig.title} - ${siteConfig.subtitle}`;
|
||||
}
|
||||
|
||||
const favicons: Favicon[] = siteConfig.favicon.length > 0 ? siteConfig.favicon : defaultFavicons
|
||||
|
||||
const siteLang = siteConfig.lang.replace('_', '-')
|
||||
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang={siteLang} isHome={isHomePage} pathname={testPathName} class="bg-[var(--page-bg)] transition text-[14px] md:text-[16px]">
|
||||
<head>
|
||||
|
||||
<title>{pageTitle}</title>
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content={description || pageTitle}>
|
||||
<meta name="author" content={profileConfig.name}>
|
||||
|
||||
<meta property="og:site_name" content={siteConfig.title}>
|
||||
<meta property="og:url" content={Astro.url}>
|
||||
<meta property="og:title" content={pageTitle}>
|
||||
<meta property="og:description" content={description || pageTitle}>
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content={Astro.url}>
|
||||
<meta name="twitter:title" content={pageTitle}>
|
||||
<meta name="twitter:description" content={description || pageTitle}>
|
||||
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
{favicons.map(favicon => (
|
||||
<link rel="icon"
|
||||
href={favicon.src.startsWith('/') ? url(favicon.src) : favicon.src}
|
||||
sizes={favicon.sizes}
|
||||
media={favicon.theme && `(prefers-color-scheme: ${favicon.theme})`}
|
||||
/>
|
||||
))}
|
||||
<!-- Set the theme before the page is rendered to avoid a flash -->
|
||||
<script define:vars={{DEFAULT_THEME: DEFAULT_THEME, LIGHT_MODE: LIGHT_MODE, DARK_MODE: DARK_MODE, AUTO_MODE: AUTO_MODE}}>
|
||||
const theme = localStorage.getItem('theme') || DEFAULT_THEME;
|
||||
switch (theme) {
|
||||
case LIGHT_MODE:
|
||||
document.documentElement.classList.remove('dark');
|
||||
break
|
||||
case DARK_MODE:
|
||||
document.documentElement.classList.add('dark');
|
||||
break
|
||||
case AUTO_MODE:
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<slot name="head"></slot>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.staticfile.org/KaTeX/0.16.9/katex.min.css" integrity="sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV" crossorigin="anonymous">
|
||||
|
||||
<link rel="alternate" type="application/rss+xml" title={profileConfig.name} href={`${Astro.site}rss.xml`}/>
|
||||
|
||||
<style define:vars={{ configHue }}></style> <!-- defines global css variables. This will be applied to <html> <body> and some other elements idk why -->
|
||||
|
||||
</head>
|
||||
<body class=" min-h-screen transition " class:list={[{"is-home": isHomePage, "enable-banner": enableBanner}]}>
|
||||
<ConfigCarrier></ConfigCarrier>
|
||||
<GlobalStyles>
|
||||
<div id="banner-wrapper" class="absolute w-full">
|
||||
<ImageWrapper id="boxtest" alt="Banner image of the blog" class:list={["object-center object-cover h-full", {"hidden": !siteConfig.banner.enable}]}
|
||||
src={siteConfig.banner.src}
|
||||
>
|
||||
</ImageWrapper>
|
||||
</div>
|
||||
<slot />
|
||||
</GlobalStyles>
|
||||
</body>
|
||||
</html>
|
||||
<style is:global>
|
||||
:root {
|
||||
--hue: var(--configHue);
|
||||
--page-width: 75rem;
|
||||
}
|
||||
</style>
|
||||
<style is:global>
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer components {
|
||||
/* TODO: temporarily make banner height same for all pages since I cannot make the transition feel good
|
||||
I want to make the height transition parallel with the content transition instead of blocking it
|
||||
*/
|
||||
|
||||
/*
|
||||
.enable-banner.is-home #banner-wrapper {
|
||||
@apply h-[var(--banner-height)] md:h-[var(--banner-height-home)]
|
||||
}
|
||||
*/
|
||||
.enable-banner #banner-wrapper {
|
||||
@apply h-[var(--banner-height)]
|
||||
}
|
||||
|
||||
/*
|
||||
.enable-banner.is-home #top-row {
|
||||
@apply h-[calc(var(--banner-height)_-_4.5rem)] md:h-[calc(var(--banner-height-home)_-_4.5rem)]
|
||||
}
|
||||
*/
|
||||
.enable-banner #top-row {
|
||||
@apply h-[calc(var(--banner-height)_-_4.5rem)]
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
import {
|
||||
OverlayScrollbars,
|
||||
ScrollbarsHidingPlugin,
|
||||
SizeObserverPlugin,
|
||||
ClickScrollPlugin
|
||||
} from 'overlayscrollbars';
|
||||
import {getHue, getStoredTheme, setHue, setTheme} from "../utils/setting-utils";
|
||||
|
||||
/* Preload fonts */
|
||||
// (async function() {
|
||||
// try {
|
||||
// await Promise.all([
|
||||
// document.fonts.load("400 1em Roboto"),
|
||||
// document.fonts.load("700 1em Roboto"),
|
||||
// ]);
|
||||
// document.body.classList.remove("hidden");
|
||||
// } catch (error) {
|
||||
// console.log("Failed to load fonts:", error);
|
||||
// }
|
||||
// })();
|
||||
|
||||
/* TODO This is a temporary solution for style flicker issue when the transition is activated */
|
||||
/* issue link: https://github.com/withastro/astro/issues/8711, the solution get from here too */
|
||||
/* update: fixed in Astro 3.2.4 */
|
||||
function disableAnimation() {
|
||||
const css = document.createElement('style')
|
||||
css.appendChild(
|
||||
document.createTextNode(
|
||||
`*{
|
||||
-webkit-transition:none!important;
|
||||
-moz-transition:none!important;
|
||||
-o-transition:none!important;
|
||||
-ms-transition:none!important;
|
||||
transition:none!important
|
||||
}`
|
||||
)
|
||||
)
|
||||
document.head.appendChild(css)
|
||||
|
||||
return () => {
|
||||
// Force restyle
|
||||
;(() => window.getComputedStyle(document.body))()
|
||||
|
||||
// Wait for next tick before removing
|
||||
setTimeout(() => {
|
||||
document.head.removeChild(css)
|
||||
}, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function setClickOutsideToClose(panel: string, ignores: string[]) {
|
||||
document.addEventListener("click", event => {
|
||||
let panelDom = document.getElementById(panel);
|
||||
let tDom = event.target;
|
||||
for (let ig of ignores) {
|
||||
let ie = document.getElementById(ig)
|
||||
if (ie == tDom || (ie?.contains(tDom))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panelDom.classList.add("float-panel-closed");
|
||||
});
|
||||
}
|
||||
setClickOutsideToClose("display-setting", ["display-setting", "display-settings-switch"])
|
||||
setClickOutsideToClose("nav-menu-panel", ["nav-menu-panel", "nav-menu-switch"])
|
||||
setClickOutsideToClose("search-panel", ["search-panel", "search-bar", "search-switch"])
|
||||
|
||||
|
||||
function loadTheme() {
|
||||
const theme = getStoredTheme()
|
||||
setTheme(theme)
|
||||
}
|
||||
|
||||
function loadHue() {
|
||||
setHue(getHue())
|
||||
}
|
||||
|
||||
function setBannerHeight() {
|
||||
const banner = document.getElementById('banner-wrapper');
|
||||
if (document.documentElement.hasAttribute('isHome')) {
|
||||
banner.classList.remove('banner-else');
|
||||
banner.classList.add('banner-home');
|
||||
} else {
|
||||
banner.classList.remove('banner-home');
|
||||
banner.classList.add('banner-else');
|
||||
}
|
||||
}
|
||||
|
||||
function initCustomScrollbar() {
|
||||
OverlayScrollbars(
|
||||
// docs say that a initialization to the body element would affect native functionality like window.scrollTo
|
||||
// but just leave it here for now
|
||||
{
|
||||
target: document.querySelector('body'),
|
||||
cancel: {
|
||||
nativeScrollbarsOverlaid: true, // don't initialize the overlay scrollbar if there is a native one
|
||||
}
|
||||
}, {
|
||||
scrollbars: {
|
||||
theme: 'scrollbar-base scrollbar-auto py-1',
|
||||
autoHide: 'move',
|
||||
autoHideDelay: 500,
|
||||
autoHideSuspend: false,
|
||||
},
|
||||
});
|
||||
document.querySelectorAll('pre').forEach((ele) => {
|
||||
OverlayScrollbars(ele, {
|
||||
scrollbars: {
|
||||
theme: 'scrollbar-base scrollbar-dark px-2',
|
||||
autoHide: 'leave',
|
||||
autoHideDelay: 500,
|
||||
autoHideSuspend: false
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
// disableAnimation()() // TODO
|
||||
setBannerHeight();
|
||||
loadTheme();
|
||||
loadHue();
|
||||
initCustomScrollbar();
|
||||
}
|
||||
|
||||
/* Load settings when entering the site */
|
||||
init();
|
||||
|
||||
/* Load settings before swapping */
|
||||
/* astro:after-swap event happened before swap animation */
|
||||
document.addEventListener('astro:after-swap', init);
|
||||
|
||||
const setup = () => {
|
||||
// TODO: temp solution to change the height of the banner
|
||||
/*
|
||||
window.swup.hooks.on('animation:out:start', () => {
|
||||
const path = window.location.pathname
|
||||
const body = document.querySelector('body')
|
||||
if (path[path.length - 1] === '/' && !body.classList.contains('is-home')) {
|
||||
body.classList.add('is-home')
|
||||
} else if (path[path.length - 1] !== '/' && body.classList.contains('is-home')) {
|
||||
body.classList.remove('is-home')
|
||||
}
|
||||
})
|
||||
*/
|
||||
// Remove the delay for the first time page load
|
||||
window.swup.hooks.on('link:click', () => {
|
||||
document.documentElement.style.setProperty('--content-delay', '0ms')
|
||||
})
|
||||
window.swup.hooks.on('content:replace', initCustomScrollbar)
|
||||
}
|
||||
if (window.swup.hooks) {
|
||||
setup()
|
||||
} else {
|
||||
document.addEventListener('swup:enable', setup)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style is:global lang="stylus">
|
||||
#banner-wrapper
|
||||
top: 0
|
||||
opacity: 1
|
||||
.banner-closed
|
||||
#banner-wrapper
|
||||
top: -120px
|
||||
opacity: 0
|
||||
</style>
|
46
src/layouts/MainGridLayout.astro
Normal file
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
import Layout from "./Layout.astro";
|
||||
import Navbar from "@components/Navbar.astro";
|
||||
import SideBar from "@components/widget/SideBar.astro";
|
||||
import {pathsEqual} from "@utils/url-utils";
|
||||
import Footer from "@components/Footer.astro";
|
||||
import BackToTop from "@components/control/BackToTop.astro";
|
||||
import {siteConfig} from "@/config";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
banner?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, banner, description } = Astro.props
|
||||
const isHomePage = pathsEqual(Astro.url.pathname, '/')
|
||||
const enableBanner = siteConfig.banner.enable
|
||||
|
||||
---
|
||||
|
||||
<Layout title={title} banner={banner} description={description}>
|
||||
<slot slot="head" name="head"></slot>
|
||||
<div class="max-w-[var(--page-width)] min-h-screen grid grid-cols-[17.5rem_auto] grid-rows-[auto_auto_1fr_auto] lg:grid-rows-[auto_1fr_auto]
|
||||
mx-auto gap-4 relative px-0 md:px-4"
|
||||
>
|
||||
<div id="top-row" class="col-span-2 grid-rows-1 z-50 onload-animation" class:list={[""]}>
|
||||
<div class="absolute h-8 left-0 right-0 -top-8 bg-[var(--card-bg)] transition"></div> <!-- used for onload animation -->
|
||||
<Navbar></Navbar>
|
||||
</div>
|
||||
<SideBar class="row-start-3 row-end-4 col-span-2 lg:row-start-2 lg:row-end-3 lg:col-span-1 lg:max-w-[17.5rem] onload-animation"></SideBar>
|
||||
|
||||
<div id="content-wrapper" class="row-start-2 row-end-3 col-span-2 lg:col-span-1 overflow-hidden onload-animation">
|
||||
<!-- the overflow-hidden here prevent long text break the layout-->
|
||||
<main id="swup" class="transition-fade">
|
||||
<slot></slot>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="footer" class="grid-rows-3 col-span-2 mt-4 onload-animation">
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
<BackToTop></BackToTop>
|
||||
</div>
|
||||
</Layout>
|
24
src/pages/[...page].astro
Normal file
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
import MainGridLayout from "../layouts/MainGridLayout.astro";
|
||||
import PostCard from "../components/PostCard.astro";
|
||||
import Pagination from "../components/control/Pagination.astro";
|
||||
import {getSortedPosts} from "../utils/content-utils";
|
||||
import {getPostUrlBySlug} from "../utils/url-utils";
|
||||
import {PAGE_SIZE} from "../constants/constants";
|
||||
import PostPage from "../components/PostPage.astro";
|
||||
|
||||
export async function getStaticPaths({ paginate }) {
|
||||
const allBlogPosts = await getSortedPosts();
|
||||
return paginate(allBlogPosts, { pageSize: PAGE_SIZE });
|
||||
}
|
||||
|
||||
const {page} = Astro.props;
|
||||
|
||||
const len = page.data.length;
|
||||
|
||||
---
|
||||
|
||||
<MainGridLayout>
|
||||
<PostPage page={page}></PostPage>
|
||||
<Pagination class="mx-auto onload-animation" page={page} style=`animation-delay: calc(var(--content-delay) + ${(len)*50}ms)`></Pagination>
|
||||
</MainGridLayout>
|
23
src/pages/about.astro
Normal file
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
|
||||
import MainGridLayout from "../layouts/MainGridLayout.astro";
|
||||
|
||||
import { getEntry } from 'astro:content'
|
||||
import {i18n} from "../i18n/translation";
|
||||
import I18nKey from "../i18n/i18nKey";
|
||||
import Markdown from "@components/misc/Markdown.astro";
|
||||
|
||||
const aboutPost = await getEntry('spec', 'about')
|
||||
|
||||
const { Content } = await aboutPost.render()
|
||||
|
||||
---
|
||||
<MainGridLayout title={i18n(I18nKey.about)} description={i18n(I18nKey.about)}>
|
||||
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative min-h-32">
|
||||
<div class="card-base z-10 px-9 py-6 relative w-full ">
|
||||
<Markdown class="mt-2">
|
||||
<Content />
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
</MainGridLayout>
|
25
src/pages/archive/category/[category].astro
Normal file
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
import {getCategoryList, getSortedPosts} from "@utils/content-utils";
|
||||
import MainGridLayout from "@layouts/MainGridLayout.astro";
|
||||
import ArchivePanel from "@components/ArchivePanel.astro";
|
||||
import {i18n} from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const categories = await getCategoryList();
|
||||
return categories.map(category => {
|
||||
return {
|
||||
params: {
|
||||
category: category.name
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { category } = Astro.params;
|
||||
|
||||
---
|
||||
|
||||
<MainGridLayout title={i18n(I18nKey.archive)} description={i18n(I18nKey.archive)}>
|
||||
<ArchivePanel categories={[category]}></ArchivePanel>
|
||||
</MainGridLayout>
|
11
src/pages/archive/category/uncategorized.astro
Normal file
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
import MainGridLayout from "@layouts/MainGridLayout.astro";
|
||||
import ArchivePanel from "@components/ArchivePanel.astro";
|
||||
import {i18n} from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import {UNCATEGORIZED} from "@constants/constants";
|
||||
---
|
||||
|
||||
<MainGridLayout title={i18n(I18nKey.archive)}>
|
||||
<ArchivePanel categories={[UNCATEGORIZED]}></ArchivePanel>
|
||||
</MainGridLayout>
|
12
src/pages/archive/index.astro
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
import { getCollection, getEntry } from "astro:content";
|
||||
import MainGridLayout from "@layouts/MainGridLayout.astro";
|
||||
import ArchivePanel from "@components/ArchivePanel.astro";
|
||||
import {i18n} from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
---
|
||||
|
||||
<MainGridLayout title={i18n(I18nKey.archive)}>
|
||||
<ArchivePanel></ArchivePanel>
|
||||
</MainGridLayout>
|
||||
|
34
src/pages/archive/tag/[tag].astro
Normal file
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
import {getSortedPosts} from "@utils/content-utils";
|
||||
import MainGridLayout from "@layouts/MainGridLayout.astro";
|
||||
import ArchivePanel from "@components/ArchivePanel.astro";
|
||||
import {i18n} from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
|
||||
|
||||
export async function getStaticPaths() {
|
||||
let posts = await getSortedPosts()
|
||||
|
||||
const allTags = posts.reduce((acc, post) => {
|
||||
post.data.tags.forEach(tag => acc.add(tag));
|
||||
return acc;
|
||||
}, new Set());
|
||||
|
||||
const allTagsArray = Array.from(allTags);
|
||||
|
||||
return allTagsArray.map(tag => {
|
||||
return {
|
||||
params: {
|
||||
tag: tag
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const { tag } = Astro.params;
|
||||
|
||||
---
|
||||
|
||||
<MainGridLayout title={i18n(I18nKey.archive)} description={i18n(I18nKey.archive)}>
|
||||
<ArchivePanel tags={[tag]}></ArchivePanel>
|
||||
</MainGridLayout>
|
139
src/pages/posts/[...slug].astro
Normal file
|
@ -0,0 +1,139 @@
|
|||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import MainGridLayout from "@layouts/MainGridLayout.astro";
|
||||
import ImageWrapper from "../../components/misc/ImageWrapper.astro";
|
||||
import {Icon} from "astro-icon/components";
|
||||
import PostMetadata from "../../components/PostMeta.astro";
|
||||
import {i18n} from "@i18n/translation";
|
||||
import I18nKey from "@i18n/i18nKey";
|
||||
import {getDir, getPostUrlBySlug} from "@utils/url-utils";
|
||||
import License from "@components/misc/License.astro";
|
||||
import {licenseConfig} from "src/config";
|
||||
import Markdown from "@components/misc/Markdown.astro";
|
||||
import path from "path";
|
||||
import {profileConfig} from "../../config";
|
||||
import {formatDateToYYYYMMDD} from "../../utils/date-utils";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const blogEntries = await getCollection('posts', ({ data }) => {
|
||||
return import.meta.env.PROD ? data.draft !== true : true;
|
||||
});
|
||||
return blogEntries.map(entry => ({
|
||||
params: { slug: entry.slug }, props: { entry },
|
||||
}));
|
||||
}
|
||||
|
||||
const { entry } = Astro.props;
|
||||
const { Content } = await entry.render();
|
||||
|
||||
const { remarkPluginFrontmatter } = await entry.render();
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
"headline": entry.data.title,
|
||||
"description": entry.data.description || entry.data.title,
|
||||
"keywords": entry.data.tags,
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": profileConfig.name,
|
||||
"url": Astro.site
|
||||
},
|
||||
"datePublished": formatDateToYYYYMMDD(entry.data.published),
|
||||
// TODO include cover image here
|
||||
}
|
||||
|
||||
---
|
||||
<MainGridLayout banner={entry.data.image} title={entry.data.title} description={entry.data.description}>
|
||||
<script slot="head" type="application/ld+json" set:html={JSON.stringify(jsonLd)}></script>
|
||||
<div class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative mb-4">
|
||||
<div id="post-container" class:list={["card-base z-10 px-6 md:px-9 pt-6 pb-4 relative w-full ",
|
||||
{}
|
||||
]}>
|
||||
<!-- word count and reading time -->
|
||||
<div class="flex flex-row text-black/30 dark:text-white/30 gap-5 mb-3 transition onload-animation">
|
||||
<div class="flex flex-row items-center">
|
||||
<div class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2">
|
||||
<Icon name="material-symbols:notes-rounded"></Icon>
|
||||
</div>
|
||||
<div class="text-sm">{remarkPluginFrontmatter.words} {" " + i18n(I18nKey.wordsCount)}</div>
|
||||
</div>
|
||||
<div class="flex flex-row items-center">
|
||||
<div class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2">
|
||||
<Icon name="material-symbols:schedule-outline-rounded"></Icon>
|
||||
</div>
|
||||
<div class="text-sm">{remarkPluginFrontmatter.minutes} {" " + i18n(I18nKey.minutesCount)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- title -->
|
||||
<div class="relative onload-animation">
|
||||
<div
|
||||
data-pagefind-body data-pagefind-weight="10" data-pagefind-meta="title"
|
||||
class="transition w-full block font-bold mb-3
|
||||
text-3xl md:text-[2.5rem]/[2.75rem]
|
||||
text-black/90 dark:text-white/90
|
||||
md:before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
|
||||
before:absolute before:top-[0.75rem] before:left-[-1.125rem]
|
||||
">
|
||||
{entry.data.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- metadata -->
|
||||
<div class="onload-animation">
|
||||
<PostMetadata
|
||||
class="mb-5"
|
||||
published={entry.data.published}
|
||||
tags={entry.data.tags}
|
||||
category={entry.data.category}
|
||||
></PostMetadata>
|
||||
{!entry.data.image && <div class="border-[var(--line-divider)] border-dashed border-b-[1px] mb-5"></div>}
|
||||
</div>
|
||||
|
||||
<!-- always show cover as long as it has one -->
|
||||
|
||||
{entry.data.image &&
|
||||
<ImageWrapper src={entry.data.image} basePath={path.join("content/posts/", getDir(entry.id))} class="mb-8 rounded-xl banner-container onload-animation"/>
|
||||
}
|
||||
|
||||
|
||||
<Markdown class="mb-6 markdown-content onload-animation">
|
||||
<Content />
|
||||
</Markdown>
|
||||
|
||||
{licenseConfig.enable && <License title={entry.data.title} slug={entry.slug} pubDate={entry.data.published} class="mb-6 rounded-xl license-container onload-animation"></License>}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row justify-between mb-4 gap-4 overflow-hidden w-full">
|
||||
<a href={getPostUrlBySlug(entry.data.nextSlug)} class="w-full font-bold overflow-hidden active:scale-95">
|
||||
{entry.data.nextSlug && <div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center justify-start gap-4" >
|
||||
<Icon name="material-symbols:chevron-left-rounded" size={32} class="text-[var(--primary)]" />
|
||||
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
|
||||
{entry.data.nextTitle}
|
||||
</div>
|
||||
</div>}
|
||||
</a>
|
||||
|
||||
<a href={getPostUrlBySlug(entry.data.prevSlug)} class="w-full font-bold overflow-hidden active:scale-95">
|
||||
{entry.data.prevSlug && <div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center justify-end gap-4">
|
||||
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
|
||||
{entry.data.prevTitle}
|
||||
</div>
|
||||
<Icon name="material-symbols:chevron-right-rounded" size={32} class="text-[var(--primary)]" />
|
||||
</div>}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</MainGridLayout>
|
||||
|
||||
<style is:global>
|
||||
#post-container :nth-child(1) { animation-delay: calc(var(--content-delay) + 0ms) }
|
||||
#post-container :nth-child(2) { animation-delay: calc(var(--content-delay) + 50ms) }
|
||||
#post-container :nth-child(3) { animation-delay: calc(var(--content-delay) + 100ms) }
|
||||
#post-container :nth-child(4) { animation-delay: calc(var(--content-delay) + 175ms) }
|
||||
#post-container :nth-child(5) { animation-delay: calc(var(--content-delay) + 250ms) }
|
||||
#post-container :nth-child(6) { animation-delay: calc(var(--content-delay) + 325ms) }
|
||||
</style>
|
16
src/pages/robots.txt.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
|
||||
const robotsTxt = `
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: ${new URL('sitemap-index.xml', import.meta.env.SITE).href}
|
||||
`.trim();
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
return new Response(robotsTxt, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
});
|
||||
};
|
25
src/pages/rss.xml.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import rss from '@astrojs/rss';
|
||||
import {siteConfig} from '@/config';
|
||||
import { getCollection } from 'astro:content';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
const parser = new MarkdownIt();
|
||||
|
||||
export async function GET(context: any) {
|
||||
const blog = await getCollection('posts');
|
||||
return rss({
|
||||
title: siteConfig.title,
|
||||
description: siteConfig.subtitle || 'No description',
|
||||
site: context.site,
|
||||
items: blog.map((post) => ({
|
||||
title: post.data.title,
|
||||
pubDate: post.data.published,
|
||||
description: post.data.description,
|
||||
link: `/posts/${post.slug}/`,
|
||||
content: sanitizeHtml(parser.render(post.body), {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img'])
|
||||
}),
|
||||
})),
|
||||
customData: `<language>${siteConfig.lang}</language>`,
|
||||
});
|
||||
}
|
31
src/plugins/rehype-component-admonition.mjs
Normal file
|
@ -0,0 +1,31 @@
|
|||
/// <reference types="mdast" />
|
||||
import { h } from 'hastscript'
|
||||
|
||||
/**
|
||||
* Creates an admonition component.
|
||||
*
|
||||
* @param {Object} properties - The properties of the component.
|
||||
* @param {string} [properties.title] - An optional title.
|
||||
* @param {('tip'|'note'|'important'|'caution'|'warning')} type - The admonition type.
|
||||
* @param {import('mdast').RootContent[]} children - The children elements of the component.
|
||||
* @returns {import('mdast').Parent} The created admonition component.
|
||||
*/
|
||||
export function AdmonitionComponent(properties, children, type) {
|
||||
if (!Array.isArray(children) || children.length === 0)
|
||||
return h("div",
|
||||
{ class: 'hidden' },
|
||||
'Invalid admonition directive. (Admonition directives must be of block type ":::note{name="name"} <content> :::")'
|
||||
);
|
||||
|
||||
let label = null
|
||||
if (properties && properties['has-directive-label']) {
|
||||
label = children[0]; // The first child is the label
|
||||
children = children.slice(1);
|
||||
label.tagName = "div"; // Change the tag <p> to <div>
|
||||
}
|
||||
|
||||
return h(`blockquote`,
|
||||
{ class: `admonition bdm-${type}` },
|
||||
[ h("span", { class: `bdm-title` }, label ? label : type.toUpperCase()), ...children]
|
||||
);
|
||||
}
|
119
src/plugins/rehype-component-github-card.mjs
Normal file
|
@ -0,0 +1,119 @@
|
|||
/// <reference types="mdast" />
|
||||
import { h } from 'hastscript'
|
||||
|
||||
/**
|
||||
* Creates a GitHub Card component.
|
||||
*
|
||||
* @param {Object} properties - The properties of the component.
|
||||
* @param {string} properties.repo - The GitHub repository in the format "owner/repo".
|
||||
* @param {import('mdast').RootContent[]} children - The children elements of the component.
|
||||
* @returns {import('mdast').Parent} The created GitHub Card component.
|
||||
*/
|
||||
export function GithubCardComponent(properties, children) {
|
||||
if (Array.isArray(children) && children.length !== 0)
|
||||
return h("div",
|
||||
{ class: 'hidden' },
|
||||
['Invalid directive. ("github" directive must be leaf type "::github{repo="owner/repo"}")']
|
||||
);
|
||||
|
||||
if (!properties.repo || !properties.repo.includes("/"))
|
||||
return h("div",
|
||||
{ class: 'hidden' },
|
||||
'Invalid repository. ("repo" attributte must be in the format "owner/repo")'
|
||||
);
|
||||
|
||||
const repo = properties.repo;
|
||||
const cardUuid = `GC${Math.random().toString(36).slice(-6)}` // Collisions are not important
|
||||
|
||||
const nAvatar = h(
|
||||
`div#${cardUuid}-avatar`,
|
||||
{ class: "gc-avatar"},
|
||||
)
|
||||
const nLanguage = h(
|
||||
`span#${cardUuid}-language`,
|
||||
{ class: "gc-language" },
|
||||
"Waiting..."
|
||||
)
|
||||
|
||||
const nTitle = h(
|
||||
`div`,
|
||||
{ class: "gc-titlebar" },
|
||||
[
|
||||
h("div", { class: "gc-titlebar-left"}, [
|
||||
h("div", { class: "gc-owner" }, [
|
||||
nAvatar,
|
||||
h("div", { class: "gc-user" }, repo.split("/")[0] ),
|
||||
]),
|
||||
h("div", { class: "gc-divider" }, "/" ),
|
||||
h("div", { class: "gc-repo" }, repo.split("/")[1] )
|
||||
]),
|
||||
h("div", { class: "github-logo"})
|
||||
]
|
||||
)
|
||||
|
||||
const nDescription = h(
|
||||
`div#${cardUuid}-description`,
|
||||
{ class: "gc-description" },
|
||||
"Waiting for api.github.com..."
|
||||
)
|
||||
|
||||
const nStars = h(
|
||||
`div#${cardUuid}-stars`,
|
||||
{ class: "gc-stars" },
|
||||
"00K"
|
||||
)
|
||||
const nForks = h(
|
||||
`div#${cardUuid}-forks`,
|
||||
{ class: "gc-forks" },
|
||||
"0K"
|
||||
)
|
||||
const nLicense = h(
|
||||
`div#${cardUuid}-license`,
|
||||
{ class: "gc-license" },
|
||||
"0K"
|
||||
)
|
||||
|
||||
const nScript = h(
|
||||
`script#${cardUuid}-script`,
|
||||
{ type: "text/javascript", defer: true },
|
||||
`
|
||||
fetch('https://api.github.com/repos/${repo}', { referrerPolicy: "no-referrer" }).then(response => response.json()).then(data => {
|
||||
document.getElementById('${cardUuid}-card').href = data.html_url;
|
||||
document.getElementById('${cardUuid}-description').innerText = data.description.replace(/:[a-zA-Z0-9_]+:/g, '');
|
||||
document.getElementById('${cardUuid}-language').innerText = data.language;
|
||||
document.getElementById('${cardUuid}-forks').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.forks).replaceAll("\u202f", '');
|
||||
document.getElementById('${cardUuid}-stars').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.stargazers_count).replaceAll("\u202f", '');
|
||||
const avatarEl = document.getElementById('${cardUuid}-avatar');
|
||||
avatarEl.style.backgroundImage = 'url(' + data.owner.avatar_url + ')';
|
||||
avatarEl.style.backgroundColor = 'transparent';
|
||||
if (data.license?.spdx_id) {
|
||||
document.getElementById('${cardUuid}-license').innerText = data.license?.spdx_id
|
||||
} else {
|
||||
document.getElementById('${cardUuid}-license').classList.add = "no-license"
|
||||
};
|
||||
document.getElementById('${cardUuid}-card').classList.remove("fetch-waiting");
|
||||
console.log("[GITHUB-CARD] Loaded card for ${repo} | ${cardUuid}.")
|
||||
}).catch(err => {
|
||||
const c = document.getElementById('${cardUuid}-card');
|
||||
c.classList.add("fetch-error");
|
||||
console.warn("[GITHUB-CARD] (Error) Loading card for ${repo} | ${cardUuid}.")
|
||||
})
|
||||
`
|
||||
)
|
||||
|
||||
return h(`a#${cardUuid}-card`,
|
||||
{ class: "card-github fetch-waiting no-styling",
|
||||
href: `https://github.com/${repo}`,
|
||||
target: '_blank',
|
||||
repo },
|
||||
[
|
||||
nTitle,
|
||||
nDescription,
|
||||
h("div",
|
||||
{ class: "gc-infobar" },
|
||||
[nStars, nForks, nLicense, nLanguage]
|
||||
),
|
||||
nScript
|
||||
]
|
||||
);
|
||||
}
|
27
src/plugins/remark-directive-rehype.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
// biome-ignore lint/suspicious/noShadowRestrictedNames: <explanation>
|
||||
import { h } from 'hastscript';
|
||||
import {visit} from 'unist-util-visit'
|
||||
|
||||
export function parseDirectiveNode() {
|
||||
return (tree, { data }) => {
|
||||
visit(tree, function (node) {
|
||||
if (
|
||||
node.type === 'containerDirective' ||
|
||||
node.type === 'leafDirective' ||
|
||||
node.type === 'textDirective'
|
||||
) {
|
||||
const data = node.data || (node.data = {})
|
||||
node.attributes = node.attributes || {}
|
||||
if (node.children.length > 0 && node.children[0].data && node.children[0].data.directiveLabel) {
|
||||
// Add a flag to the node to indicate that it has a directive label
|
||||
node.attributes['has-directive-label'] = true
|
||||
}
|
||||
const hast = h(node.name, node.attributes)
|
||||
|
||||
data.hName = hast.tagName
|
||||
data.hProperties = hast.properties
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
15
src/plugins/remark-reading-time.mjs
Normal file
|
@ -0,0 +1,15 @@
|
|||
// biome-ignore lint/suspicious/noShadowRestrictedNames: <explanation>
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
import getReadingTime from 'reading-time'
|
||||
|
||||
export function remarkReadingTime() {
|
||||
return (tree, { data }) => {
|
||||
const textOnPage = toString(tree)
|
||||
const readingTime = getReadingTime(textOnPage)
|
||||
data.astro.frontmatter.minutes = Math.max(
|
||||
1,
|
||||
Math.round(readingTime.minutes),
|
||||
)
|
||||
data.astro.frontmatter.words = readingTime.words
|
||||
}
|
||||
}
|
61
src/types/config.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import type { LIGHT_MODE, DARK_MODE, AUTO_MODE } from "@constants/constants"
|
||||
|
||||
export type SiteConfig = {
|
||||
title: string
|
||||
subtitle: string
|
||||
|
||||
lang: string
|
||||
|
||||
themeColor: {
|
||||
hue: number
|
||||
fixed: boolean
|
||||
}
|
||||
banner: {
|
||||
enable: boolean
|
||||
src: string
|
||||
position?: string
|
||||
}
|
||||
|
||||
favicon: Favicon[]
|
||||
}
|
||||
|
||||
export type Favicon = {
|
||||
src: string
|
||||
theme?: 'light' | 'dark'
|
||||
sizes?: string
|
||||
}
|
||||
|
||||
export enum LinkPreset {
|
||||
Home = 0,
|
||||
Archive = 1,
|
||||
About = 2,
|
||||
}
|
||||
|
||||
export type NavBarLink = {
|
||||
name: string
|
||||
url: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export type NavBarConfig = {
|
||||
links: (NavBarLink | LinkPreset)[]
|
||||
}
|
||||
|
||||
export type ProfileConfig = {
|
||||
avatar?: string
|
||||
name: string
|
||||
bio?: string
|
||||
links: {
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type LicenseConfig = {
|
||||
enable: boolean
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type LIGHT_DARK_MODE = typeof LIGHT_MODE | typeof DARK_MODE | typeof AUTO_MODE
|
83
src/utils/content-utils.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import I18nKey from '@i18n/i18nKey'
|
||||
import { i18n } from '@i18n/translation'
|
||||
import { getCollection } from 'astro:content'
|
||||
|
||||
export async function getSortedPosts() {
|
||||
const allBlogPosts = await getCollection('posts', ({ data }) => {
|
||||
return import.meta.env.PROD ? data.draft !== true : true
|
||||
})
|
||||
const sorted = allBlogPosts.sort((a, b) => {
|
||||
const dateA = new Date(a.data.published)
|
||||
const dateB = new Date(b.data.published)
|
||||
return dateA > dateB ? -1 : 1
|
||||
})
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
sorted[i].data.nextSlug = sorted[i - 1].slug
|
||||
sorted[i].data.nextTitle = sorted[i - 1].data.title
|
||||
}
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
sorted[i].data.prevSlug = sorted[i + 1].slug
|
||||
sorted[i].data.prevTitle = sorted[i + 1].data.title
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
export type Tag = {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export async function getTagList(): Promise<Tag[]> {
|
||||
const allBlogPosts = await getCollection('posts', ({ data }) => {
|
||||
return import.meta.env.PROD ? data.draft !== true : true
|
||||
})
|
||||
|
||||
const countMap: { [key: string]: number } = {}
|
||||
allBlogPosts.map(post => {
|
||||
post.data.tags.map((tag: string) => {
|
||||
if (!countMap[tag]) countMap[tag] = 0
|
||||
countMap[tag]++
|
||||
})
|
||||
})
|
||||
|
||||
// sort tags
|
||||
const keys: string[] = Object.keys(countMap).sort((a, b) => {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase())
|
||||
})
|
||||
|
||||
return keys.map(key => ({ name: key, count: countMap[key] }))
|
||||
}
|
||||
|
||||
export type Category = {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export async function getCategoryList(): Promise<Category[]> {
|
||||
const allBlogPosts = await getCollection('posts', ({ data }) => {
|
||||
return import.meta.env.PROD ? data.draft !== true : true
|
||||
})
|
||||
const count: { [key: string]: number } = {}
|
||||
allBlogPosts.map(post => {
|
||||
if (!post.data.category) {
|
||||
const ucKey = i18n(I18nKey.uncategorized)
|
||||
count[ucKey] = count[ucKey] ? count[ucKey] + 1 : 1
|
||||
return
|
||||
}
|
||||
count[post.data.category] = count[post.data.category]
|
||||
? count[post.data.category] + 1
|
||||
: 1
|
||||
})
|
||||
|
||||
const lst = Object.keys(count).sort((a, b) => {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase())
|
||||
})
|
||||
|
||||
const ret: Category[] = []
|
||||
for (const c of lst) {
|
||||
ret.push({ name: c, count: count[c] })
|
||||
}
|
||||
return ret
|
||||
}
|
3
src/utils/date-utils.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function formatDateToYYYYMMDD(date: Date): string {
|
||||
return date.toISOString().substring(0, 10)
|
||||
}
|
50
src/utils/setting-utils.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import {AUTO_MODE, DARK_MODE, DEFAULT_THEME, LIGHT_MODE} from "@constants/constants.ts";
|
||||
import type { LIGHT_DARK_MODE } from '@/types/config'
|
||||
|
||||
export function getDefaultHue(): number {
|
||||
const fallback = '250'
|
||||
const configCarrier = document.getElementById('config-carrier')
|
||||
return parseInt(configCarrier?.dataset.hue || fallback)
|
||||
}
|
||||
|
||||
export function getHue(): number {
|
||||
const stored = localStorage.getItem('hue')
|
||||
return stored ? parseInt(stored) : getDefaultHue()
|
||||
}
|
||||
|
||||
export function setHue(hue: number): void {
|
||||
localStorage.setItem('hue', String(hue))
|
||||
const r = document.querySelector(':root')
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
r.style.setProperty('--hue', hue)
|
||||
}
|
||||
|
||||
|
||||
export function applyThemeToDocument(theme: LIGHT_DARK_MODE) {
|
||||
switch (theme) {
|
||||
case LIGHT_MODE:
|
||||
document.documentElement.classList.remove('dark')
|
||||
break
|
||||
case DARK_MODE:
|
||||
document.documentElement.classList.add('dark')
|
||||
break
|
||||
case AUTO_MODE:
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export function setTheme(theme: LIGHT_DARK_MODE): void {
|
||||
localStorage.setItem('theme', theme)
|
||||
applyThemeToDocument(theme)
|
||||
}
|
||||
|
||||
export function getStoredTheme(): LIGHT_DARK_MODE {
|
||||
return localStorage.getItem('theme') as LIGHT_DARK_MODE || DEFAULT_THEME
|
||||
}
|
37
src/utils/url-utils.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import i18nKey from '@i18n/i18nKey'
|
||||
import { i18n } from '@i18n/translation'
|
||||
|
||||
export function pathsEqual(path1: string, path2: string) {
|
||||
const normalizedPath1 = path1.replace(/^\/|\/$/g, '').toLowerCase()
|
||||
const normalizedPath2 = path2.replace(/^\/|\/$/g, '').toLowerCase()
|
||||
return normalizedPath1 === normalizedPath2
|
||||
}
|
||||
|
||||
function joinUrl(...parts: string[]): string {
|
||||
const joined = parts.join('/')
|
||||
return joined.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
export function getPostUrlBySlug(slug: string): string | null {
|
||||
if (!slug) return null
|
||||
return url(`/posts/${slug}/`)
|
||||
}
|
||||
|
||||
export function getCategoryUrl(category: string): string | null {
|
||||
if (!category) return null
|
||||
if (category === i18n(i18nKey.uncategorized))
|
||||
return url('/archive/category/uncategorized/')
|
||||
return url(`/archive/category/${category}/`)
|
||||
}
|
||||
|
||||
export function getDir(path: string): string {
|
||||
const lastSlashIndex = path.lastIndexOf('/')
|
||||
if (lastSlashIndex < 0) {
|
||||
return '/'
|
||||
}
|
||||
return path.substring(0, lastSlashIndex + 1)
|
||||
}
|
||||
|
||||
export function url(path: string) {
|
||||
return joinUrl('', import.meta.env.BASE_URL, path)
|
||||
}
|
14
tailwind.config.cjs
Normal file
|
@ -0,0 +1,14 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
const defaultTheme = require("tailwindcss/defaultTheme")
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue,mjs}"],
|
||||
darkMode: "class", // allows toggling dark mode manually
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Roboto", "sans-serif", ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
}
|
23
tsconfig.json
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"strictNullChecks": true,
|
||||
"allowJs": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "@astrojs/ts-plugin"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@components/*": ["src/components/*"],
|
||||
"@assets/*": ["src/assets/*"],
|
||||
"@constants/*": ["src/constants/*"],
|
||||
"@utils/*": ["src/utils/*"],
|
||||
"@i18n/*": ["src/i18n/*"],
|
||||
"@layouts/*": ["src/layouts/*"],
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
1
vercel.json
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|