Initialize giscus

This commit is contained in:
2025-08-24 06:03:51 +08:00
committed by GitHub
commit 625c3837d0
80 changed files with 12616 additions and 0 deletions

23
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome", "astro-build.astro-vscode"]
}

22
.vscode/settings.json vendored Normal file
View 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
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 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.

54
README.md Normal file
View File

@@ -0,0 +1,54 @@
# Fuwari
Fuwari is a static blog template built with [Astro](https://astro.build), a refactored version of [hexo-theme-vivia](https://github.com/saicaca/hexo-theme-vivia).
[**🖥Live Demo (Vercel)**](https://fuwari.vercel.app)
![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] **View Transitions between pages**
- [is not supported by Firefox and Safari yet](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API#browser_compatibility)
- [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.
2. Edit the config file `src/config.ts` to customize your blog.
3. Run `npm run new-post -- <filename>` or `pnpm run new-post <filename>` to create a new post and edit it in `src/content/posts/`.
4. Deploy your blog to Vercel, Netlify, GitHub Pages, etc. following [the guides](https://docs.astro.build/en/guides/deploy/).
## ⚙️ 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 |
|:------------------------------------------------------------------|:-------------------------------------------------|
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
| `npm run new-post -- <filename>`<br/>`pnpm run new-post <filename>` | Create a new post |

94
astro.config.mjs Normal file
View File

@@ -0,0 +1,94 @@
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 svelte from "@astrojs/svelte"
import swup from '@swup/astro';
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: "/",
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(),
],
markdown: {
remarkPlugins: [remarkMath, remarkReadingTime],
rehypePlugins: [
rehypeKatex,
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "append",
properties: {
className: ["anchor"],
},
content: {
type: "element",
tagName: "span",
properties: {
className: ["anchor-icon"],
'data-pagefind-ignore': true,
},
children: [
{
type: "text",
value: "#",
},
],
},
},
],
],
},
vite: {
css: {
preprocessorOptions: {
stylus: {
define: {
oklchToHex: oklchToHex,
},
},
},
},
},
})

66
biome.json Normal file
View 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
View 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"
}
]
}
]
}

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"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.3.4",
"@astrojs/svelte": "^5.0.3",
"@astrojs/tailwind": "^5.1.0",
"@fontsource-variable/jetbrains-mono": "^5.0.19",
"@fontsource/roboto": "^5.0.8",
"@swup/astro": "^1.4.0",
"astro": "^4.5.1",
"astro-icon": "1.1.0",
"colorjs.io": "^0.4.5",
"mdast-util-to-string": "^4.0.0",
"overlayscrollbars": "^2.4.6",
"pagefind": "^1.0.4",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-katex": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark-math": "^6.0.0",
"svelte": "^4.2.9",
"tailwindcss": "^3.3.7",
"typescript": "^5.2.2"
},
"devDependencies": {
"@astrojs/ts-plugin": "^1.3.1",
"@biomejs/biome": "1.4.1",
"@iconify-json/fa6-brands": "^1.1.18",
"@iconify-json/fa6-regular": "^1.1.18",
"@iconify-json/fa6-solid": "^1.1.20",
"@iconify-json/material-symbols": "^1.1.69",
"@rollup/plugin-yaml": "^4.1.2",
"@tailwindcss/typography": "^0.5.10",
"astro-compress": "github:astro-community/AstroCompress#no-sharp",
"stylus": "^0.59.0"
}
}

9107
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

52
scripts/new-post.js Normal file
View 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(`ErrorFile ${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`)

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 877 KiB

View 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>

View File

@@ -0,0 +1,35 @@
---
import {giscusConfig, siteConfig} from "../config";
interface Props {
postId: string;
}
const { postId } = Astro.props;
const config = giscusConfig
const discussionTitle = `giscus - ${postId}`
---
{config.enable && <div class="card-base p-6">
<div class="giscus"></div>
</div>}
<script is:inline
src="https://giscus.app/client.js"
data-repo={config.repo}
data-repo-id={config.repoId}
data-category={config.category}
data-category-id={config.categoryId}
data-mapping="specific"
data-term={discussionTitle}
data-strict="0"
data-reactions-enabled="1"
data-emit-metadata="0"
data-input-position="top"
data-theme="preferred_color_scheme"
data-lang={siteConfig.lang}
data-loading="lazy"
crossorigin="anonymous"
async>
</script>

View File

@@ -0,0 +1,8 @@
---
import {siteConfig} from "../config";
---
<div id="config-carrier" data-hue={siteConfig.themeHue}>
</div>

