manual save(2026-01-27 15:58)
This commit is contained in:
270
src/components/Bookshelf.tsx
Normal file
270
src/components/Bookshelf.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { Book } from '../data/books'
|
||||
|
||||
type Props = {
|
||||
books: Book[]
|
||||
}
|
||||
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n))
|
||||
|
||||
const usePrefersReducedMotion = () => {
|
||||
const [reduced, setReduced] = useState(false)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const onChange = () => setReduced(mq.matches)
|
||||
onChange()
|
||||
mq.addEventListener?.('change', onChange)
|
||||
return () => mq.removeEventListener?.('change', onChange)
|
||||
}, [])
|
||||
return reduced
|
||||
}
|
||||
|
||||
const BookSpine: React.FC<{
|
||||
book: Book
|
||||
index: number
|
||||
selected: boolean
|
||||
onSelect: (event?: React.PointerEvent | React.MouseEvent) => void
|
||||
}> = ({ book, index, selected, onSelect }) => {
|
||||
const tilt = (index % 2 === 0 ? 1 : -1) * 0.9
|
||||
const height = 148 + (index % 4) * 14
|
||||
const width = 46 + (index % 3) * 6
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`选择《${book.title}》`}
|
||||
onClick={(e) => onSelect(e)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onSelect()
|
||||
}}
|
||||
className={
|
||||
'relative select-none rounded-lg cursor-pointer outline-none transition-transform duration-300 ' +
|
||||
'focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent focus-visible:ring-white/70'
|
||||
}
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
transform: selected
|
||||
? 'translateY(-10px) rotateZ(0deg)'
|
||||
: `translateY(0px) rotateZ(${tilt}deg)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 rounded-lg shadow-[0_18px_40px_rgba(0,0,0,0.45)] ' +
|
||||
(selected ? 'ring-2 ring-white/60' : 'ring-1 ring-white/10')
|
||||
}
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${book.spineColor} 0%, rgba(0,0,0,0.2) 100%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute inset-[1px] rounded-[7px]"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, rgba(255,255,255,0.10), rgba(255,255,255,0.02) 38%, rgba(0,0,0,0.18) 74%, rgba(255,255,255,0.04))',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute left-2 right-2 top-3 rounded-sm"
|
||||
style={{
|
||||
height: 3,
|
||||
background: `linear-gradient(90deg, transparent, ${book.accentColor}, transparent)`,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-2 bottom-3">
|
||||
<div
|
||||
className="text-[11px] leading-tight text-white/90 font-semibold"
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'mixed',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
{book.title}
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 text-[10px] text-white/70"
|
||||
style={{ writingMode: 'vertical-rl', textOrientation: 'mixed' }}
|
||||
>
|
||||
{book.author}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute -right-[10px] inset-y-2 rounded-r-md"
|
||||
style={{
|
||||
width: 10,
|
||||
background:
|
||||
'linear-gradient(180deg, rgba(255,255,255,0.16), rgba(0,0,0,0.2)), linear-gradient(90deg, rgba(0,0,0,0.4), transparent)',
|
||||
opacity: selected ? 0.8 : 0.45,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Bookshelf: React.FC<Props> = ({ books }) => {
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(books[0]?.slug ?? null)
|
||||
const [spark, setSpark] = useState<{ at: Point; key: number } | null>(null)
|
||||
const sparkKeyRef = useRef(0)
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null)
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const selectedBook = useMemo(() => {
|
||||
if (!selectedSlug) return undefined
|
||||
return books.find((b) => b.slug === selectedSlug)
|
||||
}, [books, selectedSlug])
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure the shelf starts from the left edge (some environments preserve scroll positions).
|
||||
if (scrollerRef.current) scrollerRef.current.scrollLeft = 0
|
||||
}, [])
|
||||
|
||||
const onSelect = (book: Book, event?: React.PointerEvent | React.MouseEvent) => {
|
||||
setSelectedSlug(book.slug)
|
||||
const rect = shelfRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
const ev = event as (React.PointerEvent | React.MouseEvent | undefined)
|
||||
const clientX = ev?.clientX ?? rect.left + rect.width * 0.32
|
||||
const clientY = ev?.clientY ?? rect.top + rect.height * 0.42
|
||||
const x = clamp(clientX - rect.left, 10, rect.width - 10)
|
||||
const y = clamp(clientY - rect.top, 10, rect.height - 10)
|
||||
sparkKeyRef.current += 1
|
||||
setSpark({ at: { x, y }, key: sparkKeyRef.current })
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="书架" className="w-full">
|
||||
<div className="surface relative overflow-hidden rounded-2xl">
|
||||
<div className="grain absolute inset-0 opacity-30" aria-hidden="true" />
|
||||
|
||||
<div className="relative px-5 py-5 sm:px-8 sm:py-7">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-white/70 text-sm">在书脊上点一下,把书抽出来</p>
|
||||
<h2 className="font-display text-[28px] sm:text-[34px] leading-tight text-white">
|
||||
从书架拿书
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{selectedBook && (
|
||||
<Link
|
||||
to={`/books/${selectedBook.slug}`}
|
||||
className="btn-ghost inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm text-white/90 transition-colors"
|
||||
aria-label={`打开《${selectedBook.title}》`}
|
||||
>
|
||||
<span className="font-semibold">翻开阅读</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={shelfRef}
|
||||
className="relative mt-5 rounded-xl p-4 sm:p-5 shelf-wood"
|
||||
style={{
|
||||
boxShadow:
|
||||
'inset 0 2px 0 rgba(255,255,255,0.08), inset 0 -10px 30px rgba(0,0,0,0.55), 0 40px 90px rgba(0,0,0,0.45)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-x-3 bottom-[10px] h-[10px] rounded-full opacity-70"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(80% 120% at 20% 10%, rgba(255,255,255,0.12), transparent 60%), radial-gradient(90% 130% at 90% 30%, rgba(0,0,0,0.65), transparent 55%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{!reducedMotion && spark && (
|
||||
<div
|
||||
key={spark.key}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: spark.at.x,
|
||||
top: spark.at.y,
|
||||
width: 12,
|
||||
height: 12,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${selectedBook?.accentColor ?? '#fff'} 0%, transparent 70%)`,
|
||||
animation: 'spark 820ms ease-out forwards',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={scrollerRef} dir="ltr" className="flex items-end gap-3 overflow-x-auto pb-3 pt-2">
|
||||
{books.map((book, i) => (
|
||||
<div key={book.id} className="flex-none">
|
||||
<BookSpine book={book} index={i} selected={book.slug === selectedSlug} onSelect={(e) => onSelect(book, e)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedBook && (
|
||||
<div
|
||||
className="mt-5 rounded-xl border border-white/12 bg-white/5 p-4 sm:p-5"
|
||||
style={{
|
||||
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.10)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">已选中</p>
|
||||
<h3 className="font-display text-[22px] text-white leading-tight">
|
||||
{selectedBook.title}
|
||||
</h3>
|
||||
{selectedBook.subtitle && (
|
||||
<p className="text-white/70 text-sm mt-1">{selectedBook.subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{selectedBook.tags.slice(0, 3).map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded-full px-3 py-1 text-xs text-white/80"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-white/55 text-xs">{selectedBook.author} · {selectedBook.year}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-white/80 leading-relaxed">{selectedBook.blurb}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes spark {
|
||||
0% { opacity: 0; transform: scale(0.6); filter: blur(0px); }
|
||||
15% { opacity: 0.95; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(7.5); filter: blur(2px); }
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,19 @@ import React from 'react'
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="bg-gray-50 border-t border-gray-200 py-8 mt-12">
|
||||
<div className="container mx-auto px-4 text-center text-gray-600">
|
||||
<p>Powered by TenantCMS</p>
|
||||
<p className="text-sm mt-2">
|
||||
Using X-Tenant-Slug for multi-tenant authentication
|
||||
</p>
|
||||
<footer className="mt-10 pb-10">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="surface rounded-2xl px-6 py-6 text-white">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-display text-lg">书架</div>
|
||||
<div className="text-white/70 text-sm mt-1">挑一本书,翻开阅读。</div>
|
||||
</div>
|
||||
<div className="text-white/60 text-xs">
|
||||
Demo UI · 2026-01-01
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,60 @@
|
||||
import React from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const location = useLocation()
|
||||
const isShelf = location.pathname === '/' || location.pathname.startsWith('/books')
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
TenantCMS <span className="text-blue-600">Demo</span>
|
||||
</h1>
|
||||
<nav className="flex items-center gap-4">
|
||||
<a href="/" className="text-gray-600 hover:text-gray-900">首页</a>
|
||||
<a href="/categories" className="text-gray-600 hover:text-gray-900">分类</a>
|
||||
</nav>
|
||||
<header className="sticky top-0 z-20">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={
|
||||
(isShelf ? 'bg-transparent' : 'bg-white') +
|
||||
' border-b border-white/10'
|
||||
}
|
||||
style={{
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className={
|
||||
(isShelf ? 'text-white' : 'text-gray-900') +
|
||||
' text-xl sm:text-2xl font-bold tracking-tight'
|
||||
}
|
||||
aria-label="返回书架"
|
||||
>
|
||||
<span className="font-display">书架</span>
|
||||
<span className={(isShelf ? 'text-white/70' : 'text-gray-500') + ' ml-2 text-sm font-semibold'}>
|
||||
Library
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-3 sm:gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className={
|
||||
(isShelf ? 'text-white/80 hover:text-white' : 'text-gray-700 hover:text-gray-900') +
|
||||
' text-sm font-semibold'
|
||||
}
|
||||
>
|
||||
书架
|
||||
</Link>
|
||||
<Link
|
||||
to="/categories"
|
||||
className={
|
||||
(isShelf ? 'text-white/80 hover:text-white' : 'text-gray-700 hover:text-gray-900') +
|
||||
' text-sm font-semibold'
|
||||
}
|
||||
>
|
||||
分类
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user