-
Thoughts and reflections powered by GitHub Issues
-
- Learn more about this implementation:{' '}
+
Thoughts and reflections, powered by GitHub Issues
+
+ Learn more:{' '}
git-thoughts
@@ -118,5 +114,4 @@ export const metadata = {
description: 'Thoughts and reflections powered by GitHub Issues'
}
-// 确保页面使用 ISR
-export const revalidate = 3600 // 1小时 (3600秒)
+export const revalidate = 3600
diff --git a/src/app/page.js b/src/app/page.js
index 4f34c0c..02b0609 100644
--- a/src/app/page.js
+++ b/src/app/page.js
@@ -3,8 +3,10 @@ import { Suspense } from 'react'
import { FloatingHeader } from '@/components/floating-header'
import { PageTitle } from '@/components/page-title'
+import { PenflowSignature } from '@/components/penflow-signature'
import { ScreenLoadingSpinner } from '@/components/screen-loading-spinner'
import { ScrollArea } from '@/components/scroll-area'
+import { SunnyOverlay, SunnyToggle } from '@/components/sunny-mode'
import { Button } from '@/components/ui/button'
import { WritingList } from '@/components/writing-list'
import { getAllPosts } from '@/lib/contentful'
@@ -22,20 +24,23 @@ export default async function Home() {
return (
-
+
+
- Hi, I am Peng.G (熊布朗)
+ Hi, I am Zen(叶振幸).
- AI Product Manager & Software Architect👋
- Shipping AI-native products—RAG platforms, autonomous agents & LLM infra.
+ Open Source Intern , Mathematics & Applied Math Student 👋
- Born in China, shaped in Seoul, now building from Paris.
+ Bridging Mathematics & AI—Python & Deep Learning frameworks (MindSpore/PyTorch).
- Exploring GenAI × productivity; writing about it every week—let’s chat!
+ From Mathematical Modeling to AI-driven diagnostics.
+
+ Exploring Open Source × Deep Learning; Passionate about building open source projects—let's chat!
+
diff --git a/src/app/robots.js b/src/app/robots.js
index aa5af41..caaeaca 100644
--- a/src/app/robots.js
+++ b/src/app/robots.js
@@ -4,7 +4,7 @@ export default function robots() {
userAgent: '*',
allow: '/'
},
- sitemap: 'https://me.deeptoai.com/sitemap.xml',
- host: 'https://me.deeptoai.com'
+ sitemap: 'https://zenhungyep.com/sitemap.xml',
+ host: 'https://zenhungyep.com'
}
}
diff --git a/src/app/shared-metadata.js b/src/app/shared-metadata.js
index 65b0965..78b13da 100644
--- a/src/app/shared-metadata.js
+++ b/src/app/shared-metadata.js
@@ -1,5 +1,5 @@
export const sharedMetadata = {
- title: '熊布朗(Peng.G)',
+ title: 'Zen',
description:
"Paris-based AI Product Manager & vibecoder, shipping AI products and open-source projects with a maker's spirit.",
url: process.env.NEXT_PUBLIC_SITE_URL || 'https://me.deeptoai.com',
diff --git a/src/app/sitemap.js b/src/app/sitemap.js
index 4abf7ea..3aa88d2 100644
--- a/src/app/sitemap.js
+++ b/src/app/sitemap.js
@@ -16,7 +16,7 @@ export default async function sitemap() {
const sortedWritings = getSortedPosts(allPosts)
const writings = sortedWritings.map((post) => {
return {
- url: `https://me.deeptoai.com/writing/${post.slug}`,
+ url: `https://zenhungyep.com/writing/${post.slug}`,
lastModified: post.sys.publishedAt,
changeFrequency: 'yearly',
priority: 0.5
@@ -25,7 +25,7 @@ export default async function sitemap() {
const mappedBookmarks = (bookmarks || []).map((bookmark) => {
return {
- url: `https://me.deeptoai.com/bookmarks/${bookmark.slug}`,
+ url: `https://zenhungyep.com/bookmarks/${bookmark.slug}`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1
@@ -45,7 +45,7 @@ export default async function sitemap() {
if (['bookmarks'].includes(page.slug)) priority = 1
return {
- url: `https://me.deeptoai.com/${page.slug}`,
+ url: `https://zenhungyep.com/${page.slug}`,
lastModified,
changeFrequency,
priority
@@ -54,13 +54,13 @@ export default async function sitemap() {
return [
{
- url: 'https://me.deeptoai.com',
+ url: 'https://zenhungyep.com',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1
},
{
- url: 'https://me.deeptoai.com/musings',
+ url: 'https://zenhungyep.com/musings',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.8
@@ -74,7 +74,7 @@ export default async function sitemap() {
// 返回基本的 sitemap,不包含书签
return [
{
- url: 'https://me.deeptoai.com',
+ url: 'https://zenhungyep.com',
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1
diff --git a/src/app/visual/opengraph-image.js b/src/app/visual/opengraph-image.js
deleted file mode 100644
index f1d540f..0000000
--- a/src/app/visual/opengraph-image.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import { ImageResponse } from 'next/og'
-
-export const runtime = 'edge'
-export const alt = 'Visual - Photography & AI Generated Art'
-export const size = {
- width: 1200,
- height: 630
-}
-export const contentType = 'image/png'
-
-export default async function Image() {
- return new ImageResponse(
- (
-
-
-
- Visual Explorer
-
-
- Photography & AI Generated Art Collection
-
-
-
- ),
- {
- ...size
- }
- )
-}
diff --git a/src/app/visual/opengraph-image/route.js b/src/app/visual/opengraph-image/route.js
new file mode 100644
index 0000000..d80cb19
--- /dev/null
+++ b/src/app/visual/opengraph-image/route.js
@@ -0,0 +1,39 @@
+import { ImageResponse } from 'next/og'
+
+export const runtime = 'edge'
+export const alt = 'Visual - Photography & AI Generated Art'
+export const size = {
+ width: 1200,
+ height: 630
+}
+export const contentType = 'image/png'
+
+export default async function Image() {
+ return new ImageResponse(
+ (
+
+
+
+ Visual Explorer
+
+
Photography & AI Generated Art Collection
+
+
+ ),
+ {
+ ...size
+ }
+ )
+}
diff --git a/src/app/writing.xml/route.js b/src/app/writing.xml/route.js
index 392db06..574cb25 100644
--- a/src/app/writing.xml/route.js
+++ b/src/app/writing.xml/route.js
@@ -9,10 +9,10 @@ export async function GET() {
const allPosts = await getAllPosts()
const sortedPosts = getSortedPosts(allPosts)
const date = new Date()
- const siteURL = 'https://me.deeptoai.com'
+ const siteURL = 'https://zenhungyep.com'
const author = {
- name: '熊布朗 (Peng.G)',
- link: 'https://me.deeptoai.com'
+ name: 'Zen',
+ link: 'https://zenhungyep.com'
}
const feed = new Feed({
diff --git a/src/app/writing/[slug]/og.png/route.js b/src/app/writing/[slug]/og.png/route.js
deleted file mode 100644
index 3b8f358..0000000
--- a/src/app/writing/[slug]/og.png/route.js
+++ /dev/null
@@ -1,109 +0,0 @@
-import { draftMode } from 'next/headers'
-import { ImageResponse } from 'next/og'
-
-import { sharedMetadata } from '@/app/shared-metadata'
-import { OpenGraphImage } from '@/components/og-image'
-import { getAllPostSlugs, getWritingSeo } from '@/lib/contentful'
-import { getBoldFont, getRegularFont } from '@/lib/fonts'
-import { isDevelopment } from '@/lib/utils'
-
-export const dynamic = 'force-static'
-
-export const size = {
- width: sharedMetadata.ogImage.width,
- height: sharedMetadata.ogImage.height
-}
-
-export async function generateStaticParams() {
- const allPosts = await getAllPostSlugs()
- return allPosts.map((post) => ({ slug: post.slug }))
-}
-
-export async function GET(_, props) {
- const params = await props.params
- const { isEnabled } = await draftMode()
- const { slug } = params
-
- try {
- const [seoData, regularFontData, boldFontData] = await Promise.all([
- getWritingSeo(slug, isDevelopment ? true : isEnabled),
- getRegularFont(),
- getBoldFont()
- ])
-
- if (!seoData || !seoData.seo) {
- // Return a default OG image for posts that don't have SEO data
- return new ImageResponse(
, {
- ...size,
- fonts: [
- {
- name: 'Geist Sans',
- data: regularFontData,
- style: 'normal',
- weight: 400
- },
- {
- name: 'Geist Sans',
- data: boldFontData,
- style: 'normal',
- weight: 500
- }
- ]
- })
- }
-
- const {
- seo: { title, ogImageTitle, ogImageSubtitle }
- } = seoData
-
- return new ImageResponse(
- (
-
- ),
- {
- ...size,
- fonts: [
- {
- name: 'Geist Sans',
- data: regularFontData,
- style: 'normal',
- weight: 400
- },
- {
- name: 'Geist Sans',
- data: boldFontData,
- style: 'normal',
- weight: 500
- }
- ]
- }
- )
- } catch (error) {
- console.error('Error generating OG image for slug:', slug, error)
-
- // Return a simple fallback OG image if everything fails
- const [regularFontData, boldFontData] = await Promise.all([getRegularFont(), getBoldFont()])
-
- return new ImageResponse(
, {
- ...size,
- fonts: [
- {
- name: 'Geist Sans',
- data: regularFontData,
- style: 'normal',
- weight: 400
- },
- {
- name: 'Geist Sans',
- data: boldFontData,
- style: 'normal',
- weight: 500
- }
- ]
- })
- }
-}
diff --git a/src/app/writing/[slug]/opengraph-image.js b/src/app/writing/[slug]/opengraph-image.js
new file mode 100644
index 0000000..4798b89
--- /dev/null
+++ b/src/app/writing/[slug]/opengraph-image.js
@@ -0,0 +1,57 @@
+import { draftMode } from 'next/headers'
+import { ImageResponse } from 'next/og'
+
+import { sharedMetadata } from '@/app/shared-metadata'
+import { OpenGraphImage } from '@/components/og-image'
+import { getAllPostSlugs, getWritingSeo } from '@/lib/contentful'
+import { getBoldFont, getRegularFont } from '@/lib/fonts'
+import { isDevelopment } from '@/lib/utils'
+
+export const size = {
+ width: sharedMetadata.ogImage.width,
+ height: sharedMetadata.ogImage.height
+}
+
+export async function generateStaticParams() {
+ const allPosts = await getAllPostSlugs()
+ return allPosts.map((post) => ({ slug: post.slug }))
+}
+
+export default async function OpenGraphImagePage({ params }) {
+ const { isEnabled } = await draftMode()
+ const { slug } = await params
+ const [seoData, regularFontData, boldFontData] = await Promise.all([
+ getWritingSeo(slug, isDevelopment ? true : isEnabled),
+ getRegularFont(),
+ getBoldFont()
+ ])
+
+ const {
+ seo: { title, ogImageTitle, ogImageSubtitle }
+ } = seoData
+
+ return new ImageResponse(
+
,
+ {
+ ...size,
+ fonts: [
+ {
+ name: 'Geist Sans',
+ data: regularFontData,
+ style: 'normal',
+ weight: 400
+ },
+ {
+ name: 'Geist Sans',
+ data: boldFontData,
+ style: 'normal',
+ weight: 500
+ }
+ ]
+ }
+ )
+}
diff --git a/src/app/writing/[slug]/page.js b/src/app/writing/[slug]/page.js
index ca6a264..8f76473 100644
--- a/src/app/writing/[slug]/page.js
+++ b/src/app/writing/[slug]/page.js
@@ -66,9 +66,9 @@ export default async function WritingSlug(props) {
dateModified,
author: {
'@type': 'Person',
- name: '熊布朗 (Peng.G)'
+ name: '叶振幸 (Zen)'
},
- url: `https://me.deeptoai.com/writing/${slug}`
+ url: `https://zenhungyep.com/writing/${slug}`
}
return (
@@ -86,7 +86,7 @@ export default async function WritingSlug(props) {
{dateString}
}
- className="mb-6 flex flex-col gap-3"
+ className="mb-6 flex flex-col gap-2"
/>
@@ -106,7 +106,7 @@ export async function generateMetadata(props) {
if (!seoData) {
return {
title: 'Blog Post',
- description: 'A blog post by 熊布朗 (Peng.G)'
+ description: 'A blog post by 叶振幸 (Zen)'
}
}
diff --git a/src/components/bookmark-card.js b/src/components/bookmark-card.js
index 26ec6ea..557498e 100644
--- a/src/components/bookmark-card.js
+++ b/src/components/bookmark-card.js
@@ -27,6 +27,7 @@ export const BookmarkCard = ({ bookmark, order }) => {
width={1200}
height={630}
loading={order < 2 ? 'eager' : 'lazy'}
+ decoding="async"
className="animate-reveal aspect-1200/630 rounded-lg border bg-cover bg-center bg-no-repeat object-cover"
onError={(e) => {
e.target.onerror = null
@@ -46,7 +47,7 @@ export const BookmarkCard = ({ bookmark, order }) => {
{/* Tags */}
{bookmark.tags && bookmark.tags.length > 0 && (
-
+
{bookmark.tags.map((tag) => (
diff --git a/src/components/contentful/code-block.js b/src/components/contentful/code-block.js
index 174bba0..3503f26 100644
--- a/src/components/contentful/code-block.js
+++ b/src/components/contentful/code-block.js
@@ -19,7 +19,7 @@ export const CodeBlock = memo(({ title, code }) => {
return (
<>
-
+
diff --git a/src/components/contentful/rich-text.js b/src/components/contentful/rich-text.js
index 0056dd6..6d3e048 100644
--- a/src/components/contentful/rich-text.js
+++ b/src/components/contentful/rich-text.js
@@ -76,6 +76,7 @@ function options(links) {
height={asset.height || 300}
alt={asset.description || asset.title}
loading={isEagerLoading ? 'eager' : 'lazy'}
+ decoding="async"
className="animate-reveal"
// eslint-disable-next-line react/no-unknown-property
nopin="nopin"
diff --git a/src/components/easter-egg.js b/src/components/easter-egg.js
index 0387bc1..a92bb6d 100644
--- a/src/components/easter-egg.js
+++ b/src/components/easter-egg.js
@@ -9,15 +9,6 @@ export const EasterEgg = ({ tools = [] }) => {
const [displayText, setDisplayText] = useState('')
const [isAnimating, setIsAnimating] = useState(false)
- useEffect(() => {
- const listener = new KonamiListener(() => {
- startTerminalAnimation()
- })
-
- listener.start()
- return () => listener.stop()
- }, [tools, startTerminalAnimation])
-
const startTerminalAnimation = useCallback(() => {
setIsActive(true)
setIsAnimating(true)
@@ -32,6 +23,15 @@ export const EasterEgg = ({ tools = [] }) => {
}, 500)
}, [tools])
+ useEffect(() => {
+ const listener = new KonamiListener(() => {
+ startTerminalAnimation()
+ })
+
+ listener.start()
+ return () => listener.stop()
+ }, [tools, startTerminalAnimation])
+
const animateTools = (allTools) => {
if (!allTools || allTools.length === 0) {
setDisplayText((prev) => prev + 'No tools found...\n')
diff --git a/src/components/easter-egg/index.js b/src/components/easter-egg/index.js
new file mode 100644
index 0000000..334a3e7
--- /dev/null
+++ b/src/components/easter-egg/index.js
@@ -0,0 +1,8 @@
+/**
+ * [INPUT]: 依赖 useSunnyMode hook
+ * [OUTPUT]: SunnyToggle + SunnyOverlay + SunnyModeWrapper 组件
+ * [POS]: easter-egg/ 的主入口,导出阳光模式彩蛋组件
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
+ */
+export { SunnyToggle, SunnyOverlay } from './sunny-mode'
+export { SunnyModeWrapper } from './sunny-mode-wrapper'
diff --git a/src/components/easter-egg/sunny-mode-wrapper.js b/src/components/easter-egg/sunny-mode-wrapper.js
new file mode 100644
index 0000000..82f42f1
--- /dev/null
+++ b/src/components/easter-egg/sunny-mode-wrapper.js
@@ -0,0 +1,27 @@
+'use client'
+
+/**
+ * [INPUT]: 依赖 useSunnyMode hook, SunnyToggle, SunnyOverlay 组件
+ * [OUTPUT]: 包装 musings 页面的阳光模式客户端组件
+ * [POS]: easter-egg/ 的客户端包装器,整合 SunnyOverlay 和 SunnyToggle 到 musings 页面
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
+ */
+import { useSunnyMode } from '@/hooks/use-sunny-mode'
+import { SunnyOverlay, SunnyToggle } from './sunny-mode'
+
+export function SunnyModeWrapper({ children }) {
+ const { active, toggle } = useSunnyMode()
+
+ return (
+ <>
+
+
+ {/* Floating sunny mode toggle in top-right corner */}
+
+
+
+ {children}
+
+ >
+ )
+}
diff --git a/src/components/easter-egg/sunny-mode.js b/src/components/easter-egg/sunny-mode.js
new file mode 100644
index 0000000..9350753
--- /dev/null
+++ b/src/components/easter-egg/sunny-mode.js
@@ -0,0 +1,163 @@
+'use client'
+
+/**
+ * [INPUT]: 依赖 useSunnyMode hook, localStorage, Audio API
+ * [OUTPUT]: SunnyToggle (iOS风格开关) + SunnyOverlay (阳光百叶窗效果)
+ * [POS]: easter-egg/ 的核心彩蛋组件,被 sunny-mode-wrapper 和 page.js 消费
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
+ */
+import { useEffect, useRef } from 'react'
+import { useSunnyMode } from '@/hooks/use-sunny-mode'
+
+const SHUTTER_COUNT = 23
+const BGM_SRC = '/assets/sunny-bgm.mp3'
+
+/**
+ * iOS-style toggle switch for sunny mode
+ * Self-contained: reads and toggles sunny mode via useSunnyMode internally
+ */
+export function SunnyToggle() {
+ const { active, toggle } = useSunnyMode()
+
+ return (
+
+
+
+ {active ? 'the sun is peeking through ☀' : '← psst… try flipping this'}
+
+
+ )
+}
+
+/**
+ * Fixed overlay with horizontal blinds effect
+ * Creates a 3D shutter animation with ambient audio
+ * Self-contained: reads sunny mode state via useSunnyMode internally
+ */
+export function SunnyOverlay() {
+ const { active } = useSunnyMode()
+ const audioRef = useRef(null)
+
+ // Audio playback control
+ useEffect(() => {
+ if (!active) return
+
+ // Create audio element
+ audioRef.current = new Audio(BGM_SRC)
+ audioRef.current.volume = 0.4
+ audioRef.current.loop = true
+ audioRef.current.play().catch(() => {
+ // Autoplay might be blocked by browser
+ })
+
+ return () => {
+ if (audioRef.current) {
+ audioRef.current.pause()
+ audioRef.current = null
+ }
+ }
+ }, [active])
+
+ if (!active) return null
+
+ // Generate 23 horizontal blinds with 60px gaps
+ const blinds = Array.from({ length: SHUTTER_COUNT }, (_, i) => i)
+
+ return (
+ <>
+ {/* SVG filter definition for wind animation */}
+
+
+ {/* Overlay container */}
+
+ {/* Main overlay panel with blinds */}
+
+ {/* Decorative leaves with wind animation */}
+
+
+ {/* 23 horizontal blinds */}
+
+
+ {blinds.map((i) => (
+
+ ))}
+
+ {/* Vertical slats */}
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/easter-egg/sunny-mode.test.js b/src/components/easter-egg/sunny-mode.test.js
new file mode 100644
index 0000000..4cf91e9
--- /dev/null
+++ b/src/components/easter-egg/sunny-mode.test.js
@@ -0,0 +1,196 @@
+/**
+ * @jest-environment jsdom
+ */
+import { render, screen, fireEvent } from '@testing-library/react'
+import { SunnyToggle, SunnyOverlay } from './sunny-mode'
+
+// Mock audio
+const audioMock = {
+ play: jest.fn(),
+ pause: jest.fn(),
+ load: jest.fn(),
+ loop: true,
+ volume: 0.4
+}
+global.Audio = jest.fn(() => audioMock)
+
+describe('SunnyToggle', () => {
+ const defaultProps = {
+ active: false,
+ onToggle: jest.fn()
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('rendering', () => {
+ it('should render toggle switch with role="switch"', () => {
+ render()
+ const toggle = screen.getByRole('switch')
+ expect(toggle).toBeInTheDocument()
+ })
+
+ it('should render hint text when off', () => {
+ render()
+ expect(screen.getByText('← psst… try flipping this')).toBeInTheDocument()
+ })
+
+ it('should render sunny hint text when on', () => {
+ render()
+ expect(screen.getByText('the sun is peeking through ☀')).toBeInTheDocument()
+ })
+ })
+
+ describe('OFF state styling', () => {
+ it('should have gray background when off', () => {
+ render()
+ const toggle = screen.getByRole('switch')
+ expect(toggle).toHaveClass('bg-gray-200')
+ expect(toggle).not.toHaveClass('bg-amber-500')
+ })
+ })
+
+ describe('ON state styling', () => {
+ it('should have amber background when on', () => {
+ render()
+ const toggle = screen.getByRole('switch')
+ expect(toggle).toHaveClass('bg-amber-500')
+ expect(toggle).not.toHaveClass('bg-gray-200')
+ })
+ })
+
+ describe('user interaction', () => {
+ it('should call onToggle when clicked', () => {
+ render()
+ const toggle = screen.getByRole('switch')
+ fireEvent.click(toggle)
+ expect(defaultProps.onToggle).toHaveBeenCalledTimes(1)
+ })
+
+ it('should be accessible with proper role', () => {
+ render()
+ const toggle = screen.getByRole('switch')
+ expect(toggle).toHaveAttribute('aria-checked', 'true')
+ })
+
+ it('should have aria-checked false when off', () => {
+ render()
+ const toggle = screen.getByRole('switch')
+ expect(toggle).toHaveAttribute('aria-checked', 'false')
+ })
+ })
+})
+
+describe('SunnyOverlay', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('rendering', () => {
+ it('should render overlay when active', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeInTheDocument()
+ })
+
+ it('should not render children when inactive', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('structure', () => {
+ it('should have fixed positioning with z-40', () => {
+ const { container } = render()
+ const overlay = container.firstChild
+ expect(overlay).toHaveClass('fixed', 'inset-0', 'z-40')
+ })
+
+ it('should have pointer-events-none', () => {
+ const { container } = render()
+ const overlay = container.firstChild
+ expect(overlay).toHaveClass('pointer-events-none')
+ })
+ })
+
+ describe('blinds effect', () => {
+ it('should render 23 horizontal blinds', () => {
+ const { container } = render()
+ const blinds = container.querySelectorAll('.sunny-blind')
+ expect(blinds.length).toBe(23)
+ })
+
+ it('each blind should be 40px tall', () => {
+ const { container } = render()
+ const blind = container.querySelector('.sunny-blind')
+ expect(blind).toHaveStyle({ height: '40px' })
+ })
+
+ it('blinds should have #1a1917 color', () => {
+ const { container } = render()
+ const blind = container.querySelector('.sunny-blind')
+ expect(blind).toHaveStyle({ backgroundColor: '#1a1917' })
+ })
+ })
+
+ describe('animation', () => {
+ it('should have matrix3d transform for 3D shutter effect', () => {
+ const { container } = render()
+ const overlay = container.firstChild
+ const style = overlay.getAttribute('style') || ''
+ expect(style).toContain('matrix3d')
+ })
+
+ it('should have perspective for 3D effect', () => {
+ const { container } = render()
+ const overlay = container.firstChild
+ const style = overlay.getAttribute('style') || ''
+ expect(style).toContain('perspective')
+ })
+ })
+
+ describe('opacity transitions', () => {
+ it('should have opacity 0.07 when active', () => {
+ const { container } = render()
+ const overlay = container.firstChild
+ expect(overlay).toHaveStyle({ opacity: '0.07' })
+ })
+
+ it('should have opacity 0 when inactive', () => {
+ const { container } = render()
+ // When inactive, component returns null
+ expect(container.firstChild).toBeNull()
+ })
+ })
+
+ describe('audio', () => {
+ it('should create audio element with loop', () => {
+ render()
+ expect(global.Audio).toHaveBeenCalledWith('/assets/sunny-bgm.mp3')
+ })
+
+ it('should set volume to 0.4', () => {
+ render()
+ expect(audioMock.volume).toBe(0.4)
+ })
+
+ it('should play audio when active', () => {
+ render()
+ expect(audioMock.play).toHaveBeenCalled()
+ })
+
+ it('should pause audio when unmounting', () => {
+ const { unmount } = render()
+ unmount()
+ expect(audioMock.pause).toHaveBeenCalled()
+ })
+ })
+
+ describe('SVG filter', () => {
+ it('should render SVG with wind animation filter', () => {
+ const { container } = render()
+ const svg = container.querySelector('svg')
+ expect(svg).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/components/floating-header.js b/src/components/floating-header.js
index 46b3d3d..8561db5 100644
--- a/src/components/floating-header.js
+++ b/src/components/floating-header.js
@@ -71,52 +71,55 @@ export const FloatingHeader = memo(({ scrollTitle, title, goBackLink, bookmarks,
)
return (
-
+
-
-
- {goBackLink ? (
-
- ) : (
- memoizedMobileDrawer
- )}
-
- {scrollTitle && (
-
- {scrollTitle}
-
- )}
- {title && memoizedBalancer}
-
- {(isWritingIndexPage || isBookmarksIndexPage) && (
-
- )}
- {isBookmarkPath && memoizedSubmitBookmarkDrawer}
-
-
-
+ {/* Left: hamburger or back button (fixed width) */}
+
+ {goBackLink ? (
+
+ ) : (
+ memoizedMobileDrawer
+ )}
+
+
+ {/* Center: title (flex-1, truncated) */}
+
+ {scrollTitle && (
+
+ {scrollTitle}
+
+ )}
+ {title && !scrollTitle && memoizedBalancer}
+
+
+ {/* Right: action buttons (fixed width) */}
+
+ {(isWritingIndexPage || isBookmarksIndexPage) && (
+
+ )}
+ {isBookmarkPath && memoizedSubmitBookmarkDrawer}
{/* This is a hack to show writing views with framer motion reveal effect */}
- {scrollTitle && isWritingPath &&
{children}
}
+ {scrollTitle && isWritingPath && children}
diff --git a/src/components/friend-card.js b/src/components/friend-card.js
new file mode 100644
index 0000000..f31284f
--- /dev/null
+++ b/src/components/friend-card.js
@@ -0,0 +1,178 @@
+'use client'
+
+/**
+ * [INPUT]: 依赖 friends.json 的友链数据,GitHub Contributions API
+ * [OUTPUT]: 对外提供 FriendCard 组件,工牌风格友链卡片
+ * [POS]: components/ 的友链展示组件,被 friends/page.js 使用
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
+ */
+
+import { useEffect, useState } from 'react'
+
+const LEVEL_COLORS = ['#ebedf0', '#c6e48b', '#7bc96f', '#239a3b', '#196127']
+const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
+
+function GithubHeatmap({ github }) {
+ const [data, setData] = useState(null)
+
+ useEffect(() => {
+ if (!github) return
+ fetch(`https://github-contributions-api.jogruber.de/v4/${github}?y=last`)
+ .then((res) => res.json())
+ .then((raw) => {
+ const contributions = raw.contributions || []
+ if (!contributions.length) return
+
+ const ascending = [...contributions].sort((a, b) => new Date(a.date) - new Date(b.date))
+ const weeks = []
+ let week = []
+ ascending.forEach((day, i) => {
+ const dow = new Date(day.date).getDay()
+ if (dow === 0 && week.length > 0) {
+ weeks.push(week)
+ week = []
+ }
+ week.push(day)
+ if (i === ascending.length - 1) weeks.push(week)
+ })
+ weeks.reverse()
+
+ let currentMonth = -1
+ const monthMarks = []
+ weeks.forEach((w, wi) => {
+ const month = new Date(w[0].date).getMonth()
+ if (month !== currentMonth) {
+ monthMarks.push({ month, weekIndex: wi })
+ currentMonth = month
+ }
+ })
+
+ setData({ weeks, monthMarks })
+ })
+ .catch(() => {})
+ }, [github])
+
+ if (!data) return null
+
+ const numWeeks = data.weeks.length
+
+ return (
+
+
+ {data.monthMarks.map(({ month, weekIndex }) => (
+
+ {MONTHS[month]}
+
+ ))}
+
+
+ {data.weeks.flatMap((week, wi) => {
+ const cells = week.map((day, di) => (
+
+ ))
+ const lastDow = new Date(week[week.length - 1].date).getDay()
+ for (let i = lastDow + 1; i < 7; i++) {
+ cells.push(
+
+ )
+ }
+ return cells
+ })}
+
+
+ )
+}
+
+function Label({ children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function FriendCard({ friend }) {
+ const { name, url, avatar, signature, github } = friend
+
+ return (
+
+
+
+
+
+
+
+
+
+ {name}
+
+
+
+
+
+
+ {signature || name}
+
+
+
+
+ {avatar ? (
+

+ ) : (
+
+ {name.charAt(0).toUpperCase()}
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/gradient-bg.js b/src/components/gradient-bg.js
index e1bcac4..a23d516 100644
--- a/src/components/gradient-bg.js
+++ b/src/components/gradient-bg.js
@@ -1,6 +1,6 @@
export const GradientBg = () => (
)
diff --git a/src/components/journey-card.js b/src/components/journey-card.js
index cb611a0..dad5c7a 100644
--- a/src/components/journey-card.js
+++ b/src/components/journey-card.js
@@ -19,6 +19,7 @@ export const JourneyCard = memo(({ title, description, image, index }) => {
width={image.width}
height={image.height}
loading={index < 1 ? 'eager' : 'lazy'}
+ decoding="async"
className="animate-reveal"
// eslint-disable-next-line react/no-unknown-property
nopin="nopin"
diff --git a/src/components/markdown-renderer.js b/src/components/markdown-renderer.js
index 0ef815d..99ff5c7 100644
--- a/src/components/markdown-renderer.js
+++ b/src/components/markdown-renderer.js
@@ -12,7 +12,7 @@ export const MarkdownRenderer = ({ options, ...rest }) => {
// Extract `className` prop to make Link component work properly
// eslint-disable-next-line no-unused-vars
a: ({ className, ...rest }) =>
,
- p: ({ children }) =>
{children}
,
+ p: ({ children }) =>
{children}
,
img: ({ alt, src }) => (
{
width={400}
height={300}
loading="lazy"
+ decoding="async"
className="animate-reveal aspect-auto w-full object-cover"
// eslint-disable-next-line react/no-unknown-property
nopin="nopin"
diff --git a/src/components/menu-content.js b/src/components/menu-content.js
index a671d9c..ab933de 100644
--- a/src/components/menu-content.js
+++ b/src/components/menu-content.js
@@ -1,28 +1,30 @@
import Link from 'next/link'
+import { VinylPlayer } from '@/components/vinyl-player'
import { NavigationLink } from '@/components/navigation-link'
import { LINKS, PROFILES } from '@/lib/constants'
+import { UsersIcon } from 'lucide-react'
export const MenuContent = () => (
- 熊布朗 (Peng.G)
- AI Product Manager
+ Zen
+ Open Source enthusiasts
-
+
{LINKS.map((link, linkIndex) => (
(
))}
+
Online
-
+
{Object.values(PROFILES).map((profile) => (
))}
diff --git a/src/components/mobile-drawer.js b/src/components/mobile-drawer.js
index b464c97..b1e8b2e 100644
--- a/src/components/mobile-drawer.js
+++ b/src/components/mobile-drawer.js
@@ -15,7 +15,7 @@ export function MobileDrawer() {
return (
-
diff --git a/src/components/musings-list.js b/src/components/musings-list.js
index c947db7..bb4a125 100644
--- a/src/components/musings-list.js
+++ b/src/components/musings-list.js
@@ -58,7 +58,7 @@ function MusingCard({ musing }) {
href={musing.url}
target="_blank"
rel="noopener noreferrer"
- className="flex items-center gap-1.5 rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-gray-900"
+ className="flex items-center gap-2 rounded-full bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-gray-900"
>
Source
@@ -91,7 +91,7 @@ function MusingCard({ musing }) {
{/* Tags */}
{musing.tags && musing.tags.length > 0 && (
-
+
{musing.tags.map((tag) => (
{
return (
-
+
{
- {`deeptoai.com${url ? `/${url}` : ''}`}
+ {`zenhungyep.com${url ? `/${url}` : ''}`}
-
-
- {icon}
-
- {title}
-
+
+ {icon && {icon}}
+ {title}
- {description && (
-
{description}
- )}
-
+ {description &&
{description}}
+
)
}
diff --git a/src/components/penflow-signature.js b/src/components/penflow-signature.js
new file mode 100644
index 0000000..35e9b5c
--- /dev/null
+++ b/src/components/penflow-signature.js
@@ -0,0 +1,43 @@
+'use client'
+
+import { Penflow } from 'penflow/react'
+import { useEffect, useState } from 'react'
+import { useInView } from 'react-intersection-observer'
+
+export function PenflowSignature() {
+ const [playCount, setPlayCount] = useState(0)
+
+ const { ref, inView } = useInView({
+ root: null, // Use viewport as root initially
+ rootMargin: '0px',
+ triggerOnce: false,
+ fallbackInView: true
+ })
+
+ useEffect(() => {
+ if (inView) {
+ setPlayCount((count) => count + 1)
+ }
+ }, [inView])
+
+ return (
+
+
+ {inView ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/src/components/quick-post-button.js b/src/components/quick-post-button.js
index c081431..7f82c0f 100644
--- a/src/components/quick-post-button.js
+++ b/src/components/quick-post-button.js
@@ -232,7 +232,7 @@ export function QuickPostButton() {
-
+
setIsOpen(false)}
diff --git a/src/components/side-menu.js b/src/components/side-menu.js
index f30ecc2..5f4930a 100644
--- a/src/components/side-menu.js
+++ b/src/components/side-menu.js
@@ -54,7 +54,7 @@ export const SideMenu = ({ children, title, bookmarks = [], isInner }) => {
{title && (
diff --git a/src/components/submit-friend/dialog.jsx b/src/components/submit-friend/dialog.jsx
new file mode 100644
index 0000000..d8356f1
--- /dev/null
+++ b/src/components/submit-friend/dialog.jsx
@@ -0,0 +1,83 @@
+'use client'
+
+/**
+ * [INPUT]: 依赖 @/components/ui/dialog 的 Dialog 组件,lucide-react 的图标
+ * [OUTPUT]: 对外提供 SubmitFriendDialog 组件,用于提交友链申请
+ * [POS]: components/submit-friend/ 的友链提交弹窗,被 friends/page.js 使用
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
+ */
+
+import { useState } from 'react'
+import { PlusIcon } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger
+} from '@/components/ui/dialog'
+
+export function SubmitFriendDialog() {
+ const [open, setOpen] = useState(false)
+ const [form, setForm] = useState({ name: '', url: '', avatar: '', github: '', signature: '' })
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ // TODO: Integrate with actual friend submission endpoint
+ console.info('Friend submission:', form)
+ setOpen(false)
+ setForm({ name: '', url: '', avatar: '', github: '', signature: '' })
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/sunny-mode.js b/src/components/sunny-mode.js
new file mode 100644
index 0000000..fbd5e9a
--- /dev/null
+++ b/src/components/sunny-mode.js
@@ -0,0 +1,7 @@
+/**
+ * [INPUT]: 依赖 useSunnyMode hook
+ * [OUTPUT]: SunnyToggle + SunnyOverlay 组件
+ * [POS]: components/ 的阳光模式彩蛋入口,对齐 Penn-Lam 的导入路径
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
+ */
+export { SunnyToggle, SunnyOverlay } from './easter-egg/sunny-mode'
diff --git a/src/components/tool-card.js b/src/components/tool-card.js
index 3b2c4a4..f75a044 100644
--- a/src/components/tool-card.js
+++ b/src/components/tool-card.js
@@ -63,7 +63,7 @@ export const ToolCard = ({ tool, index }) => {
>
-
+
{
{tool.desc}
+ return
}
function CardTitle({ className, ...props }) {
diff --git a/src/components/ui/drawer.jsx b/src/components/ui/drawer.jsx
index 1dde247..be3724d 100644
--- a/src/components/ui/drawer.jsx
+++ b/src/components/ui/drawer.jsx
@@ -47,7 +47,7 @@ const DrawerContent = memo(({ className, children, ...props }) => {
DrawerContent.displayName = 'DrawerContent'
function DrawerHeader({ className, ...props }) {
- return
+ return
}
function DrawerFooter({ className, ...props }) {
diff --git a/src/components/ui/error-boundary.js b/src/components/ui/error-boundary.js
index dc7bf9d..d458637 100644
--- a/src/components/ui/error-boundary.js
+++ b/src/components/ui/error-boundary.js
@@ -6,15 +6,15 @@ export function ErrorBoundary({ children }) {
return (
(
-
+
Oops! Something went wrong.
-
An unexpected error occurred. Please try again.
-
+ An unexpected error occurred. Please try again.
+
{error.message}
Try again
diff --git a/src/components/vinyl-player/CLAUDE.md b/src/components/vinyl-player/CLAUDE.md
new file mode 100644
index 0000000..c2e7f02
--- /dev/null
+++ b/src/components/vinyl-player/CLAUDE.md
@@ -0,0 +1,10 @@
+# vinyl-player/
+> L2 | 父级: /CLAUDE.md
+
+成员清单
+index.jsx: 主组件,组合 useMeting hook + VinylRecord 展示组件
+use-meting.js: MetingJS 桥接 hook,隐藏 Web Component,暴露播放控制 API
+vinyl-record.jsx: 纯展示组件,黑胶唱片 DOM 结构(纹路、标签、主轴)
+vinyl-record.module.css: CSS Module,唱片纹路渐变、旋转动画、标签排版
+
+[PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
diff --git a/src/components/vinyl-player/index.jsx b/src/components/vinyl-player/index.jsx
new file mode 100644
index 0000000..da04317
--- /dev/null
+++ b/src/components/vinyl-player/index.jsx
@@ -0,0 +1,86 @@
+'use client'
+
+/**
+ * [INPUT]: 依赖 useMeting hook(音频控制)、VinylRecord(视觉层)、useKeyPress(快捷键)
+ * [OUTPUT]: 对外提供 VinylPlayer 组件
+ * [POS]: vinyl-player 的主组件,组合音频控制与唱片 UI,自带展开/收起状态
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
+ */
+import { DiscIcon, SkipBack, SkipForward } from 'lucide-react'
+import { memo, useCallback, useState } from 'react'
+
+import { useKeyPress } from '@/hooks/use-key-press'
+import { cn } from '@/lib/utils'
+
+import { useMeting, PLAYLIST_ID } from './use-meting'
+import { VinylRecord } from './vinyl-record.jsx'
+
+export const VinylPlayer = memo(() => {
+ const [isOpen, setIsOpen] = useState(false)
+ const toggleOpen = useCallback(() => setIsOpen((v) => !v), [])
+ useKeyPress(toggleOpen, ['Digit0'])
+ const { containerRef, isReady, isPlaying, currentTrack, toggle, next, prev } = useMeting({ enabled: isOpen })
+
+ return (
+
+ {isOpen && (
+
+
+
+ )}
+
+
+
+ Music
+
+
+ 0
+
+
+ {isOpen && isReady && (
+
+
+
+
+
+ {currentTrack?.name || '...'}
+ {currentTrack?.name && (
+
+ {currentTrack.name}
+
+ )}
+
+
+
+
+
+ )}
+
+
+ )
+})
+
+VinylPlayer.displayName = 'VinylPlayer'
diff --git a/src/components/vinyl-player/use-meting.js b/src/components/vinyl-player/use-meting.js
new file mode 100644
index 0000000..88361b0
--- /dev/null
+++ b/src/components/vinyl-player/use-meting.js
@@ -0,0 +1,110 @@
+'use client'
+
+/**
+ * [INPUT]: 依赖动态加载的 APlayer + MetingJS 脚本(按需加载,非全局),enabled 参数控制初始化
+ * [OUTPUT]: 对外提供 useMeting hook,暴露播放控制和状态
+ * [POS]: vinyl-player 的音频数据层,桥接 MetingJS Web Component 与 React 状态
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
+ */
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+export const PLAYLIST_ID = process.env.NEXT_PUBLIC_NETEASE_PLAYLIST_ID || '450272643'
+const MAX_POLL_ATTEMPTS = 50
+
+// Singleton pattern: scripts only load once globally
+let scriptLoadPromise = null
+const loadScripts = () => {
+ if (scriptLoadPromise) return scriptLoadPromise
+
+ scriptLoadPromise = new Promise((resolve, reject) => {
+ // Load APlayer CSS first
+ const link = document.createElement('link')
+ link.rel = 'stylesheet'
+ link.href = 'https://cdn.jsdelivr.net/npm/aplayer@1.10.1/dist/APlayer.min.css'
+ document.head.appendChild(link)
+
+ // Load APlayer JS
+ const aplayer = document.createElement('script')
+ aplayer.src = 'https://cdn.jsdelivr.net/npm/aplayer@1.10.1/dist/APlayer.min.js'
+ aplayer.onload = () => {
+ // Then load Meting JS
+ const meting = document.createElement('script')
+ meting.src = 'https://cdn.jsdelivr.net/npm/meting@2.0.1/dist/Meting.min.js'
+ meting.onload = resolve
+ meting.onerror = reject
+ document.body.appendChild(meting)
+ }
+ aplayer.onerror = reject
+ document.body.appendChild(aplayer)
+ })
+
+ return scriptLoadPromise
+}
+
+const extractTrack = (audios, index) => {
+ const t = audios?.[index]
+ return t ? { name: t.name, artist: t.artist, cover: t.cover } : null
+}
+
+export function useMeting({ enabled = false } = {}) {
+ const containerRef = useRef(null)
+ const aplayerRef = useRef(null)
+ const [isReady, setIsReady] = useState(false)
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [currentTrack, setCurrentTrack] = useState(null)
+
+ useEffect(() => {
+ if (!enabled || !containerRef.current) return
+
+ // Load scripts dynamically first, then poll for meting element
+ loadScripts()
+ .then(() => {
+ const metingEl = containerRef.current?.querySelector('meting-js')
+ if (!metingEl) return
+
+ let attempts = 0
+ const poll = setInterval(() => {
+ if (++attempts > MAX_POLL_ATTEMPTS) {
+ clearInterval(poll)
+ return
+ }
+
+ const ap = metingEl.aplayer
+ if (!ap) return
+
+ clearInterval(poll)
+ aplayerRef.current = ap
+
+ if (ap.list?.audios?.length > 0) {
+ setCurrentTrack(extractTrack(ap.list.audios, ap.list.index))
+ }
+
+ const onPlay = () => setIsPlaying(true)
+ const onPause = () => setIsPlaying(false)
+ const onSwitch = (e) => setCurrentTrack(extractTrack(ap.list.audios, e.index))
+
+ ap.on('play', onPlay)
+ ap.on('pause', onPause)
+ ap.on('listswitch', onSwitch)
+
+ setIsReady(true)
+ }, 300)
+ })
+ .catch(console.error)
+
+ return () => {
+ const ap = aplayerRef.current
+ if (ap && typeof ap.off === 'function') {
+ ap.off('play')
+ ap.off('pause')
+ ap.off('listswitch')
+ }
+ }
+ }, [enabled])
+
+ const toggle = useCallback(() => aplayerRef.current?.toggle(), [])
+ const next = useCallback(() => aplayerRef.current?.skipForward(), [])
+ const prev = useCallback(() => aplayerRef.current?.skipBack(), [])
+
+ return { containerRef, isReady, isPlaying, currentTrack, toggle, next, prev }
+}
diff --git a/src/components/vinyl-player/use-meting.test.js b/src/components/vinyl-player/use-meting.test.js
new file mode 100644
index 0000000..4eb79bc
--- /dev/null
+++ b/src/components/vinyl-player/use-meting.test.js
@@ -0,0 +1,27 @@
+import { renderHook } from '@testing-library/react'
+import { useMeting } from './use-meting'
+
+describe('useMeting', () => {
+ it('returns initial state correctly', () => {
+ const { result } = renderHook(() => useMeting())
+
+ expect(result.current.isReady).toBe(false)
+ expect(result.current.isPlaying).toBe(false)
+ expect(result.current.currentTrack).toBe(null)
+ expect(result.current.containerRef.current).toBe(null)
+ })
+
+ it('exposes toggle, next, prev as functions', () => {
+ const { result } = renderHook(() => useMeting())
+
+ expect(typeof result.current.toggle).toBe('function')
+ expect(typeof result.current.next).toBe('function')
+ expect(typeof result.current.prev).toBe('function')
+ })
+
+ it('exposes containerRef as an object with current property', () => {
+ const { result } = renderHook(() => useMeting())
+
+ expect(result.current.containerRef).toHaveProperty('current')
+ })
+})
\ No newline at end of file
diff --git a/src/components/vinyl-player/vinyl-player.test.jsx b/src/components/vinyl-player/vinyl-player.test.jsx
new file mode 100644
index 0000000..27de4b4
--- /dev/null
+++ b/src/components/vinyl-player/vinyl-player.test.jsx
@@ -0,0 +1,10 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { VinylPlayer } from './'
+
+describe('VinylPlayer', () => {
+ it('renders Music button', () => {
+ render()
+ expect(screen.getByText('Music')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/vinyl-player/vinyl-record.jsx b/src/components/vinyl-player/vinyl-record.jsx
new file mode 100644
index 0000000..befeff9
--- /dev/null
+++ b/src/components/vinyl-player/vinyl-record.jsx
@@ -0,0 +1,30 @@
+'use client'
+
+import { memo } from 'react'
+
+import styles from './vinyl-record.module.css'
+
+/**
+ * [INPUT]: 接收 isPlaying、coverUrl、onClick props
+ * [OUTPUT]: 对外提供 VinylRecord 纯展示组件
+ * [POS]: vinyl-player 的视觉层,黑胶唱片 DOM 结构 + CSS 动画
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
+ */
+export const VinylRecord = memo(({ isPlaying, coverUrl, onClick }) => {
+ const animClass = isPlaying ? styles.spinning : isPlaying === false && onClick ? styles.stopping : ''
+ const labelStyle = coverUrl ? { backgroundImage: `url(${coverUrl})` } : undefined
+ const isInteractive = !!onClick
+
+ return (
+
+ )
+})
+
+VinylRecord.displayName = 'VinylRecord'
\ No newline at end of file
diff --git a/src/components/vinyl-player/vinyl-record.module.css b/src/components/vinyl-player/vinyl-record.module.css
new file mode 100644
index 0000000..b4625c9
--- /dev/null
+++ b/src/components/vinyl-player/vinyl-record.module.css
@@ -0,0 +1,92 @@
+/* 黑胶唱片:纹路 + 标签 + 旋转动画 */
+
+.record {
+ position: relative;
+ width: 140px;
+ height: 140px;
+ border-radius: 50%;
+ background: linear-gradient(45deg, #333, black, #555, black, #333);
+ cursor: default;
+ transform: rotate(0deg);
+ transition: transform 0.3s ease-out;
+}
+
+.record[role='button'] {
+ cursor: pointer;
+}
+
+/* 唱片纹路 */
+.record::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ border-radius: 50%;
+ background: repeating-radial-gradient(circle at center, black 2px, transparent 4px);
+}
+
+/* 内圈暗区 */
+.record::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 55%;
+ height: 55%;
+ border-radius: 50%;
+ background-color: rgba(0, 0, 0, 0.35);
+}
+
+/* 中心标签 */
+.label {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 38%;
+ height: 38%;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #1a1a2e, #16213e);
+ background-size: cover;
+ background-position: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+ z-index: 2;
+ overflow: hidden;
+ padding: 4px;
+}
+
+/* 主轴 */
+.spindle {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: white;
+ box-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
+ z-index: 3;
+}
+
+/* 旋转动画 */
+.spinning {
+ animation: spin 4s linear infinite;
+}
+
+.stopping {
+ animation: spinStop 1.5s ease-out forwards;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes spinStop {
+ to {
+ transform: rotate(120deg);
+ }
+}
\ No newline at end of file
diff --git a/src/components/vinyl-player/vinyl-record.test.jsx b/src/components/vinyl-player/vinyl-record.test.jsx
new file mode 100644
index 0000000..57bdb34
--- /dev/null
+++ b/src/components/vinyl-player/vinyl-record.test.jsx
@@ -0,0 +1,28 @@
+import { render, screen, fireEvent } from '@testing-library/react'
+import { VinylRecord } from './vinyl-record.jsx'
+
+describe('VinylRecord', () => {
+ it('renders without crashing', () => {
+ const { container } = render()
+ expect(container.querySelector('[class*="record"]')).toBeInTheDocument()
+ })
+
+ it('has spinning animation when playing', () => {
+ const { container } = render()
+ const record = container.querySelector('[class*="record"]')
+ expect(record.className).toMatch(/spinning/)
+ })
+
+ it('calls onClick when clicked', () => {
+ const onClick = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button'))
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('has aria-label for accessibility', () => {
+ render( {}} />)
+ expect(screen.getByRole('button', { name: /播放音乐/i })).toBeInTheDocument()
+ })
+})
diff --git a/src/components/visual/lightbox-viewer.js b/src/components/visual/lightbox-viewer.js
index 7da1df4..723c575 100644
--- a/src/components/visual/lightbox-viewer.js
+++ b/src/components/visual/lightbox-viewer.js
@@ -123,7 +123,10 @@ export function LightboxViewer({ isOpen, media, allMedia, onClose, onNavigate })
)}
diff --git a/src/components/visual/media-card.js b/src/components/visual/media-card.js
index f9bb9fc..11e9950 100644
--- a/src/components/visual/media-card.js
+++ b/src/components/visual/media-card.js
@@ -54,6 +54,7 @@ export function MediaCard({ item, isHovered, onClick }) {
alt={`${item.sourceType} ${item.mediaType}`}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
loading="lazy"
+ decoding="async"
onError={(e) => {
console.error('Video thumbnail failed to load:', thumbnailUrl)
e.target.style.display = 'none'
@@ -94,6 +95,7 @@ export function MediaCard({ item, isHovered, onClick }) {
className="h-auto w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
style={{ height: `${calculatedHeight}px` }}
loading="lazy"
+ decoding="async"
/>
)}
@@ -130,7 +132,7 @@ export function MediaCard({ item, isHovered, onClick }) {
initial={{ opacity: 0 }}
animate={{ opacity: isHovered ? 1 : 0 }}
transition={{ duration: 0.2 }}
- className="absolute bottom-3 left-3 flex items-center gap-3 text-white"
+ className="absolute bottom-3 left-3 flex items-center gap-2 text-white"
>
{item.likes && (
diff --git a/src/components/workspace/hardware-list.js b/src/components/workspace/hardware-list.js
index 1530168..349cf2c 100644
--- a/src/components/workspace/hardware-list.js
+++ b/src/components/workspace/hardware-list.js
@@ -16,6 +16,10 @@ export function HardwareList({ items }) {
return '🎧'
case 'lighting':
return '💡'
+ case 'mouse':
+ return '🖱️'
+ case 'glasses':
+ return '👓'
default:
return '⚙️'
}
@@ -27,7 +31,7 @@ export function HardwareList({ items }) {
{/* Hardware List */}
-
+
{items.map((item, index) => (
-
+
{getCategoryIcon(item.category)}
diff --git a/src/components/workspace/now-tag.js b/src/components/workspace/now-tag.js
index 8a925e0..7fe5f8c 100644
--- a/src/components/workspace/now-tag.js
+++ b/src/components/workspace/now-tag.js
@@ -15,7 +15,7 @@ export function NowTag({ projects }) {
return (
-
+
diff --git a/src/components/workspace/project-card.js b/src/components/workspace/project-card.js
index 3d3e5e4..06f68cf 100644
--- a/src/components/workspace/project-card.js
+++ b/src/components/workspace/project-card.js
@@ -52,7 +52,7 @@ export function ProjectCard({ title, tagline, status, stack, link, startDate })
{startDate &&
Started {formatDate(startDate)}}
-
+
{stack.map((tech, index) => (
{/* Hover effect overlay */}
-
+
)
}
diff --git a/src/data/friends.json b/src/data/friends.json
new file mode 100644
index 0000000..ef2a8c7
--- /dev/null
+++ b/src/data/friends.json
@@ -0,0 +1,18 @@
+{
+ "friends": [
+ {
+ "name": "Zen",
+ "url": "https://zenhungyep.com/",
+ "avatar": "https://zenhungyep.com/assets/dp.jpg",
+ "github": "autism-ip",
+ "signature": "Zen Yep"
+ },
+ {
+ "name": "Penn",
+ "url": "https://pennlam.com/",
+ "avatar": "https://www.pennlam.com/assets/me.avif",
+ "github": "penn-lam",
+ "signature": "Penn Lam"
+ }
+ ]
+}
diff --git a/src/data/tools.json b/src/data/tools.json
index a7d3ded..18f7b8b 100644
--- a/src/data/tools.json
+++ b/src/data/tools.json
@@ -21,20 +21,20 @@
"tip": "Try image + text prompts"
},
{
- "slug": "craft",
- "name": "Craft",
+ "slug": "notion",
+ "name": "Notion",
"desc": "Structured note workspace",
- "url": "https://www.craft.do",
+ "url": "https://www.notion.so",
"tags": ["Notes"],
- "tip": "Cmd + E for quick capture"
+ "tip": "Ctrl + K for AI chat in editor"
},
{
- "slug": "warp",
- "name": "Warp",
- "desc": "GPU-accelerated terminal",
- "url": "https://www.warp.dev",
- "tags": ["Dev"],
- "tip": "AI command suggestions built-in"
+ "slug": "notebooklm",
+ "name": "NotebookLM",
+ "desc": "Structured note workspace",
+ "url": "https://notebooklm.google",
+ "tags": ["Notes"],
+ "tip": "AI-powered note-taking"
}
]
},
@@ -59,94 +59,30 @@
"tip": "Cursor alternative"
},
{
- "slug": "orbstack",
- "name": "OrbStack",
- "desc": "Lightweight macOS container",
- "url": "https://orbstack.dev",
- "tags": ["Infra", "Dev"],
- "tip": "Docker Desktop replacement"
- }
- ]
- },
- {
- "id": "ai-playground",
- "name": "AI Playground",
- "tools": [
- {
- "slug": "ollama",
- "name": "Ollama",
- "desc": "Local LLM runner toolkit",
- "url": "https://ollama.ai",
- "tags": ["AI", "LLM"],
- "tip": "Run models locally & privately"
- },
- {
- "slug": "stackability-matrix",
- "name": "Stackability Matrix",
- "desc": "ComfyUI node profiling UI",
- "url": "https://github.com/stackability/matrix",
- "tags": ["AI", "Image"],
- "tip": "Optimize ComfyUI workflows"
+ "slug": "warp",
+ "name": "Warp",
+ "desc": "GPU-accelerated terminal",
+ "url": "https://www.warp.dev",
+ "tags": ["Dev"],
+ "tip": "AI command suggestions built-in"
},
{
- "slug": "langgraph",
- "name": "Langgraph",
- "desc": "Composable LLM workflows",
- "url": "https://langchain-ai.github.io/langgraph/",
- "tags": ["AI", "Framework"],
- "tip": "My choice for building agents"
- }
- ]
- },
- {
- "id": "creative-stack",
- "name": "Creative Stack",
- "tools": [
- {
- "slug": "figma",
- "name": "Figma",
- "desc": "Collaborative design canvas",
- "url": "https://figma.com",
- "tags": ["Design"],
- "tip": "Shift + 1 for zoom to fit"
- }
- ]
- },
- {
- "id": "productivity-hacks",
- "name": "Productivity Hacks",
- "tools": [
- {
- "slug": "super-productivity",
- "name": "Super Productivity",
- "desc": "Task & time tracker",
- "url": "https://super-productivity.com",
- "tags": ["Productivity"],
- "tip": "Pomodoro + task tracking in one"
+ "slug": "docker",
+ "name": "Docker",
+ "desc": "Lightweight Windows container",
+ "url": "https://www.docker.com",
+ "tags": ["Infra", "Dev"],
+ "tip": "Containerization for development"
},
{
- "slug": "ice",
- "name": "Ice",
- "desc": "Clipper & launcher app",
- "url": "https://github.com/jordanbaird/Ice",
- "tags": ["Productivity", "Launcher"],
- "tip": "Hide menu bar clutter"
- }
- ]
- },
- {
- "id": "life-utilities",
- "name": "Life Utilities",
- "tools": [
- {
- "slug": "youtube-music",
- "name": "YouTube Music",
- "desc": "Streamlined audio streaming",
- "url": "https://github.com/th-ch/youtube-music",
- "tags": ["Music"],
- "tip": "Upload your own music library"
+ "slug": "dify",
+ "name": "Dify",
+ "desc": "AI-powered workflow engine",
+ "url": "https://cloud.dify.ai",
+ "tags": ["Infra"],
+ "tip": "Try to create a AI workflow"
}
]
}
]
-}
+}
\ No newline at end of file
diff --git a/src/data/workspace/hardware.json b/src/data/workspace/hardware.json
index 399e457..b1542ab 100644
--- a/src/data/workspace/hardware.json
+++ b/src/data/workspace/hardware.json
@@ -1,38 +1,38 @@
[
{
- "name": "MacBook Pro M2",
- "detail": "64GB RAM, 2TB SSD, 16-inch",
- "role": "Main development machine",
+ "name": "GIGABYTE AERO 16 XE5",
+ "detail": "16GB RAM, 1TB SSD, 16-inch",
+ "role": "Windows development machine",
"category": "laptop"
},
{
- "name": "LG UltraFine Display",
- "detail": "32-inch, 4K UHD, USB-C",
- "role": "Primary monitor",
- "category": "display"
+ "name": "MacBook Air M1 2020",
+ "detail": "8GB RAM, 512GB SSD, 13-inch",
+ "role": "Mac development machine",
+ "category": "laptop"
},
{
- "name": "Apple Magic Trackpad",
- "detail": "Multi-Touch, Space Gray",
- "role": "Primary input device",
- "category": "input"
+ "name": "Razer Basilisk Ultimate",
+ "detail": "Wireless, compact",
+ "role": "Daily mouse",
+ "category": "mouse"
},
{
- "name": "Logitech MX Keys Mini",
- "detail": "Wireless, backlit, compact layout",
+ "name": "Logitech Alto Keys K75M",
+ "detail": "Wireless, Bluetooth, Mechanical, Backlit",
"role": "Daily keyboard",
"category": "input"
},
{
- "name": "B&O Beoplay HX & iFLYBUDS Pro",
- "detail": "Premium over-ear headphones & wireless earbuds",
- "role": "Audio monitoring and calls",
+ "name": "SONY WH-1000XM5",
+ "detail": "Noise-cancelling over-ear headphones",
+ "role": "Noise-cancelling over-ear headphones",
"category": "audio"
},
{
- "name": "Elgato Ring Light",
- "detail": "12-inch, adjustable brightness",
- "role": "Video calls lighting",
- "category": "lighting"
+ "name": "INAIR 2 Pro",
+ "detail": "AR glasses",
+ "role": "Personal AR device",
+ "category": "glasses"
}
]
diff --git a/src/data/workspace/log.json b/src/data/workspace/log.json
index 4b99256..ddbddaa 100644
--- a/src/data/workspace/log.json
+++ b/src/data/workspace/log.json
@@ -1,74 +1,14 @@
[
{
- "date": "2025-05-15",
- "title": "ARA project launch - Autonomous Deep Research Agent development",
- "details": "Started building ARA, a free image generation platform powered by autonomous research agents using LangGraph and Next.js",
- "category": "research"
- },
- {
- "date": "2025-04-20",
- "title": "MentisSandbox v1.0 released - Docker-based Agent execution environment",
- "details": "Successfully deployed secure, persistent sandbox environment with real-time WebSocket feedback for AI Agents, supporting LangGraph and CrewAI frameworks",
- "category": "deployment"
- },
- {
- "date": "2025-03-10",
- "title": "Mentis Multi-Agent framework core development",
- "details": "Completed state-driven planning Supervisor Agent architecture for coordinating specialist agents in complex task automation",
- "category": "development"
- },
- {
- "date": "2025-02-05",
- "title": "AI Agent technology research initiative launched",
- "details": "Began comprehensive research into multi-agent systems, autonomous coordination, and intelligent task planning frameworks",
- "category": "research"
- },
- {
- "date": "2024-12-15",
- "title": "Enterprise GPT product deployment completed",
- "details": "Successfully deployed enterprise-grade GPT solution with Chat, Image Analysis, Compliance Check, and Auto Dashboard features using Milvus vector database",
+ "date": "2025-10",
+ "title": "ClarifyLung AI",
+ "details": "Developed an interpretable lung cancer assisted diagnosis system fusing CNN local features and Transformer global context. Implemented Grad-CAM++ for visual explainability and deployed the full-stack solution using PyTorch, FastAPI, and React.",
"category": "deployment"
},
{
- "date": "2024-11-20",
- "title": "Auto Dashboard generation system implemented",
- "details": "Developed automatic data visualization and chart generation capabilities for enterprise GPT platform",
- "category": "development"
- },
- {
- "date": "2024-10-10",
- "title": "Image analysis and compliance check modules launched",
- "details": "Integrated advanced image processing and document compliance verification features into enterprise GPT system",
- "category": "development"
- },
- {
- "date": "2024-09-01",
- "title": "Enterprise GPT project initiated with RAG architecture",
- "details": "Started development of enterprise-grade GPT product, designed RAG architecture with Milvus vector database for embedding-based retrieval",
+ "date": "2025-04",
+ "title": "Unsupervised Lung Cancer Detection (MIS-FM Finetuned)",
+ "details": "Engineered an unsupervised anomaly detection algorithm for 3D CT scans by fine-tuning the MIS-FM foundation model. Utilized image reconstruction error analysis to identify malignant regions, significantly reducing dependency on large-scale annotated medical datasets.",
"category": "development"
- },
- {
- "date": "2024-08-15",
- "title": "ComfyUI character consistency breakthrough with Pulid+Flux",
- "details": "Mastered advanced ComfyUI workflows, achieved significant improvements in character consistency using Pulid+Flux combination",
- "category": "research"
- },
- {
- "date": "2024-07-01",
- "title": "ComfyUI workflow research and experimentation",
- "details": "Deep dive into ComfyUI ecosystem, exploring various workflows for AI image generation and processing",
- "category": "research"
- },
- {
- "date": "2024-06-10",
- "title": "Character AI comprehensive research completed",
- "details": "Conducted extensive research on Character AI, focusing on prompt engineering for natural character behavior and memory system design",
- "category": "research"
- },
- {
- "date": "2024-04-15",
- "title": "RAG (Retrieval-Augmented Generation) exploration started",
- "details": "Began exploring RAG technology, laying the foundation for future enterprise AI applications",
- "category": "research"
}
]
diff --git a/src/data/workspace/now.json b/src/data/workspace/now.json
index 8020d4c..e09a50e 100644
--- a/src/data/workspace/now.json
+++ b/src/data/workspace/now.json
@@ -1,12 +1,12 @@
[
{
- "project": "Building ARA - Autonomous Research Agent",
- "description": "Advanced AI research agent powered by LangGraph for autonomous information discovery and synthesis",
- "since": "2025-05-01"
+ "project": "ClarifyLung AI",
+ "description": "Dual-stream AI system fusing CNN local features and Transformer global context for interpretable lung cancer screening using Grad-CAM++.",
+ "since": "2025-10-20"
},
{
- "project": "Building Latour - Free Image Generation Service",
- "description": "Web-based platform providing free AI image generation services for everyone",
- "since": "2025-05-01"
+ "project": "Unsupervised Lung Cancer Detection (MIS-FM Finetuned)",
+ "description": "Unsupervised anomaly detection system for 3D CT scans using reconstruction error analysis from a fine-tuned MIS-FM foundation model.",
+ "since": "2025-04-20"
}
]
diff --git a/src/data/workspace/projects.json b/src/data/workspace/projects.json
index 7955cb0..5363b34 100644
--- a/src/data/workspace/projects.json
+++ b/src/data/workspace/projects.json
@@ -1,20 +1,26 @@
[
{
- "title": "MentisSandbox",
+ "title": "ClarifyLung AI",
"status": "Live",
- "tagline": "Secure, persistent sandbox environment for AI Agents with real-time feedback",
- "link": "https://github.com/foreveryh/sandboxai",
- "stack": ["Docker", "WebSocket", "Python", "LangGraph", "CrewAI"],
- "startDate": "2025-04-01",
+ "tagline": "Dual-stream AI system fusing CNN Mutil-Scale Attention and Transformer global context for interpretable early lung cancer screening.",
+ "link": "https://lungcancerdetection.top",
+ "stack": ["Docker", "Next.js", "Cross Attention","Mutil-Scale Attention","Pytorch","Python"],
+ "startDate": "2025-10-20",
"image": null
},
{
- "title": "Mentis",
- "status": "Building",
- "tagline": "Scalable multi-Agent Development Kit with state-driven planning supervisor",
- "link": "https://github.com/foreveryh/mentis",
- "stack": ["LangGraph", "Python", "Multi-Agent Systems", "AI Coordination"],
- "startDate": "2025-03-01",
+ "title": "Unsupervised Lung Cancer Detection (MIS-FM Finetuned)",
+ "status": "Live",
+ "tagline": "Unsupervised anomaly detection system using fine-tuned MIS-FM model and reconstruction error analysis for 3D CT scans",
+ "link": null,
+ "stack": [
+ "Python",
+ "MIS-FM (FMUNetV3)",
+ "PyTorch",
+ "Seaborn",
+ "3D U-Net"
+ ],
+ "startDate": "2025-04-20",
"image": null
}
]
diff --git a/src/globals.css b/src/globals.css
index fe4f9fa..8567e39 100644
--- a/src/globals.css
+++ b/src/globals.css
@@ -3,6 +3,14 @@
@plugin '@tailwindcss/container-queries';
@plugin 'tailwindcss-animate';
+@font-face {
+ font-family: 'Brittany Signature';
+ src: url('/fonts/BrittanySignature.ttf') format('truetype');
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+
@theme static {
--sh-identifier: #171717;
--sh-sign: #8996a3;
@@ -25,6 +33,14 @@
--breakpoint-xl: 1280px;
--animate-reveal: reveal 0.7s ease-in-out;
+
+ /* Fluid typography scale */
+ --text-display: clamp(1.75rem, 4vw + 0.5rem, 2.5rem);
+ --text-h1: clamp(1.5rem, 3vw + 0.5rem, 2rem);
+ --text-h2: clamp(1.25rem, 2vw + 0.25rem, 1.5rem);
+ --text-h3: clamp(1.125rem, 1.5vw + 0.25rem, 1.25rem);
+ --text-body: clamp(0.9375rem, 0.5vw + 0.875rem, 1rem);
+ --text-small: clamp(0.8125rem, 0.25vw + 0.75rem, 0.875rem);
}
/*
@@ -116,7 +132,7 @@
}
h3 {
- @apply md:text-lg;
+ @apply text-base md:text-lg;
}
h2 + h3 {
@@ -187,6 +203,29 @@
@apply -mx-1.5 -my-1 rounded-sm bg-transparent px-1.5 py-1 transition-colors duration-300 hover:bg-gray-200;
}
+@utility text-display {
+ font-size: var(--text-display);
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+}
+
+@utility text-h1 {
+ font-size: var(--text-h1);
+ line-height: 1.2;
+ letter-spacing: -0.015em;
+}
+
+@utility text-h2 {
+ font-size: var(--text-h2);
+ line-height: 1.3;
+ letter-spacing: -0.01em;
+}
+
+@utility text-h3 {
+ font-size: var(--text-h3);
+ line-height: 1.4;
+}
+
@utility content-wrapper {
@apply z-1 w-full px-6 pt-8 pb-8 lg:px-8 lg:pt-24 lg:pb-16;
}
diff --git a/src/hooks/use-key-press.js b/src/hooks/use-key-press.js
new file mode 100644
index 0000000..cf978d2
--- /dev/null
+++ b/src/hooks/use-key-press.js
@@ -0,0 +1,25 @@
+'use client'
+
+import { useEffect } from 'react'
+
+/**
+ * [INPUT]: 依赖 window.keydown 事件
+ * [OUTPUT]: 对外提供 useKeyPress hook,监听键盘事件
+ * [POS]: hooks/ 的键盘快捷键 hook,被 VinylPlayer 消费
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
+ */
+export function useKeyPress(callback, keyCodes, disabled = false) {
+ useEffect(() => {
+ const handler = (event) => {
+ if (disabled) return
+ if (keyCodes.includes(event.code) && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ callback(event)
+ }
+ }
+
+ window.addEventListener('keydown', handler, { passive: true })
+ return () => {
+ window.removeEventListener('keydown', handler)
+ }
+ }, [callback, keyCodes, disabled])
+}
diff --git a/src/hooks/use-key-press.test.js b/src/hooks/use-key-press.test.js
new file mode 100644
index 0000000..8a4503f
--- /dev/null
+++ b/src/hooks/use-key-press.test.js
@@ -0,0 +1,55 @@
+import { renderHook, act } from '@testing-library/react'
+import { describe, expect, it, beforeEach, vi } from 'vitest'
+import { useKeyPress } from './use-key-press'
+
+describe('useKeyPress', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('calls callback when correct key is pressed', () => {
+ const callback = vi.fn()
+ renderHook(() => useKeyPress(callback, ['KeyA']))
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyA' }))
+ })
+
+ expect(callback).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not call callback when shift key is pressed', () => {
+ const callback = vi.fn()
+ renderHook(() => useKeyPress(callback, ['KeyA']))
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyA', shiftKey: true }))
+ })
+
+ expect(callback).not.toHaveBeenCalled()
+ })
+
+ it('ignores keys not in keyCodes array', () => {
+ const callback = vi.fn()
+ renderHook(() => useKeyPress(callback, ['KeyA']))
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyB' }))
+ })
+
+ expect(callback).not.toHaveBeenCalled()
+ })
+
+ it('returns cleanup function that removes listener on unmount', () => {
+ const callback = vi.fn()
+ const { unmount } = renderHook(() => useKeyPress(callback, ['KeyA']))
+
+ unmount()
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyA' }))
+ })
+
+ expect(callback).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/hooks/use-sunny-mode.js b/src/hooks/use-sunny-mode.js
new file mode 100644
index 0000000..fa92035
--- /dev/null
+++ b/src/hooks/use-sunny-mode.js
@@ -0,0 +1,56 @@
+'use client'
+
+/**
+ * [INPUT]: 无外部依赖
+ * [OUTPUT]: 对外提供 useSunnyMode hook → { active: boolean, toggle: () => void }
+ * [POS]: hooks/ 的阳光模式状态管理,支持跨组件同步和 localStorage 持久化
+ * [PROTOCOL]: 变更时更新此头部,然后检查 CLAUDE.md
+ */
+import { useCallback, useEffect, useSyncExternalStore } from 'react'
+
+const STORAGE_KEY = 'sunny-mode'
+
+/**
+ * Cross-component sunny mode state with localStorage persistence
+ * and CustomEvent broadcast for cross-tab sync
+ * @returns {{ active: boolean, toggle: () => void }}
+ */
+export function useSunnyMode() {
+ const subscribe = useCallback((callback) => {
+ const handler = (event) => {
+ // Handle both storage events (other tabs) and custom events (same tab)
+ if (event.type === 'storage' || event.type === 'sunny-mode-storage') {
+ callback()
+ }
+ }
+
+ window.addEventListener('storage', handler)
+ window.addEventListener('sunny-mode-storage', handler)
+
+ return () => {
+ window.removeEventListener('storage', handler)
+ window.removeEventListener('sunny-mode-storage', handler)
+ }
+ }, [])
+
+ const getSnapshot = useCallback(() => {
+ if (typeof window === 'undefined') return false
+ return localStorage.getItem(STORAGE_KEY) === 'true'
+ }, [])
+
+ const active = useSyncExternalStore(subscribe, getSnapshot, () => false)
+
+ const toggle = useCallback(() => {
+ const newValue = localStorage.getItem(STORAGE_KEY) !== 'true'
+ localStorage.setItem(STORAGE_KEY, String(newValue))
+
+ // Broadcast change to other tabs and components via CustomEvent
+ window.dispatchEvent(
+ new CustomEvent('sunny-mode-storage', {
+ detail: { key: STORAGE_KEY, value: String(newValue) }
+ })
+ )
+ }, [])
+
+ return { active, toggle }
+}
diff --git a/src/hooks/use-sunny-mode.test.js b/src/hooks/use-sunny-mode.test.js
new file mode 100644
index 0000000..f984ccb
--- /dev/null
+++ b/src/hooks/use-sunny-mode.test.js
@@ -0,0 +1,112 @@
+/**
+ * @jest-environment jsdom
+ */
+import { renderHook, act } from '@testing-library/react'
+import { useSunnyMode } from './use-sunny-mode'
+
+// Mock localStorage
+const localStorageMock = {
+ getItem: jest.fn(),
+ setItem: jest.fn(),
+ removeItem: jest.fn()
+}
+
+// Mock CustomEvent
+global.CustomEvent = jest.fn()
+
+describe('useSunnyMode', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ localStorageMock.getItem.mockReturnValue(null)
+ global.localStorage = localStorageMock
+ })
+
+ describe('initial state', () => {
+ it('should return active as false when localStorage has no value', () => {
+ const { result } = renderHook(() => useSunnyMode())
+ expect(result.current.active).toBe(false)
+ })
+
+ it('should return active as true when localStorage has "true"', () => {
+ localStorageMock.getItem.mockReturnValue('true')
+ const { result } = renderHook(() => useSunnyMode())
+ expect(result.current.active).toBe(true)
+ })
+
+ it('should return active as false when localStorage has "false"', () => {
+ localStorageMock.getItem.mockReturnValue('false')
+ const { result } = renderHook(() => useSunnyMode())
+ expect(result.current.active).toBe(false)
+ })
+ })
+
+ describe('toggle', () => {
+ it('should toggle from false to true', () => {
+ localStorageMock.getItem.mockReturnValue('false')
+ const { result } = renderHook(() => useSunnyMode())
+
+ expect(result.current.active).toBe(false)
+
+ act(() => {
+ result.current.toggle()
+ })
+
+ expect(result.current.active).toBe(true)
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('sunny-mode', 'true')
+ })
+
+ it('should toggle from true to false', () => {
+ localStorageMock.getItem.mockReturnValue('true')
+ const { result } = renderHook(() => useSunnyMode())
+
+ expect(result.current.active).toBe(true)
+
+ act(() => {
+ result.current.toggle()
+ })
+
+ expect(result.current.active).toBe(false)
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('sunny-mode', 'false')
+ })
+ })
+
+ describe('persistence', () => {
+ it('should save state to localStorage on toggle', () => {
+ localStorageMock.getItem.mockReturnValue('false')
+ const { result } = renderHook(() => useSunnyMode())
+
+ act(() => {
+ result.current.toggle()
+ })
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith('sunny-mode', 'true')
+ })
+ })
+
+ describe('cross-tab sync via CustomEvent', () => {
+ it('should dispatch CustomEvent when toggling', () => {
+ const addEventListenerSpy = jest.spyOn(window, 'addEventListener')
+ const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener')
+
+ const { result, unmount } = renderHook(() => useSunnyMode())
+
+ // Check that event listener is registered
+ expect(addEventListenerSpy).toHaveBeenCalledWith('sunny-mode-storage', expect.any(Function))
+
+ act(() => {
+ result.current.toggle()
+ })
+
+ // CustomEvent should be dispatched
+ expect(CustomEvent).toHaveBeenCalledWith('sunny-mode-storage', expect.any(Object))
+
+ unmount()
+
+ // Check that event listener is removed on unmount
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('sunny-mode-storage', expect.any(Function))
+
+ addEventListenerSpy.mockRestore()
+ removeEventListenerSpy.mockRestore()
+ })
+ })
+})
diff --git a/src/lib/constants.js b/src/lib/constants.js
index 8ac7aeb..4971f30 100644
--- a/src/lib/constants.js
+++ b/src/lib/constants.js
@@ -8,14 +8,15 @@ import {
NavigationIcon,
PencilLineIcon,
SparklesIcon,
- Wand2Icon
+ Wand2Icon,
+ UsersIcon
} from 'lucide-react'
export const PROFILES = {
twitter: {
title: 'X (Twitter)',
- username: '熊布朗', // 请替换为您的 Twitter 用户名
- url: 'https://x.com/Stephen4171127', // 请替换为您的 Twitter 个人资料 URL
+ username: 'autism', // 请替换为您的 Twitter 用户名
+ url: 'https://x.com/autism539937', // 请替换为您的 Twitter 个人资料 URL
icon: (
- },
- linkedin: {
- title: 'LinkedIn',
- url: 'https://www.linkedin.com/in/peng-g', // 请替换为您的 LinkedIn 个人资料 URL
- icon:
}
/* medium: {
title: 'Medium',
@@ -78,14 +74,14 @@ export const PROFILES = {
}
// 支持多个 Twitter 收藏夹
-export const TWEETS_COLLECTION_IDS = [56292239, 57413563, 57414510] // 添加你的第二个 Twitter 收藏夹 ID
+export const TWEETS_COLLECTION_IDS = [65591006,65591012,65590917]
export const COLLECTION_IDS = [
- 55349123,
+ 0,
...TWEETS_COLLECTION_IDS, // 展开所有 Twitter 收藏夹 ID
- 55351618,
- 55351620,
- 55858229,
+ 65582294,
+ 65591019,
+ 65591239,
55858636,
55858640,
56212510
@@ -131,6 +127,11 @@ export const LINKS = [
href: '/musings',
label: 'Musings',
icon:
+ },
+ {
+ href: '/friends',
+ label: 'Friends',
+ icon:
}
]
diff --git a/src/middleware.js b/src/middleware.js
index 63ecf00..af01b32 100644
--- a/src/middleware.js
+++ b/src/middleware.js
@@ -7,7 +7,7 @@ export function middleware(request, event) {
async function sendAnalytics() {
const URL =
process.env.NODE_ENV === 'production'
- ? 'https://me.deeptoai.com/api/increment-views'
+ ? '${process.env.NEXT_PUBLIC_BASE_URL}/api/increment-views'
: 'http://localhost:3000/api/increment-views'
try {
diff --git a/src/test/setup.js b/src/test/setup.js
new file mode 100644
index 0000000..c44951a
--- /dev/null
+++ b/src/test/setup.js
@@ -0,0 +1 @@
+import '@testing-library/jest-dom'
diff --git a/test-revalidate.js b/test-revalidate.js
index b3b54d6..9468a05 100644
--- a/test-revalidate.js
+++ b/test-revalidate.js
@@ -1,6 +1,6 @@
#!/usr/bin/env node
-const SECRET = 'R5SIkzrbOocVTaBngWP8tRN3ESUljZAUvf/fhk0/rMI='
+const SECRET = 'G3n_9LoNg_R4ndom_Str1nG_2025'
// 测试函数
async function testRevalidateAPI(baseUrl, testCase) {
diff --git a/vitest.config.js b/vitest.config.js
new file mode 100644
index 0000000..b3de296
--- /dev/null
+++ b/vitest.config.js
@@ -0,0 +1,19 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vitest/config'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setup.js'],
+ globals: true,
+ include: ['src/**/*.{test,spec}.{js,mjs,jsx}'],
+ exclude: ['node_modules', '.next', 'dist']
+ },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src')
+ }
+ }
+})