Browse Source

feat: implement photo gallery page with masonry layout and infinite scroll

- Added a new photo gallery page with a responsive masonry layout.
- Implemented infinite scrolling to load more photo cards dynamically.
- Created various card components for different content types (text, image, portfolio, etc.).
- Enhanced user experience with loading animations and responsive design adjustments.
- Updated styles for improved visual presentation and accessibility.
beauty
npmrun 3 weeks ago
parent
commit
6c04904e44
  1. 1
      app/assets/css/main.css
  2. 85
      app/components/TopNav.vue
  3. 6
      app/layouts/default.vue
  4. 648
      app/pages/auth/index.vue
  5. 746
      app/pages/index/index.vue
  6. 18
      app/pages/photo/index.vue

1
app/assets/css/main.css

@ -17,6 +17,7 @@
--color-hairline: #e6dfd8;
--color-hairline-soft: #ebe6df;
--color-on-dark: #faf9f5;
--color-on-dark-soft: #a09d96;
--color-on-primary: #ffffff;
--color-accent-teal: #5db8a6;
--color-accent-amber: #e8a55a;

85
app/components/TopNav.vue

@ -6,13 +6,13 @@ const navHidden = ref(false)
const route = useRoute()
const links = [
{ label: '发现', to: '/discover' },
{ label: '摄影', to: '/photo' },
{ label: '作品集', to: '/portfolio' },
{ label: '关于', to: '/about' },
]
let lastScrollY = 0
const HIDE_OFFSET = 80
const HIDE_OFFSET = 520
function onScroll() {
const y = window.scrollY
@ -58,8 +58,17 @@ watch(() => route.path, () => {
{{ link.label }}
</NuxtLink>
</li>
<li class="mobile-auth">
<NuxtLink to="/auth?tab=login" class="auth-link">登录</NuxtLink>
<NuxtLink to="/auth?tab=register" class="auth-btn">注册</NuxtLink>
</li>
</ul>
<div class="nav-auth">
<NuxtLink to="/auth?tab=login" class="auth-link">登录</NuxtLink>
<NuxtLink to="/auth?tab=register" class="auth-btn">注册</NuxtLink>
</div>
<button
class="nav-toggle"
:class="{ open: menuOpen }"
@ -90,8 +99,10 @@ watch(() => route.path, () => {
right: 0;
z-index: 100;
height: 64px;
background: var(--color-canvas);
border-bottom: 1px solid var(--color-hairline);
background: rgba(250, 249, 245, 0.72);
backdrop-filter: blur(16px) saturate(200%);
-webkit-backdrop-filter: blur(16px) saturate(200%);
border-bottom: 1px solid rgba(230, 223, 216, 0.6);
transition: transform 0.35s ease;
will-change: transform;
}
@ -151,6 +162,52 @@ watch(() => route.path, () => {
color: var(--color-ink);
}
/* ── Auth buttons ── */
.nav-auth {
display: flex;
align-items: center;
gap: 16px;
}
.mobile-auth {
display: none;
}
.auth-link {
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
color: var(--color-muted);
text-decoration: none;
transition: color 0.2s ease;
padding: 6px 0;
}
.auth-link:hover {
color: var(--color-ink);
}
.auth-btn {
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
color: var(--color-on-primary);
background: var(--color-primary);
text-decoration: none;
padding: 7px 18px;
border-radius: 9999px;
transition: background 0.2s ease, transform 0.15s ease;
}
.auth-btn:hover {
background: var(--color-primary-active);
}
.auth-btn:active {
transform: scale(0.97);
}
/* ── Hamburger ── */
.nav-toggle {
@ -231,8 +288,28 @@ watch(() => route.path, () => {
border-bottom: none;
}
.mobile-auth {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 24px;
border-bottom: 1px solid var(--color-hairline-soft);
}
.mobile-auth .auth-link {
color: var(--color-muted);
}
.mobile-auth .auth-btn {
align-self: flex-start;
}
.nav-toggle {
display: flex;
}
.nav-auth {
display: none;
}
}
</style>

6
app/layouts/default.vue

@ -1,15 +1,17 @@
<template>
<TopNav />
<TopNav v-if="!isAuthPage" />
<div class="page-content">
<NuxtPage />
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const isAuthPage = computed(() => route.path.startsWith('/auth'))
</script>
<style>
.page-content {
padding-top: 64px;
/* TopNav is fixed, no space needed */
}
</style>

648
app/pages/auth/index.vue

@ -0,0 +1,648 @@
<script setup lang="ts">
const route = useRoute()
const activeTab = ref<'login' | 'register'>(route.query.tab === 'register' ? 'register' : 'login')
const loginForm = reactive({
email: '',
password: '',
})
const registerForm = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
})
function handleLogin() {
console.log('Login:', loginForm)
}
function handleRegister() {
console.log('Register:', registerForm)
}
</script>
<template>
<div class="auth-page">
<!-- Left decorative panel -->
<div class="auth-panel">
<div class="panel-content">
<!-- Floating photo cards -->
<div class="floating-photos">
<div class="photo-card photo-card-1">
<img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=300&h=200&fit=crop" alt="" />
</div>
<div class="photo-card photo-card-2">
<img src="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=300&h=200&fit=crop" alt="" />
</div>
<div class="photo-card photo-card-3">
<img src="https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=300&h=200&fit=crop" alt="" />
</div>
</div>
<div class="panel-text">
<h2>发现美的<br/>收藏世界</h2>
<p>在这里存储和展示您的收藏与志同道合的人分享您的视觉故事</p>
</div>
<div class="panel-deco">
<div class="deco-circle deco-circle-1"></div>
<div class="deco-circle deco-circle-2"></div>
<div class="deco-circle deco-circle-3"></div>
<div class="deco-dots"></div>
</div>
</div>
</div>
<!-- Right form panel -->
<div class="auth-form-panel">
<div class="auth-card">
<div class="auth-header">
<h1 class="auth-title">{{ activeTab === 'login' ? '欢迎回来' : '创建账户' }}</h1>
<p class="auth-subtitle">{{ activeTab === 'login' ? '登录您的账户继续探索' : '加入我们开始探索之旅' }}</p>
</div>
<div class="auth-tabs">
<button
class="auth-tab"
:class="{ active: activeTab === 'login' }"
@click="activeTab = 'login'"
>
登录
</button>
<button
class="auth-tab"
:class="{ active: activeTab === 'register' }"
@click="activeTab = 'register'"
>
注册
</button>
</div>
<Transition name="form-fade" mode="out-in">
<form v-if="activeTab === 'login'" key="login" class="auth-form" @submit.prevent="handleLogin">
<div class="form-group">
<label for="login-email">邮箱地址</label>
<div class="input-wrapper">
<svg class="input-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M22 6L12 13 2 6"/>
</svg>
<input
id="login-email"
v-model="loginForm.email"
type="email"
placeholder="your@email.com"
required
/>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="login-password">密码</label>
<a href="#" class="forgot-link">忘记密码</a>
</div>
<div class="input-wrapper">
<svg class="input-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="11" width="18" height="11" rx="2"/>
<path d="M7 11V7a5 5 0 0110 0v4"/>
</svg>
<input
id="login-password"
v-model="loginForm.password"
type="password"
placeholder="输入密码"
required
/>
</div>
</div>
<label class="remember-me">
<input type="checkbox" />
<span>记住我</span>
</label>
<button type="submit" class="submit-btn">
{{ activeTab === 'login' ? '登录' : '创建账户' }}
</button>
</form>
<form v-else class="auth-form" @submit.prevent="handleRegister">
<div class="form-group">
<label for="register-username">用户名</label>
<div class="input-wrapper">
<svg class="input-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 4-6 8-6s8 2 8 6"/>
</svg>
<input
id="register-username"
v-model="registerForm.username"
type="text"
placeholder="选择一个用户名"
required
/>
</div>
</div>
<div class="form-group">
<label for="register-email">邮箱地址</label>
<div class="input-wrapper">
<svg class="input-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M22 6L12 13 2 6"/>
</svg>
<input
id="register-email"
v-model="registerForm.email"
type="email"
placeholder="your@email.com"
required
/>
</div>
</div>
<div class="form-group">
<label for="register-password">密码</label>
<div class="input-wrapper">
<svg class="input-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="11" width="18" height="11" rx="2"/>
<path d="M7 11V7a5 5 0 0110 0v4"/>
</svg>
<input
id="register-password"
v-model="registerForm.password"
type="password"
placeholder="至少 8 位"
required
/>
</div>
</div>
<div class="form-group">
<label for="register-confirm">确认密码</label>
<div class="input-wrapper">
<svg class="input-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="11" width="18" height="11" rx="2"/>
<path d="M7 11V7a5 5 0 0110 0v4"/>
<circle cx="12" cy="16" r="1" fill="currentColor"/>
</svg>
<input
id="register-confirm"
v-model="registerForm.confirmPassword"
type="password"
placeholder="再次输入密码"
required
/>
</div>
</div>
<button type="submit" class="submit-btn">
创建账户
</button>
</form>
</Transition>
<div class="auth-divider">
<span></span>
</div>
<button class="social-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
使用 Google 继续
</button>
<p class="auth-footer">
<template v-if="activeTab === 'login'">
还没有账户<a href="#" @click.prevent="activeTab = 'register'">立即注册</a>
</template>
<template v-else>
已有账户<a href="#" @click.prevent="activeTab = 'login'">立即登录</a>
</template>
</p>
</div>
</div>
</div>
</template>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
}
/* ── Left Panel ── */
.auth-panel {
flex: 1;
display: none;
background: var(--color-surface-dark);
padding: 48px;
position: relative;
overflow: hidden;
}
@media (min-width: 900px) {
.auth-panel {
display: flex;
}
}
.panel-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
height: 100%;
}
/* ── Floating Photos ── */
.floating-photos {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 320px;
height: 240px;
}
.photo-card {
position: absolute;
background: var(--color-surface-dark-elevated);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
}
.photo-card img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-card-1 {
width: 180px;
height: 120px;
top: 0;
left: 0;
transform: rotate(-8deg);
z-index: 3;
}
.photo-card-2 {
width: 160px;
height: 100px;
top: 20px;
right: 10px;
transform: rotate(6deg);
z-index: 2;
}
.photo-card-3 {
width: 140px;
height: 90px;
bottom: 10px;
left: 60px;
transform: rotate(3deg);
z-index: 1;
}
/* ── Dots Pattern ── */
.deco-dots {
position: absolute;
inset: 0;
background-image: radial-gradient(circle, rgba(255,255,255,0.08) 1px, transparent 1px);
background-size: 24px 24px;
}
.panel-text {
margin-top: auto;
margin-bottom: 48px;
}
.panel-text h2 {
font-family: var(--font-display);
font-size: 42px;
font-weight: 400;
color: var(--color-on-dark);
letter-spacing: -1px;
line-height: 1.15;
margin: 0 0 20px;
}
.panel-text p {
font-family: var(--font-body);
font-size: 16px;
color: var(--color-on-dark-soft);
line-height: 1.6;
margin: 0;
max-width: 360px;
}
.panel-deco {
position: absolute;
inset: 0;
pointer-events: none;
}
.deco-circle {
position: absolute;
border-radius: 50%;
background: linear-gradient(135deg, var(--color-primary) 0%, transparent 60%);
opacity: 0.15;
}
.deco-circle-1 {
width: 400px;
height: 400px;
top: -100px;
right: -100px;
}
.deco-circle-2 {
width: 300px;
height: 300px;
bottom: 20%;
left: -80px;
background: linear-gradient(135deg, var(--color-accent-teal) 0%, transparent 60%);
opacity: 0.1;
}
.deco-circle-3 {
width: 200px;
height: 200px;
bottom: -50px;
right: 20%;
opacity: 0.08;
}
/* ── Right Form Panel ── */
.auth-form-panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px;
background: linear-gradient(135deg, var(--color-canvas) 0%, var(--color-surface-soft) 100%);
min-height: 100vh;
}
.auth-card {
width: 100%;
max-width: 400px;
}
/* ── Form Transition ── */
.form-fade-enter-active,
.form-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.form-fade-enter-from {
opacity: 0;
transform: translateX(12px);
}
.form-fade-leave-to {
opacity: 0;
transform: translateX(-12px);
}
.auth-header {
margin-bottom: 32px;
}
.auth-title {
font-family: var(--font-display);
font-size: 32px;
font-weight: 400;
color: var(--color-ink);
letter-spacing: -0.5px;
margin: 0 0 8px;
}
.auth-subtitle {
font-family: var(--font-body);
font-size: 15px;
color: var(--color-muted);
margin: 0;
}
.auth-tabs {
display: flex;
gap: 4px;
margin-bottom: 32px;
padding: 4px;
background: var(--color-surface-card);
border-radius: 12px;
}
.auth-tab {
flex: 1;
padding: 12px 20px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
color: var(--color-muted);
background: transparent;
border: none;
border-radius: 10px;
cursor: pointer;
transition: all 0.25s ease;
}
.auth-tab.active {
background: var(--color-canvas);
color: var(--color-ink);
box-shadow: 0 2px 8px rgba(20, 20, 19, 0.08);
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
color: var(--color-body-strong);
letter-spacing: 0.3px;
}
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--color-muted-soft);
pointer-events: none;
}
.form-group input {
width: 100%;
padding: 14px 14px 14px 44px;
font-family: var(--font-body);
font-size: 15px;
color: var(--color-ink);
background: var(--color-canvas);
border: 1.5px solid var(--color-hairline);
border-radius: 12px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-sizing: border-box;
}
.form-group input::placeholder {
color: var(--color-muted-soft);
}
.form-group input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 4px rgba(204, 120, 92, 0.12);
}
.label-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.forgot-link {
font-size: 13px;
color: var(--color-primary);
text-decoration: none;
}
.forgot-link:hover {
text-decoration: underline;
}
.remember-me {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
margin-top: -4px;
}
.remember-me input {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
.remember-me span {
font-size: 13px;
color: var(--color-muted);
}
.submit-btn {
width: 100%;
padding: 16px;
margin-top: 8px;
font-family: var(--font-body);
font-size: 15px;
font-weight: 500;
color: var(--color-on-primary);
background: var(--color-primary);
border: none;
border-radius: 12px;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
box-shadow: 0 4px 14px rgba(204, 120, 92, 0.25);
}
.submit-btn:hover {
background: var(--color-primary-active);
box-shadow: 0 6px 20px rgba(204, 120, 92, 0.35);
}
.submit-btn:active {
transform: scale(0.98);
}
.auth-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 28px 0;
}
.auth-divider::before,
.auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-hairline);
}
.auth-divider span {
font-size: 13px;
color: var(--color-muted-soft);
}
.social-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
padding: 14px 20px;
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
color: var(--color-ink);
background: var(--color-canvas);
border: 1.5px solid var(--color-hairline);
border-radius: 12px;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
}
.social-btn:hover {
background: var(--color-surface-soft);
border-color: var(--color-hairline-soft);
}
.auth-footer {
text-align: center;
margin-top: 28px;
font-size: 14px;
color: var(--color-muted);
}
.auth-footer a {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
}
.auth-footer a:hover {
text-decoration: underline;
}
</style>

