├── .husky ├── pre-commit └── commit-msg ├── components ├── blur │ ├── index.ts │ └── blur.tsx ├── card │ ├── index.ts │ └── card.tsx ├── list │ ├── index.ts │ └── list.tsx ├── logo │ ├── index.ts │ └── logo.tsx ├── mdx │ ├── index.ts │ ├── link.tsx │ ├── audio.tsx │ ├── image.tsx │ ├── copy-button.tsx │ ├── figure.tsx │ └── mdx.tsx ├── page │ ├── index.ts │ ├── page.tsx │ └── nav.tsx ├── toc │ ├── index.ts │ └── toc.tsx ├── article │ ├── index.ts │ └── article.tsx ├── footer │ ├── index.ts │ ├── theme-switcher.tsx │ └── footer.tsx ├── github │ ├── index.ts │ └── github.tsx ├── helper │ ├── index.ts │ ├── helper.tsx │ ├── tiny-button.tsx │ └── scroll-top.tsx ├── spinner │ ├── index.ts │ └── spinner.tsx ├── datetime │ ├── index.ts │ └── datetime.tsx ├── discussion │ ├── index.ts │ └── discussion.tsx ├── photo-view │ ├── index.ts │ └── photo-view.tsx ├── audio-player │ ├── index.ts │ ├── title.tsx │ └── audio-player.tsx ├── info │ ├── index.ts │ ├── about.tsx │ └── last.tsx └── icons │ ├── index.ts │ ├── laptop.tsx │ ├── discord.tsx │ ├── sun.tsx │ ├── moon.tsx │ ├── signature.tsx │ └── space.tsx ├── lib ├── meta │ ├── index.ts │ └── generate-feed.ts ├── utils │ ├── style.ts │ ├── env.ts │ ├── edge.ts │ ├── dom.ts │ ├── lodash.ts │ ├── helper.ts │ └── content.ts ├── mdx │ ├── rehype-callout.ts │ ├── rehype-audio.ts │ ├── rehype-toc.ts │ ├── rehype-code.ts │ ├── index.ts │ └── rehype-image.ts ├── constants │ └── config.ts └── api │ ├── fetch.ts │ └── github.ts ├── styles ├── vendor.css ├── tailwindcss.css ├── base.css ├── theme.css └── utilities.css ├── app ├── apple-icon.png ├── articles │ ├── [slug] │ │ └── page.tsx │ └── page.tsx ├── journal │ ├── [slug] │ │ └── page.tsx │ └── page.tsx ├── snippets │ ├── [slug] │ │ └── page.tsx │ └── page.tsx ├── page.tsx ├── feed │ └── route.ts ├── not-found.tsx ├── robots.ts ├── providers │ ├── motion-provider.tsx │ └── vercel-provider.tsx ├── icon.svg ├── sitemap.ts ├── api │ ├── content │ │ └── route.ts │ └── discussions │ │ └── route.ts ├── projects │ └── page.tsx ├── gallery │ └── page.tsx ├── og │ └── [slug] │ │ └── route.tsx └── layout.tsx ├── public └── assets │ ├── beach.jpg │ └── merriweather.ttf ├── postcss.config.mjs ├── types └── react.d.ts ├── env.ts ├── hooks ├── use-is-mounted.ts ├── use-device-listener.ts ├── use-loading.ts ├── use-theme-transition.ts ├── use-copy-to-clipboard.ts ├── use-local-storage.ts └── use-shortcut.ts ├── stores └── use-device.ts ├── .vscode └── settings.json ├── commitlint.config.ts ├── .gitignore ├── prettier.config.mjs ├── tsconfig.json ├── next.config.ts ├── LICENSE ├── scripts └── sync-blog.ts ├── eslint.config.ts ├── package.json ├── .agent └── rules │ └── rules.md ├── content-collections.ts ├── README.md └── lint_report.txt /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit $1 -------------------------------------------------------------------------------- /components/blur/index.ts: -------------------------------------------------------------------------------- 1 | export * from './blur' 2 | -------------------------------------------------------------------------------- /components/card/index.ts: -------------------------------------------------------------------------------- 1 | export * from './card' 2 | -------------------------------------------------------------------------------- /components/list/index.ts: -------------------------------------------------------------------------------- 1 | export * from './list' 2 | -------------------------------------------------------------------------------- /components/logo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logo' 2 | -------------------------------------------------------------------------------- /components/mdx/index.ts: -------------------------------------------------------------------------------- 1 | export * from './mdx' 2 | -------------------------------------------------------------------------------- /components/page/index.ts: -------------------------------------------------------------------------------- 1 | export * from './page' 2 | -------------------------------------------------------------------------------- /components/toc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './toc' 2 | -------------------------------------------------------------------------------- /lib/meta/index.ts: -------------------------------------------------------------------------------- 1 | export * from './generate-feed' 2 | -------------------------------------------------------------------------------- /components/article/index.ts: -------------------------------------------------------------------------------- 1 | export * from './article' 2 | -------------------------------------------------------------------------------- /components/footer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './footer' 2 | -------------------------------------------------------------------------------- /components/github/index.ts: -------------------------------------------------------------------------------- 1 | export * from './github' 2 | -------------------------------------------------------------------------------- /components/helper/index.ts: -------------------------------------------------------------------------------- 1 | export * from './helper' 2 | -------------------------------------------------------------------------------- /components/spinner/index.ts: -------------------------------------------------------------------------------- 1 | export * from './spinner' 2 | -------------------------------------------------------------------------------- /styles/vendor.css: -------------------------------------------------------------------------------- 1 | @import 'katex/dist/katex.min.css'; 2 | -------------------------------------------------------------------------------- /components/datetime/index.ts: -------------------------------------------------------------------------------- 1 | export * from './datetime' 2 | -------------------------------------------------------------------------------- /components/discussion/index.ts: -------------------------------------------------------------------------------- 1 | export * from './discussion' 2 | -------------------------------------------------------------------------------- /components/photo-view/index.ts: -------------------------------------------------------------------------------- 1 | export * from './photo-view' 2 | -------------------------------------------------------------------------------- /components/audio-player/index.ts: -------------------------------------------------------------------------------- 1 | export * from './audio-player' 2 | -------------------------------------------------------------------------------- /components/info/index.ts: -------------------------------------------------------------------------------- 1 | export * from './about' 2 | export * from './last' 3 | -------------------------------------------------------------------------------- /app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chanshiyucx/zero/HEAD/app/apple-icon.png -------------------------------------------------------------------------------- /public/assets/beach.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chanshiyucx/zero/HEAD/public/assets/beach.jpg -------------------------------------------------------------------------------- /app/articles/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | export { Article as default, generateMetadata } from '@/components/article' 2 | -------------------------------------------------------------------------------- /app/journal/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | export { Article as default, generateMetadata } from '@/components/article' 2 | -------------------------------------------------------------------------------- /app/snippets/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | export { Article as default, generateMetadata } from '@/components/article' 2 | -------------------------------------------------------------------------------- /public/assets/merriweather.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chanshiyucx/zero/HEAD/public/assets/merriweather.ttf -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | } 6 | 7 | export default config 8 | -------------------------------------------------------------------------------- /types/react.d.ts: -------------------------------------------------------------------------------- 1 | import 'react' 2 | 3 | declare module 'react' { 4 | interface CSSProperties { 5 | [key: `--${string}`]: string | number 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /components/icons/index.ts: -------------------------------------------------------------------------------- 1 | export * from './discord' 2 | export * from './laptop' 3 | export * from './moon' 4 | export * from './signature' 5 | export * from './space' 6 | export * from './sun' 7 | -------------------------------------------------------------------------------- /components/mdx/link.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef } from 'react' 2 | 3 | export function Link(props: ComponentPropsWithoutRef<'a'>) { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /lib/utils/style.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /components/blur/blur.tsx: -------------------------------------------------------------------------------- 1 | export function Blur() { 2 | return ( 3 |
4 | ) 5 | } 6 | -------------------------------------------------------------------------------- /lib/utils/env.ts: -------------------------------------------------------------------------------- 1 | export const isClientSide = typeof window !== 'undefined' 2 | export const isServerSide = !isClientSide 3 | 4 | export const isDev = process.env.NODE_ENV === 'development' 5 | export const isProd = !isDev 6 | -------------------------------------------------------------------------------- /components/helper/helper.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollTop } from './scroll-top' 2 | 3 | export function Helper() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /styles/tailwindcss.css: -------------------------------------------------------------------------------- 1 | @import './vendor.css'; 2 | 3 | @import 'tailwindcss'; 4 | 5 | @import './theme.css'; 6 | @import './base.css'; 7 | @import './utilities.css'; 8 | 9 | @plugin "@tailwindcss/typography"; 10 | @plugin 'tailwindcss-animate'; 11 | -------------------------------------------------------------------------------- /env.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from '@t3-oss/env-nextjs' 2 | import { z } from 'zod' 3 | 4 | export const env = createEnv({ 5 | server: { 6 | GITHUB_TOKEN: z.string().startsWith('ghp_'), 7 | }, 8 | experimental__runtimeEnv: process.env, 9 | }) 10 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { About, Last } from '@/components/info' 2 | import { PageLayout } from '@/components/page' 3 | 4 | export default function Page() { 5 | return ( 6 | 7 | 8 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /lib/utils/edge.ts: -------------------------------------------------------------------------------- 1 | import { siteConfig } from '@/lib/constants/config' 2 | import { isProd } from '@/lib/utils/env' 3 | 4 | const baseUrl = isProd ? siteConfig.host : 'http://localhost:3000' 5 | 6 | export const getAbsoluteUrl = (path: string) => { 7 | return `${baseUrl}${path}` 8 | } 9 | -------------------------------------------------------------------------------- /hooks/use-is-mounted.ts: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from 'react' 2 | 3 | const emptySubscribe = () => () => void 0 4 | 5 | export function useIsMounted(): boolean { 6 | return useSyncExternalStore( 7 | emptySubscribe, 8 | () => true, 9 | () => false, 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /components/mdx/audio.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef } from 'react' 2 | import { AudioPlayer } from '@/components/audio-player' 3 | 4 | export function Audio(props: ComponentPropsWithoutRef<'audio'>) { 5 | if (!props.src) return null 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /stores/use-device.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | 3 | interface DeviceState { 4 | isMobile: boolean 5 | setIsMobile: (value: boolean) => void 6 | } 7 | 8 | export const useDevice = create((set) => ({ 9 | isMobile: false, 10 | setIsMobile: (value) => set({ isMobile: value }), 11 | })) 12 | -------------------------------------------------------------------------------- /app/feed/route.ts: -------------------------------------------------------------------------------- 1 | import { generateFeed } from '@/lib/meta/generate-feed' 2 | 3 | export const dynamic = 'force-static' 4 | 5 | export async function GET() { 6 | const feed = await generateFeed() 7 | 8 | return new Response(feed, { 9 | headers: { 10 | 'Content-Type': 'application/xml', 11 | }, 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Space } from '@/components/icons' 2 | 3 | export default function NotFound() { 4 | return ( 5 |
6 | 7 |

404 | This page could not be found.

8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /app/robots.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next' 2 | import { siteConfig } from '@/lib/constants/config' 3 | 4 | export default function robots(): MetadataRoute.Robots { 5 | return { 6 | rules: { 7 | userAgent: '*', 8 | allow: '/', 9 | }, 10 | sitemap: `${siteConfig.host}/sitemap.xml`, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /components/mdx/image.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef } from 'react' 2 | import { PhotoView } from '@/components/photo-view' 3 | 4 | export interface ImageProps extends ComponentPropsWithoutRef<'img'> { 5 | originalsrc: string 6 | } 7 | 8 | export function Image(props: ImageProps) { 9 | return 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.formatOnPaste": true, 4 | "editor.formatOnSave": true, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": "explicit", 8 | "source.fixAll.format": "explicit" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/providers/motion-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { domAnimation, LazyMotion } from 'framer-motion' 4 | import type { ReactNode } from 'react' 5 | 6 | export default function MotionProvider({ children }: { children: ReactNode }) { 7 | return ( 8 | 9 | {children} 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /app/providers/vercel-provider.tsx: -------------------------------------------------------------------------------- 1 | import { Analytics } from '@vercel/analytics/react' 2 | import { SpeedInsights } from '@vercel/speed-insights/react' 3 | import type { ReactNode } from 'react' 4 | 5 | export default function VercelProvider({ children }: { children: ReactNode }) { 6 | return ( 7 | <> 8 | {children} 9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /app/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /commitlint.config.ts: -------------------------------------------------------------------------------- 1 | const config = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | [ 8 | 'feat', 9 | 'fix', 10 | 'docs', 11 | 'chore', 12 | 'style', 13 | 'refactor', 14 | 'ci', 15 | 'test', 16 | 'perf', 17 | 'revert', 18 | 'vercel', 19 | ], 20 | ], 21 | 'scope-case': [2, 'always', 'kebab-case'], 22 | }, 23 | } 24 | 25 | export default config 26 | -------------------------------------------------------------------------------- /components/datetime/datetime.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | interface DateTimeProps { 4 | dateString: string 5 | dateFormat?: string 6 | className?: string 7 | } 8 | 9 | export function DateTime({ 10 | dateString, 11 | dateFormat = 'MMM DD, YYYY', 12 | className, 13 | }: DateTimeProps) { 14 | if (!dateString) return null 15 | 16 | const date = dayjs(dateString) 17 | if (!date.isValid()) return null 18 | const formattedDate = date.format(dateFormat) 19 | 20 | return ( 21 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next' 2 | import { siteConfig } from '@/lib/constants/config' 3 | import { sortedContent } from '@/lib/utils/content' 4 | 5 | export const dynamic = 'force-static' 6 | 7 | export default function sitemap(): MetadataRoute.Sitemap { 8 | const contentRoutes = sortedContent.map((item) => ({ 9 | url: `${siteConfig.host}${item.url}`, 10 | lastModified: new Date(item.date), 11 | })) 12 | 13 | const routes = [ 14 | { 15 | url: siteConfig.host, 16 | lastModified: new Date(), 17 | }, 18 | ...contentRoutes, 19 | ] 20 | 21 | return routes 22 | } 23 | -------------------------------------------------------------------------------- /hooks/use-device-listener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { getIsMobile } from '@/lib/utils/dom' 3 | import { debounce } from '@/lib/utils/lodash' 4 | import { useDevice } from '@/stores/use-device' 5 | 6 | export function useDeviceListener() { 7 | const setIsMobile = useDevice((s) => s.setIsMobile) 8 | 9 | useEffect(() => { 10 | setIsMobile(getIsMobile()) 11 | 12 | const check = debounce(() => { 13 | setIsMobile(getIsMobile()) 14 | }, 200) 15 | 16 | window.addEventListener('resize', check) 17 | return () => window.removeEventListener('resize', check) 18 | }, [setIsMobile]) 19 | } 20 | -------------------------------------------------------------------------------- /app/journal/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { List } from '@/components/list' 3 | import { groupByYear, sortedJournals } from '@/lib/utils/content' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Journal', 7 | description: 8 | 'A collection of my personal posts and thoughts on a variety of topics I enjoy, with a focus on technology.', 9 | keywords: ['blog', 'posts', 'thoughts', 'technical', 'tutorials', 'journal'], 10 | } 11 | 12 | export default function Page() { 13 | const groupList = groupByYear(sortedJournals) 14 | 15 | return 16 | } 17 | -------------------------------------------------------------------------------- /app/articles/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { List } from '@/components/list' 3 | import { groupByYear, sortedArticles } from '@/lib/utils/content' 4 | 5 | export const metadata: Metadata = { 6 | title: 'Articles', 7 | description: 8 | 'A collection of my personal posts and thoughts on a variety of topics I enjoy, with a focus on technology.', 9 | keywords: ['blog', 'posts', 'thoughts', 'technical', 'tutorials', 'articles'], 10 | } 11 | 12 | export default function Page() { 13 | const groupList = groupByYear(sortedArticles) 14 | 15 | return 16 | } 17 | -------------------------------------------------------------------------------- /hooks/use-loading.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react' 2 | 3 | export function useLoading(duration = 1000) { 4 | if (duration < 0) { 5 | console.warn('useLoading: duration should not be negative') 6 | duration = 0 7 | } 8 | 9 | const startTimeRef = useRef(0) 10 | 11 | const delay = async (): Promise => { 12 | const interval = duration - (Date.now() - startTimeRef.current) 13 | if (interval > 0) { 14 | await new Promise((resolve) => setTimeout(resolve, interval)) 15 | } 16 | } 17 | 18 | const reset = () => { 19 | startTimeRef.current = Date.now() 20 | } 21 | 22 | return [delay, reset] as const 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # env files (can opt-in for committing if needed) 33 | .env* 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | 42 | # contentlayer 43 | .content-collections 44 | /public/blog -------------------------------------------------------------------------------- /components/helper/tiny-button.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { cn } from '@/lib/utils/style' 3 | 4 | interface TinyButtonProps { 5 | show: boolean 6 | children: ReactNode 7 | onClick: () => void 8 | label: string 9 | } 10 | 11 | export function TinyButton({ 12 | show, 13 | children, 14 | onClick, 15 | label, 16 | }: TinyButtonProps) { 17 | return ( 18 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /lib/mdx/rehype-callout.ts: -------------------------------------------------------------------------------- 1 | import type { Element, Root } from 'hast' 2 | import type { Plugin } from 'unified' 3 | import { visit } from 'unist-util-visit' 4 | 5 | export const rehypeCallout: Plugin<[], Root> = () => { 6 | return (tree) => { 7 | visit(tree, { type: 'element', tagName: 'blockquote' }, (node: Element) => { 8 | const targetIndex = node.children.findIndex((child) => { 9 | if (child.type !== 'element' || child.tagName !== 'p') return false 10 | const firstChild = child.children[0] 11 | return firstChild?.type === 'text' && /^\[!.+?\]/.test(firstChild.value) 12 | }) 13 | 14 | if (targetIndex !== -1) { 15 | node.children.splice(targetIndex, 1) 16 | } 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /components/page/page.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | import { cn } from '@/lib/utils/style' 3 | import { Nav } from './nav' 4 | 5 | interface PageProps { 6 | children: ReactNode 7 | title?: string 8 | className?: string 9 | } 10 | 11 | export function PageLayout({ children, title, className }: PageProps) { 12 | return ( 13 |
14 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /hooks/use-theme-transition.ts: -------------------------------------------------------------------------------- 1 | import { useTheme } from 'next-themes' 2 | import { useCallback } from 'react' 3 | import { flushSync } from 'react-dom' 4 | import { transitionViewIfSupported } from '@/lib/utils/dom' 5 | 6 | export const Theme = { 7 | Light: 'light', 8 | Dark: 'dark', 9 | } 10 | 11 | export function useThemeTransition() { 12 | const { setTheme, theme, resolvedTheme } = useTheme() 13 | 14 | const toggleTheme = useCallback(() => { 15 | transitionViewIfSupported(() => { 16 | flushSync(() => 17 | setTheme(resolvedTheme === Theme.Light ? Theme.Dark : Theme.Light), 18 | ) 19 | }) 20 | }, [resolvedTheme, setTheme]) 21 | 22 | return { 23 | theme, 24 | resolvedTheme, 25 | toggleTheme, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | const config = { 3 | printWidth: 80, 4 | useTabs: false, 5 | tabWidth: 2, 6 | semi: false, 7 | singleQuote: true, 8 | trailingComma: 'all', 9 | arrowParens: 'always', 10 | endOfLine: 'lf', 11 | plugins: [ 12 | '@ianvs/prettier-plugin-sort-imports', 13 | 'prettier-plugin-tailwindcss', 14 | ], 15 | 16 | importOrder: [ 17 | '', 18 | '', 19 | '^@/(.*)$', 20 | '^[./]', 21 | ], 22 | importOrderParserPlugins: [ 23 | 'typescript', 24 | 'jsx', 25 | 'classProperties', 26 | 'decorators-legacy', 27 | ], 28 | importOrderTypeScriptVersion: '5.0.0', 29 | tailwindFunctions: ['clsx', 'cn'], 30 | } 31 | 32 | export default config 33 | -------------------------------------------------------------------------------- /components/footer/theme-switcher.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Moon, Sun } from '@/components/icons' 4 | import { useIsMounted } from '@/hooks/use-is-mounted' 5 | import { Theme, useThemeTransition } from '@/hooks/use-theme-transition' 6 | 7 | function ThemeButton() { 8 | const { toggleTheme, resolvedTheme } = useThemeTransition() 9 | const Icon = resolvedTheme === Theme.Light ? Sun : Moon 10 | 11 | return ( 12 | 19 | ) 20 | } 21 | 22 | export function ThemeSwitcher() { 23 | const mounted = useIsMounted() 24 | return
{mounted && }
25 | } 26 | -------------------------------------------------------------------------------- /lib/constants/config.ts: -------------------------------------------------------------------------------- 1 | const host = 'https://shiyui.vercel.app' 2 | 3 | export const siteConfig = { 4 | host, 5 | metadata: { 6 | title: "Shiyu's Hideout", 7 | description: 8 | 'My internet hideout, where you can explore topics I am learning, projects I am building, tech blog posts, and know more about who I am...', 9 | }, 10 | author: { 11 | name: 'Shiyu', 12 | email: 'chanshiyucx@gmail.com', 13 | link: host, 14 | }, 15 | links: { 16 | github: 'http://github.com/chanshiyucx', 17 | repo: 'http://github.com/chanshiyucx/zero', 18 | linkedIn: 'https://linkedin.com/in/xinchen-cx', 19 | twitter: 'https://x.com/chanshiyucx', 20 | discord: 'https://discord.gg/vPw79xTZxP', 21 | cvPdf: 22 | 'https://chanshiyucx.github.io/cv/Xin%20Chen%20-%20FrontEnd%20Engineer%20Resume.pdf', 23 | cv: 'https://chanshiyucx.github.io/cv/', 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /components/mdx/copy-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { CheckIcon, CopyIcon } from '@phosphor-icons/react/dist/ssr' 4 | import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' 5 | import { cn } from '@/lib/utils/style' 6 | 7 | export function CopyButton({ text }: { text: string }) { 8 | const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 3000 }) 9 | const handleCopy = () => copyToClipboard(text) 10 | 11 | return ( 12 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/api/content/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, type NextRequest } from 'next/server' 2 | import { z } from 'zod' 3 | import { findContentBySlug } from '@/lib/utils/content' 4 | 5 | export const revalidate = false 6 | 7 | const QuerySchema = z.object({ 8 | slug: z.string().min(1, 'Slug cannot be empty.'), 9 | }) 10 | 11 | export function GET(request: NextRequest) { 12 | try { 13 | const searchParams = request.nextUrl.searchParams 14 | const query = { 15 | slug: searchParams.get('slug'), 16 | } 17 | 18 | const { slug } = QuerySchema.parse(query) 19 | 20 | const content = findContentBySlug(slug) 21 | const meta = { title: content?.title } 22 | 23 | return NextResponse.json(meta) 24 | } catch (error) { 25 | if (error instanceof z.ZodError) { 26 | return NextResponse.json({ error: error.issues }, { status: 400 }) 27 | } 28 | return NextResponse.json(null, { status: 500 }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/mdx/rehype-audio.ts: -------------------------------------------------------------------------------- 1 | import type { Element, Root } from 'hast' 2 | import type { MdxJsxAttribute } from 'mdast-util-mdx' 3 | import type { Plugin } from 'unified' 4 | import { SKIP, visit } from 'unist-util-visit' 5 | 6 | export const rehypeAudio: Plugin<[], Root> = () => { 7 | return (tree) => { 8 | visit(tree, 'mdxJsxFlowElement', (node, index, parent) => { 9 | if (!parent || typeof index !== 'number') return 10 | if (node.name !== 'audio') return 11 | 12 | const props: Record = {} 13 | for (const attr of node.attributes as MdxJsxAttribute[]) { 14 | props[attr.name] = attr.value === null ? true : (attr.value as string) 15 | } 16 | 17 | const audioNode: Element = { 18 | type: 'element', 19 | tagName: 'audio', 20 | properties: props, 21 | children: [], 22 | } 23 | 24 | parent.children.splice(index, 1, audioNode) 25 | return [SKIP, index] 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "react-jsx", 15 | "incremental": true, 16 | "noUncheckedIndexedAccess": true, 17 | "verbatimModuleSyntax": true, 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ], 23 | "baseUrl": ".", 24 | "paths": { 25 | "@/*": ["./*"], 26 | "content-collections": ["./.content-collections/generated"] 27 | } 28 | }, 29 | "include": [ 30 | "next-env.d.ts", 31 | "**/*.ts", 32 | "**/*.tsx", 33 | ".next/types/**/*.ts", 34 | ".content-collections/generated/**/*.ts", 35 | ".next/dev/types/**/*.ts" 36 | ], 37 | "exclude": ["node_modules", ".next", "out", "dist"] 38 | } 39 | -------------------------------------------------------------------------------- /components/spinner/spinner.tsx: -------------------------------------------------------------------------------- 1 | import type { CSSProperties } from 'react' 2 | import { range } from '@/lib/utils/helper' 3 | import { cn } from '@/lib/utils/style' 4 | 5 | type Size = 'small' | 'large' 6 | 7 | interface SpinnerProps { 8 | size?: Size 9 | } 10 | 11 | interface LineProps { 12 | order: number 13 | size: Size 14 | } 15 | 16 | const Line = ({ order, size }: LineProps) => { 17 | const style: CSSProperties = { animationDelay: `${order * 0.1}s` } 18 | const h = size === 'small' ? 'h-6' : 'h-10' 19 | 20 | return ( 21 | 28 | ) 29 | } 30 | 31 | export function Spinner({ size = 'small' }: SpinnerProps) { 32 | const gap = size === 'small' ? 'gap-0.5' : 'gap-2' 33 | return ( 34 | 35 | {range(0, 5).map((i) => ( 36 | 37 | ))} 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /lib/api/fetch.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_TIMEOUT = 10000 // 10 seconds 2 | 3 | export class APIError extends Error { 4 | constructor( 5 | public status: number, 6 | message: string, 7 | ) { 8 | super(message) 9 | this.name = 'APIError' 10 | } 11 | } 12 | 13 | export async function fetchData( 14 | url: string, 15 | options: RequestInit, 16 | ): Promise { 17 | try { 18 | const controller = new AbortController() 19 | const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT) 20 | 21 | options.signal = controller.signal 22 | 23 | const response = await fetch(url, options) 24 | 25 | clearTimeout(timeoutId) 26 | 27 | if (!response.ok) { 28 | throw new APIError(response.status, response.statusText) 29 | } 30 | 31 | return (await response.json()) as T 32 | } catch (error: unknown) { 33 | if (error instanceof APIError) throw error 34 | if (error instanceof Error) { 35 | throw new Error(`Fetch error: ${error.message}`) 36 | } 37 | throw new Error('Unknown fetch error occurred') 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import { withContentCollections } from '@content-collections/next' 2 | import withBundleAnalyzer from '@next/bundle-analyzer' 3 | import type { NextConfig } from 'next' 4 | 5 | let nextConfig: NextConfig = { 6 | cacheComponents: true, 7 | images: { 8 | remotePatterns: [ 9 | { 10 | protocol: 'https', 11 | hostname: 'cx-onedrive.pages.dev', 12 | pathname: '**', 13 | }, 14 | ], 15 | }, 16 | async rewrites() { 17 | return [ 18 | { 19 | source: '/rss', 20 | destination: '/feed', 21 | }, 22 | { 23 | source: '/rss.xml', 24 | destination: '/feed', 25 | }, 26 | { 27 | source: '/feed.xml', 28 | destination: '/feed', 29 | }, 30 | { 31 | source: '/sitemap', 32 | destination: '/sitemap.xml', 33 | }, 34 | ] 35 | }, 36 | } 37 | 38 | if (process.env.ANALYZE === 'true') { 39 | nextConfig = withBundleAnalyzer({ 40 | enabled: true, 41 | })(nextConfig) 42 | } 43 | 44 | export default withContentCollections(nextConfig) 45 | -------------------------------------------------------------------------------- /lib/mdx/rehype-toc.ts: -------------------------------------------------------------------------------- 1 | import type { Meta } from '@content-collections/core' 2 | import type { Element, Root } from 'hast' 3 | import { toString } from 'hast-util-to-string' 4 | import type { Plugin } from 'unified' 5 | import { visit } from 'unist-util-visit' 6 | 7 | interface Options { 8 | headings?: string[] 9 | } 10 | 11 | export interface TocEntry { 12 | id: string 13 | title: string 14 | depth: number 15 | } 16 | 17 | interface MetaWithToc extends Meta { 18 | toc: TocEntry[] 19 | } 20 | 21 | export const rehypeToc: Plugin<[Options?], Root> = (options = {}) => { 22 | const { headings = ['h2', 'h3'] } = options 23 | 24 | return (tree, file) => { 25 | const toc: TocEntry[] = [] 26 | 27 | visit(tree, 'element', (node: Element) => { 28 | if (!headings.includes(node.tagName) || !node.properties?.id) return 29 | 30 | const depth = parseInt(node.tagName.charAt(1)) 31 | toc.push({ 32 | id: node.properties.id as string, 33 | title: toString(node), 34 | depth, 35 | }) 36 | }) 37 | const meta = file.data._meta as MetaWithToc 38 | meta.toc = toc 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 chanshiyu(蝉時雨) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /hooks/use-copy-to-clipboard.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react' 2 | 3 | interface UseCopyToClipboardOptions { 4 | timeout?: number 5 | onCopy?: () => void 6 | } 7 | 8 | export function useCopyToClipboard({ 9 | timeout = 2000, 10 | onCopy, 11 | }: UseCopyToClipboardOptions) { 12 | const [isCopied, setIsCopied] = useState(false) 13 | 14 | const copyToClipboard = useCallback( 15 | async (value: string) => { 16 | if (!navigator?.clipboard?.writeText) { 17 | console.warn('Clipboard API not supported.') 18 | return 19 | } 20 | 21 | if (!value) { 22 | console.warn('No value provided to copy.') 23 | return 24 | } 25 | 26 | try { 27 | await navigator.clipboard.writeText(value) 28 | setIsCopied(true) 29 | 30 | if (onCopy) { 31 | onCopy() 32 | } 33 | 34 | setTimeout(() => { 35 | setIsCopied(false) 36 | }, timeout) 37 | } catch (error) { 38 | console.error('Failed to copy to clipboard:', error) 39 | } 40 | }, 41 | [timeout, onCopy], 42 | ) 43 | 44 | return { isCopied, copyToClipboard } 45 | } 46 | -------------------------------------------------------------------------------- /lib/utils/dom.ts: -------------------------------------------------------------------------------- 1 | import { isServerSide } from './env' 2 | 3 | const prefersReducedMotion = () => 4 | window.matchMedia('(prefers-reduced-motion: reduce)').matches 5 | 6 | /** 7 | * Executes a view transition if supported by the browser and not disabled by user preferences 8 | * @param updateCb - Callback function that updates the DOM 9 | */ 10 | export const transitionViewIfSupported = (updateCb: () => void): void => { 11 | if (!document.startViewTransition || prefersReducedMotion()) { 12 | updateCb() 13 | return 14 | } 15 | 16 | document.startViewTransition(updateCb) 17 | } 18 | 19 | export const getIsMobile = (breakpoint = 768): boolean => { 20 | if (isServerSide) return false 21 | 22 | const isSmallScreen = window.innerWidth <= breakpoint 23 | const hasTouchSupport = 24 | 'ontouchstart' in window || 25 | navigator.maxTouchPoints > 0 || 26 | 'msMaxTouchPoints' in navigator 27 | 28 | return isSmallScreen && hasTouchSupport 29 | } 30 | 31 | export const toggleFullscreen = () => { 32 | if (!document.fullscreenElement) { 33 | document.documentElement.requestFullscreen?.().catch(console.error) 34 | } else { 35 | document.exitFullscreen?.().catch(console.error) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/projects/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import { Card } from '@/components/card' 3 | import { Github } from '@/components/github' 4 | import { PageLayout } from '@/components/page' 5 | import { getGithubRepositories } from '@/lib/api/github' 6 | 7 | export const metadata: Metadata = { 8 | title: 'Projects', 9 | description: 10 | 'A personal portfolio showcasing my active and past projects, websites, apps, and more.', 11 | keywords: ['projects', 'portfolio', 'programming', 'softwares', 'apps'], 12 | } 13 | 14 | export default async function Page() { 15 | const repositories = await getGithubRepositories() 16 | 17 | return ( 18 | 19 |
    23 | {repositories.map((repo) => ( 24 |
  • 25 | 26 | 30 | 31 |
  • 32 | ))} 33 |
34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /scripts/sync-blog.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { spawn } from 'node:child_process' 3 | 4 | const REPO_URL = 'https://github.com/chanshiyucx/blog.git' 5 | const CONTENT_PATH = './public/blog' 6 | 7 | const runBashCommand = (command: string) => 8 | new Promise((resolve, reject) => { 9 | console.log(`Run bash command: ${command}`) 10 | const child = spawn(command, [], { shell: true }) 11 | child.stdout.setEncoding('utf8') 12 | child.stdout.on('data', (data: string) => process.stdout.write(data)) 13 | child.stderr.setEncoding('utf8') 14 | child.stderr.on('data', (data: string) => process.stderr.write(data)) 15 | child.on('close', function (code) { 16 | if (code === 0) { 17 | resolve(void 0) 18 | } else { 19 | reject(new Error(`Command failed with exit code ${code}`)) 20 | } 21 | }) 22 | }) 23 | 24 | export async function syncBlogFromGit() { 25 | console.log('Syncing content files from git') 26 | if (fs.existsSync(CONTENT_PATH)) { 27 | await runBashCommand(`cd ${CONTENT_PATH} && git pull`) 28 | } else { 29 | await runBashCommand( 30 | `git clone --depth 1 --single-branch ${REPO_URL} ${CONTENT_PATH}`, 31 | ) 32 | } 33 | } 34 | 35 | syncBlogFromGit().catch(console.error) 36 | -------------------------------------------------------------------------------- /components/info/about.tsx: -------------------------------------------------------------------------------- 1 | import { HandWavingIcon } from '@phosphor-icons/react/dist/ssr' 2 | import { Logo } from '@/components/logo' 3 | 4 | export function About() { 5 | return ( 6 |
7 |
8 |
9 | 10 |
11 | 12 |
13 |

14 | Full-Stack Developer / Budding Photographer 15 |

16 | 17 |

18 | 19 | Hello, I'm Shiyu. A curious soul with big dreams. 20 |

21 |
22 |
23 | 24 |

25 | Crafting interfaces. Designing elegant software and immersive 26 | digital experiences. Following curiosity relentlessly, weaving intention 27 | into every pixel. 28 |

29 | 30 |

31 | Seize the day, gather ye rosebuds while ye may. Sleeping, 32 | coding, learning German, chasing light through photography. 33 |

34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /components/mdx/figure.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef } from 'react' 2 | import { cn } from '@/lib/utils/style' 3 | import { CopyButton } from './copy-button' 4 | 5 | const LanguageMap = { 6 | typescript: 'TS', 7 | javascript: 'JS', 8 | python: 'PY', 9 | shell: 'SH', 10 | } as const 11 | 12 | interface FigureProps extends ComponentPropsWithoutRef<'figure'> { 13 | raw?: string 14 | 'data-rehype-pretty-code-figure'?: boolean 15 | 'data-language'?: string 16 | } 17 | 18 | function Language({ language }: { language: string }) { 19 | const languageText = ( 20 | LanguageMap[language as keyof typeof LanguageMap] ?? language 21 | ).toUpperCase() 22 | return ( 23 | 24 | {languageText} 25 | 26 | ) 27 | } 28 | 29 | export function Figure({ children, raw, className, ...rest }: FigureProps) { 30 | const showCopyButton = raw && 'data-rehype-pretty-code-figure' in rest 31 | const language = rest['data-language'] 32 | 33 | return ( 34 |
35 | {children} 36 | {showCopyButton && } 37 | {language && } 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/helper/scroll-top.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { CaretDoubleUpIcon } from '@phosphor-icons/react/dist/ssr' 4 | import { useCallback, useEffect, useState } from 'react' 5 | import { throttle } from '@/lib/utils/lodash' 6 | import { TinyButton } from './tiny-button' 7 | 8 | export function ScrollTop() { 9 | const [showBackTop, setShowBackTop] = useState(false) 10 | 11 | const backToTop = useCallback(() => { 12 | window.scrollTo({ top: 0, behavior: 'smooth' }) 13 | }, []) 14 | 15 | const handleScrollAndResize = useCallback(() => { 16 | const { innerHeight, scrollY } = window 17 | setShowBackTop(scrollY > innerHeight / 5) 18 | }, []) 19 | 20 | useEffect(() => { 21 | const throttledHandler = throttle(handleScrollAndResize, 16) 22 | throttledHandler() 23 | 24 | window.addEventListener('scroll', throttledHandler) 25 | window.addEventListener('resize', throttledHandler) 26 | 27 | return () => { 28 | window.removeEventListener('scroll', throttledHandler) 29 | window.removeEventListener('resize', throttledHandler) 30 | } 31 | }, [handleScrollAndResize]) 32 | 33 | return ( 34 | 35 | 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /lib/mdx/rehype-code.ts: -------------------------------------------------------------------------------- 1 | import type { Element, Root } from 'hast' 2 | import type { Plugin } from 'unified' 3 | import { visit } from 'unist-util-visit' 4 | 5 | interface CodeElement extends Element { 6 | tagName: 'code' 7 | children: [{ type: 'text'; value: string }] 8 | } 9 | 10 | const isCodeElement = (node: Element): node is CodeElement => 11 | node.tagName === 'code' && 12 | Array.isArray(node.children) && 13 | node.children.length === 1 && 14 | node.children[0]?.type === 'text' 15 | 16 | export const rehypeCode: Plugin<[], Root> = () => { 17 | return (tree) => { 18 | // code copy support 19 | visit(tree, { type: 'element', tagName: 'pre' }, (node: Element) => { 20 | const [codeEl] = node.children as Element[] 21 | if (!codeEl || !isCodeElement(codeEl)) return 22 | 23 | node.properties = node.properties || {} 24 | node.properties.raw = codeEl.children[0].value 25 | const className = codeEl.properties?.className as string[] | undefined 26 | const language = className?.[0]?.split('-')[1] ?? 'text' 27 | node.properties['data-language'] = language 28 | }) 29 | 30 | // replace   with space 31 | visit(tree, 'text', (node) => { 32 | if (typeof node.value === 'string') { 33 | node.value = node.value.replace(/\u00A0/g, ' ') 34 | } 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /components/icons/laptop.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from 'react' 2 | 3 | export function Laptop(props: SVGProps) { 4 | return ( 5 | 6 | 13 | 20 | 27 | 33 | 34 | 35 | 42 | 43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /components/page/nav.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ArrowBendUpLeftIcon } from '@phosphor-icons/react/dist/ssr' 4 | import Link from 'next/link' 5 | import { usePathname } from 'next/navigation' 6 | import { useMemo } from 'react' 7 | 8 | export function Nav() { 9 | const pathname = usePathname() 10 | 11 | const backPath = useMemo(() => { 12 | const segments = pathname.split('/').filter(Boolean) 13 | 14 | if (segments.length === 0) return null 15 | 16 | const parentHref = 17 | segments.length === 1 ? '/' : `/${segments.slice(0, -1).join('/')}` 18 | 19 | let parentName = '' 20 | if (parentHref === '/') { 21 | parentName = 'Index' 22 | } else { 23 | const parentKey = parentHref.split('/').pop()! 24 | parentName = parentKey.charAt(0).toUpperCase() + parentKey.slice(1) 25 | } 26 | 27 | if (!parentName) return null 28 | return { name: parentName, href: parentHref } 29 | }, [pathname]) 30 | 31 | if (!backPath) { 32 | return null 33 | } 34 | 35 | return ( 36 | 40 | 41 | {backPath.name} 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /components/logo/logo.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useRef } from 'react' 4 | import { Signature } from '@/components/icons' 5 | import { cn } from '@/lib/utils/style' 6 | 7 | interface SignatureBoxProps { 8 | animate?: boolean 9 | className?: string 10 | } 11 | 12 | const SignatureBox = ({ animate, className }: SignatureBoxProps) => { 13 | const svgGroupRef = useRef(null) 14 | 15 | useEffect(() => { 16 | const svgGroupElement = svgGroupRef.current 17 | if (!svgGroupElement || !animate) return 18 | 19 | const paths = svgGroupElement.querySelectorAll('path') 20 | paths.forEach((path: SVGPathElement, i: number) => { 21 | const length = path.getTotalLength() 22 | path.style.strokeDasharray = `${length}px` 23 | path.style.strokeDashoffset = `${length}px` 24 | path.style.stroke = 'var(--color-text)' 25 | path.style.setProperty('--path-length', `${length}px`) 26 | path.style.setProperty('--delay', `${i * 0.1}s`) 27 | path.classList.add('animate-svg-text') 28 | }) 29 | }, [animate]) 30 | 31 | return ( 32 | 36 | ) 37 | } 38 | 39 | export function Logo() { 40 | return ( 41 |
42 | 43 | 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /components/icons/discord.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from 'react' 2 | 3 | export function Discord(props: SVGProps) { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /hooks/use-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { isClientSide } from '@/lib/utils/env' 3 | 4 | export function useLocalStorage( 5 | key: string, 6 | initialValue: T, 7 | expire?: number, 8 | ): readonly [T, (value: T | ((prev: T) => T)) => void] { 9 | const expireKey = `${key}-expire` 10 | 11 | const [storedValue, setStoredValue] = useState(() => { 12 | try { 13 | if (!isClientSide) { 14 | return initialValue 15 | } 16 | 17 | const expireDate = window.localStorage.getItem(expireKey) 18 | if (expireDate && Date.now() > Number(expireDate)) { 19 | return initialValue 20 | } 21 | 22 | const item = window.localStorage.getItem(key) 23 | return item ? (JSON.parse(item) as T) : initialValue 24 | } catch (error) { 25 | console.error('useLocalStorage Error:', error) 26 | return initialValue 27 | } 28 | }) 29 | 30 | const setValue = (value: T | ((prev: T) => T)) => { 31 | try { 32 | const newValue = value instanceof Function ? value(storedValue) : value 33 | setStoredValue(newValue) 34 | window.localStorage.setItem(key, JSON.stringify(newValue)) 35 | if (expire) { 36 | const expireDate = String(Date.now() + expire) 37 | window.localStorage.setItem(expireKey, expireDate) 38 | } 39 | } catch (error) { 40 | console.error('useLocalStorage Error:', error) 41 | } 42 | } 43 | 44 | return [storedValue, setValue] 45 | } 46 | -------------------------------------------------------------------------------- /components/github/github.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | GitForkIcon, 3 | GithubLogoIcon, 4 | StarIcon, 5 | } from '@phosphor-icons/react/dist/ssr' 6 | import type { Repository } from '@/lib/api/github' 7 | import { cn } from '@/lib/utils/style' 8 | 9 | interface GithubProps { 10 | repo: Repository 11 | className?: string 12 | } 13 | 14 | export function Github({ repo, className }: GithubProps) { 15 | return ( 16 |
22 |
23 |
{repo.name}
24 | 31 | 32 | 33 |
34 |
{repo.description}
35 |
36 |
37 | 38 | {repo.stargazers_count} 39 |
40 |
41 | 42 | {repo.forks_count} 43 |
44 |
45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /app/gallery/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Album } from 'content-collections' 2 | import type { Metadata } from 'next' 3 | import { DateTime } from '@/components/datetime' 4 | import { MDX } from '@/components/mdx' 5 | import { PageLayout } from '@/components/page' 6 | import { sortedAlbums } from '@/lib/utils/content' 7 | 8 | export const metadata: Metadata = { 9 | title: 'Album', 10 | description: 11 | 'A collection of my photography, each image capturing a unique moment and emotion.', 12 | keywords: ['album', 'photo', 'photography', 'travel'], 13 | } 14 | 15 | function AlbumItem({ album }: { album: Album }) { 16 | return ( 17 |
18 |
22 |

{album.title}

23 | 27 |
28 | 29 |
30 | ) 31 | } 32 | 33 | export default function Page() { 34 | return ( 35 | 36 |
37 | {sortedAlbums.map((album) => ( 38 | 39 | ))} 40 |
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /lib/meta/generate-feed.ts: -------------------------------------------------------------------------------- 1 | import { Feed } from 'feed' 2 | import { siteConfig } from '@/lib/constants/config' 3 | import { getMdxToHtmlProcessor } from '@/lib/mdx' 4 | import { sortedContent } from '@/lib/utils/content' 5 | 6 | export async function generateFeed() { 7 | const list = sortedContent 8 | const date = new Date() 9 | const feed = new Feed({ 10 | id: siteConfig.host, 11 | link: siteConfig.host, 12 | title: siteConfig.metadata.title, 13 | description: siteConfig.metadata.description, 14 | author: siteConfig.author, 15 | favicon: `${siteConfig.host}/icon.svg`, 16 | image: `${siteConfig.host}/icon.svg`, 17 | copyright: `All rights reserved ${date.getFullYear()}, Chanshiyu.`, 18 | updated: list[0] ? new Date(list[0].date) : date, 19 | docs: siteConfig.links.repo, 20 | feedLinks: { 21 | rss2: `${siteConfig.host}/feed`, 22 | }, 23 | generator: 'https://github.com/jpmonette/feed', 24 | }) 25 | 26 | await Promise.all( 27 | list.map(async (item) => { 28 | const link = `${siteConfig.host}${item.url}` 29 | const content = await getMdxToHtmlProcessor(item.type).process( 30 | item.content, 31 | ) 32 | feed.addItem({ 33 | link, 34 | title: item.title, 35 | id: item.slug, 36 | description: item.description, 37 | content: String(content), 38 | author: [siteConfig.author], 39 | date: new Date(item.date), 40 | category: item.tags.map((tag) => ({ name: tag })), 41 | }) 42 | }), 43 | ) 44 | 45 | return feed.rss2() 46 | } 47 | -------------------------------------------------------------------------------- /hooks/use-shortcut.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react' 2 | 3 | export function useShortcut() { 4 | const shortcuts = useRef void>>(new Map()) 5 | const keySequence = useRef([]) 6 | const keyTimeout = useRef(null) 7 | 8 | const register = useCallback((shortcut: string[], action: () => void) => { 9 | if (shortcut && shortcut.length > 0) { 10 | const key = shortcut.join(',') 11 | shortcuts.current.set(key, action) 12 | } 13 | }, []) 14 | 15 | const handleKeypress = useCallback((key: string) => { 16 | if (keyTimeout.current) { 17 | clearTimeout(keyTimeout.current) 18 | } 19 | 20 | keySequence.current.push(key) 21 | const currentSequence = keySequence.current.join(',') 22 | 23 | if (shortcuts.current.has(currentSequence)) { 24 | const action = shortcuts.current.get(currentSequence) 25 | if (action) { 26 | action() 27 | } 28 | // keySequence.current = [] 29 | // return 30 | } 31 | 32 | const hasPartialMatch = Array.from(shortcuts.current.keys()).some( 33 | (shortcut) => shortcut.startsWith(currentSequence + ','), 34 | ) 35 | 36 | if (hasPartialMatch) { 37 | keyTimeout.current = window.setTimeout(() => { 38 | keySequence.current = [] 39 | }, 1000) 40 | } else { 41 | keySequence.current = [] 42 | } 43 | }, []) 44 | 45 | const clearSequence = useCallback(() => { 46 | keySequence.current = [] 47 | if (keyTimeout.current) { 48 | clearTimeout(keyTimeout.current) 49 | } 50 | }, []) 51 | 52 | return { register, handleKeypress, clearSequence } 53 | } 54 | -------------------------------------------------------------------------------- /app/snippets/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Snippet } from 'content-collections' 2 | import type { Metadata } from 'next' 3 | import Link from 'next/link' 4 | import { DateTime } from '@/components/datetime' 5 | import { MDX } from '@/components/mdx' 6 | import { PageLayout } from '@/components/page' 7 | import { sortedSnippets } from '@/lib/utils/content' 8 | 9 | export const metadata: Metadata = { 10 | title: 'Snippets', 11 | description: 'A collection of snippets on things I have learned recently.', 12 | keywords: ['blog', 'snippets', 'learn', 'study', 'skills', 'code'], 13 | } 14 | 15 | function SnippetItem({ snippet }: { snippet: Snippet }) { 16 | return ( 17 |
18 |
22 | 23 |

{snippet.title}

24 | 25 |
26 | 27 |
28 |
29 | 30 |
31 | ) 32 | } 33 | 34 | export default function Page() { 35 | return ( 36 | 37 |
38 | {sortedSnippets.map((snippet) => ( 39 | 40 | ))} 41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /styles/base.css: -------------------------------------------------------------------------------- 1 | @layer base { 2 | :root { 3 | @apply scroll-smooth; 4 | } 5 | 6 | ::view-transition-new(root) { 7 | @apply animate-turn-off; 8 | } 9 | 10 | ::view-transition-old(root) { 11 | animation: none; 12 | } 13 | 14 | [data-theme='dark']::view-transition-new(root) { 15 | @apply animate-turn-on; 16 | } 17 | 18 | *, 19 | ::after, 20 | ::before { 21 | @apply border-overlay; 22 | } 23 | 24 | html { 25 | color-scheme: light dark; 26 | } 27 | 28 | html[data-theme='light'] { 29 | color-scheme: light; 30 | } 31 | 32 | html[data-theme='dark'] { 33 | color-scheme: dark; 34 | } 35 | 36 | body { 37 | @apply bg-base text-text mx-auto! flex min-h-screen max-w-2xl flex-col px-4 font-sans font-medium tabular-nums antialiased; 38 | @apply selection:bg-overlay; 39 | } 40 | 41 | h2, 42 | h3 { 43 | @apply scroll-mt-8; 44 | } 45 | 46 | pre[data-language] { 47 | @apply p-0!; 48 | 49 | code { 50 | @apply py-3!; 51 | } 52 | 53 | span[data-line=''] { 54 | @apply px-4; 55 | } 56 | } 57 | 58 | code { 59 | @apply bg-overlay rounded-md px-1 py-0.5; 60 | 61 | &::before, 62 | &::after { 63 | @apply content-none!; 64 | } 65 | } 66 | 67 | code[data-theme], 68 | code[data-theme] span { 69 | @apply font-medium; 70 | color: light-dark(var(--shiki-light), var(--shiki-dark)); 71 | } 72 | 73 | span[data-highlighted-line] { 74 | @apply bg-base border-l-2; 75 | } 76 | 77 | blockquote { 78 | @apply text-subtle!; 79 | 80 | p { 81 | &:first-child::before, 82 | &:last-child::after { 83 | @apply content-none!; 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import nextPlugin from '@next/eslint-plugin-next' 3 | import prettierConfig from 'eslint-config-prettier' 4 | import reactPlugin from 'eslint-plugin-react' 5 | import reactHooksPlugin from 'eslint-plugin-react-hooks' 6 | import tseslint from 'typescript-eslint' 7 | 8 | export default [ 9 | { 10 | ignores: [ 11 | '.next/**', 12 | 'out/**', 13 | 'dist/**', 14 | 'node_modules/**', 15 | '.content-collections/**', 16 | '**/*.d.ts', 17 | ], 18 | }, 19 | 20 | js.configs.recommended, 21 | ...tseslint.configs.recommendedTypeChecked.map((conf) => ({ 22 | ...conf, 23 | files: ['**/*.{ts,tsx}'], 24 | })), 25 | ...tseslint.configs.stylisticTypeChecked.map((conf) => ({ 26 | ...conf, 27 | files: ['**/*.{ts,tsx}'], 28 | })), 29 | 30 | { 31 | files: ['**/*.{js,jsx,ts,tsx}'], 32 | plugins: { 33 | '@next/next': nextPlugin, 34 | react: reactPlugin, 35 | 'react-hooks': reactHooksPlugin, 36 | }, 37 | settings: { 38 | react: { 39 | version: 'detect', 40 | }, 41 | }, 42 | rules: { 43 | ...reactPlugin.configs.flat?.recommended?.rules, 44 | ...reactPlugin.configs.flat?.['jsx-runtime']?.rules, 45 | ...reactHooksPlugin.configs.recommended.rules, 46 | ...nextPlugin.configs.recommended.rules, 47 | ...nextPlugin.configs['core-web-vitals'].rules, 48 | }, 49 | }, 50 | { 51 | files: ['**/*.{ts,tsx}'], 52 | languageOptions: { 53 | parserOptions: { 54 | projectService: true, 55 | tsconfigRootDir: import.meta.dirname, 56 | }, 57 | }, 58 | }, 59 | { 60 | files: ['**/*.config.{js,ts,mjs}', 'eslint.config.ts'], 61 | ...tseslint.configs.disableTypeChecked, 62 | }, 63 | prettierConfig, 64 | ] 65 | -------------------------------------------------------------------------------- /lib/utils/lodash.ts: -------------------------------------------------------------------------------- 1 | interface ThrottleOptions { 2 | leading?: boolean 3 | trailing?: boolean 4 | } 5 | 6 | export const debounce = ( 7 | func: (this: T, ...args: A) => R, 8 | wait: number, 9 | immediate = false, 10 | ): ((this: T, ...args: A) => void) => { 11 | let timeoutId: ReturnType | undefined 12 | 13 | return function (this: T, ...args: A) { 14 | // eslint-disable-next-line @typescript-eslint/no-this-alias 15 | const context = this 16 | const later = () => { 17 | timeoutId = undefined 18 | if (!immediate) func.apply(context, args) 19 | } 20 | 21 | const shouldCallNow = immediate && timeoutId === undefined 22 | clearTimeout(timeoutId) 23 | timeoutId = setTimeout(later, wait) 24 | 25 | if (shouldCallNow) func.apply(context, args) 26 | } 27 | } 28 | 29 | export const throttle = ( 30 | func: (this: T, ...args: A) => R, 31 | wait: number, 32 | { leading = true, trailing = true }: ThrottleOptions = {}, 33 | ): ((this: T, ...args: A) => void) => { 34 | let timeoutId: ReturnType | undefined 35 | let lastTime = 0 36 | 37 | const invoke = (context: T, args: A) => { 38 | func.apply(context, args) 39 | lastTime = Date.now() 40 | } 41 | 42 | return function (this: T, ...args: A) { 43 | const now = Date.now() 44 | const remaining = wait - (now - lastTime) 45 | 46 | if (remaining <= 0) { 47 | if (timeoutId) { 48 | clearTimeout(timeoutId) 49 | timeoutId = undefined 50 | } 51 | lastTime = now 52 | invoke(this, args) 53 | } else if (!timeoutId && trailing) { 54 | timeoutId = setTimeout(() => { 55 | lastTime = leading ? Date.now() : 0 56 | timeoutId = undefined 57 | invoke(this, args) 58 | }, remaining) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /components/footer/footer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CompassIcon, 3 | DiscordLogoIcon, 4 | GithubLogoIcon, 5 | LinkedinLogoIcon, 6 | RssSimpleIcon, 7 | XLogoIcon, 8 | } from '@phosphor-icons/react/dist/ssr' 9 | import { siteConfig } from '@/lib/constants/config' 10 | import { ThemeSwitcher } from './theme-switcher' 11 | 12 | const socialLinks = [ 13 | { href: '/feed', icon: RssSimpleIcon, label: 'RSS Feed' }, 14 | { href: '/sitemap', icon: CompassIcon, label: 'Sitemap' }, 15 | { 16 | href: siteConfig.links.twitter, 17 | icon: XLogoIcon, 18 | label: 'X (formerly Twitter)', 19 | }, 20 | { href: siteConfig.links.discord, icon: DiscordLogoIcon, label: 'Discord' }, 21 | { href: siteConfig.links.github, icon: GithubLogoIcon, label: 'GitHub' }, 22 | { 23 | href: siteConfig.links.linkedIn, 24 | icon: LinkedinLogoIcon, 25 | label: 'LinkedIn', 26 | }, 27 | ] as const 28 | 29 | export function Footer() { 30 | return ( 31 |
32 |
33 | 34 |

35 | Shiyu 36 | © 37 | 2016-{new Date().getFullYear()} 38 |

39 |
40 | 41 |
42 | {socialLinks.map(({ href, icon: Icon, label }, index) => { 43 | return ( 44 | 51 | 55 | 56 | ) 57 | })} 58 |
59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /components/mdx/mdx.tsx: -------------------------------------------------------------------------------- 1 | import { MDXContent } from '@content-collections/mdx/react' 2 | import { lazy, Suspense, type ComponentProps } from 'react' 3 | import { Spinner } from '@/components/spinner' 4 | import { cn } from '@/lib/utils/style' 5 | import { Figure } from './figure' 6 | import { Image } from './image' 7 | import { Link } from './link' 8 | 9 | export type MDXComponents = ComponentProps['components'] 10 | 11 | const LazyAudio = lazy(() => 12 | import('./audio').then((module) => ({ default: module.Audio })), 13 | ) 14 | 15 | const SuspendedAudio = (props: ComponentProps) => ( 16 | 19 | 20 | 21 | } 22 | > 23 | 24 | 25 | ) 26 | 27 | const defaultComponents: MDXComponents = { 28 | img: Image, 29 | a: Link, 30 | figure: Figure, 31 | audio: SuspendedAudio, 32 | } 33 | 34 | interface MDXProps { 35 | contentCode: string 36 | className?: string 37 | components?: MDXComponents 38 | staggerStart?: number 39 | slideAuto?: boolean 40 | } 41 | 42 | export function MDX({ 43 | contentCode, 44 | className, 45 | components, 46 | staggerStart, 47 | slideAuto = true, 48 | }: MDXProps) { 49 | const mergedComponents = { 50 | ...defaultComponents, 51 | ...components, 52 | } 53 | 54 | return ( 55 |
66 | 67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /lib/utils/helper.ts: -------------------------------------------------------------------------------- 1 | export const delay = (time: number): Promise => 2 | new Promise((resolve) => setTimeout(resolve, time)) 3 | 4 | export const random = (min: number, max: number): number => 5 | Math.floor(Math.random() * (max - min + 1)) + min 6 | 7 | export const createSeededRandom = (initialSeed = 1) => { 8 | let seed = initialSeed 9 | return () => { 10 | const x = Math.sin(seed++) * 10000 11 | return x - Math.floor(x) 12 | } 13 | } 14 | 15 | export const range = (start: number, end: number): number[] => 16 | Array.from({ length: end - start }, (_, i) => start + i) 17 | 18 | export const shuffle = (arr: T[]): T[] => { 19 | let i = arr.length 20 | let j 21 | while (i) { 22 | j = Math.floor(Math.random() * i--) 23 | const temp = arr[i]! 24 | arr[i] = arr[j]! 25 | arr[j] = temp 26 | } 27 | return arr 28 | } 29 | 30 | export const formatTime = (timeInSeconds: number): string => { 31 | if (!Number.isFinite(timeInSeconds) || timeInSeconds < 0) { 32 | return '0:00' 33 | } 34 | const minutes = Math.floor(timeInSeconds / 60) 35 | const seconds = Math.floor(timeInSeconds % 60) 36 | return `${minutes}:${seconds.toString().padStart(2, '0')}` 37 | } 38 | 39 | export const formatDate = (date: Date): string => { 40 | const year = date.getFullYear() 41 | const month = (date.getMonth() + 1).toString().padStart(2, '0') 42 | const day = date.getDate().toString().padStart(2, '0') 43 | return `${year}-${month}-${day}` 44 | } 45 | 46 | export const formatFileSize = (bytes: number): string => { 47 | if (bytes === 0) return '0 B' 48 | const k = 1024 49 | const sizes = ['B', 'KB', 'MB', 'GB'] 50 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 51 | return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] 52 | } 53 | 54 | export const clamp = (value: number, min: number, max: number) => { 55 | return Math.min(Math.max(value, min), max) 56 | } 57 | 58 | export const isExternalLink = (url: string): boolean => /^https?:\/\//.test(url) 59 | -------------------------------------------------------------------------------- /components/audio-player/title.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import { range } from '@/lib/utils/helper' 3 | 4 | interface TitleProps { 5 | title: string 6 | } 7 | 8 | function MarqueeTitle({ title }: TitleProps) { 9 | return ( 10 | <> 11 | {range(0, 2).map((i) => ( 12 | 16 | {title} 17 | 18 | ))} 19 | 20 | ) 21 | } 22 | 23 | export function Title({ title }: TitleProps) { 24 | const [shouldScroll, setShouldScroll] = useState(false) 25 | const containerRef = useRef(null) 26 | const textRef = useRef(null) 27 | 28 | useEffect(() => { 29 | const checkOverflow = () => { 30 | const container = containerRef.current 31 | const text = textRef.current 32 | if (!container || !text) return 33 | 34 | const containerStyle = window.getComputedStyle(container) 35 | const paddingLeft = parseFloat(containerStyle.paddingLeft) 36 | const paddingRight = parseFloat(containerStyle.paddingRight) 37 | const availableWidth = container.clientWidth - paddingLeft - paddingRight 38 | 39 | setShouldScroll(text.scrollWidth > availableWidth) 40 | } 41 | 42 | const timer = setTimeout(checkOverflow, 100) 43 | 44 | const resizeObserver = new ResizeObserver(checkOverflow) 45 | if (containerRef.current) { 46 | resizeObserver.observe(containerRef.current) 47 | } 48 | 49 | return () => { 50 | clearTimeout(timer) 51 | resizeObserver.disconnect() 52 | } 53 | }, [title]) 54 | 55 | return ( 56 |
60 | {shouldScroll ? ( 61 | 62 | ) : ( 63 | 64 | {title} 65 | 66 | )} 67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /lib/utils/content.ts: -------------------------------------------------------------------------------- 1 | import { 2 | allAlbums, 3 | allArticles, 4 | allJournals, 5 | allSnippets, 6 | type Album, 7 | type Article, 8 | type Journal, 9 | type Snippet, 10 | } from 'content-collections' 11 | import dayjs from 'dayjs' 12 | 13 | export type Content = Article | Snippet | Journal 14 | 15 | export interface ContentGroup { 16 | year: number 17 | list: Content[] 18 | } 19 | 20 | export interface BlogSummary { 21 | articles: number 22 | snippets: number 23 | journals: number 24 | } 25 | 26 | export const content: Content[] = [ 27 | ...allArticles, 28 | ...allSnippets, 29 | ...allJournals, 30 | ] 31 | 32 | const sortByDate = (items: readonly T[]): T[] => 33 | [...items].sort((a, b) => dayjs(b.date).diff(dayjs(a.date))) 34 | 35 | const sortedByPriority = ( 36 | items: readonly T[], 37 | ): T[] => [...items].sort((a, b) => b.priority - a.priority) 38 | 39 | export const groupByYear = (items: Content[]): ContentGroup[] => { 40 | const groups: Record = {} 41 | 42 | items.forEach((item) => { 43 | const year = dayjs(item.date).year() 44 | groups[year] ??= [] 45 | groups[year].push(item) 46 | }) 47 | 48 | return Object.entries(groups) 49 | .map(([year, list]) => ({ 50 | year: Number(year), 51 | list, 52 | })) 53 | .sort((a, b) => b.year - a.year) 54 | } 55 | 56 | export const sortedAlbums: Album[] = sortByDate(allAlbums) 57 | 58 | export const sortedArticles: Article[] = sortByDate(allArticles) 59 | 60 | export const sortedJournals: Journal[] = sortByDate(allJournals) 61 | 62 | export const sortedSnippets: Snippet[] = sortByDate(allSnippets) 63 | 64 | export const sortedContent: Content[] = sortByDate(content) 65 | 66 | export const sortedPriorityContent: Content[] = sortedByPriority(sortedContent) 67 | 68 | export const summary: BlogSummary = { 69 | articles: allArticles.length, 70 | snippets: allSnippets.length, 71 | journals: allJournals.length, 72 | } 73 | 74 | export const findContentBySlug = (slug: string): Content | undefined => 75 | content.find((c) => c.slug === slug) 76 | -------------------------------------------------------------------------------- /components/list/list.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { Fragment, type ReactNode } from 'react' 3 | import { DateTime } from '@/components/datetime' 4 | import { PageLayout } from '@/components/page' 5 | import type { Content, ContentGroup } from '@/lib/utils/content' 6 | import { cn } from '@/lib/utils/style' 7 | 8 | interface ExtraInfo { 9 | className: string 10 | text: string 11 | } 12 | 13 | interface ListProps { 14 | title: string 15 | groups: ContentGroup[] 16 | extractInfo?: (article: Content) => ExtraInfo 17 | renderTitle?: (article: Content) => ReactNode 18 | } 19 | 20 | function renderExtraInfo(extraInfo: ExtraInfo) { 21 | return ( 22 | 28 | {extraInfo.text} 29 | 30 | ) 31 | } 32 | 33 | export function List({ title, groups, extractInfo, renderTitle }: ListProps) { 34 | return ( 35 | 36 |
    40 | {groups.map((group) => ( 41 | 42 |
  • 43 | {group.year} 44 |
  • 45 | {group.list.map((article) => ( 46 |
  • 47 | 51 | 52 | 57 | {extractInfo && renderExtraInfo(extractInfo(article))} 58 | 59 | 60 | {renderTitle ? renderTitle(article) : article.title} 61 | 62 | 63 |
  • 64 | ))} 65 |
    66 | ))} 67 |
68 |
69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /app/api/discussions/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, type NextRequest } from 'next/server' 2 | import { z } from 'zod' 3 | import { 4 | createDiscussion, 5 | getDiscussions, 6 | updateDiscussion, 7 | } from '@/lib/api/github' 8 | 9 | export const dynamic = 'force-dynamic' 10 | 11 | const GetSchema = z.object({ 12 | title: z.string().optional(), 13 | label: z.string().optional(), 14 | }) 15 | 16 | const CreateSchema = z.object({ 17 | title: z.string().min(1), 18 | label: z.string().min(1), 19 | body: z.string().min(1), 20 | }) 21 | 22 | const UpdateSchema = z.object({ 23 | discussionId: z.string().min(1), 24 | body: z.string().min(1), 25 | }) 26 | 27 | export async function GET(request: NextRequest) { 28 | try { 29 | const searchParams = request.nextUrl.searchParams 30 | const query = { 31 | title: searchParams.get('title'), 32 | label: searchParams.get('label'), 33 | } 34 | 35 | const { title, label } = GetSchema.parse(query) 36 | 37 | const discussions = await getDiscussions() 38 | const discussion = discussions.find( 39 | (d) => d.title === title && d.labels.some((l) => l.name === label), 40 | ) 41 | return NextResponse.json(discussion) 42 | } catch (error) { 43 | if (error instanceof z.ZodError) { 44 | return NextResponse.json({ error: error.issues }, { status: 400 }) 45 | } 46 | return NextResponse.json(null, { status: 500 }) 47 | } 48 | } 49 | 50 | export async function POST(request: NextRequest) { 51 | try { 52 | const json: unknown = await request.json() 53 | const { title, label, body } = CreateSchema.parse(json) 54 | 55 | const discussion = await createDiscussion(title, label, body) 56 | return NextResponse.json(discussion) 57 | } catch (error) { 58 | if (error instanceof z.ZodError) { 59 | return NextResponse.json({ error: error.issues }, { status: 400 }) 60 | } 61 | return NextResponse.json(null, { status: 500 }) 62 | } 63 | } 64 | 65 | export async function PUT(request: NextRequest) { 66 | try { 67 | const json: unknown = await request.json() 68 | const { discussionId, body } = UpdateSchema.parse(json) 69 | 70 | const discussion = await updateDiscussion(discussionId, body) 71 | return NextResponse.json(discussion) 72 | } catch (error) { 73 | if (error instanceof z.ZodError) { 74 | return NextResponse.json({ error: error.issues }, { status: 400 }) 75 | } 76 | return NextResponse.json( 77 | { error: 'Internal Server Error' }, 78 | { status: 500 }, 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/og/[slug]/route.tsx: -------------------------------------------------------------------------------- 1 | import { cacheLife } from 'next/cache' 2 | import { ImageResponse } from 'next/og' 3 | import type { NextRequest } from 'next/server' 4 | import { Signature } from '@/components/icons' 5 | import { siteConfig } from '@/lib/constants/config' 6 | import { getAbsoluteUrl } from '@/lib/utils/edge' 7 | 8 | async function loadFont() { 9 | 'use cache' 10 | cacheLife('max') 11 | 12 | return fetch(getAbsoluteUrl('/assets/merriweather.ttf')).then((res) => 13 | res.arrayBuffer(), 14 | ) 15 | } 16 | 17 | export async function GET( 18 | request: NextRequest, 19 | { params }: { params: Promise<{ slug: string }> }, 20 | ) { 21 | 'use cache' 22 | cacheLife('max') 23 | 24 | const { slug } = await params 25 | const response = await fetch(getAbsoluteUrl(`/api/content?slug=${slug}`)) 26 | if (!response.ok) { 27 | return new Response('Failed to fetch article metadata', { status: 500 }) 28 | } 29 | const meta = (await response.json()) as { title: string } | null 30 | const title = meta?.title ?? siteConfig.metadata.title 31 | 32 | const fontData = await loadFont() 33 | 34 | try { 35 | return new ImageResponse( 36 |
49 |
58 | 59 |
60 |
74 | {title} 75 |
76 |
, 77 | { 78 | width: 1200, 79 | height: 630, 80 | fonts: [ 81 | { 82 | name: 'Merriweather', 83 | data: fontData, 84 | style: 'italic', 85 | weight: 700, 86 | }, 87 | ], 88 | }, 89 | ) 90 | } catch (error: unknown) { 91 | console.error(`Failed to generate OG image: ${(error as Error).message}`) 92 | return new Response(`Failed to generate OG image`, { 93 | status: 500, 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /lib/mdx/index.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from '@content-collections/mdx' 2 | import type { Root as HastRoot } from 'hast' 3 | import type { Root as MdastRoot } from 'mdast' 4 | import rehypeAutolinkHeadings from 'rehype-autolink-headings' 5 | import rehypeExternalLinks from 'rehype-external-links' 6 | import rehypeKatex from 'rehype-katex' 7 | import rehypePrettyCode from 'rehype-pretty-code' 8 | import rehypeSlug from 'rehype-slug' 9 | import rehypeStringify from 'rehype-stringify' 10 | import remarkBreaks from 'remark-breaks' 11 | import remarkGfm from 'remark-gfm' 12 | import remarkMath from 'remark-math' 13 | import remarkParse from 'remark-parse' 14 | import remarkRehype from 'remark-rehype' 15 | import { unified, type Pluggable, type Processor } from 'unified' 16 | import { rehypeAudio } from './rehype-audio' 17 | import { rehypeCallout } from './rehype-callout' 18 | import { rehypeCode } from './rehype-code' 19 | import { rehypeImageGallery, rehypeImageSize } from './rehype-image' 20 | import { rehypeToc } from './rehype-toc' 21 | 22 | export * from './rehype-audio' 23 | export * from './rehype-image' 24 | export * from './rehype-code' 25 | export * from './rehype-toc' 26 | export * from './rehype-callout' 27 | 28 | export const remarkPlugins: Pluggable[] = [remarkGfm, remarkBreaks, remarkMath] 29 | 30 | export const getRehypePlugins = (contentType: string): Pluggable[] => [ 31 | rehypeSlug, 32 | [rehypeKatex, { output: 'html' }], 33 | [rehypeAutolinkHeadings, { behavior: 'wrap' }], 34 | [ 35 | rehypeExternalLinks, 36 | { target: '_blank', rel: ['nofollow', 'noopener', 'noreferrer'] }, 37 | ], 38 | rehypeCode, 39 | [ 40 | rehypePrettyCode, 41 | { 42 | theme: { 43 | light: 'rose-pine-dawn', 44 | dark: 'rose-pine-moon', 45 | }, 46 | }, 47 | ], 48 | [rehypeImageSize, { root: 'public', contentType }], 49 | rehypeImageGallery, 50 | rehypeAudio, 51 | rehypeCallout, 52 | rehypeToc, 53 | ] 54 | 55 | const optionsCache = new Map() 56 | 57 | export const getOptions = (contentType: string): Options => { 58 | if (!optionsCache.has(contentType)) { 59 | optionsCache.set(contentType, { 60 | remarkPlugins, 61 | rehypePlugins: getRehypePlugins(contentType), 62 | }) 63 | } 64 | return optionsCache.get(contentType)! 65 | } 66 | const processorCache = new Map< 67 | string, 68 | Processor 69 | >() 70 | 71 | export const getMdxToHtmlProcessor = (contentType: string) => { 72 | if (!processorCache.has(contentType)) { 73 | const processor = unified() 74 | .use(remarkParse) 75 | .use(remarkPlugins) 76 | .use(remarkRehype, { allowDangerousHtml: true }) 77 | .use(getRehypePlugins(contentType).filter((p) => p !== rehypeToc)) 78 | .use(rehypeStringify, { allowDangerousHtml: true }) 79 | processorCache.set(contentType, processor) 80 | } 81 | 82 | return processorCache.get(contentType)! 83 | } 84 | -------------------------------------------------------------------------------- /styles/theme.css: -------------------------------------------------------------------------------- 1 | @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); 2 | 3 | /* https://rosepinetheme.com/zh-cn/palette/ingredients/ */ 4 | @theme { 5 | --color-base: light-dark(hsl(32deg 57% 95%), hsl(246deg, 24%, 17%)); 6 | --color-surface: light-dark(hsl(35deg 100% 98%), hsl(248deg, 24%, 20%)); 7 | --color-overlay: light-dark(hsl(33deg 43% 91%), hsl(248deg, 21%, 26%)); 8 | --color-muted: light-dark(hsl(257deg 9% 61%), hsl(249deg, 12%, 47%)); 9 | --color-subtle: light-dark(hsl(248deg 12% 52%), hsl(248deg, 15%, 61%)); 10 | --color-text: light-dark(hsl(248deg 19% 40%), hsl(245deg, 50%, 91%)); 11 | --color-love: light-dark(hsl(343deg 35% 55%), hsl(343deg, 76%, 68%)); 12 | --color-gold: light-dark(hsl(35deg 81% 56%), hsl(35deg, 88%, 72%)); 13 | --color-rose: light-dark(hsl(3deg 53% 67%), hsl(2deg, 66%, 75%)); 14 | --color-pine: light-dark(hsl(197deg 53% 34%), hsl(197deg, 48%, 47%)); 15 | --color-foam: light-dark(hsl(189deg 30% 48%), hsl(189deg, 43%, 73%)); 16 | --color-iris: light-dark(hsl(268deg 21% 57%), hsl(267deg, 57%, 78%)); 17 | } 18 | 19 | @theme inline { 20 | --font-sans: var(--font-merriweather), ui-sans-serif, system-ui, sans-serif; 21 | --font-mono: var(--font-jetbrains-mono), ui-monospace, monospace; 22 | } 23 | 24 | @theme inline { 25 | --animate-turn-on: turnOn 600ms ease-in-out; 26 | --animate-turn-off: turnOff 600ms ease-in-out; 27 | --animate-spinner-scale: spinner-scale var(--duration, 30s) var(--delay, 30s) 28 | cubic-bezier(0.2, 0.68, 0.18, 1.08) infinite; 29 | --animate-svg-text: svg-text 10s var(--delay, 0s) infinite ease-in-out; 30 | 31 | @keyframes turnOn { 32 | 0% { 33 | clip-path: polygon(0% 0%, 100% 0, 100% 0, 0 0); 34 | } 35 | 100% { 36 | clip-path: polygon(0% 0%, 100% 0, 100% 100%, 0 100%); 37 | } 38 | } 39 | 40 | @keyframes turnOff { 41 | 0% { 42 | clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0% 100%); 43 | } 44 | 100% { 45 | clip-path: polygon(0 100%, 100% 100%, 100% 0, 0 0); 46 | } 47 | } 48 | 49 | @keyframes spinner-scale { 50 | 0% { 51 | transform: scaley(1); 52 | } 53 | 50% { 54 | transform: scaley(0.4); 55 | } 56 | 100% { 57 | transform: scaley(1); 58 | } 59 | } 60 | 61 | @keyframes svg-text { 62 | 0% { 63 | stroke-dashoffset: var(--path-length); 64 | fill: var(--color-muted); 65 | } 66 | 30% { 67 | fill: var(--color-text); 68 | } 69 | 40% { 70 | stroke-dashoffset: 0; 71 | fill: var(--color-text); 72 | } 73 | 50% { 74 | fill: var(--color-text); 75 | } 76 | 80% { 77 | stroke-dashoffset: var(--path-length); 78 | fill: var(--color-muted); 79 | } 80 | 100% { 81 | stroke-dashoffset: var(--path-length); 82 | fill: var(--color-muted); 83 | } 84 | } 85 | 86 | @keyframes slide-enter { 87 | 0% { 88 | transform: translateY(10px); 89 | opacity: 0; 90 | } 91 | 100% { 92 | transform: translateY(0); 93 | opacity: 1; 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /components/icons/sun.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from 'react' 2 | 3 | export function Sun(props: SVGProps) { 4 | return ( 5 | 6 | 13 | 20 | 27 | 33 | 34 | 40 | 47 | 54 | 55 | 62 | 63 | 69 | 76 | 83 | 84 | 91 | 92 | 93 | 94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata, Viewport } from 'next' 2 | import { JetBrains_Mono, Merriweather } from 'next/font/google' 3 | import type { ReactNode } from 'react' 4 | import { Blur } from '@/components/blur' 5 | import { Footer } from '@/components/footer' 6 | import { Helper } from '@/components/helper' 7 | import { siteConfig } from '@/lib/constants/config' 8 | import { cn } from '@/lib/utils/style' 9 | import '@/styles/tailwindcss.css' 10 | import { ThemeProvider } from 'next-themes' 11 | import MotionProvider from './providers/motion-provider' 12 | import VercelProvider from './providers/vercel-provider' 13 | 14 | const merriweather = Merriweather({ 15 | subsets: ['latin'], 16 | weight: ['400', '500', '600', '700', '800'], 17 | style: ['normal', 'italic'], 18 | variable: '--font-merriweather', 19 | display: 'swap', 20 | }) 21 | 22 | const jetbrainsMono = JetBrains_Mono({ 23 | subsets: ['latin'], 24 | weight: ['400', '500', '600', '700'], 25 | variable: '--font-jetbrains-mono', 26 | display: 'swap', 27 | }) 28 | 29 | export const viewport: Viewport = { 30 | themeColor: [ 31 | { media: '(prefers-color-scheme: dark)', color: '#232136' }, 32 | { media: '(prefers-color-scheme: light)', color: '#faf4ed' }, 33 | ], 34 | colorScheme: 'dark light', 35 | width: 'device-width', 36 | initialScale: 1, 37 | } 38 | 39 | export const metadata: Metadata = { 40 | ...siteConfig.metadata, 41 | metadataBase: new URL(siteConfig.host), 42 | title: { 43 | default: siteConfig.metadata.title, 44 | template: `%s • ${siteConfig.metadata.title}`, 45 | }, 46 | applicationName: siteConfig.metadata.title, 47 | authors: [{ name: siteConfig.author.name, url: siteConfig.author.link }], 48 | category: 'Personal Website', 49 | keywords: 'Blog, Code, ACG, Web, Zero, Programming, Knowledge', 50 | robots: { 51 | follow: true, 52 | index: true, 53 | }, 54 | openGraph: { 55 | ...siteConfig.metadata, 56 | siteName: siteConfig.metadata.title, 57 | type: 'website', 58 | url: '/', 59 | emails: [siteConfig.author.email], 60 | images: [ 61 | { 62 | url: '/og/home', 63 | width: 1200, 64 | height: 630, 65 | alt: siteConfig.metadata.title, 66 | }, 67 | ], 68 | }, 69 | twitter: { 70 | ...siteConfig.metadata, 71 | creator: siteConfig.author.name, 72 | images: ['/og/home'], 73 | card: 'summary_large_image', 74 | }, 75 | } 76 | 77 | export default function RootLayout({ 78 | children, 79 | }: Readonly<{ 80 | children: ReactNode 81 | }>) { 82 | return ( 83 | 89 | 90 | 91 | 92 | 93 | 94 | {children} 95 | 96 |