This commit is contained in:
ljy
2026-08-11 01:34:54 +08:00
parent 738f00d442
commit eba21aedf9
133 changed files with 15595 additions and 21 deletions
+458
View File
@@ -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;
}
}
+544
View File
@@ -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} />
}
+68
View File
@@ -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;
}
}
+29
View File
@@ -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>
)
}
+113
View File
@@ -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);
}
+70
View File
@@ -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>
)
}
+430
View File
@@ -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;
}
}
+195
View File
@@ -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>
)
}
+35
View File
@@ -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>
)
}
+48
View File
@@ -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>
)
}