Repository: lxchapu/astro-gyoza Branch: main Commit: 7261f40b6708 Files: 131 Total size: 135.3 KB Directory structure: gitextract_63w4a159/ ├── .gitattributes ├── .github/ │ └── workflows/ │ └── close-inactive-issues.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode/ │ ├── extensions.json │ └── launch.json ├── LICENSE ├── README.md ├── astro.config.js ├── commitlint.config.js ├── package.json ├── scripts/ │ ├── new-friend.js │ ├── new-post.js │ ├── new-project.js │ └── utils.js ├── src/ │ ├── components/ │ │ ├── AnimatedSignature.tsx │ │ ├── BackToTopFAB.tsx │ │ ├── CategoryList.astro │ │ ├── Flashlight.tsx │ │ ├── FriendList.astro │ │ ├── Highlight.astro │ │ ├── MarkdownWrapper.astro │ │ ├── ProjectList.astro │ │ ├── RootPortal.tsx │ │ ├── SectionBlock.astro │ │ ├── TagList.astro │ │ ├── Timeline.astro │ │ ├── TimelineProgress.tsx │ │ ├── ToastContainer.tsx │ │ ├── comment/ │ │ │ ├── Comments.astro │ │ │ ├── Waline.tsx │ │ │ └── index.ts │ │ ├── footer/ │ │ │ ├── Footer.astro │ │ │ ├── Link.astro │ │ │ ├── RunningDays.tsx │ │ │ └── ThemeSwitch.tsx │ │ ├── head/ │ │ │ ├── AccentColorInjector.astro │ │ │ ├── CommonHead.astro │ │ │ ├── PrintVersion.astro │ │ │ ├── ThemeLoader.astro │ │ │ ├── WebAnalytics.tsx │ │ │ └── index.ts │ │ ├── head-gradient/ │ │ │ ├── HeadGradient.tsx │ │ │ └── index.ts │ │ ├── header/ │ │ │ ├── AnimatedLogo.tsx │ │ │ ├── BluredBackground.tsx │ │ │ ├── Header.tsx │ │ │ ├── HeaderContent.tsx │ │ │ ├── HeaderDrawer.tsx │ │ │ ├── HeaderMeta.tsx │ │ │ ├── SearchButton.tsx │ │ │ └── hooks.ts │ │ ├── hero/ │ │ │ ├── Hero.astro │ │ │ └── SocialList.tsx │ │ ├── post/ │ │ │ ├── ActionAside.tsx │ │ │ ├── Outdate.tsx │ │ │ ├── PostArchiveInfo.astro │ │ │ ├── PostCard.astro │ │ │ ├── PostCardHoverOverlay.tsx │ │ │ ├── PostCopyright.tsx │ │ │ ├── PostList.astro │ │ │ ├── PostMetaInfo.astro │ │ │ ├── PostNav.astro │ │ │ ├── PostPagination.astro │ │ │ ├── PostToc.tsx │ │ │ ├── ReadingProgress.tsx │ │ │ └── RelativeDate.tsx │ │ ├── provider/ │ │ │ ├── HeaderMetaInfoProvider.tsx │ │ │ ├── PageScrollInfoProvider.tsx │ │ │ ├── Provider.tsx │ │ │ ├── ThemeProvider.tsx │ │ │ └── ViewportProvider.tsx │ │ └── ui/ │ │ └── modal/ │ │ ├── Modal.tsx │ │ ├── ModalStack.tsx │ │ ├── context.ts │ │ ├── hooks.ts │ │ └── index.ts │ ├── config.json │ ├── content/ │ │ ├── config.ts │ │ ├── friends/ │ │ │ ├── Keigo.yml │ │ │ ├── astro-docs.yaml │ │ │ └── lxchapu.yaml │ │ ├── posts/ │ │ │ ├── embed.md │ │ │ ├── guide.md │ │ │ ├── how-to-use-icons.md │ │ │ └── markdown.md │ │ ├── projects/ │ │ │ └── gyoza.yaml │ │ └── spec/ │ │ ├── about.md │ │ ├── friends.md │ │ └── projects.md │ ├── env.d.ts │ ├── hooks/ │ │ └── useDebounceValue.ts │ ├── layouts/ │ │ ├── Layout.astro │ │ ├── MarkdownLayout.astro │ │ └── PageLayout.astro │ ├── pages/ │ │ ├── 404.astro │ │ ├── [...page].astro │ │ ├── [spec].astro │ │ ├── archives.astro │ │ ├── categories/ │ │ │ └── [category].astro │ │ ├── posts/ │ │ │ └── [...slug].astro │ │ ├── robots.txt.ts │ │ ├── rss.xml.ts │ │ └── tags/ │ │ ├── [tag].astro │ │ └── index.astro │ ├── plugins/ │ │ ├── rehypeCodeBlock.js │ │ ├── rehypeCodeHighlight.js │ │ ├── rehypeHeading.js │ │ ├── rehypeImage.js │ │ ├── rehypeLink.js │ │ ├── rehypeTableBlock.js │ │ ├── remarkEmbed.js │ │ ├── remarkReadingTime.js │ │ └── remarkSpoiler.js │ ├── store/ │ │ ├── metaInfo.ts │ │ ├── modalStack.ts │ │ ├── scrollInfo.ts │ │ ├── theme.ts │ │ └── viewport.ts │ ├── styles/ │ │ ├── global.css │ │ ├── iconfont.css │ │ ├── markdown.css │ │ ├── shiki.css │ │ ├── signature.css │ │ └── swup.css │ └── utils/ │ ├── content.ts │ ├── date.ts │ └── theme.ts ├── tailwind.config.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf ================================================ FILE: .github/workflows/close-inactive-issues.yml ================================================ name: Close inactive issues on: schedule: - cron: '30 1 * * *' jobs: close-issues: if: github.repository == 'lxchapu/astro-gyoza' runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - uses: actions/stale@v9 with: days-before-stale: 14 days-before-close: 7 days-before-pr-close: -1 stale-issue-label: 'stale' stale-issue-message: 'This issue is stale because it has been open 14 days with no activity. Remove stale label or comment or this will be closed in 7 days.' stale-pr-message: 'This PR is stale because it has been open 14 days with no activity.' close-issue-message: 'This issue was closed because it has been inactive for 7 days since being marked as stale.' ================================================ FILE: .gitignore ================================================ # 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 # jetbrains setting folder .idea/ ================================================ FILE: .prettierignore ================================================ .astro/ node_modules/ dist/ pnpm-lock.yaml ================================================ FILE: .prettierrc ================================================ { "printWidth": 100, "semi": false, "singleQuote": true, "plugins": ["prettier-plugin-astro"], "overrides": [ { "files": "*.astro", "options": { "parser": "astro" } } ] } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["astro-build.astro-vscode"], "unwantedRecommendations": [] } ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "command": "./node_modules/.bin/astro dev", "name": "Development server", "request": "launch", "type": "node-terminal" } ] } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 柃夏chapu 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. ================================================ FILE: README.md ================================================ # Gyoza Gyoza is a static blog template built with Astro and React. ![astro version](https://img.shields.io/badge/astro-4.6-red) ![node version](https://img.shields.io/badge/node-18.18-green) Demo Site: - [gyoza.lxchapu.com](https://gyoza.lxchapu.com) - [www.lxchapu.com](https://www.lxchapu.com) Enjoy it! ## 📷 Screenshots ![Preview](https://s2.loli.net/2024/05/06/A9rzC3Uym7RwdQc.webp) ## 🎉 Features - ✅ 有着规范的 URL 和 OpenGraph 信息,对 SEO 友好 - ✅ 支持站点地图 - ✅ 支持 RSS 订阅 - ✅ 支持夜间模式 - ✅ 特殊日期变灰 - ✅ 简单干净的配色和主题 - ✅ 支持评论系统 - ✅ 支持代码高亮 ## 🔧 Tech Stack - [Astro](https://astro.build/) - [React](https://reactjs.org/) - [Tailwind CSS](https://tailwindcss.com/) - [Framer Motion](https://www.framer.com/motion/) - [Jotai](https://jotai.org/) ## 📖 Documentation 前往:[Documentation](https://gyoza.lxchapu.com/posts/guide) ## 🚀 Project Structure ```text ├── public/ ├── src/ │   ├── components/ │   ├── content/ │   ├── layouts/ │   ├── pages/ │   ├── plugins/ │   ├── store/ │   ├── styles/ │   ├── utils/ │   └── config.json ├── astro.config.mjs ├── README.md ├── package.json └── tsconfig.json ``` 网站配置保存在 `config.json` 文件。 ## 🧞 Commands | Command | Action | | :------------- | :------------------------------------------- | | `pnpm i` | Installs dependencies | | `pnpm dev` | Starts local dev server at `localhost:4321` | | `pnpm build` | Build your production site to `./dist/` | | `pnpm preview` | Preview your build locally, before deploying | | `pnpm format` | Format code using Prettier | ================================================ FILE: astro.config.js ================================================ import { defineConfig } from 'astro/config' import { remarkReadingTime } from './src/plugins/remarkReadingTime' import { rehypeCodeBlock } from './src/plugins/rehypeCodeBlock' import { rehypeTableBlock } from './src/plugins/rehypeTableBlock' import { rehypeCodeHighlight } from './src/plugins/rehypeCodeHighlight' import { rehypeImage } from './src/plugins/rehypeImage' import { rehypeLink } from './src/plugins/rehypeLink' import { rehypeHeading } from './src/plugins/rehypeHeading' import remarkDirective from 'remark-directive' import { remarkSpoiler } from './src/plugins/remarkSpoiler' import { remarkEmbed } from './src/plugins/remarkEmbed' import tailwind from '@astrojs/tailwind' import react from '@astrojs/react' import sitemap from '@astrojs/sitemap' import { rehypeHeadingIds } from '@astrojs/markdown-remark' import { site } from './src/config.json' import remarkMath from 'remark-math' import rehypeKatex from 'rehype-katex' import swup from '@swup/astro' // https://astro.build/config export default defineConfig({ site: site.url, integrations: [ tailwind(), react(), sitemap(), swup({ theme: false, animationClass: 'swup-transition-', containers: ['main'], morph: ['[component-export="Provider"]'], }), ], markdown: { syntaxHighlight: false, smartypants: false, remarkPlugins: [remarkMath, remarkDirective, remarkEmbed, remarkSpoiler, remarkReadingTime], rehypePlugins: [ rehypeHeadingIds, rehypeKatex, rehypeLink, rehypeImage, rehypeHeading, rehypeCodeBlock, rehypeCodeHighlight, rehypeTableBlock, ], remarkRehype: { footnoteLabel: '参考', footnoteBackLabel: '返回正文' }, }, vite: { build: { rollupOptions: { external: ['/pagefind/pagefind.js'], }, }, }, }) ================================================ FILE: commitlint.config.js ================================================ export default { extends: ['@commitlint/config-conventional'], } ================================================ FILE: package.json ================================================ { "name": "astro-gyoza", "type": "module", "version": "0.0.1", "scripts": { "prepare": "pnpm exec simple-git-hooks", "dev": "astro dev", "build": "astro check && astro build && pagefind --site dist", "preview": "astro preview", "astro": "astro", "lint": "prettier --write .", "new-friend": "node scripts/new-friend.js", "new-post": "node scripts/new-post.js", "new-project": "node scripts/new-project.js" }, "dependencies": { "@astrojs/check": "^0.5.10", "@astrojs/markdown-remark": "^5.1.0", "@astrojs/react": "^3.3.0", "@astrojs/rss": "^4.0.5", "@astrojs/sitemap": "^3.1.3", "@astrojs/tailwind": "^5.1.0", "@radix-ui/react-dialog": "^1.0.5", "@shikijs/rehype": "^1.3.0", "@swup/astro": "^1.4.1", "@types/chroma-js": "^2.4.4", "@types/lodash-es": "^4.17.12", "@types/react": "^18.2.78", "@types/react-dom": "^18.2.25", "@waline/client": "^3.1.3", "astro": "^4.6.1", "chroma-js": "^2.4.2", "clsx": "^2.1.0", "framer-motion": "^11.1.5", "hastscript": "^9.0.0", "jotai": "^2.8.0", "katex": "^0.16.10", "lodash-es": "^4.17.21", "mdast-util-to-string": "^4.0.0", "pagefind": "^1.1.0", "qrcode.react": "^3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-toastify": "^10.0.5", "reading-time": "^1.5.0", "rehype-katex": "^7.0.0", "remark-directive": "^3.0.0", "remark-math": "^6.0.0", "tailwindcss": "^3.4.3", "typescript": "^5.4.5", "unist-util-visit": "^5.0.0" }, "devDependencies": { "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", "@inquirer/prompts": "^5.0.2", "lint-staged": "^15.2.2", "micromark-util-symbol": "^2.0.0", "prettier": "^3.2.5", "prettier-plugin-astro": "^0.13.0", "simple-git-hooks": "^2.11.1" }, "simple-git-hooks": { "commit-msg": "pnpm exec commitlint --edit $1", "pre-commit": "pnpm exec lint-staged" }, "lint-staged": { "*.{js,jsx,ts,tsx.astro,md,css,json}": "prettier --write" } } ================================================ FILE: scripts/new-friend.js ================================================ import { input } from '@inquirer/prompts' import fs from 'fs' import path from 'path' import { isFileNameSafe } from './utils.js' function getFriendFullPath(fileName) { return path.join('./src/content/friends', `${fileName}.yaml`) } const fileName = await input({ message: '请输入文件名称', validate: (value) => { if (!isFileNameSafe(value)) { return '文件名只能包含字母、数字和连字符' } const fullPath = getFriendFullPath(value) if (fs.existsSync(fullPath)) { return `${fullPath} 已存在` } return true }, }) const title = await input({ message: '请输入标题', }) const description = await input({ message: '请输入描述', }) const link = await input({ message: '请输入地址', }) const avatar = await input({ message: '请输入头像地址', }) const content = `title: ${title} description: ${description} link: ${link} avatar: ${avatar} ` const fullPath = getFriendFullPath(fileName) fs.writeFileSync(fullPath, content) console.log(`${fullPath} 创建成功`) ================================================ FILE: scripts/new-post.js ================================================ import { input } from '@inquirer/prompts' import fs from 'fs' import path from 'path' import { isFileNameSafe } from './utils.js' function getPostFullPath(fileName) { return path.join('./src/content/posts', `${fileName}.md`) } const fileName = await input({ message: '请输入文件名称', validate: (value) => { if (!isFileNameSafe(value)) { return '文件名只能包含字母、数字和连字符' } const fullPath = getPostFullPath(value) if (fs.existsSync(fullPath)) { return `${fullPath} 已存在` } return true }, }) const title = await input({ message: '请输入文章标题', }) const content = `--- title: ${title} date: ${new Date().toISOString()} tags: [] comments: true draft: false --- ` const fullPath = getPostFullPath(fileName) fs.writeFileSync(fullPath, content) console.log(`${fullPath} 创建成功`) ================================================ FILE: scripts/new-project.js ================================================ import { input } from '@inquirer/prompts' import fs from 'fs' import path from 'path' import { isFileNameSafe } from './utils.js' function getProjectFullPath(fileName) { return path.join('./src/content/projects', `${fileName}.yaml`) } const fileName = await input({ message: '请输入文件名称', validate: (value) => { if (!isFileNameSafe(value)) { return '文件名只能包含字母、数字和连字符' } const fullPath = getProjectFullPath(value) if (fs.existsSync(fullPath)) { return `${fullPath} 已存在` } return true }, }) const title = await input({ message: '请输入项目名称', }) const description = await input({ message: '请输入项目描述', }) const link = await input({ message: '请输入项目地址', }) const image = await input({ message: '请输入预览图片地址', }) const content = `title: ${title} description: ${description} link: ${link} image: ${image} ` const fullPath = getProjectFullPath(fileName) fs.writeFileSync(fullPath, content) console.log(`${fullPath} 创建成功`) ================================================ FILE: scripts/utils.js ================================================ export function isFileNameSafe(fileName) { return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(fileName) } ================================================ FILE: src/components/AnimatedSignature.tsx ================================================ import Svg from '@/assets/signature.svg?raw' export function AnimatedSignature() { return (
) } ================================================ FILE: src/components/BackToTopFAB.tsx ================================================ import { useAtomValue } from 'jotai' import { pageScrollLocationAtom } from '@/store/scrollInfo' import { AnimatePresence, motion } from 'framer-motion' export function BackToTopFAB() { const scrollY = useAtomValue(pageScrollLocationAtom) const isShow = scrollY > 100 return (
{isShow && }
) } function BackToTop() { const handleBackToTop = () => { window.scrollTo({ top: 0, behavior: 'smooth', }) } return ( ) } ================================================ FILE: src/components/CategoryList.astro ================================================ --- interface Props { categories: { name: string slug: string count: number }[] } const { categories } = Astro.props --- { categories.length === 0 ? (
作者懒得分类🤪
) : (
{categories.map((category) => (
{category.name} {category.count}
))}
) } ================================================ FILE: src/components/Flashlight.tsx ================================================ import { useLayoutEffect, useState } from 'react' export function Flashlight() { const [cursorX, setCursorX] = useState(0) const [cursorY, setCursorY] = useState(0) const isMobile = !window.matchMedia('(hover: hover)').matches if (isMobile) { return null } const backgroundImage = `radial-gradient( circle 16vmax at ${cursorX}px ${cursorY}px, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.5) 80%, rgba(0, 0, 0, 0.8) 100% )` useLayoutEffect(() => { const handleMouseMove = (event: MouseEvent) => { setCursorX(event.clientX) setCursorY(event.clientY) } document.addEventListener('mousemove', handleMouseMove) return () => { document.removeEventListener('mousemove', handleMouseMove) } }, []) return (
) } ================================================ FILE: src/components/FriendList.astro ================================================ --- import { getCollection } from 'astro:content' const friends = await getCollection('friends') --- ================================================ FILE: src/components/Highlight.astro ================================================ --- interface Props { class?: string } const { class: className } = Astro.props --- ================================================ FILE: src/components/MarkdownWrapper.astro ================================================ --- interface Props { class?: string } const { class: className } = Astro.props ---
================================================ FILE: src/components/ProjectList.astro ================================================ --- import { getCollection } from 'astro:content' const projects = await getCollection('projects') --- ================================================ FILE: src/components/RootPortal.tsx ================================================ import { createPortal } from 'react-dom' export function RootPortal({ to = document.body, children, }: { to?: HTMLElement children: React.ReactNode }) { return createPortal(children, to) } ================================================ FILE: src/components/SectionBlock.astro ================================================ --- interface Props { title: string } const { title } = Astro.props ---
{title}
================================================ FILE: src/components/TagList.astro ================================================ --- interface Props { tags: { name: string slug: string count?: number }[] } const { tags } = Astro.props --- { tags.length === 0 ? (
作者没有准备标签😦
) : (
{tags.map((tag) => ( ) } ================================================ FILE: src/components/Timeline.astro ================================================ --- import type { CollectionEntry } from 'astro:content' import { getShortDate } from '@/utils/date' interface Props { posts: CollectionEntry<'posts'>[] } const { posts } = Astro.props const groupedPosts = posts.reduce< { year: number posts: CollectionEntry<'posts'>[] }[] >((acc, cur) => { const year = cur.data.date.getFullYear() const lastYearGroup = acc[acc.length - 1] if (lastYearGroup && lastYearGroup.year === year) { lastYearGroup.posts.push(cur) } else { acc.push({ year, posts: [cur], }) } return acc }, []) ---
{ groupedPosts.map((year) => (

{year.year}

    {year.posts.map((post) => (
  • {getShortDate(post.data.date)} {post.data.title} {post.data.sticky > 0 && }
  • ))}
)) }
================================================ FILE: src/components/TimelineProgress.tsx ================================================ import { useEffect, useRef, useState } from 'react' import { animate } from 'framer-motion' import { getDaysInYear, getDiffInDays, getStartOfDay, getStartOfYear } from '@/utils/date' export function TimelineProgress() { const [currentYear, setCurrentYear] = useState(0) const [dayOfYear, setDayOfYear] = useState(0) const [percentOfYear, setPercentOfYear] = useState(0) const [percentOfToday, setPercentOfToday] = useState(0) const updateInfo = () => { const now = new Date() setCurrentYear(now.getFullYear()) const pastDays = getDiffInDays(getStartOfYear(now), now) setDayOfYear(pastDays) setPercentOfYear((pastDays / getDaysInYear(now)) * 100) const pastTime = now.getTime() - getStartOfDay(now).getTime() setPercentOfToday((pastTime / 86400 / 1000) * 100) } useEffect(() => { updateInfo() const interval = setInterval(updateInfo, 1000) return () => { clearInterval(interval) } }, []) return ( <>

今天是 {currentYear} 年的第

今年已过 %

今天已过 %

) } function CountUp({ to, decimals, duration = 1, }: { to: number decimals: number duration?: number }) { const node = useRef(null) const prev = useRef(0) useEffect(() => { if (!node.current) return const control = animate(prev.current, to, { duration, onUpdate: (value) => { node.current!.textContent = value.toFixed(decimals) }, }) prev.current = to return () => { control.stop() } }, [to, decimals, duration]) return } ================================================ FILE: src/components/ToastContainer.tsx ================================================ import { ToastContainer as ReactToastContainer } from 'react-toastify' import 'react-toastify/dist/ReactToastify.css'; export function ToastContainer() { return } function CloseButton({ closeToast }: { closeToast: (event: React.MouseEvent) => void }) { return } ================================================ FILE: src/components/comment/Comments.astro ================================================ --- import { Waline } from './Waline' import { waline } from '@/config.json' ---
{waline.serverURL && }
================================================ FILE: src/components/comment/Waline.tsx ================================================ import { useEffect, useRef } from 'react' import { init } from '@waline/client' import '@waline/client/style' export function Waline({ serverURL }: { serverURL: string }) { const ref = useRef(null) useEffect(() => { const walineInst = init({ el: ref.current, serverURL, dark: "[data-theme='dark']", login: 'force', imageUploader: false, search: false, locale: { placeholder: '发条友善的评论吧(支持 Markdown 语法)…', }, emoji: ['//unpkg.com/@waline/emojis@1.1.0/bilibili'], }) return () => { if (ref.current) { walineInst?.destroy() } } }, [serverURL]) return
} ================================================ FILE: src/components/comment/index.ts ================================================ export { default as Comments } from './Comments.astro' ================================================ FILE: src/components/footer/Footer.astro ================================================ --- import { ThemeSwitch } from './ThemeSwitch' import { author, footer } from '@/config.json' import { getAllPostsWordCount } from '@/utils/content' import { RunningDays } from './RunningDays' import Link from './Link.astro' const sinceYear = new Date(footer.startTime).getFullYear() const thisYear = new Date().getFullYear() const copyDate = sinceYear === thisYear ? thisYear : `${sinceYear} - ${thisYear}` const wordCount = await getAllPostsWordCount() const wordCountStr = (wordCount / 1000).toFixed(1) + 'k' ---
Powered by Astro & Designed by Gyoza
©{copyDate} {author.name}. RSS 站点地图
| 共嘚嘚了 {wordCountStr} 字
================================================ FILE: src/components/footer/Link.astro ================================================ --- import type { HTMLAttributes } from 'astro/types' interface Props extends HTMLAttributes<'a'> { href: string } const { href, ...attrs } = Astro.props const isExternal = href.startsWith('http') --- ================================================ FILE: src/components/footer/RunningDays.tsx ================================================ import { useLayoutEffect, useState } from 'react' import { footer } from '@/config.json' import { getDiffInDays } from '@/utils/date' export function RunningDays() { const [days, setDays] = useState(0) useLayoutEffect(() => { const diffDays = getDiffInDays(new Date(footer.startTime)) setDays(diffDays) }, []) if (days < 0) { return Ops! 网站还没有发布 } return 已经运行了 {days} 天 } ================================================ FILE: src/components/footer/ThemeSwitch.tsx ================================================ import { themeAtom } from '@/store/theme' import { useAtom } from 'jotai' export function ThemeSwitch() { const [theme, setTheme] = useAtom(themeAtom) const left = { light: 4, system: 36, dark: 68 }[theme] return (
) } ================================================ FILE: src/components/head/AccentColorInjector.astro ================================================ ================================================ FILE: src/components/head/CommonHead.astro ================================================ --- import { site, author } from '@/config.json' interface Props { title?: string description?: string image?: string } const { title = site.title, description = site.description, image } = Astro.props const titleWithSiteSuffix = title === site.title ? title : `${title} - ${site.title}` --- {titleWithSiteSuffix} {image && } {author.twitterId && } {image && } ================================================ FILE: src/components/head/PrintVersion.astro ================================================ ================================================ FILE: src/components/head/ThemeLoader.astro ================================================ ================================================ FILE: src/components/head/WebAnalytics.tsx ================================================ import { analytics } from '@/config.json' export function WebAnalytics() { if (import.meta.env.DEV || !analytics.enable) return null return <> { analytics.umami.websiteId && } { analytics.google.measurementId && } { analytics.microsoftClarity.projectId && } } function UmamiAnalytics({ serverUrl, websiteId, }: { serverUrl?: string, websiteId: string, }) { const src = `${serverUrl || 'https://cloud.umami.is'}/script.js` return ) } function MicrosoftClarity({ projectId, }: { projectId: string, }) { return ( <> ) } ================================================ FILE: src/components/head/index.ts ================================================ export { default as AccentColorInjector } from './AccentColorInjector.astro' export { default as CommonHead } from './CommonHead.astro' export { default as PrintVersion } from './PrintVersion.astro' export { default as ThemeLoader } from './ThemeLoader.astro' export { WebAnalytics } from './WebAnalytics' ================================================ FILE: src/components/head-gradient/HeadGradient.tsx ================================================ import { motion } from 'framer-motion' export function HeadGradient() { return ( ) } ================================================ FILE: src/components/head-gradient/index.ts ================================================ export { HeadGradient } from './HeadGradient' ================================================ FILE: src/components/header/AnimatedLogo.tsx ================================================ import { AnimatePresence, motion } from 'framer-motion' import { useShouldHeaderMetaShow, useIsMobile } from './hooks' import { author } from '@/config.json' export function AnimatedLogo() { const isMobile = useIsMobile() const shouldHeaderMetaShow = useShouldHeaderMetaShow() if (!isMobile) { return } return ( {!shouldHeaderMetaShow && ( )} ) } function Logo() { return ( Site owner avatar ) } ================================================ FILE: src/components/header/BluredBackground.tsx ================================================ import { useHeaderBgOpacity } from './hooks' export function BluredBackground() { const opacity = useHeaderBgOpacity() return (
) } ================================================ FILE: src/components/header/Header.tsx ================================================ import { BluredBackground } from './BluredBackground' import { HeaderContent } from './HeaderContent' import { SearchButton } from './SearchButton' import { AnimatedLogo } from './AnimatedLogo' import { HeaderMeta } from './HeaderMeta' import { HeaderDrawer } from './HeaderDrawer' import { useIsMobile } from './hooks' export function Header() { const isMobile = useIsMobile() return (
{isMobile ? : }
{isMobile ? : }
) } ================================================ FILE: src/components/header/HeaderContent.tsx ================================================ import { useState } from 'react' import { menus } from '@/config.json' import { clsx } from 'clsx' import { AnimatePresence, motion } from 'framer-motion' import { usePathName, useShouldAccessibleMenuShow, useShouldHeaderMenuBgShow, useShouldHeaderMetaShow, } from './hooks' import { RootPortal } from '@/components/RootPortal' export function HeaderContent() { return ( <> ) } function AnimatedMenu() { const shouldBgShow = useShouldHeaderMenuBgShow() const shouldHeaderMetaShow = useShouldHeaderMetaShow() return ( {!shouldHeaderMetaShow && ( )} ) } function AccessibleMenu() { const shouldShow = useShouldAccessibleMenuShow() return ( {shouldShow && ( )} ) } function HeaderMenu({ isBgShow }: { isBgShow: boolean }) { const pathName = usePathName() const [mouseX, setMouseX] = useState(0) const [mouseY, setMouseY] = useState(0) const [radius, setRadius] = useState(0) const background = `radial-gradient(${radius}px circle at ${mouseX}px ${mouseY}px, rgb(var(--color-accent) / 0.12) 0%, transparent 65%)` const handleMouseMove = ({ clientX, clientY, currentTarget }: React.MouseEvent) => { const bounds = currentTarget.getBoundingClientRect() setMouseX(clientX - bounds.left) setMouseY(clientY - bounds.top) setRadius(Math.sqrt(bounds.width ** 2 + bounds.height ** 2) / 2.5) } return ( ) } function HeaderMenuItem({ href, isActive, title, icon, }: { href: string isActive: boolean title: string icon: string }) { return (
{isActive && ( )} {title}
{isActive && (
)}
) } ================================================ FILE: src/components/header/HeaderDrawer.tsx ================================================ import { menus } from '@/config.json' import { createContext, useContext, useState, forwardRef } from 'react' import * as Dialog from '@radix-ui/react-dialog' import { motion, AnimatePresence } from 'framer-motion' import clsx from 'clsx' const contentVariants = { hidden: { x: '-100%', transition: { duration: 0.2, ease: 'easeOut', }, }, visible: { x: 0, transition: { staggerChildren: 0.1, delayChildren: 0.1, duration: 0.2, ease: 'easeOut', }, }, } const menuItemVariants = { hidden: { opacity: 0, x: '-100%', }, visible: { opacity: 1, x: 0, }, } export function HeaderDrawer({ zIndex = 999 }: { zIndex?: number }) { const [isOpen, setIsOpen] = useState(false) const overlayZIndex = zIndex - 1 const contentZIndex = zIndex return ( {isOpen && ( )} ) } const TriggerButton = forwardRef((props, ref) => { return ( ) }) function DrawerContentImpl() { const { dismiss } = useContext(DrawerContext) return ( ) } const DrawerContext = createContext<{ dismiss(): void }>(null!) ================================================ FILE: src/components/header/HeaderMeta.tsx ================================================ import { site } from '@/config.json' import { AnimatePresence, motion } from 'framer-motion' import { useHeaderMetaInfo, useShouldHeaderMetaShow } from './hooks' export function HeaderMeta() { const { title, description, slug } = useHeaderMetaInfo() const shouldShow = useShouldHeaderMetaShow() return ( {shouldShow && (
{description}

{title}

{slug}
{site.title}
)}
) } ================================================ FILE: src/components/header/SearchButton.tsx ================================================ import { motion } from 'framer-motion' import { useCurrentModal, useModal } from '@/components/ui/modal' import { useEffect, useState } from 'react' import { useDebounceValue } from '@/hooks/useDebounceValue' let pagefind: any = null async function loadPagefind() { if (import.meta.env.PROD && !pagefind) { const url = '/pagefind/pagefind.js' pagefind = await import(/* @vite-ignore */ url) } } export function SearchButton() { const { present } = useModal() const openModal = () => { present({ content: , }) } useSearchKeyboardEvents({ onOpen: openModal }) return ( ) } function SearchPanel() { const [keyword, setKeyword] = useState('') const [isLoading, setIsLoading] = useState(false) const [results, setResults] = useState([]) const debouncedKeyword = useDebounceValue(keyword, 350) const { dismiss } = useCurrentModal() async function search(value: string) { if (!value) { setResults([]) return } setIsLoading(true) await loadPagefind() if (pagefind) { const res = await pagefind.search(value) const nextResults = await Promise.all(res.results.map((r: any) => r.data())) setResults(nextResults) } setIsLoading(false) } useEffect(() => { search(debouncedKeyword) }, [debouncedKeyword]) let resultList = null if (import.meta.env.DEV) { resultList = (
抱歉
该功能基于 pagefind,请在构建后再次尝试。
) } else if (isLoading) { resultList = (
) } else if (keyword.length === 0) { resultList = (
) } else if (results.length === 0) { resultList = (
无内容
) } else { resultList = ( <>
找到以下 {results.length} 条结果
{results.map((item) => { return (
{item.meta.title}

) })} ) } return ( setKeyword(e.target.value)} />
{resultList}
) } function useSearchKeyboardEvents({ onOpen }: { onOpen: () => void }) { useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) { event.preventDefault() onOpen() } } window.addEventListener('keydown', handleKeyDown) return () => { window.removeEventListener('keydown', handleKeyDown) } }, [onOpen]) } ================================================ FILE: src/components/header/hooks.ts ================================================ import { useAtomValue } from 'jotai' import { pathNameAtom, metaTitleAtom, metaDescriptionAtom, metaSlugAtom, hasMetaInfoAtom, } from '@/store/metaInfo' import { pageScrollLocationAtom, pageScrollDirectionAtom } from '@/store/scrollInfo' import { isMobileAtom } from '@/store/viewport' import { floor } from 'lodash-es' const threshold = 60 export function useHeaderBgOpacity() { const scrollY = useAtomValue(pageScrollLocationAtom) if (scrollY >= threshold * 2) { return 1 } else if (scrollY <= threshold) { return 0 } else { return floor((scrollY - threshold) / threshold, 2) } } export function useHasMetaInfo() { return useAtomValue(hasMetaInfoAtom) } export function useShouldHeaderMenuBgShow() { const scrollY = useAtomValue(pageScrollLocationAtom) return scrollY < threshold } export function useIsMobile() { return useAtomValue(isMobileAtom) } export function useShouldHeaderMetaShow() { const hasMetaInfo = useHasMetaInfo() const scrollY = useAtomValue(pageScrollLocationAtom) return hasMetaInfo && scrollY >= threshold } export function useHeaderMetaInfo() { const title = useAtomValue(metaTitleAtom) const description = useAtomValue(metaDescriptionAtom) const slug = useAtomValue(metaSlugAtom) return { title, description, slug, } } export function usePathName() { return useAtomValue(pathNameAtom) } export function useShouldAccessibleMenuShow() { const scrollY = useAtomValue(pageScrollLocationAtom) const scrollDirection = useAtomValue(pageScrollDirectionAtom) const hasMetaInfo = useHasMetaInfo() return hasMetaInfo && scrollY >= 400 && scrollDirection === 'up' } ================================================ FILE: src/components/hero/Hero.astro ================================================ --- import { hero, author } from '@/config.json' import { SocialList } from './SocialList' import Highlight from '@/components/Highlight.astro' ---

