You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

743 lines
19 KiB

<script setup lang="ts">
const hero = {
img: 'https://picsum.photos/id/60/1200/900',
title: '这些是我的',
sub: '不是最好的,但都是有故事的',
}
const collections = [
{ id: 10, title: '午后', aspect: 'v', size: 'lg' },
{ id: 20, title: '城市', aspect: 'h', size: 'sm' },
{ id: 30, title: '海岸', aspect: 'v', size: 'md' },
{ id: 40, title: '远行', aspect: 'h', size: 'sm' },
{ id: 50, title: '静物', aspect: 'sq', size: 'md' },
{ id: 60, title: '人像', aspect: 'v', size: 'lg' },
{ id: 70, title: '夜色', aspect: 'h', size: 'md' },
{ id: 80, title: '山川', aspect: 'v', size: 'sm' },
{ id: 100, title: '森林', aspect: 'h', size: 'sm' },
{ id: 110, title: '街道', aspect: 'sq', size: 'lg' },
{ id: 120, title: '雨季', aspect: 'v', size: 'md' },
{ id: 130, title: '晴空', aspect: 'h', size: 'sm' },
{ id: 140, title: '咖啡', aspect: 'sq', size: 'md' },
{ id: 150, title: '旅途', aspect: 'v', size: 'lg' },
{ id: 160, title: '废墟', aspect: 'h', size: 'sm' },
{ id: 170, title: '浪潮', aspect: 'v', size: 'md' },
]
function imgSrc(id: number, aspect: string, h: number) {
const sizes: Record<string, number> = { h: 900, v: 600, sq: 700 }
const w = sizes[aspect] ?? 700
return `https://picsum.photos/id/${id}/${w}/${h}`
}
// ── Animation refs ──
const heroRef = ref<HTMLElement | null>(null)
const heroImgRef = ref<HTMLElement | null>(null)
const bentoItems = ref<HTMLElement[]>([])
const dwPhotos = ref<HTMLElement[]>([])
const scrollY = ref(0)
function onScroll() {
scrollY.value = window.scrollY
if (heroImgRef.value) {
const progress = scrollY.value * 0.35
heroImgRef.value.style.transform = `translateY(${progress}px) scale(1.0)`
}
}
// ── Typewriter for hero title ──
const displayTitle = ref('')
const titleText = hero.title
let charIndex = 0
function startTypewriter() {
if (charIndex < titleText.length) {
displayTitle.value += titleText[charIndex]
charIndex++
setTimeout(startTypewriter, 90 + Math.random() * 60)
}
}
// ── Bento scroll reveal ──
let bentoObserver: IntersectionObserver | null = null
function setupBentoObserver() {
bentoObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement
const delay = Number(el.dataset.delay ?? 0)
setTimeout(() => {
el.classList.add('is-visible')
}, delay)
bentoObserver?.unobserve(el)
}
})
},
{ threshold: 0.08 }
)
bentoItems.value.forEach((el, i) => {
if (el) {
el.dataset.delay = String(i * 75)
bentoObserver?.observe(el)
}
})
}
// ── Dark wall reveal ──
let dwObserver: IntersectionObserver | null = null
function setupDwObserver() {
dwObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target as HTMLElement
const delay = Number(el.dataset.delay ?? 0)
setTimeout(() => {
el.classList.add('is-visible')
}, delay)
dwObserver?.unobserve(el)
}
})
},
{ threshold: 0.1 }
)
dwPhotos.value.forEach((el, i) => {
if (el) {
el.dataset.delay = String(i * 100)
dwObserver?.observe(el)
}
})
}
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
setupBentoObserver()
setupDwObserver()
// Start typewriter after hero text animation begins
setTimeout(startTypewriter, 800)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
bentoObserver?.disconnect()
dwObserver?.disconnect()
})
</script>
<template>
<main class="canvas">
<!-- Hero -->
<section class="hero" ref="heroRef">
<div class="hero-img-wrap" ref="heroImgRef">
<img :src="hero.img" :alt="hero.title" class="hero-img" />
<div class="hero-vignette" />
<div class="hero-sweep" />
</div>
<div class="hero-content">
<div class="hero-eyebrow reveal-item">私人收藏 · 随记</div>
<h1 class="hero-title"><span class="typewriter-text">{{ displayTitle }}</span><span class="typewriter-cursor">|</span></h1>
<p class="hero-sub reveal-item">{{ hero.sub }}</p>
</div>
</section>
<!-- Bento grid -->
<section class="bento-section">
<div class="bento">
<!-- Row 1 -->
<div class="b-item b-lg reveal-card" :ref="(el) => { if (el) bentoItems[0] = el as HTMLElement }">
<div class="b-img-wrap">
<img src="https://picsum.photos/id/10/700/1000" alt="午后" loading="lazy" />
</div>
</div>
<div class="b-item b-col-2 reveal-card" :ref="(el) => { if (el) bentoItems[1] = el as HTMLElement }">
<div class="b-img-wrap"><img src="https://picsum.photos/id/20/800/500" alt="城市" loading="lazy" /></div>
<div class="b-img-wrap mt-4"><img src="https://picsum.photos/id/40/800/500" alt="远行" loading="lazy" /></div>
</div>
<div class="b-item b-tall reveal-card" :ref="(el) => { if (el) bentoItems[2] = el as HTMLElement }">
<div class="b-img-wrap">
<img src="https://picsum.photos/id/30/600/1000" alt="海岸" loading="lazy" />
</div>
</div>
<!-- Row 2 note card + medium -->
<div class="b-item b-note reveal-card" :ref="(el) => { if (el) bentoItems[3] = el as HTMLElement }">
<p class="note-text">"每件东西都有自己的气味,闻到就穿越了"</p>
<span class="note-source"> 手记 #1</span>
</div>
<div class="b-item b-md reveal-card" :ref="(el) => { if (el) bentoItems[4] = el as HTMLElement }">
<div class="b-img-wrap">
<img src="https://picsum.photos/id/50/700/700" alt="静物" loading="lazy" />
</div>
</div>
<!-- Row 3 -->
<div class="b-item b-tall-right reveal-card" :ref="(el) => { if (el) bentoItems[5] = el as HTMLElement }">
<div class="b-img-wrap">
<img src="https://picsum.photos/id/60/600/1000" alt="人像" loading="lazy" />
</div>
</div>
<div class="b-item b-wide reveal-card" :ref="(el) => { if (el) bentoItems[6] = el as HTMLElement }">
<div class="b-img-wrap">
<img src="https://picsum.photos/id/70/1200/600" alt="夜色" loading="lazy" />
</div>
</div>
<div class="b-item b-sm reveal-card" :ref="(el) => { if (el) bentoItems[7] = el as HTMLElement }">
<div class="b-img-wrap">
<img src="https://picsum.photos/id/80/600/600" alt="山川" loading="lazy" />
</div>
</div>
<!-- Row 4 note + grid -->
<div class="b-item b-note reveal-card" :ref="(el) => { if (el) bentoItems[8] = el as HTMLElement }">
<p class="note-text">"收集的过程比结果更重要"</p>
<span class="note-source"> 手记 #2</span>
</div>
<div class="b-item b-4grid reveal-card" :ref="(el) => { if (el) bentoItems[9] = el as HTMLElement }">
<div class="b-img-wrap"><img src="https://picsum.photos/id/100/600/400" alt="森林" loading="lazy" /></div>
<div class="b-img-wrap"><img src="https://picsum.photos/id/110/600/600" alt="街道" loading="lazy" /></div>
<div class="b-img-wrap"><img src="https://picsum.photos/id/120/600/800" alt="雨季" loading="lazy" /></div>
<div class="b-img-wrap"><img src="https://picsum.photos/id/130/600/400" alt="晴空" loading="lazy" /></div>
</div>
<!-- Row 5 -->
<div class="b-item b-md reveal-card" :ref="(el) => { if (el) bentoItems[10] = el as HTMLElement }">
<div class="b-img-wrap">
<img src="https://picsum.photos/id/140/700/700" alt="咖啡" loading="lazy" />
</div>
</div>
<div class="b-item b-tall reveal-card" :ref="(el) => { if (el) bentoItems[11] = el as HTMLElement }">
<div class="b-img-wrap">
<img src="https://picsum.photos/id/150/600/1000" alt="旅途" loading="lazy" />
</div>
</div>
<div class="b-item b-col-2-bottom reveal-card" :ref="(el) => { if (el) bentoItems[12] = el as HTMLElement }">
<div class="b-img-wrap"><img src="https://picsum.photos/id/160/800/500" alt="废墟" loading="lazy" /></div>
<div class="b-img-wrap mt-4"><img src="https://picsum.photos/id/170/800/500" alt="浪潮" loading="lazy" /></div>
</div>
<!-- Row 6 note -->
<div class="b-item b-note b-note-last reveal-card" :ref="(el) => { if (el) bentoItems[13] = el as HTMLElement }">
<p class="note-text">"有些东西扔不掉,不是因为值钱,是因为记得"</p>
<span class="note-source"> 手记 #3</span>
</div>
</div>
</section>
<!-- Dark feature wall -->
<section class="dark-wall">
<div class="dark-wall-inner">
<div class="dw-label">最珍贵的几件</div>
<div class="dw-photos">
<div
class="dw-photo reveal-card"
v-for="(c, i) in collections.slice(0, 6)"
:key="c.id"
:ref="(el) => { if (el) dwPhotos[i] = el as HTMLElement }"
>
<img :src="imgSrc(c.id, c.aspect, 600)" :alt="c.title" loading="lazy" />
<div class="dw-photo-label">{{ c.title }}</div>
</div>
</div>
</div>
</section>
<!-- ── Footer ── -->
<footer class="site-footer">
<div class="footer-grid">
<div class="footer-brand">收藏室</div>
<div class="footer-links">
<NuxtLink to="/photo">全部</NuxtLink>
<NuxtLink to="/portfolio">作品集</NuxtLink>
<NuxtLink to="/about">关于</NuxtLink>
</div>
</div>
<p class="footer-copy">© 2024 · 私人收藏 · 不必打扰</p>
</footer>
</main>
</template>
<style scoped>
.canvas {
background: var(--color-canvas);
}
/* ── Hero ── */
.hero {
position: relative;
height: 100vh;
min-height: 600px;
display: flex;
align-items: flex-end;
overflow: hidden;
}
.hero-img-wrap {
position: absolute;
inset: 0;
cursor: pointer;
}
.hero-img {
width: 100%;
height: 100%;
object-fit: cover;
transform-origin: center center;
transition: transform 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
.hero-img-wrap:hover .hero-img {
transform: scale(1.03) translateY(-4px);
}
.hero-vignette {
position: absolute;
inset: 0;
background: linear-gradient(
to top,
rgba(20, 20, 19, 0.85) 0%,
rgba(20, 20, 19, 0.3) 50%,
transparent 100%
);
}
/* ── Light sweep ── */
.hero-sweep {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.hero-sweep::after {
content: '';
position: absolute;
top: -60%;
left: -80%;
width: 60%;
height: 200%;
background: linear-gradient(
105deg,
transparent 30%,
rgba(250, 249, 245, 0.06) 45%,
rgba(250, 249, 245, 0.12) 50%,
rgba(250, 249, 245, 0.06) 55%,
transparent 70%
);
transform: translateX(-100%) translateY(-20%);
animation: light-sweep 1.6s cubic-bezier(0.16, 1, 0.3, 1) 0.3s forwards;
}
@keyframes light-sweep {
0% {
transform: translateX(-100%) translateY(-20%);
opacity: 0;
}
20% {
opacity: 1;
}
100% {
transform: translateX(300%) translateY(-20%);
opacity: 0;
}
}
.hero-content {
position: relative;
z-index: 1;
padding: 0 64px 80px;
}
.hero-eyebrow {
font-family: var(--font-body);
font-size: 11px;
font-weight: 500;
letter-spacing: 3px;
text-transform: uppercase;
color: rgba(250, 249, 245, 0.6);
margin-bottom: 12px;
}
.hero-title {
font-family: var(--font-display);
font-size: clamp(56px, 9vw, 112px);
font-weight: 400;
color: var(--color-on-dark);
letter-spacing: -3px;
line-height: 0.95;
margin: 0 0 20px;
}
.hero-sub {
font-family: var(--font-body);
font-size: 18px;
color: rgba(250, 249, 245, 0.7);
margin: 0;
line-height: 1.6;
}
/* ── Bento ── */
.bento-section {
padding: 80px 40px 96px;
max-width: 1400px;
margin: 0 auto;
}
.bento {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 16px;
align-items: start;
}
/* item helpers */
.b-img-wrap {
position: relative;
overflow: hidden;
border-radius: 8px;
background: var(--color-surface-card);
}
.b-img-wrap img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s var(--ease-out-quint);
}
.b-img-wrap:hover img {
transform: scale(1.04);
}
.mt-4 { margin-top: 16px; }
/* Bento item sizes */
.b-lg { grid-column: span 4; grid-row: span 2; }
.b-lg .b-img-wrap { height: 520px; }
.b-col-2 { grid-column: span 4; }
.b-col-2 .b-img-wrap { height: 245px; }
.b-col-2 .mt-4 { margin-top: 16px; }
.b-tall { grid-column: span 3; grid-row: span 2; }
.b-tall .b-img-wrap { height: 520px; }
.b-tall-right { grid-column: span 3; grid-row: span 2; }
.b-tall-right .b-img-wrap { height: 520px; }
.b-wide { grid-column: span 5; }
.b-wide .b-img-wrap { height: 320px; }
.b-sm { grid-column: span 4; }
.b-sm .b-img-wrap { height: 320px; }
.b-md { grid-column: span 4; }
.b-md .b-img-wrap { height: 400px; }
.b-note {
grid-column: span 4;
background: var(--color-surface-card);
border-radius: 8px;
padding: 32px;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 200px;
}
.b-note-last { grid-column: span 12; }
.note-text {
font-family: var(--font-display);
font-size: clamp(18px, 2.5vw, 26px);
font-weight: 400;
color: var(--color-ink);
letter-spacing: -0.3px;
line-height: 1.4;
margin: 0 0 16px;
font-style: italic;
}
.note-source {
font-family: var(--font-body);
font-size: 12px;
color: var(--color-muted);
letter-spacing: 1px;
text-transform: uppercase;
}
.b-4grid { grid-column: span 8; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.b-4grid .b-img-wrap { height: 240px; }
.b-col-2-bottom { grid-column: span 4; }
.b-col-2-bottom .b-img-wrap { height: 245px; }
.b-col-2-bottom .mt-4 { margin-top: 16px; }
/* ── Dark wall ── */
.dark-wall {
background: var(--color-surface-dark);
padding: 80px 40px;
}
.dark-wall-inner {
max-width: 1400px;
margin: 0 auto;
}
.dw-label {
font-family: var(--font-body);
font-size: 11px;
font-weight: 500;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--color-on-dark-soft);
margin-bottom: 32px;
}
.dw-photos {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.dw-photo {
position: relative;
overflow: hidden;
border-radius: 8px;
aspect-ratio: 4/3;
}
.dw-photo img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s var(--ease-out-quint);
}
.dw-photo:hover img {
transform: scale(1.05);
}
.dw-photo-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20px 16px 12px;
background: linear-gradient(to top, rgba(20, 20, 19, 0.7) 0%, transparent 100%);
font-family: var(--font-body);
font-size: 13px;
color: rgba(250, 249, 245, 0.85);
font-weight: 500;
}
/* ── Footer ── */
.site-footer {
background: var(--color-surface-dark);
padding: 48px 64px;
border-top: 1px solid rgba(250, 249, 245, 0.08);
}
.footer-grid {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
.footer-brand {
font-family: var(--font-display);
font-size: 28px;
font-weight: 400;
color: var(--color-on-dark);
letter-spacing: -1px;
}
.footer-links {
display: flex;
gap: 32px;
}
.footer-links a {
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
color: var(--color-on-dark-soft);
text-decoration: none;
transition: color 0.2s;
}
.footer-links a:hover {
color: var(--color-on-dark);
}
.footer-copy {
font-family: var(--font-body);
font-size: 13px;
color: var(--color-on-dark-soft);
margin: 0;
padding-top: 24px;
border-top: 1px solid rgba(250, 249, 245, 0.08);
}
/* ── Mobile ── */
@media (max-width: 768px) {
.hero-content {
padding: 0 24px 48px;
}
.bento-section {
padding: 48px 16px;
}
.bento {
display: flex;
flex-direction: column;
gap: 12px;
}
.b-img-wrap,
.b-lg .b-img-wrap,
.b-tall .b-img-wrap,
.b-tall-right .b-img-wrap { height: 260px !important; }
.b-note { min-height: 160px; }
.b-4grid { display: flex; flex-direction: column; }
.b-col-2 .b-img-wrap,
.b-col-2-bottom .b-img-wrap { height: 160px !important; }
.b-col-2 .mt-4,
.b-col-2-bottom .mt-4 { margin-top: 12px; }
.dark-wall { padding: 48px 16px; }
.dw-photos { grid-template-columns: 1fr 1fr; gap: 12px; }
.dw-photo { aspect-ratio: 1; }
.site-footer { padding: 40px 24px; }
.footer-grid { flex-direction: column; gap: 20px; }
}
/* ── Animations ── */
@keyframes hero-img-reveal {
from {
transform: scale(1.08);
opacity: 0.6;
}
to {
transform: scale(1.0);
opacity: 1;
}
}
@keyframes hero-text-reveal {
from {
opacity: 0;
transform: translateY(28px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes card-reveal {
from {
opacity: 0;
transform: translateY(36px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Hero entrance */
.hero-img {
transform: scale(1.08);
animation: hero-img-reveal 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.hero-eyebrow {
opacity: 0;
animation: hero-text-reveal 0.8s cubic-bezier(0.25, 1, 0.5, 1) 0.5s forwards;
}
.hero-title {
opacity: 0;
animation: hero-text-reveal 0.6s cubic-bezier(0.25, 1, 0.5, 1) 0.65s forwards;
}
.hero-sub {
opacity: 0;
animation: hero-text-reveal 0.8s cubic-bezier(0.25, 1, 0.5, 1) 0.85s forwards;
}
/* Bento scroll reveal */
.reveal-card {
opacity: 0;
transform: translateY(36px);
transition: opacity 0.65s cubic-bezier(0.25, 1, 0.5, 1), transform 0.65s cubic-bezier(0.25, 1, 0.5, 1);
}
.reveal-card.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Dark wall photos reveal */
.dw-photo {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s cubic-bezier(0.25, 1, 0.5, 1), transform 0.6s cubic-bezier(0.25, 1, 0.5, 1);
}
.dw-photo.is-visible {
opacity: 1;
transform: translateY(0);
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.hero-img,
.hero-eyebrow,
.hero-title,
.hero-sub {
animation: none !important;
opacity: 1;
transform: none;
}
.hero-sweep::after {
animation: none;
opacity: 0;
}
.reveal-card,
.dw-photo {
opacity: 1;
transform: none;
transition: none;
}
.typewriter-cursor {
animation: none;
opacity: 1;
}
}
/* ── Typewriter ── */
.typewriter-cursor {
display: inline-block;
color: var(--color-primary);
animation: cursor-blink 1s step-end infinite;
margin-left: 2px;
}
@keyframes cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
</style>