TIJIAO
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
.deep-page {
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.deep-hero {
|
||||
position: relative;
|
||||
min-height: 18rem;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 5rem 0 2.5rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 2.5rem;
|
||||
background: linear-gradient(160deg, var(--paper-deep), var(--paper) 55%);
|
||||
}
|
||||
|
||||
.deep-hero.has-image {
|
||||
min-height: 22rem;
|
||||
}
|
||||
|
||||
.deep-hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.deep-hero-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(247, 242, 234, 0.25), rgba(247, 242, 234, 0.94) 72%);
|
||||
}
|
||||
|
||||
.theme-tech .deep-hero-mask {
|
||||
background: linear-gradient(180deg, rgba(18, 16, 20, 0.35), rgba(18, 16, 20, 0.92) 72%);
|
||||
}
|
||||
|
||||
.deep-hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.deep-back {
|
||||
display: inline-flex;
|
||||
margin-top: 0.75rem;
|
||||
color: var(--brand);
|
||||
min-height: 2.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.theme-tech .deep-back {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.deep-body {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.deep-prose {
|
||||
max-width: 46rem;
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.deep-h {
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--brand);
|
||||
font-size: 1.45rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.theme-tech .deep-h {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.deep-p {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.deep-quote {
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 1.4rem 1.35rem;
|
||||
border-left: 2px solid var(--brand);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.theme-tech .deep-quote {
|
||||
border-left-color: var(--gold);
|
||||
background: var(--tech-card);
|
||||
}
|
||||
|
||||
.deep-quote p {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.deep-quote cite {
|
||||
display: block;
|
||||
margin-top: 0.85rem;
|
||||
font-style: normal;
|
||||
color: var(--ink-soft);
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.deep-list {
|
||||
margin: 0 0 1rem;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.deep-media img,
|
||||
.deep-media-solo img {
|
||||
width: 100%;
|
||||
max-height: 28rem;
|
||||
object-fit: contain;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.deep-media-solo {
|
||||
max-width: 40rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* person */
|
||||
.deep-person-grid {
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* timeline */
|
||||
.deep-timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 1.25rem;
|
||||
border-left: 2px solid rgba(166, 28, 32, 0.18);
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.deep-timeline-item {
|
||||
position: relative;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.deep-timeline-dot {
|
||||
position: absolute;
|
||||
left: -1.55rem;
|
||||
top: 0.45rem;
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
box-shadow: 0 0 0 0.25rem rgba(166, 28, 32, 0.12);
|
||||
}
|
||||
|
||||
.deep-timeline-item p {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
/* shrine */
|
||||
.deep-shrine-stack {
|
||||
max-width: 42rem;
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.deep-shrine-stack .deep-list {
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* sites */
|
||||
.deep-sites-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.deep-site-card {
|
||||
padding: 1.35rem 1.2rem;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border-top: 2px solid var(--gold);
|
||||
}
|
||||
|
||||
/* figures */
|
||||
.deep-figure-rail {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.deep-figure-card {
|
||||
display: grid;
|
||||
grid-template-columns: 3.5rem 1fr;
|
||||
gap: 0.75rem 1.1rem;
|
||||
padding: 1.25rem 0;
|
||||
border-bottom: 1px solid rgba(166, 28, 32, 0.12);
|
||||
}
|
||||
|
||||
.deep-figure-index {
|
||||
color: var(--gold);
|
||||
font-size: 1.5rem;
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.deep-figure-card .deep-h {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.deep-figure-card .deep-p {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
/* factory */
|
||||
.deep-factory-panel {
|
||||
padding: 2rem 1.75rem;
|
||||
background: linear-gradient(135deg, rgba(166, 28, 32, 0.06), rgba(196, 163, 90, 0.08));
|
||||
border: 1px solid rgba(166, 28, 32, 0.12);
|
||||
}
|
||||
|
||||
.deep-factory-caps {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.85rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.deep-factory-cap {
|
||||
padding: 1rem 1.1rem;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
min-height: 4.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.deep-factory-cap p {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* poem wall */
|
||||
.deep-poem-wall {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.25rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.deep-poem-slip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 1.5rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
border: 1px solid rgba(166, 28, 32, 0.1);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.deep-poem-slip p {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.deep-poem-slip cite {
|
||||
margin-top: auto;
|
||||
padding-top: 0.85rem;
|
||||
display: block;
|
||||
font-style: normal;
|
||||
color: var(--ink-soft);
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
/* ink scroll */
|
||||
.deep-ink-flow {
|
||||
max-width: 40rem;
|
||||
margin-inline: auto;
|
||||
border-left: 1px solid var(--gold-soft);
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
.deep-ink-flow .deep-quote {
|
||||
writing-mode: vertical-rl;
|
||||
min-height: 14rem;
|
||||
max-width: 100%;
|
||||
border-left: none;
|
||||
border-top: 2px solid var(--brand);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* manifesto */
|
||||
.deep-manifesto-lead {
|
||||
max-width: 48rem;
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.deep-loop-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.deep-loop-node {
|
||||
padding: 1.2rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border-bottom: 3px solid var(--brand);
|
||||
min-height: 9rem;
|
||||
}
|
||||
|
||||
.deep-loop-num {
|
||||
display: block;
|
||||
color: var(--gold);
|
||||
margin-bottom: 0.55rem;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.deep-loop-node p {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.65;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* matrix */
|
||||
.deep-matrix {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.15rem;
|
||||
}
|
||||
|
||||
.deep-matrix-card {
|
||||
padding: 1.4rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(166, 28, 32, 0.1);
|
||||
}
|
||||
|
||||
/* triad */
|
||||
.deep-triad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.deep-triad-col {
|
||||
padding: 1.5rem 1.2rem;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, rgba(166, 28, 32, 0.08), transparent);
|
||||
border-top: 2px solid var(--brand);
|
||||
}
|
||||
|
||||
/* steps */
|
||||
.deep-steps {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
max-width: 40rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.deep-step {
|
||||
display: grid;
|
||||
grid-template-columns: 3.5rem 1fr;
|
||||
gap: 1rem;
|
||||
padding: 1.35rem 0;
|
||||
border-bottom: 1px dashed rgba(166, 28, 32, 0.2);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.deep-step-index {
|
||||
color: var(--brand);
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.deep-step p {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--ink);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* scenes */
|
||||
.deep-scenes {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.deep-scene {
|
||||
padding: 1.5rem 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border-left: 3px solid var(--gold);
|
||||
}
|
||||
|
||||
/* tour */
|
||||
.deep-tour-flow {
|
||||
max-width: 44rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.deep-tour-steps {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.deep-tour-step {
|
||||
display: grid;
|
||||
grid-template-columns: 2.5rem 1fr;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
padding: 0.95rem 1.1rem;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.deep-tour-step span {
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.deep-tour-step p {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.deep-person-grid,
|
||||
.deep-sites-grid,
|
||||
.deep-poem-wall,
|
||||
.deep-loop-strip,
|
||||
.deep-matrix,
|
||||
.deep-triad,
|
||||
.deep-factory-caps {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.deep-ink-flow .deep-quote {
|
||||
writing-mode: horizontal-tb;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.deep-figure-card {
|
||||
grid-template-columns: 2.5rem 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { ReactElement } from 'react'
|
||||
import { Reveal } from './Reveal'
|
||||
import type { DeepBlock, DeepLayoutKind, DeepPageContent } from '../content/deep/types'
|
||||
import './DeepLayout.css'
|
||||
|
||||
function backPath(group: DeepPageContent['group']) {
|
||||
if (group === 'qingxiang') return '/qingxiang'
|
||||
if (group === 'poetry') return '/poetry'
|
||||
return '/brand'
|
||||
}
|
||||
|
||||
function Blocks({ blocks }: { blocks: DeepBlock[] }) {
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, i) => (
|
||||
<Reveal key={`${block.type}-${i}`} variant={i % 2 === 0 ? 'anim-slide-left' : 'anim-rise'} delayMs={(i % 5) * 60}>
|
||||
{block.type === 'heading' ? <h2 className="deep-h serif">{block.text}</h2> : null}
|
||||
{block.type === 'paragraph' ? <p className="deep-p">{block.text}</p> : null}
|
||||
{block.type === 'quote' ? (
|
||||
<blockquote className="deep-quote serif">
|
||||
<p>{block.text}</p>
|
||||
{block.source ? <cite>{block.source}</cite> : null}
|
||||
</blockquote>
|
||||
) : null}
|
||||
{block.type === 'list' ? (
|
||||
<ul className="deep-list">
|
||||
{block.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</Reveal>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Hero({ page, image }: { page: DeepPageContent; image?: string }) {
|
||||
return (
|
||||
<header className={`deep-hero ${image ? 'has-image' : 'no-image'}`}>
|
||||
{image ? <div className="deep-hero-bg" style={{ backgroundImage: `url(${image})` }} /> : null}
|
||||
<div className="deep-hero-mask" />
|
||||
<div className="container deep-hero-copy">
|
||||
<Reveal variant="anim-ink">
|
||||
<p className="eyebrow">{page.eyebrow}</p>
|
||||
<h1 className="page-title">{page.title}</h1>
|
||||
<p className="lead">{page.lead}</p>
|
||||
<Link className="deep-back" to={backPath(page.group)}>
|
||||
← 返回主篇
|
||||
</Link>
|
||||
</Reveal>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function Closing({ text }: { text?: string }) {
|
||||
if (!text) return null
|
||||
return (
|
||||
<Reveal variant="anim-rise">
|
||||
<p className="closing">{text}</p>
|
||||
</Reveal>
|
||||
)
|
||||
}
|
||||
|
||||
function SoloImage({ src }: { src?: string }) {
|
||||
if (!src) return null
|
||||
return (
|
||||
<Reveal variant="anim-zoom">
|
||||
<div className="media-frame deep-media deep-media-solo">
|
||||
<img src={src} alt="" loading="lazy" />
|
||||
</div>
|
||||
</Reveal>
|
||||
)
|
||||
}
|
||||
|
||||
function listBlocks(blocks: DeepBlock[]) {
|
||||
return blocks.filter((b): b is Extract<DeepBlock, { type: 'list' }> => b.type === 'list')
|
||||
}
|
||||
|
||||
function quoteBlocks(blocks: DeepBlock[]) {
|
||||
return blocks.filter((b): b is Extract<DeepBlock, { type: 'quote' }> => b.type === 'quote')
|
||||
}
|
||||
|
||||
function headingPairs(blocks: DeepBlock[]) {
|
||||
const pairs: { heading: string; body: DeepBlock[] }[] = []
|
||||
let current: { heading: string; body: DeepBlock[] } | null = null
|
||||
for (const b of blocks) {
|
||||
if (b.type === 'heading') {
|
||||
current = { heading: b.text, body: [] }
|
||||
pairs.push(current)
|
||||
} else if (current) {
|
||||
current.body.push(b)
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
function LayoutEssay({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
return (
|
||||
<article className="deep-page deep-layout-essay">
|
||||
<Hero page={page} image={img} />
|
||||
<div className="container deep-body deep-prose">
|
||||
<Blocks blocks={page.blocks} />
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutPerson({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
return (
|
||||
<article className="deep-page deep-layout-person">
|
||||
<Hero page={page} />
|
||||
<div className="container deep-body deep-person-grid">
|
||||
<div className="deep-person-copy">
|
||||
<Blocks blocks={page.blocks} />
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
<SoloImage src={img} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutTimeline({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
const lists = listBlocks(page.blocks)
|
||||
const rest = page.blocks.filter((b) => b.type !== 'list')
|
||||
return (
|
||||
<article className="deep-page deep-layout-timeline">
|
||||
<Hero page={page} />
|
||||
<div className="container deep-body">
|
||||
<SoloImage src={img} />
|
||||
{lists.map((list, li) => (
|
||||
<ol key={li} className="deep-timeline">
|
||||
{list.items.map((item, i) => (
|
||||
<Reveal key={item} variant="anim-slide-left" delayMs={i * 70} className="deep-timeline-item">
|
||||
<span className="deep-timeline-dot" />
|
||||
<p>{item}</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</ol>
|
||||
))}
|
||||
<div className="deep-prose">
|
||||
<Blocks blocks={rest} />
|
||||
</div>
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutShrine({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
return (
|
||||
<article className="deep-page deep-layout-shrine">
|
||||
<Hero page={page} image={img} />
|
||||
<div className="container deep-body deep-shrine-stack">
|
||||
<Blocks blocks={page.blocks} />
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutSites({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
const pairs = headingPairs(page.blocks)
|
||||
return (
|
||||
<article className="deep-page deep-layout-sites">
|
||||
<Hero page={page} />
|
||||
<div className="container deep-body">
|
||||
<div className="deep-sites-grid">
|
||||
{pairs.map((pair, i) => (
|
||||
<Reveal key={pair.heading} variant="anim-rise" delayMs={i * 90} className="deep-site-card">
|
||||
<h2 className="deep-h serif">{pair.heading}</h2>
|
||||
{pair.body.map((b, j) =>
|
||||
b.type === 'paragraph' ? (
|
||||
<p key={j} className="deep-p">
|
||||
{b.text}
|
||||
</p>
|
||||
) : null,
|
||||
)}
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
<SoloImage src={img} />
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutFigures({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
const pairs = headingPairs(page.blocks)
|
||||
return (
|
||||
<article className="deep-page deep-layout-figures">
|
||||
<Hero page={page} />
|
||||
<div className="container deep-body">
|
||||
<div className="deep-figure-rail">
|
||||
{pairs.map((pair, i) => (
|
||||
<Reveal key={pair.heading} variant="anim-expand" delayMs={i * 100} className="deep-figure-card">
|
||||
<span className="deep-figure-index serif">{String(i + 1).padStart(2, '0')}</span>
|
||||
<h2 className="deep-h serif">{pair.heading}</h2>
|
||||
{pair.body.map((b, j) =>
|
||||
b.type === 'paragraph' ? (
|
||||
<p key={j} className="deep-p">
|
||||
{b.text}
|
||||
</p>
|
||||
) : null,
|
||||
)}
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
<SoloImage src={img} />
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutFactory({ page }: { page: DeepPageContent }) {
|
||||
const lists = listBlocks(page.blocks)
|
||||
const rest = page.blocks.filter((b) => b.type !== 'list')
|
||||
return (
|
||||
<article className="deep-page deep-layout-factory">
|
||||
<Hero page={page} />
|
||||
<div className="container deep-body">
|
||||
<div className="deep-factory-panel">
|
||||
<Blocks blocks={rest} />
|
||||
{lists.map((list, i) => (
|
||||
<div key={i} className="deep-factory-caps">
|
||||
{list.items.map((item) => (
|
||||
<Reveal key={item} variant="anim-pop" className="deep-factory-cap">
|
||||
<p>{item}</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutPoemWall({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
const quotes = quoteBlocks(page.blocks)
|
||||
const rest = page.blocks.filter((b) => b.type !== 'quote')
|
||||
return (
|
||||
<article className="deep-page deep-layout-poem-wall">
|
||||
<Hero page={page} />
|
||||
<div className="container deep-body">
|
||||
<div className="deep-poem-wall">
|
||||
{quotes.map((q, i) => (
|
||||
<Reveal key={`${q.source}-${i}`} variant="anim-unroll" delayMs={i * 120} className={`deep-poem-slip slip-${i % 3}`}>
|
||||
<p className="serif">{q.text}</p>
|
||||
{q.source ? <cite>{q.source}</cite> : null}
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
<div className="deep-prose">
|
||||
<Blocks blocks={rest} />
|
||||
</div>
|
||||
<SoloImage src={img} />
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutInkScroll({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
return (
|
||||
<article className="deep-page deep-layout-ink-scroll">
|
||||
<Hero page={page} image={img} />
|
||||
<div className="container deep-body deep-ink-flow">
|
||||
<Blocks blocks={page.blocks} />
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutManifesto({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
const lists = listBlocks(page.blocks)
|
||||
const rest = page.blocks.filter((b) => b.type !== 'list')
|
||||
return (
|
||||
<article className="deep-page deep-layout-manifesto">
|
||||
<Hero page={page} />
|
||||
<div className="container deep-body">
|
||||
<div className="deep-manifesto-lead">
|
||||
<Blocks blocks={rest} />
|
||||
</div>
|
||||
{lists.map((list, i) => (
|
||||
<div key={i} className="deep-loop-strip">
|
||||
{list.items.map((item, j) => (
|
||||
<Reveal key={item} variant="anim-pop" delayMs={j * 80} className="deep-loop-node">
|
||||
<span className="deep-loop-num serif">{String(j + 1).padStart(2, '0')}</span>
|
||||
<p>{item}</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<SoloImage src={img} />
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutMatrix({ page }: { page: DeepPageContent }) {
|
||||
const pairs = headingPairs(page.blocks)
|
||||
const orphan = page.blocks.filter((b, i, arr) => {
|
||||
if (b.type === 'heading') return false
|
||||
const prevHeading = [...arr.slice(0, i)].reverse().find((x) => x.type === 'heading')
|
||||
return !prevHeading
|
||||
})
|
||||
return (
|
||||
<article className="deep-page deep-layout-matrix">
|
||||
<Hero page={page} />
|
||||
<div className="container deep-body">
|
||||
{orphan.length ? (
|
||||
<div className="deep-prose">
|
||||
<Blocks blocks={orphan} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="deep-matrix">
|
||||
{pairs.map((pair, i) => (
|
||||
<Reveal key={pair.heading} variant="anim-expand" delayMs={i * 90} className="deep-matrix-card">
|
||||
<h2 className="deep-h serif">{pair.heading}</h2>
|
||||
{pair.body.map((b, j) => {
|
||||
if (b.type === 'list') {
|
||||
return (
|
||||
<ul key={j} className="deep-list">
|
||||
{b.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
if (b.type === 'paragraph') {
|
||||
return (
|
||||
<p key={j} className="deep-p">
|
||||
{b.text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutTriad({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
const pairs = headingPairs(page.blocks)
|
||||
return (
|
||||
<article className="deep-page deep-layout-triad">
|
||||
<Hero page={page} />
|
||||
<div className="container deep-body">
|
||||
<div className="deep-triad">
|
||||
{pairs.slice(0, 3).map((pair, i) => (
|
||||
<Reveal key={pair.heading} variant="anim-rise" delayMs={i * 100} className="deep-triad-col">
|
||||
<h2 className="deep-h serif">{pair.heading}</h2>
|
||||
{pair.body.map((b, j) => {
|
||||
if (b.type === 'paragraph') {
|
||||
return (
|
||||
<p key={j} className="deep-p">
|
||||
{b.text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (b.type === 'list') {
|
||||
return (
|
||||
<ul key={j} className="deep-list">
|
||||
{b.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
{pairs.slice(3).length ? (
|
||||
<div className="deep-prose">
|
||||
{pairs.slice(3).map((pair) => (
|
||||
<div key={pair.heading}>
|
||||
<h2 className="deep-h serif">{pair.heading}</h2>
|
||||
<Blocks blocks={pair.body} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<SoloImage src={img} />
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutSteps({ page }: { page: DeepPageContent }) {
|
||||
const lists = listBlocks(page.blocks)
|
||||
const rest = page.blocks.filter((b) => b.type !== 'list')
|
||||
const steps = lists[0]?.items ?? []
|
||||
return (
|
||||
<article className="deep-page deep-layout-steps">
|
||||
<Hero page={page} />
|
||||
<div className="container deep-body">
|
||||
<div className="deep-steps">
|
||||
{steps.map((item, i) => (
|
||||
<Reveal key={item} variant="anim-slide-left" delayMs={i * 100} className="deep-step">
|
||||
<span className="deep-step-index serif">{String(i + 1).padStart(2, '0')}</span>
|
||||
<p>{item}</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
<div className="deep-prose">
|
||||
<Blocks blocks={rest} />
|
||||
{lists.slice(1).map((list, i) => (
|
||||
<ul key={i} className="deep-list">
|
||||
{list.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutScenes({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
const pairs = headingPairs(page.blocks)
|
||||
const quotes = quoteBlocks(page.blocks)
|
||||
return (
|
||||
<article className="deep-page deep-layout-scenes">
|
||||
<Hero page={page} />
|
||||
<div className="container deep-body">
|
||||
<div className="deep-scenes">
|
||||
{pairs.map((pair, i) => (
|
||||
<Reveal key={pair.heading} variant="anim-rise" delayMs={i * 90} className="deep-scene">
|
||||
<h2 className="deep-h serif">{pair.heading}</h2>
|
||||
{pair.body.map((b, j) => {
|
||||
if (b.type === 'list') {
|
||||
return (
|
||||
<ul key={j} className="deep-list">
|
||||
{b.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
if (b.type === 'paragraph') {
|
||||
return (
|
||||
<p key={j} className="deep-p">
|
||||
{b.text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
{quotes.map((q, i) => (
|
||||
<blockquote key={i} className="deep-quote serif">
|
||||
<p>{q.text}</p>
|
||||
{q.source ? <cite>{q.source}</cite> : null}
|
||||
</blockquote>
|
||||
))}
|
||||
<SoloImage src={img} />
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutTour({ page }: { page: DeepPageContent }) {
|
||||
const img = page.images[0]
|
||||
const lists = listBlocks(page.blocks)
|
||||
const rest = page.blocks.filter((b) => b.type !== 'list')
|
||||
return (
|
||||
<article className="deep-page deep-layout-tour">
|
||||
<Hero page={page} image={img} />
|
||||
<div className="container deep-body deep-tour-flow">
|
||||
{lists.map((list, i) => (
|
||||
<div key={i} className="deep-tour-steps">
|
||||
{list.items.map((item, j) => (
|
||||
<Reveal key={item} variant="anim-slide-right" delayMs={j * 70} className="deep-tour-step">
|
||||
<span className="serif">{j + 1}</span>
|
||||
<p>{item}</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="deep-prose">
|
||||
<Blocks blocks={rest} />
|
||||
</div>
|
||||
<Closing text={page.closing} />
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
const layoutMap: Record<DeepLayoutKind, (p: { page: DeepPageContent }) => ReactElement> = {
|
||||
essay: LayoutEssay,
|
||||
person: LayoutPerson,
|
||||
timeline: LayoutTimeline,
|
||||
shrine: LayoutShrine,
|
||||
sites: LayoutSites,
|
||||
figures: LayoutFigures,
|
||||
factory: LayoutFactory,
|
||||
'poem-wall': LayoutPoemWall,
|
||||
'ink-scroll': LayoutInkScroll,
|
||||
manifesto: LayoutManifesto,
|
||||
matrix: LayoutMatrix,
|
||||
triad: LayoutTriad,
|
||||
steps: LayoutSteps,
|
||||
scenes: LayoutScenes,
|
||||
tour: LayoutTour,
|
||||
}
|
||||
|
||||
export function DeepLayout({ page }: { page: DeepPageContent }) {
|
||||
const View = layoutMap[page.layout] ?? LayoutEssay
|
||||
return <View page={page} />
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
.site-footer {
|
||||
background: #1a1512;
|
||||
color: rgba(247, 242, 234, 0.78);
|
||||
padding: 2.75rem 0 2.25rem;
|
||||
border-top: 1px solid rgba(196, 163, 90, 0.15);
|
||||
}
|
||||
|
||||
.theme-tech .site-footer {
|
||||
background: #0c0a0e;
|
||||
border-top: 1px solid rgba(196, 163, 90, 0.28);
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
display: grid;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
object-fit: contain;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.footer-title {
|
||||
margin: 0;
|
||||
color: #f7f2ea;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.footer-slogan {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--gold, #c4a35a);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.footer-meta {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.8;
|
||||
color: rgba(247, 242, 234, 0.78);
|
||||
}
|
||||
|
||||
.footer-meta p {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.footer-meta a {
|
||||
color: var(--gold, #c4a35a);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.footer-meta a:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.footer-inner {
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
align-items: end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { footer, images } from '../content/site'
|
||||
import './Footer.css'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="container footer-inner">
|
||||
<div className="footer-brand">
|
||||
<img src={images.logo} alt="" className="footer-logo" />
|
||||
<div>
|
||||
<p className="serif footer-title">{footer.brandLine}</p>
|
||||
<p className="footer-slogan">{footer.slogan}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-meta">
|
||||
<p>
|
||||
{footer.copyright} · {footer.icp}
|
||||
</p>
|
||||
<p>{footer.compliance}</p>
|
||||
<p>{footer.dataNote}</p>
|
||||
<p>
|
||||
<Link to="/contact">联系我们</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
.site-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.site-shell > main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Global stage-mode HTML/Body scroll lock */
|
||||
html.stage-mode,
|
||||
body.stage-mode {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body.stage-mode #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page-section {
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
.page-section.tight {
|
||||
padding-top: 3.5rem;
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 0.95fr;
|
||||
gap: 3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-frame {
|
||||
overflow: hidden;
|
||||
border-radius: 0.2rem;
|
||||
box-shadow: var(--shadow-soft);
|
||||
background: var(--paper-deep);
|
||||
}
|
||||
|
||||
.media-frame img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
aspect-ratio: 4 / 5;
|
||||
}
|
||||
|
||||
.card-grid,
|
||||
.module-grid,
|
||||
.ends-grid,
|
||||
.ports-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.module-grid {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
.soft-card {
|
||||
padding: 1.4rem 1.35rem;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(166, 28, 32, 0.1);
|
||||
border-radius: 0.2rem;
|
||||
transition: transform 0.35s var(--ease), border-color 0.35s var(--ease), background 0.35s var(--ease);
|
||||
}
|
||||
|
||||
.soft-card:hover {
|
||||
transform: translateY(-0.2rem);
|
||||
border-color: rgba(166, 28, 32, 0.28);
|
||||
}
|
||||
|
||||
.theme-tech .soft-card {
|
||||
background: var(--tech-card);
|
||||
border-color: var(--tech-line);
|
||||
}
|
||||
|
||||
.soft-card h3 {
|
||||
margin: 0 0 0.65rem;
|
||||
font-size: 1.15rem;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.theme-tech .soft-card h3 {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.soft-card p {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.cta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import { useIsMobile } from '../hooks/useMobile'
|
||||
import { StageProvider, useStageMode } from '../hooks/useStageMode'
|
||||
import { MobileShell } from '../mobile/MobileShell'
|
||||
import { MobileViews } from '../mobile/MobileViews'
|
||||
import { StageShell } from '../stage/StageShell'
|
||||
import { Footer } from './Footer'
|
||||
import { Nav } from './Nav'
|
||||
import './Layout.css'
|
||||
|
||||
const techRoutes = new Set(['/tech', '/subsidy', '/partner', '/tech-detail', '/contact'])
|
||||
|
||||
function LayoutInner() {
|
||||
const location = useLocation()
|
||||
const isStage = useStageMode()
|
||||
const isMobile = useIsMobile(768)
|
||||
const techLike = techRoutes.has(location.pathname)
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'auto' })
|
||||
}, [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('stage-mode', isStage)
|
||||
document.body.classList.toggle('stage-mode', isStage)
|
||||
return () => {
|
||||
document.documentElement.classList.remove('stage-mode')
|
||||
document.body.classList.remove('stage-mode')
|
||||
}
|
||||
}, [isStage])
|
||||
|
||||
// 1. Stage Mode Branch
|
||||
if (isStage) {
|
||||
return <StageShell />
|
||||
}
|
||||
|
||||
// 2. Mobile Dedicated Layout Branch (< 768px)
|
||||
if (isMobile) {
|
||||
// If accessing deep pages (e.g. /culture/status), render through Outlet; main modules render through MobileViews
|
||||
const isDeepPage =
|
||||
location.pathname.startsWith('/culture/') ||
|
||||
location.pathname.startsWith('/poetry/') ||
|
||||
location.pathname.startsWith('/qingxiang/')
|
||||
return (
|
||||
<MobileShell>
|
||||
{isDeepPage ? <Outlet /> : <MobileViews />}
|
||||
</MobileShell>
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Desktop Normal Mode Branch
|
||||
return (
|
||||
<div className={`site-shell ${techLike ? 'theme-tech' : ''}`}>
|
||||
<Nav />
|
||||
<main className="page-enter" key={location.pathname}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<StageProvider>
|
||||
<LayoutInner />
|
||||
</StageProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
.topnav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
height: var(--nav-h);
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(247, 242, 234, 0.86);
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 0.3s var(--ease), background 0.3s var(--ease);
|
||||
}
|
||||
|
||||
.topnav.has-drawer-open {
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.theme-tech .topnav {
|
||||
background: rgba(18, 16, 20, 0.9);
|
||||
}
|
||||
|
||||
.topnav.is-scrolled {
|
||||
border-bottom-color: rgba(166, 28, 32, 0.12);
|
||||
background: rgba(247, 242, 234, 0.95);
|
||||
}
|
||||
|
||||
.theme-tech .topnav.is-scrolled {
|
||||
border-bottom-color: rgba(196, 163, 90, 0.22);
|
||||
background: rgba(18, 16, 20, 0.96);
|
||||
}
|
||||
|
||||
.theme-tech .nav-links a {
|
||||
color: rgba(243, 236, 228, 0.72);
|
||||
}
|
||||
|
||||
.theme-tech .nav-links a:hover,
|
||||
.theme-tech .nav-links a.is-active {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.theme-tech .nav-links a.is-active::after {
|
||||
background: var(--gold);
|
||||
}
|
||||
|
||||
.theme-tech .mobile-drawer {
|
||||
background: rgba(18, 16, 20, 0.98);
|
||||
}
|
||||
|
||||
.theme-tech .mobile-drawer a {
|
||||
color: #f3ece4;
|
||||
border-bottom-color: rgba(196, 163, 90, 0.15);
|
||||
}
|
||||
|
||||
.topnav-inner {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.15rem;
|
||||
color: var(--brand);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.15rem 0.85rem;
|
||||
max-width: 58rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
position: relative;
|
||||
font-size: 0.86rem;
|
||||
color: var(--ink-soft);
|
||||
padding: 0.35rem 0;
|
||||
transition: color 0.25s var(--ease);
|
||||
}
|
||||
|
||||
.nav-links a:hover,
|
||||
.nav-links a.is-active {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.nav-links a.is-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: var(--brand);
|
||||
transform-origin: left;
|
||||
animation: lineGrow 0.45s var(--ease) both;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
position: relative;
|
||||
z-index: 1002;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.menu-toggle span {
|
||||
width: 1.35rem;
|
||||
height: 1.5px;
|
||||
background: var(--brand);
|
||||
transition: transform 0.3s var(--ease);
|
||||
}
|
||||
|
||||
.mobile-drawer {
|
||||
position: fixed;
|
||||
inset: var(--nav-h) 0 0 0;
|
||||
z-index: 1001;
|
||||
background: rgba(247, 242, 234, 0.98);
|
||||
backdrop-filter: blur(16px);
|
||||
padding: 1.5rem 1.25rem 2rem;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.4s var(--ease);
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-drawer.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.mobile-drawer a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 2.75rem;
|
||||
border-bottom: 1px solid rgba(166, 28, 32, 0.1);
|
||||
font-size: 1.05rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mobile-drawer a:hover {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.nav-groups {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.35rem 1.1rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nav-group-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 2.75rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--ink-soft);
|
||||
letter-spacing: 0.06em;
|
||||
transition: color 0.25s var(--ease);
|
||||
}
|
||||
|
||||
.nav-chevron {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-right: 1.5px solid currentColor;
|
||||
border-bottom: 1.5px solid currentColor;
|
||||
transform: rotate(45deg) translateY(-0.1rem);
|
||||
opacity: 0.55;
|
||||
transition: transform 0.3s var(--ease), opacity 0.25s var(--ease);
|
||||
}
|
||||
|
||||
.nav-group:hover .nav-group-title,
|
||||
.nav-group.is-current .nav-group-title,
|
||||
.nav-group.is-open .nav-group-title {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.nav-group:hover .nav-chevron,
|
||||
.nav-group.is-open .nav-chevron {
|
||||
opacity: 1;
|
||||
transform: rotate(225deg) translateY(-0.05rem);
|
||||
}
|
||||
|
||||
.nav-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% - 0.1rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(0.55rem) scale(0.98);
|
||||
min-width: 13.5rem;
|
||||
padding: 0.85rem 0.75rem 0.9rem;
|
||||
background: var(--paper);
|
||||
border: 1px solid rgba(166, 28, 32, 0.12);
|
||||
border-radius: 0.2rem;
|
||||
box-shadow: 0 1.1rem 2.4rem rgba(26, 21, 18, 0.12);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.28s var(--ease), transform 0.28s var(--ease), visibility 0.28s;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.nav-dropdown::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.85rem;
|
||||
right: 0.85rem;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--gold), transparent);
|
||||
}
|
||||
|
||||
.nav-dropdown.is-wide {
|
||||
min-width: 22rem;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.nav-group:hover .nav-dropdown,
|
||||
.nav-group.is-open .nav-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.nav-drop-section + .nav-drop-section {
|
||||
margin-top: 0.7rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px solid rgba(166, 28, 32, 0.1);
|
||||
}
|
||||
|
||||
.nav-drop-label {
|
||||
margin: 0 0.35rem 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--gold);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.nav-drop-list.is-cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.1rem 0.35rem;
|
||||
}
|
||||
|
||||
.nav-drop-item {
|
||||
--i: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 2.35rem;
|
||||
padding: 0 0.85rem 0 1.05rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--ink-soft);
|
||||
white-space: nowrap;
|
||||
border-radius: 0.12rem;
|
||||
opacity: 0;
|
||||
transform: translateY(0.35rem);
|
||||
transition: color 0.22s var(--ease), background 0.22s var(--ease), transform 0.22s var(--ease);
|
||||
}
|
||||
|
||||
.nav-group:hover .nav-drop-item,
|
||||
.nav-group.is-open .nav-drop-item {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition-delay: calc(var(--i) * 35ms);
|
||||
}
|
||||
|
||||
.nav-drop-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.25rem;
|
||||
top: 50%;
|
||||
width: 2px;
|
||||
height: 0;
|
||||
background: var(--brand);
|
||||
transform: translateY(-50%);
|
||||
transition: height 0.22s var(--ease);
|
||||
}
|
||||
|
||||
.nav-drop-item:hover,
|
||||
.nav-drop-item.is-active {
|
||||
color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
transform: translateX(0.15rem);
|
||||
}
|
||||
|
||||
.nav-drop-item:hover::before,
|
||||
.nav-drop-item.is-active::before {
|
||||
height: 1.05rem;
|
||||
}
|
||||
|
||||
.nav-drop-item.deep {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.stage-links {
|
||||
max-width: 70rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mobile-group-title {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 2.9rem;
|
||||
border-bottom: 1px solid rgba(166, 28, 32, 0.1);
|
||||
color: var(--ink);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.mobile-chevron {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-right: 1.5px solid var(--brand);
|
||||
border-bottom: 1.5px solid var(--brand);
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.3s var(--ease);
|
||||
}
|
||||
|
||||
.mobile-group.is-open .mobile-chevron {
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
|
||||
.mobile-group-panel {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
padding: 0 0 0 0.75rem;
|
||||
border-left: 2px solid transparent;
|
||||
transition: max-height 0.35s var(--ease), opacity 0.28s var(--ease), padding 0.28s var(--ease), border-color 0.28s;
|
||||
}
|
||||
|
||||
.mobile-group.is-open .mobile-group-panel {
|
||||
max-height: 40rem;
|
||||
opacity: 1;
|
||||
padding: 0.35rem 0 0.85rem 0.85rem;
|
||||
border-left-color: rgba(166, 28, 32, 0.22);
|
||||
}
|
||||
|
||||
.mobile-drop-label {
|
||||
margin: 0.55rem 0 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.mobile-group-panel a {
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink-soft);
|
||||
border-bottom-color: rgba(166, 28, 32, 0.06);
|
||||
}
|
||||
|
||||
.mobile-group-panel a.is-deep {
|
||||
font-size: 0.9rem;
|
||||
padding-left: 0.35rem;
|
||||
}
|
||||
|
||||
.theme-tech .nav-group-title,
|
||||
.theme-tech .nav-drop-item {
|
||||
color: rgba(243, 236, 228, 0.72);
|
||||
}
|
||||
|
||||
.theme-tech .nav-group:hover .nav-group-title,
|
||||
.theme-tech .nav-group.is-current .nav-group-title,
|
||||
.theme-tech .nav-drop-item:hover,
|
||||
.theme-tech .nav-drop-item.is-active {
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.theme-tech .nav-dropdown {
|
||||
background: rgba(28, 25, 31, 0.98);
|
||||
border-color: var(--tech-line);
|
||||
}
|
||||
|
||||
.theme-tech .nav-drop-item::before {
|
||||
background: var(--gold);
|
||||
}
|
||||
|
||||
.theme-tech .nav-drop-item:hover,
|
||||
.theme-tech .nav-drop-item.is-active {
|
||||
background: rgba(196, 163, 90, 0.1);
|
||||
}
|
||||
|
||||
.theme-tech .nav-drop-section + .nav-drop-section {
|
||||
border-top-color: rgba(196, 163, 90, 0.18);
|
||||
}
|
||||
|
||||
@media (max-width: 64rem) and (min-width: 48.0625rem) {
|
||||
.nav-links {
|
||||
gap: 0.1rem 0.55rem;
|
||||
}
|
||||
|
||||
.nav-links a,
|
||||
.nav-group-title {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.nav-groups {
|
||||
gap: 0.25rem 0.65rem;
|
||||
}
|
||||
|
||||
.nav-dropdown.is-wide {
|
||||
min-width: 18rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { NavLink, Link, useLocation } from 'react-router-dom'
|
||||
import { useEffect, useState, type CSSProperties } from 'react'
|
||||
import { images } from '../content/site'
|
||||
import { findGroupByPath, navGroups, stageNavItems, type NavChild, type NavGroup } from '../content/nav'
|
||||
import { useStageMode } from '../hooks/useStageMode'
|
||||
import './Nav.css'
|
||||
|
||||
function withStage(path: string, isStage: boolean) {
|
||||
if (!isStage) return path
|
||||
if (path === '/') return '/?mode=stage'
|
||||
return `${path}?mode=stage`
|
||||
}
|
||||
|
||||
function splitChildren(children: NavChild[]) {
|
||||
return {
|
||||
main: children.filter((c) => c.kind === 'main'),
|
||||
deep: children.filter((c) => c.kind === 'deep'),
|
||||
}
|
||||
}
|
||||
|
||||
function isWideGroup(group: NavGroup) {
|
||||
return group.id === 'culture' || group.id === 'qingxiang'
|
||||
}
|
||||
|
||||
export function Nav() {
|
||||
const isStage = useStageMode()
|
||||
const location = useLocation()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [openGroup, setOpenGroup] = useState<string | null>(null)
|
||||
const activeGroup = findGroupByPath(location.pathname)
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 12)
|
||||
onScroll()
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = drawerOpen ? 'hidden' : ''
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [drawerOpen])
|
||||
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false)
|
||||
setOpenGroup(null)
|
||||
}, [location.pathname, location.search])
|
||||
|
||||
if (isStage) {
|
||||
return (
|
||||
<header className={`topnav ${scrolled ? 'is-scrolled' : ''}`}>
|
||||
<div className="topnav-inner container">
|
||||
<Link to={withStage('/', true)} className="brand">
|
||||
<img src={images.logo} alt="杜康好客" className="brand-logo" />
|
||||
<span className="brand-text serif">杜康好客 · 展控</span>
|
||||
</Link>
|
||||
<nav className="nav-links stage-links" aria-label="展控导航">
|
||||
{stageNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={withStage(item.path, true)}
|
||||
end={item.path === '/'}
|
||||
className={({ isActive }) => (isActive ? 'is-active' : '')}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={`topnav ${scrolled ? 'is-scrolled' : ''} ${drawerOpen ? 'has-drawer-open' : ''}`}>
|
||||
<div className="topnav-inner container">
|
||||
<Link to="/" className="brand" onClick={() => setDrawerOpen(false)}>
|
||||
<img src={images.logo} alt="杜康好客" className="brand-logo" />
|
||||
<span className="brand-text serif">杜康好客</span>
|
||||
</Link>
|
||||
|
||||
<nav className="nav-groups desktop-only" aria-label="主导航">
|
||||
{navGroups.map((group) => {
|
||||
const { main, deep } = splitChildren(group.children)
|
||||
const wide = isWideGroup(group)
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className={`nav-group ${activeGroup?.id === group.id ? 'is-current' : ''} ${openGroup === group.id ? 'is-open' : ''}`}
|
||||
onMouseEnter={() => setOpenGroup(group.id)}
|
||||
onMouseLeave={() => setOpenGroup(null)}
|
||||
>
|
||||
<Link to={group.entryPath} className="nav-group-title">
|
||||
<span>{group.label}</span>
|
||||
<span className="nav-chevron" aria-hidden />
|
||||
</Link>
|
||||
<div className={`nav-dropdown ${wide ? 'is-wide' : ''}`} role="menu">
|
||||
<div className="nav-drop-section">
|
||||
<p className="nav-drop-label">主模块</p>
|
||||
<div className="nav-drop-list">
|
||||
{main.map((child, i) => (
|
||||
<NavLink
|
||||
key={child.path}
|
||||
to={child.path}
|
||||
role="menuitem"
|
||||
style={{ '--i': i } as CSSProperties}
|
||||
className={({ isActive }) => `nav-drop-item main ${isActive ? 'is-active' : ''}`}
|
||||
>
|
||||
{child.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{deep.length > 0 ? (
|
||||
<div className="nav-drop-section">
|
||||
<p className="nav-drop-label">深度阅读</p>
|
||||
<div className={`nav-drop-list ${wide ? 'is-cols' : ''}`}>
|
||||
{deep.map((child, i) => (
|
||||
<NavLink
|
||||
key={child.path}
|
||||
to={child.path}
|
||||
role="menuitem"
|
||||
style={{ '--i': i } as CSSProperties}
|
||||
className={({ isActive }) => `nav-drop-item deep ${isActive ? 'is-active' : ''}`}
|
||||
>
|
||||
{child.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
className="menu-toggle mobile-only"
|
||||
type="button"
|
||||
aria-expanded={drawerOpen}
|
||||
aria-label={drawerOpen ? '关闭菜单' : '打开菜单'}
|
||||
onClick={() => setDrawerOpen((v) => !v)}
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`mobile-drawer mobile-only ${drawerOpen ? 'is-open' : ''}`}>
|
||||
<nav aria-label="移动导航">
|
||||
{navGroups.map((group) => {
|
||||
const expanded = openGroup === group.id
|
||||
const { main, deep } = splitChildren(group.children)
|
||||
return (
|
||||
<div key={group.id} className={`mobile-group ${expanded ? 'is-open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-group-title"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setOpenGroup(expanded ? null : group.id)}
|
||||
>
|
||||
<span>{group.label}</span>
|
||||
<i className="mobile-chevron" aria-hidden />
|
||||
</button>
|
||||
<div className="mobile-group-panel">
|
||||
<p className="mobile-drop-label">主模块</p>
|
||||
{main.map((child) => (
|
||||
<NavLink key={child.path} to={child.path} onClick={() => setDrawerOpen(false)}>
|
||||
{child.label}
|
||||
</NavLink>
|
||||
))}
|
||||
{deep.length > 0 ? (
|
||||
<>
|
||||
<p className="mobile-drop-label">深度阅读</p>
|
||||
{deep.map((child) => (
|
||||
<NavLink key={child.path} to={child.path} className="is-deep" onClick={() => setDrawerOpen(false)}>
|
||||
{child.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Reveal } from './Reveal'
|
||||
|
||||
type PageHeroProps = {
|
||||
eyebrow: string
|
||||
title: string
|
||||
lead: string
|
||||
image?: string
|
||||
imageAlt?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function PageHero({ eyebrow, title, lead, image, imageAlt = '', children }: PageHeroProps) {
|
||||
return (
|
||||
<section className="page-section tight">
|
||||
<div className="container">
|
||||
<div className={`split ${image ? '' : 'single'}`.trim()}>
|
||||
<Reveal variant="anim-rise">
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
<p className="lead">{lead}</p>
|
||||
{children}
|
||||
</Reveal>
|
||||
{image ? (
|
||||
<Reveal variant="anim-zoom" delayMs={120}>
|
||||
<div className="media-frame">
|
||||
<img src={image} alt={imageAlt} loading="lazy" />
|
||||
</div>
|
||||
</Reveal>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
|
||||
type RevealProps = {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** Custom animation class applied when in view, e.g. anim-slide-left */
|
||||
variant?: string
|
||||
delayMs?: number
|
||||
}
|
||||
|
||||
export function Reveal({ children, className = '', variant = 'anim-rise', delayMs = 0 }: RevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current
|
||||
if (!node) return
|
||||
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
node.classList.add('is-in')
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
node.classList.add('is-in')
|
||||
observer.unobserve(node)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ threshold: 0.16, rootMargin: '0px 0px -6% 0px' },
|
||||
)
|
||||
|
||||
observer.observe(node)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`reveal-base ${variant} ${className}`.trim()}
|
||||
style={delayMs ? { transitionDelay: `${delayMs}ms` } : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user