Hi there, I'm {hero.name}👋
{hero.bio}

{hero.description}
Site owner avatar

{hero.yiyan}

================================================ FILE: src/components/hero/SocialList.tsx ================================================ import clsx from 'clsx' import { hero } from '@/config.json' import { motion } from 'framer-motion' const itemVariants = { hidden: { opacity: 0, y: 40, }, visible: { opacity: 1, y: 0, }, } export function SocialList({ className }: { className?: string }) { return ( {hero.socials.map((social) => ( ))} ) } ================================================ FILE: src/components/post/ActionAside.tsx ================================================ import { sponsor, site } from '@/config.json' import { motion } from 'framer-motion' import * as QR from 'qrcode.react' import { useAtomValue } from 'jotai' import { metaSlugAtom, metaTitleAtom } from '@/store/metaInfo' import clsx from 'clsx' import { toast } from 'react-toastify' import { useModal } from '@/components/ui/modal' interface ShareData { url: string text: string } const shareList = [ { name: 'Twitter', icon: 'icon-x', onClick: (data: ShareData) => { window.open( `https://twitter.com/intent/tweet?url=${encodeURIComponent(data.url)}&text=${encodeURIComponent(data.text)}&via=${encodeURIComponent(site.title)}`, ) }, }, { name: '复制链接', icon: 'icon-link', onClick: (data: ShareData) => { navigator.clipboard.writeText(data.url) toast.success('已复制到剪贴板') }, }, ] export function ActionAside() { return (
) } function ShareButton() { const postSlug = useAtomValue(metaSlugAtom) const postTitle = useAtomValue(metaTitleAtom) const { present } = useModal() const url = new URL(postSlug, site.url).href const text = `嘿,我发现了一片宝藏文章「${postTitle}」哩,快来看看吧!` const openModal = () => { present({ content: , }) } return ( ) } function ShareModal({ url, text }: { url: string; text: string }) { return (

分享此内容


分享到...
    {shareList.map((item) => (
  • item.onClick({ url, text })} role="button" aria-label={`Share to ${item.name}`} > {item.name}
  • ))}
) } function DonateButton() { const { present } = useModal() const openDonate = () => { present({ content: , }) } return ( ) } function DonateContent() { return (

感谢您的支持,这将成为我前进的最大动力。

微信赞赏码
) } ================================================ FILE: src/components/post/Outdate.tsx ================================================ import { useEffect, useState } from 'react' import { getDiffInDays, getFormattedDate } from '@/utils/date' import { motion, AnimatePresence } from 'framer-motion' export function Outdate({ lastMod }: { lastMod: Date }) { const [isShow, setIsShow] = useState(false) useEffect(() => { const diffDays = getDiffInDays(lastMod) if (diffDays > 30) { setIsShow(true) } }, [lastMod]) return ( {isShow && ( 这篇文章最后修改于 {getFormattedDate(lastMod)} ,部分内容可能已经不适用,如有疑问可联系作者。 )} ) } ================================================ FILE: src/components/post/PostArchiveInfo.astro ================================================ --- import { slugify } from '@/utils/content' interface Props { tags: string[] category?: string class?: string } const { tags, category, class: className } = Astro.props ---
{ category && ( ) } { tags.length > 0 && (
{tags.map((tag, index) => ( <> {index > 0 && , } {tag} ))}
) }
================================================ FILE: src/components/post/PostCard.astro ================================================ --- import type { CollectionEntry } from 'astro:content' import { PostCardHoverOverlay } from './PostCardHoverOverlay' import PostMetaInfo from './PostMetaInfo.astro' interface Props { entry: CollectionEntry<'posts'> } const { entry } = Astro.props const { remarkPluginFrontmatter } = await entry.render() ---

