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
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="photo">
|
|
<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>
|
|
.photo {
|
|
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) {
|
|
.photo {
|
|
padding: 30px 16px 0;
|
|
}
|
|
|
|
.page-header {
|
|
margin: 16px 0 40px;
|
|
padding: 40px 24px;
|
|
}
|
|
|
|
.page-header h1 {
|
|
font-size: clamp(40px, 12vw, 64px);
|
|
}
|
|
}
|
|
</style>
|
|
|