manual save(2026-01-23 14:38)

This commit is contained in:
SiteAgent Bot
2026-01-23 14:38:13 +08:00
parent 005dfa4ce7
commit f477fc8740
3 changed files with 432 additions and 336 deletions

328
src/components/FlipBook.tsx Normal file
View File

@@ -0,0 +1,328 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
type FlipBookSheet = {
id: string
front: React.ReactNode
back?: React.ReactNode
}
type FlipBookProps = {
title: string
sheets: FlipBookSheet[]
className?: string
}
const TRANSITION_MS = 720
function usePrefersReducedMotion() {
const [reduced, setReduced] = useState(false)
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
const update = () => setReduced(media.matches)
update()
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [])
return reduced
}
const PageHalf: React.FC<{ side: 'left' | 'right'; children?: React.ReactNode }> = ({ side, children }) => {
const isLeft = side === 'left'
return (
<div
className={[
'absolute inset-y-0 w-1/2 overflow-hidden',
isLeft ? 'left-0 rounded-l-[22px]' : 'left-1/2 rounded-r-[22px]',
].join(' ')}
>
<div
className={[
'h-full',
'bg-[color-mix(in_srgb,var(--tf-bg)_55%,white)]',
'shadow-[inset_0_1px_0_rgba(255,255,255,0.65)]',
isLeft
? 'shadow-[inset_-18px_0_40px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,0.65)]'
: 'shadow-[inset_18px_0_40px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,0.65)]',
].join(' ')}
>
<div className="h-full p-[clamp(18px,2.2vw,28px)]">{children}</div>
</div>
</div>
)
}
const SheetSide: React.FC<{
side: 'front' | 'back'
contentSide: 'left' | 'right'
children?: React.ReactNode
}> = ({ side, contentSide, children }) => {
const isBack = side === 'back'
return (
<div
className={[
'absolute inset-0',
'[backface-visibility:hidden]',
'rounded-[22px]',
'bg-transparent',
isBack ? '[transform:rotateY(180deg)]' : '',
].join(' ')}
>
<PageHalf side={contentSide}>{children}</PageHalf>
</div>
)
}
const Sheet: React.FC<{
sheet: FlipBookSheet
index: number
sheetCount: number
shouldBeFlipped: boolean
isTurning: boolean
transitionMs: number
}> = ({ sheet, index, sheetCount, shouldBeFlipped, isTurning, transitionMs }) => {
const zIndex = isTurning ? sheetCount + 10 : sheetCount - index
const clipPath = useMemo(() => {
if (isTurning) return undefined
if (shouldBeFlipped) return 'inset(0 50% 0 0)'
return 'inset(0 0 0 50%)'
}, [isTurning, shouldBeFlipped])
return (
<div
className={[
'absolute inset-0',
'[transform-style:preserve-3d]',
'rounded-[22px]',
'transition-transform',
].join(' ')}
style={{
zIndex,
transitionDuration: `${transitionMs}ms`,
transitionTimingFunction: 'cubic-bezier(0.2, 0.9, 0.2, 1)',
transformOrigin: '50% 50%',
transform: shouldBeFlipped ? 'rotateY(-180deg)' : 'rotateY(0deg)',
clipPath,
}}
aria-hidden="true"
>
<SheetSide side="front" contentSide="right">
{sheet.front}
</SheetSide>
<SheetSide side="back" contentSide="left">
{sheet.back ?? sheet.front}
</SheetSide>
</div>
)
}
export const FlipBook: React.FC<FlipBookProps> = ({ title, sheets, className }) => {
const reducedMotion = usePrefersReducedMotion()
const transitionMs = reducedMotion ? 0 : TRANSITION_MS
const [currentIndex, setCurrentIndex] = useState(0)
const [turning, setTurning] = useState<null | { index: number; direction: 'next' | 'prev' }>(null)
const busy = Boolean(turning)
const canPrev = !busy && currentIndex > 0
const canNext = !busy && currentIndex < sheets.length - 1
const swipeStartX = useRef<number | null>(null)
const scheduleTurn = useCallback(
(action: () => void) => {
if (transitionMs === 0) {
action()
setTurning(null)
return
}
window.setTimeout(() => {
action()
setTurning(null)
}, transitionMs)
},
[transitionMs],
)
const turnToNext = useCallback(() => {
if (!canNext) return
setTurning({ index: currentIndex, direction: 'next' })
scheduleTurn(() => {
setCurrentIndex((value) => Math.min(sheets.length - 1, value + 1))
})
}, [canNext, currentIndex, scheduleTurn, sheets.length])
const turnToPrev = useCallback(() => {
if (!canPrev) return
setTurning({ index: currentIndex - 1, direction: 'prev' })
scheduleTurn(() => {
setCurrentIndex((value) => Math.max(0, value - 1))
})
}, [canPrev, currentIndex, scheduleTurn])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowRight' || event.key === ' ') {
event.preventDefault()
turnToNext()
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
turnToPrev()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [turnToNext, turnToPrev])
const onPointerDown: React.PointerEventHandler<HTMLDivElement> = (event) => {
swipeStartX.current = event.clientX
}
const onPointerUp: React.PointerEventHandler<HTMLDivElement> = (event) => {
const startX = swipeStartX.current
swipeStartX.current = null
if (startX === null) return
const deltaX = event.clientX - startX
if (Math.abs(deltaX) < 34) return
if (deltaX < 0) {
turnToNext()
return
}
turnToPrev()
}
const progressLabel = useMemo(() => {
const page = Math.min(currentIndex + 1, sheets.length)
return `${page} / ${sheets.length}`
}, [currentIndex, sheets.length])
const currentSheet = sheets[currentIndex]
return (
<section
aria-label={title}
className={[
'w-full',
'max-w-[980px]',
'mx-auto',
'select-none',
className ?? '',
].join(' ')}
>
<div className="text-center">
<h1 className="font-[var(--tf-font-serif)] text-[clamp(32px,4.2vw,54px)] leading-[1.08] tracking-tight">
{title}
</h1>
<p className="mt-4 text-sm text-[var(--tf-text-subtle)]">使 / </p>
</div>
<div className="mt-10 flex items-center justify-center">
<div
className={[
'relative',
'w-[min(92vw,940px)]',
'h-[min(68vh,560px)]',
'[perspective:1800px]',
].join(' ')}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
>
<div
className={[
'absolute inset-0 rounded-[26px]',
'bg-[color-mix(in_srgb,var(--tf-bg)_42%,white)]',
'shadow-[0_26px_80px_rgba(0,0,0,0.14)]',
'border border-[color-mix(in_srgb,var(--tf-border)_70%,white)]',
].join(' ')}
/>
<div
aria-hidden="true"
className="absolute inset-y-[22px] left-1/2 w-px bg-[color-mix(in_srgb,var(--tf-border)_80%,white)]"
/>
<div
className={[
'absolute inset-0 z-[60] transition-opacity',
busy ? 'opacity-0 pointer-events-none' : 'opacity-100',
].join(' ')}
>
<PageHalf side="left">{currentSheet?.back ?? currentSheet?.front}</PageHalf>
<PageHalf side="right">{currentSheet?.front}</PageHalf>
</div>
{sheets
.map((sheet, index) => {
const flippedAlready = index < currentIndex
const shouldBeFlipped =
turning?.index === index
? turning.direction === 'next'
: flippedAlready
const isTurning = turning?.index === index
return (
<Sheet
key={sheet.id}
sheet={sheet}
index={index}
sheetCount={sheets.length}
shouldBeFlipped={shouldBeFlipped}
isTurning={isTurning}
transitionMs={transitionMs}
/>
)
})
.reverse()}
</div>
</div>
<div className="mt-8 flex items-center justify-center gap-3">
<button
type="button"
onClick={turnToPrev}
disabled={!canPrev}
className={[
'inline-flex items-center justify-center rounded-full border px-4 py-2 text-sm transition',
'border-[var(--tf-border)] bg-[color-mix(in_srgb,var(--tf-bg)_50%,white)]',
canPrev
? 'text-[var(--tf-text)] hover:bg-[color-mix(in_srgb,var(--tf-bg)_34%,white)]'
: 'text-[var(--tf-text-subtle)] opacity-60 cursor-not-allowed',
].join(' ')}
aria-label="上一页"
>
Prev
</button>
<div className="min-w-[88px] text-center text-xs font-[var(--tf-font-ui)] tracking-wide text-[var(--tf-text-subtle)]">
{progressLabel}
</div>
<button
type="button"
onClick={turnToNext}
disabled={!canNext}
className={[
'inline-flex items-center justify-center rounded-full border px-4 py-2 text-sm transition',
'border-[var(--tf-border)] bg-[color-mix(in_srgb,var(--tf-bg)_50%,white)]',
canNext
? 'text-[var(--tf-text)] hover:bg-[color-mix(in_srgb,var(--tf-bg)_34%,white)]'
: 'text-[var(--tf-text-subtle)] opacity-60 cursor-not-allowed',
].join(' ')}
aria-label="下一页"
>
Next
</button>
</div>
</section>
)
}

View File

@@ -5,6 +5,7 @@ import { Footer } from './Footer'
export const SiteLayout: React.FC = () => {
const location = useLocation()
const isHome = location.pathname === '/'
useEffect(() => {
if (location.hash) {
@@ -21,11 +22,11 @@ export const SiteLayout: React.FC = () => {
return (
<div className="min-h-screen bg-[var(--tf-bg)] text-[var(--tf-text)]">
<Header />
{isHome ? null : <Header />}
<main id="main" className="bg-[var(--tf-bg)]">
<Outlet />
</main>
<Footer />
{isHome ? null : <Footer />}
</div>
)
}