{entry.data.title} { entry.data.sticky > 0 && ( ) }

{ entry.data.cover && ( {entry.data.title} ) } {entry.data.summary &&

{entry.data.summary}

}
继续阅读
================================================ FILE: src/components/post/PostCardHoverOverlay.tsx ================================================ import { AnimatePresence, motion } from 'framer-motion' import { useEffect, useRef, useState } from 'react' export function PostCardHoverOverlay() { const ref = useRef(null) const [enter, setEnter] = useState(false) const handleMouseEnter = () => { setEnter(true) } const handleMouseLeave = () => { setEnter(false) } const handleFocus = () => { setEnter(true) } const handleBlur = () => { setEnter(false) } useEffect(() => { const $ref = ref.current if (!$ref) return const $parent = $ref.parentElement?.parentElement if (!$parent) return $parent.addEventListener('mouseenter', handleMouseEnter) $parent.addEventListener('mouseleave', handleMouseLeave) $parent.addEventListener('focus', handleFocus) $parent.addEventListener('blur', handleBlur) return () => { $parent.removeEventListener('mouseenter', handleMouseEnter) $parent.removeEventListener('mouseleave', handleMouseLeave) $parent.removeEventListener('focus', handleFocus) $parent.removeEventListener('blur', handleBlur) } }, []) return ( <>
{enter && ( )} ) } ================================================ FILE: src/components/post/PostCopyright.tsx ================================================ import { author, site } from '@/config.json' import { getFormattedDateTime } from '@/utils/date' import { AnimatedSignature } from '../AnimatedSignature' import { useEffect, useState } from 'react' import { toast } from "react-toastify"; function getPostUrl(slug: string) { return new URL(slug, site.url).href } export function PostCopyright({ title, slug, lastMod, }: { title: string slug: string lastMod: Date }) { const [lastModStr, setLastModStr] = useState('') const url = getPostUrl(slug) function handleCopyUrl() { navigator.clipboard.writeText(url) toast.success('已复制文章链接') } useEffect(() => { setLastModStr(getFormattedDateTime(lastMod)) }, [lastMod]) return (

文章标题:{title}

文章作者:{author.name}

文章链接:{url} [复制]

最后修改时间:{lastModStr}


商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。
本文采用 CC BY-NC-SA 4.0 进行许可。

) } ================================================ FILE: src/components/post/PostList.astro ================================================ --- import type { CollectionEntry } from 'astro:content' import PostCard from './PostCard.astro' interface Props { posts: CollectionEntry<'posts'>[] } const { posts } = Astro.props ---
    { posts.map((post) => (
  • )) }
