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.
 
 
 
 

368 lines
8.4 KiB

<script setup lang="ts">
interface CardItem {
id: number
type: 'text' | 'image' | 'image-text' | 'portfolio' | 'project'
image?: string
images?: string[]
title: string
description?: string
tags?: string[]
aspectRatio: number
}
const { capture, play } = useFlipAnimation()
const masonryEl = ref<HTMLElement | null>(null)
const allItems = ref<CardItem[]>([])
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const initLoading = ref(true)
const sentinel = ref<HTMLElement | null>(null)
const columnCount = ref(4)
const columns = ref<CardItem[][]>([[], [], [], []])
const columnHeights = ref<number[]>([0, 0, 0, 0])
function getColumnWidth() {
const padding = 48
const maxWidth = 1400
const containerWidth = Math.min(window.innerWidth - padding, maxWidth)
const gap = 20
return (containerWidth - gap * (columnCount.value - 1)) / columnCount.value
}
function estimateCardHeight(item: CardItem) {
const colWidth = getColumnWidth()
if (item.type === 'text') {
const textWidth = colWidth - 48
const charsPerLine = Math.max(1, Math.floor(textWidth / 13.5))
const charCount = (item.description || '').length
const lines = Math.ceil(charCount / charsPerLine)
return 109 + lines * 24
}
return colWidth / item.aspectRatio
}
function addToShortestColumn(item: CardItem) {
let minIdx = 0
for (let i = 1; i < columnCount.value; i++) {
if (columnHeights.value[i] < columnHeights.value[minIdx]) minIdx = i
}
columns.value[minIdx].push(item)
columnHeights.value[minIdx] += estimateCardHeight(item)
}
function distributeAll() {
const n = columnCount.value
columns.value = Array.from({ length: n }, () => [])
columnHeights.value = new Array(n).fill(0)
for (const item of allItems.value) {
addToShortestColumn(item)
}
}
let resizeTimer: ReturnType<typeof setTimeout> | null = null
function onResize() {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
updateColumns()
}, 150)
}
function updateColumns() {
const w = window.innerWidth
let n: number
if (w < 640) n = 1
else if (w < 768) n = 2
else if (w < 1024) n = 3
else if (w < 1280) n = 4
else n = 5
if (n !== columnCount.value) {
if (masonryEl.value) capture(masonryEl.value, '.card-reveal')
columnCount.value = n
distributeAll()
nextTick(() => {
if (masonryEl.value) {
play(masonryEl.value, '.card-reveal', { duration: 420 })
}
})
}
}
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const data = await $fetch<{ items: CardItem[]; hasMore: boolean }>('/api/cards', {
query: { page: page.value, pageSize: 12 },
})
for (const item of data.items) {
allItems.value.push(item)
addToShortestColumn(item)
}
hasMore.value = data.hasMore
page.value++
} finally {
loading.value = false
initLoading.value = false
}
await nextTick()
tryLoadMoreIfSentinelVisible()
}
function tryLoadMoreIfSentinelVisible() {
if (!sentinel.value || loading.value || !hasMore.value) return
const rect = sentinel.value.getBoundingClientRect()
if (rect.top < window.innerHeight + 300) {
loadMore()
}
}
onMounted(() => {
updateColumns()
window.addEventListener('resize', onResize)
loadMore()
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
if (resizeTimer) clearTimeout(resizeTimer)
})
let observer: IntersectionObserver | null = null
watch(sentinel, (el) => {
observer?.disconnect()
if (!el) return
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) loadMore()
},
{ rootMargin: '300px' },
)
observer.observe(el)
})
</script>
<template>
<div class="home">
<header class="page-header">
<h1>发现</h1>
<p class="subtitle">灵感与美学的无声对话</p>
</header>
<div ref="masonryEl" class="masonry">
<div
v-for="(col, ci) in columns"
:key="ci"
class="masonry-col"
>
<template v-for="(item, ri) in col" :key="item.id">
<div
class="card-reveal"
:style="{ '--enter-delay': `${ci * 80 + ri * 60}ms` }"
>
<IndexWaterfallCard
v-if="item.type === 'image-text'"
:image="item.image!"
:title="item.title"
:description="item.description!"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallTextCard
v-else-if="item.type === 'text'"
:title="item.title"
:description="item.description!"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallImageCard
v-else-if="item.type === 'image'"
:image="item.image!"
:title="item.title"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallPortfolioCard
v-else-if="item.type === 'portfolio'"
:images="item.images!"
:title="item.title"
:description="item.description!"
:aspect-ratio="item.aspectRatio"
/>
<IndexWaterfallProjectCard
v-else
:image="item.image!"
:title="item.title"
:description="item.description!"
:tags="item.tags!"
:aspect-ratio="item.aspectRatio"
/>
</div>
</template>
</div>
</div>
<div ref="sentinel" class="sentinel">
<template v-if="initLoading">
<div class="loading-bars">
<span v-for="i in 3" :key="i" class="bar" :style="{ animationDelay: `${(i - 1) * 0.15}s` }" />
</div>
</template>
<template v-else-if="loading">
<div class="loading-bars">
<span v-for="i in 3" :key="i" class="bar" :style="{ animationDelay: `${(i - 1) * 0.15}s` }" />
</div>
</template>
<span v-else-if="hasMore" class="sentinel-text">继续滚动查看更多</span>
<span v-else class="sentinel-text end"> 已经到底了 </span>
</div>
</div>
</template>
<style scoped>
.home {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
}
/* ── Header: cream canvas, coral type ── */
.page-header {
padding: clamp(64px, 10vw, 108px) 0 clamp(20px, 3vw, 32px);
animation: header-reveal 0.8s var(--ease-out-expo) both;
}
@keyframes header-reveal {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-header h1 {
font-family: var(--font-display);
font-size: clamp(64px, 10vw, 132px);
font-weight: 400;
color: var(--color-primary);
margin: 0;
line-height: 0.88;
letter-spacing: -0.02em;
}
.subtitle {
color: var(--color-muted);
font-size: clamp(16px, 2vw, 20px);
font-weight: 400;
margin: clamp(14px, 2.5vw, 24px) 0 0;
letter-spacing: 0.06em;
animation: fade-slide-up 0.7s var(--ease-out-expo) both;
animation-delay: 200ms;
}
@keyframes fade-slide-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Masonry ── */
.masonry {
display: flex;
gap: 20px;
align-items: flex-start;
}
.masonry-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── Card reveal ── */
.card-reveal {
animation: card-enter 0.65s var(--ease-out-expo) both;
animation-delay: var(--enter-delay, 0ms);
}
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Sentinel ── */
.sentinel {
display: flex;
justify-content: center;
align-items: center;
padding: 56px 0 96px;
min-height: 60px;
}
.sentinel-text {
font-size: 14px;
color: var(--color-muted-soft);
}
.sentinel-text.end {
color: var(--color-hairline);
}
.loading-bars {
display: flex;
gap: 6px;
align-items: center;
}
.bar {
width: 5px;
height: 20px;
background: var(--color-primary);
border-radius: 3px;
opacity: 0.5;
animation: pulse 0.9s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scaleY(0.6); }
50% { opacity: 0.8; transform: scaleY(1); }
}
/* ── Responsive ── */
@media (max-width: 768px) {
.home {
padding: 0 16px;
}
.page-header {
margin: 16px 0 40px;
padding: 40px 24px;
}
.page-header h1 {
font-size: clamp(40px, 12vw, 64px);
}
}
</style>