View 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">
© 2023 {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>

View File

@@ -0,0 +1,279 @@
---
---
<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.17 0.017 var(--hue))
--btn-plain-bg-active: oklch(0.98 0.01 var(--hue)) oklch(0.19 0.017 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
})
/* 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>
@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)]
}
.link {
@apply transition hover:bg-[var(--link-hover)] active:bg-[var(--link-active)] rounded-md p-1 -m-1;
}
.link-lg {
@apply transition hover:bg-[var(--link-hover)] active:bg-[var(--link-active)] rounded-md p-1.5 -m-1.5;
}
.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 top-[4.75rem] 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 flex items-center justify-center bg-none hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)]
text-black/75 hover:text-[var(--primary)] dark:text-white/75 dark: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>

117
src/components/Navbar.astro Normal file
View File

@@ -0,0 +1,117 @@
---
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";
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="/" class="btn-plain h-[3.25rem] px-5 font-bold rounded-lg 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.url} target={l.external ? "_blank" : null}
class="btn-plain h-11 font-bold px-5 rounded-lg 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(&#45;&#45;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>
<button aria-label="Display Settings" class="btn-plain h-11 w-11 rounded-lg active:scale-90" id="display-settings-switch">
<Icon name="material-symbols:palette-outline" size={"1.25rem"}></Icon>
</button>
<button aria-label="Light/Dark Mode" class="btn-plain h-11 w-11 rounded-lg active:scale-90" id="scheme-switch">
<Icon name="material-symbols:wb-sunny-outline-rounded" size={"1.25rem"} class="absolute opacity-[var(--display-light-icon)]"></Icon>
<Icon name="material-symbols:dark-mode-outline-rounded" size={"1.25rem"} class="absolute opacity-[var(--display-dark-icon)]"></Icon>
</button>
<button aria-label="Menu" name="Nav Menu" class="btn-plain w-11 h-11 rounded-lg 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");
settingBtn.addEventListener("click", function () {
let settingPanel = document.getElementById("display-setting");
settingPanel.classList.toggle("closed");
});
let menuBtn = document.getElementById("nav-menu-switch");
menuBtn.addEventListener("click", function () {
let menuPanel = document.getElementById("nav-menu-panel");
menuPanel.classList.toggle("closed");
});
}
loadButtonScript();
document.addEventListener('astro:after-swap', () => {
loadButtonScript();
}, { once: false });
</script>
{import.meta.env.PROD && <script is:raw>
async function loadPagefind() {
const pagefind = await import('/pagefind/pagefind.js')
await pagefind.options({
'excerptLength': 20
})
pagefind.init()
window.pagefind = pagefind
pagefind.search('') // speed up the first search
}
loadPagefind()
</script>}

View File

@@ -0,0 +1,93 @@
---
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 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.15rem]" name="material-symbols:chevron-right-rounded" size={28} ></Icon>
</a>
<!-- metadata -->
<PostMetadata published={published} tags={tags} category={category} hideTagsForMobile={true} class:list={{"mb-4": description, "mb-6": !description}}></PostMetadata>
<!-- description -->
<div class="transition text-75 mb-3.5">
{ 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 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>

View 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";
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">
<div><a href=`/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>
</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">
{(tags && tags.length > 0) && tags.map(tag => <div
class="with-divider"
>
<a href=`/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>
</div>)}
{!(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>

View 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>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte'
let keywordDesktop = ''
let keywordMobile = ''
let result = []
const fakeResult = [{
url: '/',
meta: {
title: 'This Is a Fake Search Result'
},
excerpt: 'Because the search cannot work in the <mark>dev</mark> environment.'
}, {
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("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 wont 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 wont 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("closed")
return
}
if (isDesktop) {
panel.classList.remove("closed")
}
result = arr
}
})
const togglePanel = () => {
let panel = document.getElementById('search-panel')
panel?.classList.toggle("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="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 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 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,93 @@
---
import type { Page } from "astro";
import { Icon } from 'astro-icon/components';
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 parts: string[] = page.url.current.split('/');
const commonUrl: string = parts.slice(0, -1).join('/') + '/';
const getPageUrl = (p: number) => {
if (p == 1)
return commonUrl;
return commonUrl + p;
}
---
<div class:list={[className, "flex flex-row gap-3 justify-center"]} style={style}>
<a href={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={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={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>

View File

@@ -0,0 +1,32 @@
---
import path from "path";
interface Props {
id?: string
src: string;
class?: string;
alt?: string
basePath?: string
}
import { Image } from 'astro:assets';
const {id, src, alt, basePath = '/'} = Astro.props;
const className = Astro.props.class;
const isLocal = !(src.startsWith('/') || src.startsWith('http') || src.startsWith('https') || src.startsWith('data:'));
// 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])();
}
---
<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="w-full h-full object-center object-cover" />}
{!isLocal && <img src={src} alt={alt || ""} class="w-full h-full object-center object-cover" />}
</div>

View 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>

View File

@@ -0,0 +1,151 @@
---
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>
<style lang="stylus" is:global>
.custom-md
h1,h2,h3,h4,h5,h6
.anchor
margin: -0.125rem
margin-left: 0.2ch
padding: 0.125rem
user-select: none
opacity: 0
text-decoration: none
transition: opacity 0.15s ease-in-out, background 0.15s ease-in-out
.anchor-icon
margin-left: 0.45ch
margin-right: 0.45ch
&:hover
.anchor
opacity: 1
a
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)
/*&:after*/
/* background: 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)
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
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%
</style>
<style lang="css" is:global>
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.custom-md h1 {
@apply text-3xl
}
}
</style>

View 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>

View 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 closed absolute transition-all w-80 fixed 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>

View File

@@ -0,0 +1,32 @@
---
import {NavBarLink} from "../../types/config";
import {siteConfig} from "../../config";
import {Icon} from "astro-icon/components";
interface Props {
links: NavBarLink[],
}
const links = Astro.props.links;
---
<div id="nav-menu-panel" class:list={["float-panel closed absolute transition-all fixed right-4 px-2 py-2"]}>
{links.map((link) => (
<a href={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>

View File

@@ -0,0 +1,31 @@
---
import ImageWrapper from "../misc/ImageWrapper.astro";
import {Icon} from "astro-icon/components";
import {profileConfig} from "../../config";
const config = profileConfig;
---
<div class="card-base">
<a aria-label="Go to About Page" href="/about"
class="group block relative mx-auto mt-4 lg:mx-3 lg:mt-3 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 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="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 mx-2 justify-center mb-4">
{config.links.map(item =>
<a 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>
)}
</div>
</div>

View 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>

View File

@@ -0,0 +1,31 @@
---
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";
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={`/archive/tag/${t.name}`} label={`View all posts with the ${t.name} tag`}>
{t.name}
</ButtonTag>
))}
</div>
</WidgetLayout>

View 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 w-full h-9 rounded-lg">
<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>

69
src/config.ts Normal file
View File

@@ -0,0 +1,69 @@
import type {
GiscusConfig,
LicenseConfig,
NavBarConfig,
ProfileConfig,
SiteConfig,
} from './types/config'
import { LinkPreset } from './types/config'
export const siteConfig: SiteConfig = {
title: 'Fuwari',
subtitle: 'Demo Site',
lang: 'en',
themeHue: 250,
banner: {
enable: false,
src: 'assets/images/demo-banner.png',
},
}
export const navBarConfig: NavBarConfig = {
links: [
LinkPreset.Home,
LinkPreset.Archive,
LinkPreset.About,
{
name: 'GitHub',
url: 'https://github.com/saicaca/fuwari',
external: true,
},
],
}
export const profileConfig: ProfileConfig = {
avatar: 'assets/images/demo-avatar.png',
name: 'Lorem Ipsum',
bio: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
links: [
{
name: 'Twitter',
icon: 'fa6-brands:twitter',
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/',
}
export const giscusConfig: GiscusConfig = {
enable: true,
repo: 'saicaca/f-giscus',
repoId: 'R_kgDOLUcjpw',
category: 'Announcements',
categoryId: 'DIC_kwDOLUcjp84CdVSH',
}

View File

@@ -0,0 +1,3 @@
export const UNCATEGORIZED = '__uncategorized__'
export const PAGE_SIZE = 8

View 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
View 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,
}

View 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
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

@@ -0,0 +1,51 @@
---
title: Simple Guides for Fuwari
published: 2023-09-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
```

View 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]&#40;./demo-banner.png "An exemplary image"&#41;)
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.

View File

@@ -0,0 +1,28 @@
---
title: Include Video in the Posts
published: 2022-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>

View File

@@ -0,0 +1,7 @@
# About
This is the demo site for [Fuwari](https://github.com/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
View File

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

32
src/i18n/i18nKey.ts Normal file
View File

@@ -0,0 +1,32 @@
enum I18nKey {
home = 'home',
about = 'about',
archive = 'archive',
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',
more = 'more',
author = 'author',
publishedAt = 'publishedAt',
license = 'license',
}
export default I18nKey

33
src/i18n/languages/en.ts Normal file
View File

@@ -0,0 +1,33 @@
import Key from '../i18nKey'
import type { Translation } from '../translation'
export const en: Translation = {
[Key.home]: 'Home',
[Key.about]: 'About',
[Key.archive]: 'Archive',
[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.more]: 'More',
[Key.author]: 'Author',
[Key.publishedAt]: 'Published at',
[Key.license]: 'License',
}

33
src/i18n/languages/ja.ts Normal file
View File

@@ -0,0 +1,33 @@
import Key from '../i18nKey'
import type { Translation } from '../translation'
export const ja: Translation = {
[Key.home]: 'Home',
[Key.about]: 'About',
[Key.archive]: 'Archive',
[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.more]: 'もっと',
[Key.author]: '作者',
[Key.publishedAt]: '公開日',
[Key.license]: 'ライセンス',
}

View File

@@ -0,0 +1,33 @@
import Key from '../i18nKey'
import type { Translation } from '../translation'
export const zh_CN: Translation = {
[Key.home]: '主页',
[Key.about]: '关于',
[Key.archive]: '归档',
[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.more]: '更多',
[Key.author]: '作者',
[Key.publishedAt]: '发布于',
[Key.license]: '许可协议',
}

View File

@@ -0,0 +1,33 @@
import Key from '../i18nKey'
import type { Translation } from '../translation'
export const zh_TW: Translation = {
[Key.home]: '首頁',
[Key.about]: '關於',
[Key.archive]: '彙整',
[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.more]: '更多',
[Key.author]: '作者',
[Key.publishedAt]: '發佈於',
[Key.license]: '許可協議',
}

32
src/i18n/translation.ts Normal file
View 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]
}

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

@@ -0,0 +1,311 @@
---
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 {siteConfig} from "@/config";
interface Props {
title: string;
banner: string;
}
let { title, banner } = 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.themeHue;
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}`;
}
---
<!DOCTYPE html>
<html lang="en" 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="Astro description">
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon/favicon-light-32.png" sizes="32x32">
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon/favicon-light-128.png" sizes="128x128">
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon/favicon-light-180.png" sizes="180x180">
<link rel="icon" media="(prefers-color-scheme: light)" href="/favicon/favicon-light-192.png" sizes="192x192">
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon/favicon-dark-32.png" sizes="32x32">
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon/favicon-dark-128.png" sizes="128x128">
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon/favicon-dark-180.png" sizes="180x180">
<link rel="icon" media="(prefers-color-scheme: dark)" href="/favicon/favicon-dark-192.png" sizes="192x192">
<link rel="stylesheet" href="https://cdn.staticfile.org/KaTeX/0.16.9/katex.min.css" integrity="sha384-n8MVd4RsNIU0tAv4ct0nTaAbDJwPJzDEaqSD1odI+WdtXRGWt2kTvGFasHpSy3SV" crossorigin="anonymous">
<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, setHue} 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("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() {
if (localStorage.theme === 'dark' || (!('theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
} else {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
}
}
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')
})
}
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>

View File

@@ -0,0 +1,44 @@
---
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;
}
const { title, banner } = Astro.props
const isHomePage = pathsEqual(Astro.url.pathname, '/')
const enableBanner = siteConfig.banner.enable
---
<Layout title={title} banner={banner}>
<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
View 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
View 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)}>
<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>

View 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)}>
<ArchivePanel categories={[category]}></ArchivePanel>
</MainGridLayout>

View 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>

View 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>

View 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)}>
<ArchivePanel tags={[tag]}></ArchivePanel>
</MainGridLayout>

View File

@@ -0,0 +1,125 @@
---
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 Comment from "@components/Comment.astro";
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();
---
<MainGridLayout banner={entry.data.image} title={entry.data.title}>
<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>
<!-- Comment -->
<Comment postId={entry.slug}></Comment>
</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>

View 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
}
}

53
src/types/config.ts Normal file
View File

@@ -0,0 +1,53 @@
export type SiteConfig = {
title: string
subtitle: string
lang: string
themeHue: number
banner: {
enable: boolean
src: 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 GiscusConfig = {
enable: boolean
repo: string
repoId: string
category: string
categoryId: string
}

View 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
}

7
src/utils/date-utils.ts Normal file
View File

@@ -0,0 +1,7 @@
export function formatDateToYYYYMMDD(date: Date): string {
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${year}-${month}-${day}`
}

View File

@@ -0,0 +1,19 @@
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)
}

33
src/utils/url-utils.ts Normal file
View File

@@ -0,0 +1,33 @@
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, '$1')
}
export function getPostUrlBySlug(slug: string): string | null {
if (!slug) return null
return `/posts/${slug}`
}
export function getCategoryUrl(category: string): string | null {
if (!category) return null
if (category === i18n(i18nKey.uncategorized))
return '/archive/category/uncategorized'
return `/archive/category/${category}`
}
export function getDir(path: string): string {
const lastSlashIndex = path.lastIndexOf('/')
if (lastSlashIndex < 0) {
return '/'
}
return path.substring(0, lastSlashIndex + 1)
}

14
tailwind.config.cjs Normal file
View 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}"],
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
View 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
View File

@@ -0,0 +1 @@
{}