================================================ FILE: src/components/post/PostMetaInfo.astro ================================================ --- import { RelativeDate } from './RelativeDate' interface Props { date: Date lastMod?: Date words: number readingMinutes: number class?: string } const { date, lastMod, words, readingMinutes, class: className } = Astro.props ---
{lastMod && (已编辑)}
{words} 字
{Math.ceil(readingMinutes)} 分钟
================================================ FILE: src/components/post/PostNav.astro ================================================ --- import type { CollectionEntry } from 'astro:content' interface Props { prev?: CollectionEntry<'posts'> next?: CollectionEntry<'posts'> class?: string } const { prev, next, class: className } = Astro.props ---
{ prev && ( {prev.data.title} ) }
{ next && ( {next.data.title} ) }
================================================ FILE: src/components/post/PostPagination.astro ================================================ --- interface Props { current: number total: number getPageUrl: (page: number) => string } const { current, total, getPageUrl } = Astro.props const items = [] if (current > 1) items.push({ isButton: true, page: current - 1, icon: 'left', label: '上一页' }) if (current > 1) items.push({ isButton: true, page: 1 }) if (current > 3) items.push({ isButton: false, icon: 'more' }) if (current > 2) items.push({ isButton: true, page: current - 1 }) items.push({ isButton: false, page: current }) if (current < total - 1) items.push({ isButton: true, page: current + 1 }) if (current < total - 2) items.push({ isButton: false, icon: 'more' }) if (current < total) items.push({ isButton: true, page: total }) if (current < total) items.push({ isButton: true, page: current + 1, icon: 'right', label: '下一页' }) ---
{ items.map((item) => { if (item.isButton) { return ( {item.icon ? : item.page} ) } else { return ( {item.icon ? : item.page} ) } }) }
================================================ FILE: src/components/post/PostToc.tsx ================================================ import { pageScrollLocationAtom, pageScrollDirectionAtom } from '@/store/scrollInfo' import type { MarkdownHeading } from 'astro' import clsx from 'clsx' import { useAtomValue } from 'jotai' import { startTransition, useEffect, useRef, useState } from 'react' function useActiveItem() { const [activeItem, setActiveItem] = useState('') const scrollY = useAtomValue(pageScrollLocationAtom) useEffect(() => { const $article = document.querySelector('#markdown-wrapper') if (!$article) return const $headings = Array.from($article.querySelectorAll('h1,h2,h3,h4,h5,h6')) for (let i = 0; i < $headings.length; i++) { const item = $headings[i] const nextItem = $headings[i + 1] const itemTop = item.getBoundingClientRect().top const nextItemTop = nextItem ? nextItem.getBoundingClientRect().top : 10000 if (itemTop <= 80 && nextItemTop > 80) { startTransition(() => { setActiveItem(item.id) }) break } } }, [scrollY]) return activeItem } export function PostToc({ headings }: { headings: MarkdownHeading[] }) { const activeItem = useActiveItem() return (
    {headings.map((item) => ( ))}
) } export function TocItem({ slug, text, depth, isActive, }: { slug: string text: string depth: number isActive: boolean }) { const itemRef = useRef(null) const scrollDirection = useAtomValue(pageScrollDirectionAtom) useEffect(() => { if (!isActive) return const $item = itemRef.current if (!$item) return const $container = $item.parentElement if (!$container) return const containerHeight = $container.clientHeight const itemHeight = $item.clientHeight const itemOffsetTop = $item.offsetTop const scrollTop = $container.scrollTop const itemTop = itemOffsetTop - scrollTop const itemBottom = itemTop + itemHeight if (itemTop < 0 || itemBottom > containerHeight) { if (scrollDirection === 'up') { $container.scrollTop = itemOffsetTop - containerHeight + itemHeight } else { $container.scrollTop = itemOffsetTop } } }, [isActive]) return (
  • {text}
  • ) } ================================================ FILE: src/components/post/ReadingProgress.tsx ================================================ import { useEffect, useState } from 'react' import { useAtomValue } from 'jotai' import { pageScrollLocationAtom } from '@/store/scrollInfo' import { floor } from 'lodash-es' export function ReadingProgress() { const [percent, setPercent] = useState(0) const scrollY = useAtomValue(pageScrollLocationAtom) useEffect(() => { const $article = document.querySelector('#markdown-wrapper') if (!$article) return const { offsetHeight, offsetTop } = $article as HTMLElement const fullHeight = offsetHeight + offsetTop - window.innerHeight if (scrollY > fullHeight) { setPercent(100) } else { setPercent(floor((scrollY / fullHeight) * 100)) } }, [scrollY]) return (
    进度 {percent}%
    ) } ================================================ FILE: src/components/post/RelativeDate.tsx ================================================ import { getRelativeTime, getFormattedDate } from '@/utils/date' import { useEffect, useState } from 'react' export function RelativeDate({ date }: { date: Date }) { const [dateStr, setDateStr] = useState(getFormattedDate(date)) useEffect(() => { const relative = getRelativeTime(date) if (relative) { setDateStr(relative) } }, [date]) return {dateStr} } ================================================ FILE: src/components/provider/HeaderMetaInfoProvider.tsx ================================================ import { useSetAtom } from 'jotai' import { useEffect } from 'react' import { pathNameAtom, metaTitleAtom, metaDescriptionAtom, metaSlugAtom } from '@/store/metaInfo' export function HeaderMetaInfoProvider({ pathName, title = '', description = '', slug = '', }: { pathName: string title?: string description?: string slug?: string }) { const setPathName = useSetAtom(pathNameAtom) const setTitle = useSetAtom(metaTitleAtom) const setDescription = useSetAtom(metaDescriptionAtom) const setSlug = useSetAtom(metaSlugAtom) useEffect(() => { // 去掉 pathName 结尾的 '/' if (pathName !== '/') { setPathName(pathName.replace(/\/$/, '')) } else { setPathName(pathName) } setTitle(title) setDescription(description) setSlug(slug) }, [pathName, title, description, slug]) return null } ================================================ FILE: src/components/provider/PageScrollInfoProvider.tsx ================================================ import { useLayoutEffect, useRef } from 'react' import { throttle } from 'lodash-es' import { useSetAtom } from 'jotai' import { pageScrollLocationAtom, pageScrollDirectionAtom } from '@/store/scrollInfo' export function PageScrollInfoProvider() { const setScrollLocation = useSetAtom(pageScrollLocationAtom) const setScrollDirection = useSetAtom(pageScrollDirectionAtom) const prevScrollY = useRef(0) const scrollHandler = throttle( () => { let currentTop = document.documentElement.scrollTop if (currentTop === 0) { const bodyStyle = document.body.style if (bodyStyle.position === 'fixed') { const bodyTop = bodyStyle.top currentTop = Math.abs(parseInt(bodyTop, 10)) } } setScrollDirection(prevScrollY.current - currentTop > 0 ? 'up' : 'down') prevScrollY.current = currentTop setScrollLocation(currentTop) }, 16, { leading: false, }, ) useLayoutEffect(() => { scrollHandler() window.addEventListener('scroll', scrollHandler) return () => { window.removeEventListener('scroll', scrollHandler) } }, []) return null } ================================================ FILE: src/components/provider/Provider.tsx ================================================ import { HeaderMetaInfoProvider } from './HeaderMetaInfoProvider' import { PageScrollInfoProvider } from './PageScrollInfoProvider' import { ThemeProvider } from './ThemeProvider' import { ViewportProvider } from './ViewportProvider' export function Provider(props: { pathName: string title?: string description?: string slug?: string }) { return ( <> ) } ================================================ FILE: src/components/provider/ThemeProvider.tsx ================================================ import { useAtomValue } from 'jotai' import { useEffect } from 'react' import { getSystemTheme, changePageTheme, setLocalTheme } from '@/utils/theme' import { themeAtom } from '@/store/theme' export function ThemeProvider() { const theme = useAtomValue(themeAtom) function handlePrefersColorSchemeChange(event: MediaQueryListEvent) { if (theme === 'system') { changePageTheme(event.matches ? 'dark' : 'light') } } useEffect(() => { setLocalTheme(theme) if (theme === 'system') { const systemTheme = getSystemTheme() changePageTheme(systemTheme) } else { changePageTheme(theme) } const query = window.matchMedia('(prefers-color-scheme: dark)') query.addEventListener('change', handlePrefersColorSchemeChange) return () => { query.removeEventListener('change', handlePrefersColorSchemeChange) } }, [theme]) return null } ================================================ FILE: src/components/provider/ViewportProvider.tsx ================================================ import { useSetAtom } from 'jotai' import { useEffect } from 'react' import { isMobileAtom } from '@/store/viewport' export function ViewportProvider() { const setIsMobile = useSetAtom(isMobileAtom) const handleResize = (event: MediaQueryListEvent) => { setIsMobile(!event.matches) } useEffect(() => { const query = window.matchMedia('(min-width: 768px)') setIsMobile(!query.matches) query.addEventListener('change', handleResize) return () => { query.removeEventListener('change', handleResize) } }, []) return null } ================================================ FILE: src/components/ui/modal/Modal.tsx ================================================ import { modalStackAtom } from '@/store/modalStack' import { useSetAtom } from 'jotai' import * as Dialog from '@radix-ui/react-dialog' import { motion } from 'framer-motion' import { CurrentModalContext } from './context' export function Modal({ children, index, id, }: { index: number children: React.ReactNode id: string }) { const baseZIndex = 1000 const overlayZIndex = baseZIndex + index const contentZIndex = baseZIndex + index + 1 const setModalStack = useSetAtom(modalStackAtom) const close = () => { setModalStack((stack) => stack.filter((modal) => modal.id !== id)) } return ( { if (!isOpen) { close() } }} > { if (e.target === e.currentTarget) { close() } }} > {children} ) } ================================================ FILE: src/components/ui/modal/ModalStack.tsx ================================================ import { useAtomValue } from 'jotai' import { Modal } from './Modal' import { modalStackAtom } from '@/store/modalStack' import { AnimatePresence } from 'framer-motion' export function ModalStack() { const modalStack = useAtomValue(modalStackAtom) return ( {modalStack.map((modal, index) => ( {modal.content} ))} ) } ================================================ FILE: src/components/ui/modal/context.ts ================================================ import { createContext } from 'react' export const CurrentModalContext = createContext<{ dismiss: () => void }>(null as any) ================================================ FILE: src/components/ui/modal/hooks.ts ================================================ import { useContext, useId, useRef } from 'react' import { useSetAtom } from 'jotai' import { modalStackAtom } from '@/store/modalStack' import { CurrentModalContext } from './context' type ModalProps = { id?: string content: React.ReactNode } export function useModal() { const id = useId() const currentCount = useRef(0) const setModalStack = useSetAtom(modalStackAtom) return { present(props: ModalProps) { const modalId = `${id}-${currentCount.current++}` const modalProps = { ...props, id: props.id ?? modalId, } setModalStack((stack) => [...stack, modalProps]) return () => { setModalStack((stack) => stack.filter((modal) => modal.id !== modalProps.id)) } }, } } export function useCurrentModal() { return useContext(CurrentModalContext) } ================================================ FILE: src/components/ui/modal/index.ts ================================================ export * from './hooks' export * from './ModalStack' ================================================ FILE: src/config.json ================================================ { "site": { "url": "https://gyoza.lxchapu.com", "title": "Gyoza", "description": "这是一个使用 Astro 和 React 开发的博客主题。", "keywords": "Gyoza,blog,Astro,theme,lxchapu,博客主题", "lang": "zh-CN", "favicon": "/favicon.ico", "appleTouchIcon": "/apple-touch-icon.png" }, "author": { "name": "lxchapu", "twitterId": "@lxchapu", "avatar": "https://s2.loli.net/2024/04/30/ozsnuS5Ihf3xMBG.webp" }, "hero": { "name": "Gyoza", "bio": "A static blog template build with Astro and React.", "description": "Clean, Cute, Fast.", "socials": [ { "name": "Github", "icon": "icon-github", "url": "https://github.com/lxchapu/astro-gyoza", "color": "rgb(24, 23, 23)" }, { "name": "X", "icon": "icon-x", "url": "https://twitter.com/lxchapu", "color": "rgb(36, 46, 54)" }, { "name": "Email", "icon": "icon-mail", "url": "mailto:lxchapu@outlook.com", "color": "rgb(212, 70, 56)" } ], "yiyan": "当第一颗卫星飞向大气层外,我们便以为自己终有一日会征服宇宙。" }, "color": { "accent": [ { "light": "#F55555", "dark": "#FCCF31" }, { "light": "#0396FF", "dark": "#ABDCFF" }, { "light": "#fb7287", "dark": "#99D8CF" }, { "light": "#F072B6", "dark": "#FFF886" }, { "light": "#9F44D3", "dark": "#E2B0FF" }, { "light": "#FF6666", "dark": "#A1CCD1" }, { "light": "#F6416C", "dark": "#838BC6" }, { "light": "#32CCBC", "dark": "#90F7EC" }, { "light": "#33A6B8", "dark": "#79F1A4" }, { "light": "#F55555", "dark": "#FCCF31" } ], "bg": { "primary": { "light": "#ffffff", "dark": "#1c1c1e" }, "secondary": { "light": "#f4f4f5", "dark": "#27272a" } }, "text": { "primary": { "light": "#373a3c", "dark": "#ffffff" }, "secondary": { "light": "#71717a", "dark": "#d1d5db" } }, "border": { "primary": { "light": "#e4e4e7", "dark": "#3f3f46" } } }, "menus": [ { "name": "首页", "link": "/", "icon": "icon-pantone" }, { "name": "归档", "link": "/archives", "icon": "icon-archive" }, { "name": "项目", "link": "/projects", "icon": "icon-flask" }, { "name": "关于", "link": "/about", "icon": "icon-ghost" }, { "name": "友链", "link": "/friends", "icon": "icon-hearts" } ], "posts": { "perPage": 10 }, "footer": { "startTime": "2024-04-14T00:00:00Z" }, "waline": { "serverURL": "https://waline.lxchapu.com" }, "sponsor": { "wechat": "https://object.lxchapu.com/bed%2F2024%2F0507_6e3e8f73df2d4e6d.webp" }, "analytics": { "enable": false, "google": { "measurementId": "" }, "umami": { "serverUrl": "", "websiteId": "" }, "microsoftClarity": { "projectId": "" } } } ================================================ FILE: src/content/config.ts ================================================ import { z, defineCollection } from 'astro:content' const postsCollection = defineCollection({ type: 'content', schema: z.object({ title: z.string(), date: z.date(), lastMod: z.date().optional(), summary: z.string().optional(), cover: z.string().optional(), category: z.string().optional(), tags: z.array(z.string()).default([]), comments: z.boolean().default(true), draft: z.boolean().default(false), sticky: z.number().default(0), }), }) const projectsCollection = defineCollection({ type: 'data', schema: z.object({ title: z.string(), description: z.string(), image: z.string(), link: z.string().url(), }), }) const specCollection = defineCollection({ type: 'content', schema: z.object({ title: z.string(), description: z.string(), comments: z.boolean().default(true), }), }) const friendsCollection = defineCollection({ type: 'data', schema: z.object({ title: z.string(), description: z.string(), avatar: z.string(), link: z.string().url(), }), }) export const collections = { posts: postsCollection, projects: projectsCollection, spec: specCollection, friends: friendsCollection, } ================================================ FILE: src/content/friends/Keigo.yml ================================================ title: Keigo description: 那天早上的霧散了,不止早上,不止霧 link: https://astro.sliverkeigo.top/ avatar: https://www.sliverkeigo.top/_next/image?url=https%3A%2F%2Fsliverkeigo.top%2Fapi%2Fv2%2Fobjects%2Favatar%2Fd7mox619mtisq9vtxt.png&w=384&q=75 ================================================ FILE: src/content/friends/astro-docs.yaml ================================================ title: Astro Docs description: Astro 入门指南 link: https://docs.astro.build/en/getting-started/ avatar: https://s2.loli.net/2023/12/13/YbKirkO21CtdvMD.png ================================================ FILE: src/content/friends/lxchapu.yaml ================================================ title: '柃夏chapu' description: '生活明朗,万物可爱。' link: 'https://www.lxchapu.com' avatar: 'https://s2.loli.net/2024/04/23/tIxXmT45RbBDWH8.webp' ================================================ FILE: src/content/posts/embed.md ================================================ --- title: 在文章中嵌入视频和代码 date: 2024-04-04 lastMod: 2024-05-18T07:29:49.820Z tags: [Video, Markdown] category: 例子 summary: 这篇文章介绍了如何在文章中嵌入视频和代码。 --- ## Codepen ```md ::codepen{#gOyLepE author="lxchapu"} ``` ::codepen{#gOyLepE author="lxchapu"} ## YouTube ```md ::youtube{#BuKft9LpL_0} ``` ::youtube{#BuKft9LpL_0} ## Bilibili ```md ::bilibili{#BV1Mx4y1Y7pJ} ``` ::bilibili{#BV1Mx4y1Y7pJ} ================================================ FILE: src/content/posts/guide.md ================================================ --- title: Gyoza 使用指南 date: 2024-04-01 lastMod: 2024-08-10T03:58:16.758Z summary: 欢迎使用 Gyoza,Gyoza 是一款 Astro 博客主题,它保持简洁和可爱的风格。本篇文章将会介绍如何使用并部署 Gyoza。 category: 教程 tags: [Astro, Gyoza] sticky: 1 --- ## 前置条件 - node 版本 >= 18.18.0 - pnpm 版本 > 8.1.0 ## 安装 ### 克隆仓库 登录 Github 账号,打开 [lxchapu/astro-gyoza](https://github.com/lxchapu/astro-gyoza),点击右上角的 Fork 按钮,将仓库克隆到你自己的账号下。 复制这个仓库的地址,打开终端,使用 `git clone` 命令将仓库克隆到本地。 > 本项目推荐使用 pnpm 作为你的包管理器,如果你还没有安装 pnpm,请先安装 pnpm。 ### 安装依赖 ```sh cd astro-gyoza pnpm install ``` ### 命令介绍 本地运行 ```sh pnpm dev ``` 打包静态文件 ```sh pnpm build ``` 本地预览 ```sh pnpm preview ``` ### 配置项 本项目中的绝大部分配置都定义在 `src/config.json` 文件中。 你应该首先将 `site.url` 修改成自己的域名,避免导航错误。 以下是配置项的说明: ```json { "site": { "url": "", // 网站地址 "title": "", // 网站标题 "description": "", // 通用的网站描述 SEO "keywords": "", // 通用的网站关键词 SEO "lang": "zh-CN", // 网站的语言 "favicon": "", // 浏览器图标,存放在 public 目录下 "appleTouchIcon": "" // 苹果设备图标,存放在 public 目录下 }, "author": { "name": "", // 作者名称 "twitterId": "", // 推特账号 ID,以 @ 开头,用于 Open Graph "avatar": "" // 作者头像地址 }, // 首页 Hero 组件 "hero": { "name": "", // 显示的名称 "bio": "", // 一句话介绍 "description": "", // 补充描述 // 社交账号 "socials": [ { "name": "", // 社交平台类型 "icon": "", // 社交平台图标 "url": "", // 链接 "color": "" // 图标颜色 } ], "yiyan": "" // 显示一言 }, "color": { // 强调色,请填写 16 进制颜色值。每次会从中随机取出一组 "accent": [{ "light": "", "dark": "" }], // 背景色 "bg": { "primary": { "light": "", "dark": "" }, "secondary": { "light": "", "dark": "" } }, // 文字颜色 "text": { "primary": { "light": "", "dark": "" }, "secondary": { "light": "", "dark": "" } }, // 边框颜色 "border": { "primary": { "light": "", "dark": "" } } }, // 顶部导航栏 "menus": [ { "name": "首页", "link": "/", "icon": "icon-pantone" } ], "posts": { "perPage": 10 // 每一页显示的文章数量 }, "footer": { "startTime": "" // 博客网站开始时间 请使用 ISO 格式 }, // Waline 评论系统,前往 https://waline.js.org/ 查看 "waline": { "serverURL": "" }, // 赞助 "sponsor": { "wechat": "" // 微信赞赏码图片地址 }, // 如果需要使用网站数据统计,将 enable 修改为 true,并填写对应的配置 "analytics": { "enable": false, // https://analytics.google.com "google": { "measurementId": "" }, // https://umami.is/docs "umami": { "serverUrl": "", "websiteId": "" }, // https://clarity.microsoft.com/ "microsoftClarity": { "projectId": "" } } } ``` ## 部署 > 这里只介绍了 Vercel,你当然可以选择其他平台例如:Cloudflare Pages 或你自己的服务器。 > 部署之前,确保你已经修改 `site.url`。 ### 部署到 Vercel 登录 Vercel 账号,点击右上角的 Add new... 选择 Project。然后在 Import Git Repository 中选择刚刚 Fork 的仓库,点击 Import 按钮。 进入项目配置页面,直接点击 Deploy 按钮,静静等待部署完成就 👌 了。 Vercel 会为你分配一个域名,你可以在项目设置中设置自定义域名,更多操作请参考 Vercel 文档。 ================================================ FILE: src/content/posts/how-to-use-icons.md ================================================ --- title: 如何在 Gyoza 中使用图标? date: 2024-05-08T10:54:27.000Z tags: [Icon] category: 教程 comments: true draft: false --- Gyoza 选择 font-class 的方式引用图标。这些图标大部分来源于 [Remix Icons](https://remixicon.com/),并且在 [iconfont](https://www.iconfont.cn/) 上进行管理和导出。 下图展示了项目中的所有图标: ![所有图标](https://s2.loli.net/2024/05/08/mbdT5HqYMEajyRG.webp) 当你在添加首页显示的社交账号时,你可能会想要使用这些图标。在对应的配置项中填写图标下面有 `icon-` 前缀的名称即可。 如果是在组件中使用图标,可以按照如下方式: ```jsx ``` ## 为什么不是 SVG 图标? 你可能看到很多的项目在使用 [iconify](https://iconify.design/)。iconify 是一个开源图标集,包含超过 20 万个图标,提供了多种框架的引入方式。Astro 中也有对应的插件 astro-icon 可以使用(如果对此感兴趣,可以查看他们的[文档](https://github.com/natemoo-re/astro-icon))。 我在项目中也尝试使用过 iconify,但是出于以下几个原因,我最终还是转向了 font-class 的方式: - 由于项目中同时使用了 Astro 和 React,而在 Astro 组件和 React 组件中使用 iconify 图标的方式是不同的,这会导致代码中不得不存在两种使用方式。 - iconify 在加载时需要请求它的服务器,~~我会担心请求失败~~,虽然这种担心是多余的。 - 有一个功能是我会在渲染文章时往 markdown 中注入一些图标,例如外部链接尾部的图标,iconify 想要做到这一点并不方便。 - 在 HTML 中直接嵌入 SVG icon 的方式并不优雅,使用 font-class 只需要对应的类名,感觉相较而言最终的 HTML 体积小一点,页面加载会快点。我还没有做过具体的测试,但是至少我会尽量避免页面中出现大量的 SVG 仅仅只是作为图标使用。 - 该项目中用到的图标并不多,主要是一些常用的社交账号的图标,供自定义联系方式时使用。我希望所有图标集中在一起管理,这样更方便一点。 我必须要承认,目前的图标方案并不优雅,每当图标集合发生修改时我都需要更新对应的字体文件和 CSS 文件。而且其他人想要管理图标集合也变得困难。 也许我会在未来尝试其他方式,例如 [@iconify/tailwind](https://github.com/iconify/iconify/tree/main/plugins/tailwind),如果你有更好的方案,也欢迎给我留言。 ## 自定义图标 如果你想要替换 iconfont 的图标,请修改以下文件: ```text public/fonts/iconfont.ttf public/fonts/iconfont.woff public/fonts/iconfont.woff2 src/styles/iconfont.css ``` 注意,这将会替换掉项目中使用的所有图标,所以请确保你知道自己在做什么。 ================================================ FILE: src/content/posts/markdown.md ================================================ --- title: Markdown 示例 date: 2024-04-01 summary: 这是一篇 Markdown 文章的示例。展示了 Markdown 的语法和渲染效果。 category: 例子 tags: [Markdown] --- 下面是在 Astro 中编写 Markdown 内容时,可以使用的一些基本 Markdown 语法示例。 ## 标题 你应该避免在 Markdown 正文中重复创建文章标题,因为文章标题会根据 `frontmatter` 中 `title` 自动生成。 > 避免标题层级过深,一般到三级标题就够了。 # 一级 ## 二级 ### 三级 `inline code` #### 四级 ##### 五级 ###### 六级 ## 段落 Xerum, quo qui aut unt expliquam qui dolut labo. Aque venitatiusda cum, voluptionse latur sitiae dolessi aut parist aut dollo enim qui voluptate ma dolestendit peritin re plis aut quas inctum laceat est volestemque commosa as cus endigna tectur, offic to cor sequas etum rerum idem sintibus eiur? Quianimin porecus evelectur, cum que nis nust voloribus ratem aut omnimi, sitatur? Quiatem. Nam, omnis sum am facea corem alique molestrunt et eos evelece arcillit ut aut eos eos nus, sin conecerem erum fuga. Ri oditatquam, ad quibus unda veliamenimin cusam et facea ipsamus es exerum sitate dolores editium rerore eost, temped molorro ratiae volorro te reribus dolorer sperchicium faceata tiustia prat. 使用行尾使用两个空格进行段落内的换行 All work and no play makes Jack a dull boy. All work and no play makes Jack a dull boy. ## 图片 小尺寸的图片 ![图片描述](https://picsum.photos/seed/picsum/250/400) 大尺寸的图片 ![图片描述](https://picsum.photos/seed/picsum/1200/900) 带标题的图片 ![图片描述](https://picsum.photos/seed/picsum/400/300 '图片标题') ## 强调 这是**重要内容**,这是*次要内容* ## 删除线 ~~这是一段被删除的文本。~~ ## 引用 The blockquote element represents content that is quoted from another source, optionally with a citation which must be within a `footer` or `cite` element, and optionally with in-line changes such as annotations and abbreviations. > Tiam, ad mint andaepu dandae nostion secatur sequo quae. > **Note** that you can use _Markdown syntax_ within a blockquote. 嵌套的引用 > 引用 > > > 嵌套的引用 带脚标的引用 > Don't communicate by sharing memory, share memory by communicating.
    > — Rob Pike[^1] [^1]: The above quote is excerpted from Rob Pike's [talk](https://www.youtube.com/watch?v=PAAkCSZUG1c) during Gopherfest, November 18, 2015. ## 分割线 --- ## 链接 这是内部链接 [Gyoza 使用指南](/posts/guide) 这是外部连接 [React **中文**文档](https://zh-hans.react.dev/) 自动渲染成连接 邮箱地址 ## 表格 设置单元格对齐 | Name | Age | Fruit | | :---- | :-: | -----: | | Bob | 27 | Apple | | Alice | 23 | Banana | | John | 28 | Orange | 支持行内 Markdown | Italics | Bold | Code | | --------- | -------- | ------ | | _italics_ | **bold** | `code` | 表格溢出 | A | B | C | D | E | F | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------- | | Lorem ipsum dolor sit amet, consectetur adipiscing elit. | Phasellus ultricies, sapien non euismod aliquam, dui ligula tincidunt odio, at accumsan nulla sapien eget ex. | Proin eleifend dictum ipsum, non euismod ipsum pulvinar et. Vivamus sollicitudin, quam in pulvinar aliquam, metus elit pretium purus | Proin sit amet velit nec enim imperdiet vehicula. | Ut bibendum vestibulum quam, eu egestas turpis gravida nec | Sed scelerisque nec turpis vel viverra. Vivamus vitae pretium sapien | ## 代码块 ### Syntax we can use 3 backticks ``` in new line and write snippet and close with 3 backticks on new line and to highlight language specific syntac, write one word of language name after first 3 backticks, for eg. html, javascript, css, markdown, typescript, txt, bash ```html Example HTML5 Document

    Test

    ``` ``` const var text = "hello world" ``` ## KaTeX 公式 使用 `$` 符号包裹公式生成行内公式,例如:$E = mc^2$。 使用 `$$` 符号包裹公式来生成独立公式。例如: $$ e^{i\pi} + 1 = 0 $$ 也可以使用代码块(` ```math `)的方式: ```math \oint_{\partial V} \mathbf{E} \cdot d\mathbf{A} = \frac{Q}{\epsilon_0} ``` ## List Types ### Ordered List #### Syntax ```markdown 1. First item 2. Second item 3. Third item ``` #### Output 1. First item 2. Second item 3. Third item ### Unordered List #### Syntax ```markdown - List item - Another item - And another item ``` #### Output - List item - Another item - And another item ### Nested list #### Syntax ```markdown - Fruit - Apple - Orange - Banana - Dairy - Milk - Cheese ``` #### Output - Fruit - Apple - Orange - Banana - Dairy - Milk - Cheese ## Other Elements — abbr, sub, sup, kbd, mark ### Syntax ```markdown GIF is a bitmap image format. H2O Xn + Yn = Zn Press CTRL+ALT+Delete to end the session. Most salamanders are nocturnal, and hunt for insects, worms, and other small creatures. ``` #### Output GIF is a bitmap image format. H2O Xn + Yn = Zn Press CTRL+ALT+Delete to end the session. Most salamanders are nocturnal, and hunt for insects, worms, and other small creatures. ## Spoiler ```md ||hide content|| ``` 正常情况下,该内容会隐藏 ||hide content||,鼠标悬浮时才会显示。 ================================================ FILE: src/content/projects/gyoza.yaml ================================================ title: Gyoza description: Gyoza 是一个使用 Astro 和 React 开发的响应式博客主题 image: https://s2.loli.net/2024/03/02/hba5MAilRXguZtr.webp link: https://github.com/lxchapu/astro-gyoza ================================================ FILE: src/content/spec/about.md ================================================ --- title: 自述 description: 这是一份站长的自述报告,请查收。 comments: false --- ## 关于 Gyoza Gyoza 是一个使用 Astro 和 React 开发的博客主题。Gyoza 借鉴了 [Shiro](https://github.com/innei/Shiro) 和一些网站的设计。 Gyoza 的核心理念是简洁,快速,可爱。 - 在 [Markdown 示例](/posts/markdown) 中展示了 Markdown 的渲染样式 - 可以前往 [Gyoza 使用指南](/posts/guide) 了解 Gyoza 的使用方法 Gyoza 是开源的,如果你对这个项目感兴趣,欢迎前往 Gyoza 的 [Github 仓库](https://github.com/lxchapu/astro-gyoza) 来提 Issue 或者 PR。 ## 关于作者 我是 lxchapu,是一名前端开发者,喜欢各种有趣的东西。欢迎访问我的个人网站 [www.lxchapu.com](https://www.lxchapu.com),了解关于我更多的信息。 ## 致谢 感谢以下项目: - [Astro](https://astro.build/) - [React](https://reactjs.org/) - [Tailwind CSS](https://tailwindcss.com/) - [Framer Motion](https://www.framer.com/motion/) - [Jotai](https://jotai.org/) ================================================ FILE: src/content/spec/friends.md ================================================ --- title: 朋友们 description: 我的小伙伴们和一些有趣的站点。 comments: true --- ## 怎么申请友链? 想要交换友链的小伙伴们,欢迎去本站的 [Github 仓库](https://github.com/lxchapu/astro-gyoza/tree/main/src/content/friends)提交一个 PR。审核通过后,就可以在这里展示啦。 请在`/src/content/friends/`目录下添加一个`.yaml`文件,参考格式: ```yml title: 网站名称 description: 一句话介绍下你的网站或者你自己 link: 网站地址 avatar: 头像地址 ``` ================================================ FILE: src/content/spec/projects.md ================================================ --- title: 项目 description: 这些是我创建或参与的项目,如果你感兴趣不妨去给个 Star。 comments: false --- ================================================ FILE: src/env.d.ts ================================================ /// /// ================================================ FILE: src/hooks/useDebounceValue.ts ================================================ import { useEffect, useState } from 'react' export function useDebounceValue(value: T, delay: number) { const [debouncedValue, setDebouncedValue] = useState(value) useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value) }, delay) return () => { clearTimeout(handler) } }, [value, delay]) return debouncedValue } ================================================ FILE: src/layouts/Layout.astro ================================================ --- import { CommonHead, WebAnalytics, ThemeLoader, AccentColorInjector, PrintVersion, } from '@/components/head' import { BackToTopFAB } from '@/components/BackToTopFAB' import { ToastContainer } from '@/components/ToastContainer' import { ModalStack } from '@/components/ui/modal' import { site } from '@/config.json' import '@/styles/global.css' interface Props { title?: string description?: string image?: string } const { title, description, image } = Astro.props --- ================================================ FILE: src/layouts/MarkdownLayout.astro ================================================ --- import Layout from './Layout.astro' import { HeadGradient } from '@/components/head-gradient' import Footer from '@/components/footer/Footer.astro' import { Header } from '@/components/header/Header' import { Provider } from '@/components/provider/Provider' import 'katex/dist/katex.min.css' interface Props { title: string description?: string image?: string mdTitle: string mdDescription: string mdSlug: string } const { title, description, image, mdTitle, mdDescription, mdSlug } = Astro.props ---