manual save(2026-01-27 15:58)

This commit is contained in:
SiteAgent Bot
2026-01-27 15:58:12 +08:00
parent a7a56ddd9c
commit 205fee0831
9 changed files with 677 additions and 117 deletions

View 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>
)
}

View File

@@ -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>
)

View File

@@ -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>