746
app/pages/index/index.vue

@ -1,5 +1,743 @@
<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>
<div>
asda
</div>
</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>

18
app/pages/discover/index.vue → app/pages/photo/index.vue

@ -47,10 +47,10 @@ function estimateCardHeight(item: CardItem) {
function addToShortestColumn(item: CardItem) {
let minIdx = 0
for (let i = 1; i < columnCount.value; i++) {
if (columnHeights.value[i] < columnHeights.value[minIdx]) minIdx = i
if (columnHeights.value[i]! < columnHeights.value[minIdx]!) minIdx = i
}
columns.value[minIdx].push(item)
columnHeights.value[minIdx] += estimateCardHeight(item)
columns.value[minIdx]!.push(item)
columnHeights.value[minIdx]! += estimateCardHeight(item)
}
function distributeAll() {
@ -138,7 +138,7 @@ watch(sentinel, (el) => {
if (!el) return
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) loadMore()
if (entry!.isIntersecting) loadMore()
},
{ rootMargin: '300px' },
)
@ -147,9 +147,9 @@ watch(sentinel, (el) => {
</script>
<template>
<div class="home">
<div class="photo">
<header class="page-header">
<h1>发现</h1>
<h1>摄影</h1>
<p class="subtitle">灵感与美学的无声对话</p>
</header>
@ -221,7 +221,7 @@ watch(sentinel, (el) => {
</template>
<style scoped>
.home {
.photo {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
@ -352,8 +352,8 @@ watch(sentinel, (el) => {
/* ── Responsive ── */
@media (max-width: 768px) {
.home {
padding: 0 16px;
.photo {
padding: 30px 16px 0;
}
.page-header {
Loading…
Cancel
Save