Browse Source

feat: add various Waterfall card components and enhance main.css with theme variables and styles

beauty
npmrun 3 weeks ago
parent
commit
2a65533cc7
  1. 42
      app/assets/css/main.css
  2. 150
      app/components/index/WaterfallCard.vue
  3. 99
      app/components/index/WaterfallImageCard.vue
  4. 151
      app/components/index/WaterfallPortfolioCard.vue
  5. 172
      app/components/index/WaterfallProjectCard.vue
  6. 63
      app/components/index/WaterfallTextCard.vue
  7. 321
      app/pages/index/index.vue
  8. 140
      server/api/cards.get.ts

42
app/assets/css/main.css

@ -1 +1,41 @@
@import "tailwindcss";
@import "tailwindcss";
@theme {
--color-canvas: #faf9f5;
--color-surface-card: #efe9de;
--color-surface-soft: #f5f0e8;
--color-surface-dark: #181715;
--color-surface-dark-elevated: #252320;
--color-surface-dark-soft: #1f1e1b;
--color-primary: #cc785c;
--color-primary-active: #a9583e;
--color-ink: #141413;
--color-body: #3d3d3a;
--color-body-strong: #252523;
--color-muted: #6c6a64;
--color-muted-soft: #8e8b82;
--color-hairline: #e6dfd8;
--color-hairline-soft: #ebe6df;
}
body {
background-color: var(--color-canvas);
color: var(--color-body);
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
:root {
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

150
app/components/index/WaterfallCard.vue

@ -0,0 +1,150 @@
<script setup lang="ts">
const props = defineProps<{
image: string
title: string
description: string
aspectRatio: number
}>()
const imgLoaded = ref(false)
const imgFailed = ref(false)
function onImgLoad() {
imgLoaded.value = true
}
function onImgError() {
imgFailed.value = true
imgLoaded.value = true
}
</script>
<template>
<div class="card" :style="{ aspectRatio: props.aspectRatio }">
<div class="img-placeholder" :class="{ loaded: imgLoaded }" />
<img
v-if="!imgFailed"
:src="image"
:alt="title"
loading="lazy"
class="card-img"
:class="{ loaded: imgLoaded }"
@load="onImgLoad"
@error="onImgError"
>
<div v-if="imgFailed" class="img-fallback">
<span class="fallback-icon">&#128247;</span>
</div>
<div class="gradient-fade" />
<div class="card-text">
<h3>{{ title }}</h3>
<p>{{ description }}</p>
</div>
</div>
</template>
<style scoped>
.card {
position: relative;
border-radius: 12px;
background: var(--color-surface-card);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 30px rgba(20, 20, 19, 0.07);
}
.img-placeholder {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #e8e0d2 0%, #efe9de 50%, #e8e0d2 100%);
background-size: 200% 200%;
animation: shimmer 1.8s ease-in-out infinite;
}
.img-placeholder.loaded {
display: none;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.card-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
transition: opacity 0.5s ease;
}
.card-img.loaded {
opacity: 1;
}
.gradient-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 55%;
background: linear-gradient(
to bottom,
transparent 0%,
rgba(20, 20, 19, 0) 30%,
rgba(20, 20, 19, 0.45) 60%,
rgba(20, 20, 19, 0.8) 100%
);
pointer-events: none;
}
.card-text {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0 20px 22px;
z-index: 1;
}
.card-text h3 {
color: #fff;
font-family: "Times New Roman", Georgia, serif;
font-size: 17px;
font-weight: 400;
line-height: 1.35;
margin: 0 0 5px;
letter-spacing: -0.2px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.card-text p {
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 400;
line-height: 1.55;
margin: 0;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.img-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e8e0d2 0%, #efe9de 50%, #e8e0d2 100%);
}
.fallback-icon {
font-size: 40px;
opacity: 0.35;
}
</style>

99
app/components/index/WaterfallImageCard.vue

@ -0,0 +1,99 @@
<script setup lang="ts">
defineProps<{
image: string
title: string
aspectRatio: number
}>()
const imgLoaded = ref(false)
const imgFailed = ref(false)
function onImgLoad() {
imgLoaded.value = true
}
function onImgError() {
imgFailed.value = true
imgLoaded.value = true
}
</script>
<template>
<div class="card" :style="{ aspectRatio }">
<div class="img-placeholder" :class="{ loaded: imgLoaded }" />
<img
v-if="!imgFailed"
:src="image"
:alt="title"
loading="lazy"
class="card-img"
:class="{ loaded: imgLoaded }"
@load="onImgLoad"
@error="onImgError"
>
<div v-if="imgFailed" class="img-fallback">
<span class="fallback-icon">&#128247;</span>
</div>
</div>
</template>
<style scoped>
.card {
position: relative;
border-radius: 12px;
background: var(--color-surface-card);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 30px rgba(20, 20, 19, 0.07);
}
.img-placeholder {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #e8e0d2 0%, #efe9de 50%, #e8e0d2 100%);
background-size: 200% 200%;
animation: shimmer 1.8s ease-in-out infinite;
}
.img-placeholder.loaded {
display: none;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.card-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
transition: opacity 0.5s ease;
}
.card-img.loaded {
opacity: 1;
}
.img-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e8e0d2 0%, #efe9de 50%, #e8e0d2 100%);
}
.fallback-icon {
font-size: 40px;
opacity: 0.35;
}
</style>

151
app/components/index/WaterfallPortfolioCard.vue

@ -0,0 +1,151 @@
<script setup lang="ts">
const props = defineProps<{
images: string[]
title: string
description: string
aspectRatio: number
}>()
const loadedCount = ref(0)
const failedSet = reactive(new Set<number>())
function onImgLoad() {
loadedCount.value++
}
function onImgError(idx: number) {
failedSet.add(idx)
loadedCount.value++
}
const totalImages = computed(() => props.images.length)
</script>
<template>
<div class="card" :style="{ aspectRatio }">
<div class="img-grid">
<div
v-for="(img, i) in images"
:key="i"
class="grid-cell"
>
<div class="cell-placeholder" :class="{ loaded: loadedCount >= totalImages }" />
<img
v-if="!failedSet.has(i)"
:src="img"
:alt="`${title} ${i + 1}`"
loading="lazy"
class="cell-img"
:class="{ loaded: loadedCount >= totalImages }"
@load="onImgLoad"
@error="onImgError(i)"
>
<div v-else class="cell-fallback" />
</div>
</div>
<div class="overlay">
<h3>{{ title }}</h3>
<p>{{ description }}</p>
</div>
</div>
</template>
<style scoped>
.card {
position: relative;
border-radius: 12px;
background: var(--color-surface-card);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 30px rgba(20, 20, 19, 0.07);
}
.img-grid {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 2px;
}
.grid-cell {
position: relative;
overflow: hidden;
}
.cell-placeholder {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #e8e0d2 0%, #efe9de 50%, #e8e0d2 100%);
background-size: 200% 200%;
animation: shimmer 1.8s ease-in-out infinite;
}
.cell-placeholder.loaded {
display: none;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.cell-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
transition: opacity 0.4s ease;
}
.cell-img.loaded {
opacity: 1;
}
.cell-fallback {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e8e0d2 0%, #efe9de 50%, #e8e0d2 100%);
}
.overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 18px 20px;
background: linear-gradient(
to bottom,
transparent 0%,
rgba(20, 20, 19, 0.5) 50%,
rgba(20, 20, 19, 0.85) 100%
);
z-index: 1;
}
.overlay h3 {
color: #fff;
font-family: "Times New Roman", Georgia, serif;
font-size: 16px;
font-weight: 400;
line-height: 1.3;
margin: 0 0 4px;
letter-spacing: -0.2px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.overlay p {
color: rgba(255, 255, 255, 0.75);
font-size: 12.5px;
font-weight: 400;
line-height: 1.45;
margin: 0;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
</style>

172
app/components/index/WaterfallProjectCard.vue

@ -0,0 +1,172 @@
<script setup lang="ts">
defineProps<{
image: string
title: string
description: string
tags: string[]
aspectRatio: number
}>()
const imgLoaded = ref(false)
const imgFailed = ref(false)
function onImgLoad() {
imgLoaded.value = true
}
function onImgError() {
imgFailed.value = true
imgLoaded.value = true
}
</script>
<template>
<div class="card" :style="{ aspectRatio }">
<div class="img-placeholder" :class="{ loaded: imgLoaded }" />
<img
v-if="!imgFailed"
:src="image"
:alt="title"
loading="lazy"
class="card-img"
:class="{ loaded: imgLoaded }"
@load="onImgLoad"
@error="onImgError"
>
<div v-if="imgFailed" class="img-fallback">
<span class="fallback-icon">&#128247;</span>
</div>
<div class="gradient-fade" />
<div class="card-body">
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<div class="tags">
<span v-for="tag in tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.card {
position: relative;
border-radius: 12px;
background: var(--color-surface-card);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 30px rgba(20, 20, 19, 0.07);
}
.img-placeholder {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #e8e0d2 0%, #efe9de 50%, #e8e0d2 100%);
background-size: 200% 200%;
animation: shimmer 1.8s ease-in-out infinite;
}
.img-placeholder.loaded {
display: none;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.card-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
opacity: 0;
transition: opacity 0.5s ease;
}
.card-img.loaded {
opacity: 1;
}
.img-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e8e0d2 0%, #efe9de 50%, #e8e0d2 100%);
}
.fallback-icon {
font-size: 40px;
opacity: 0.3;
}
.gradient-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 62%;
background: linear-gradient(
to bottom,
transparent 0%,
rgba(20, 20, 19, 0) 25%,
rgba(20, 20, 19, 0.35) 50%,
rgba(20, 20, 19, 0.78) 100%
);
pointer-events: none;
}
.card-body {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0 20px 22px;
z-index: 1;
}
.card-body h3 {
color: #fff;
font-family: "Times New Roman", Georgia, serif;
font-size: 17px;
font-weight: 400;
line-height: 1.3;
margin: 0 0 5px;
letter-spacing: -0.2px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.card-body p {
color: rgba(255, 255, 255, 0.78);
font-size: 13px;
font-weight: 400;
line-height: 1.5;
margin: 0 0 14px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.tag {
font-size: 11.5px;
font-weight: 400;
color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
padding: 4px 10px;
border-radius: 9999px;
line-height: 1.3;
backdrop-filter: blur(4px);
}
</style>

63
app/components/index/WaterfallTextCard.vue

@ -0,0 +1,63 @@
<script setup lang="ts">
defineProps<{
title: string
description: string
aspectRatio: number
}>()
</script>
<template>
<div class="card">
<div class="card-inner">
<div class="accent-bar" />
<h3>{{ title }}</h3>
<p>{{ description }}</p>
</div>
</div>
</template>
<style scoped>
.card {
border-radius: 12px;
background: linear-gradient(145deg, #efe9de 0%, #e8e0d2 40%, #f5f0e8 100%);
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
display: flex;
align-items: flex-start;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 30px rgba(20, 20, 19, 0.07);
}
.card-inner {
padding: 28px 24px;
}
.accent-bar {
width: 28px;
height: 3px;
background: var(--color-primary);
border-radius: 2px;
margin-bottom: 16px;
}
.card-inner h3 {
color: var(--color-ink);
font-family: "Times New Roman", Georgia, serif;
font-size: 18px;
font-weight: 400;
line-height: 1.3;
margin: 0 0 10px;
letter-spacing: -0.3px;
}
.card-inner p {
color: var(--color-muted);
font-size: 13.5px;
font-weight: 400;
line-height: 1.75;
margin: 0;
}
</style>

321
app/pages/index/index.vue

@ -1,10 +1,323 @@
<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 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) {
columnCount.value = n
distributeAll()
}
}
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="flex flex-col items-center justify-center min-h-screen">
<h1 class="text-4xl font-bold mb-4">Welcome to Nuxt 3!</h1>
<p class="text-lg text-gray-600">This is the index page.</p>
<div class="home">
<header class="page-header">
<h1>发现</h1>
<p class="subtitle">灵感与美学的无声对话</p>
</header>
<div 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 * 60 + ri * 40}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>
</template>
</div>
</template>
<style scoped>
.home {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
}
.page-header {
text-align: center;
padding: 72px 0 56px;
}
.page-header h1 {
font-family: "Times New Roman", Georgia, Garamond, serif;
font-size: 48px;
font-weight: 400;
color: var(--color-ink);
margin: 0 0 10px;
letter-spacing: -1px;
line-height: 1.1;
animation: fade-slide-up 0.7s var(--ease-out-expo) both;
}
.subtitle {
color: var(--color-muted);
font-size: 16px;
font-weight: 400;
margin: 0;
animation: fade-slide-up 0.7s var(--ease-out-expo) both;
animation-delay: 120ms;
}
@keyframes fade-slide-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-reveal {
animation: card-enter 0.55s var(--ease-out-expo) both;
animation-delay: var(--enter-delay, 0ms);
}
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.masonry {
display: flex;
gap: 20px;
align-items: flex-start;
}
.masonry-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
.sentinel {
display: flex;
justify-content: center;
align-items: center;
padding: 48px 0 80px;
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); }
}
</style>

140
server/api/cards.get.ts

@ -0,0 +1,140 @@
type CardType = 'text' | 'image' | 'image-text' | 'portfolio' | 'project'
interface CardData {
id: number
type: CardType
image?: string
images?: string[]
title: string
description?: string
tags?: string[]
aspectRatio: number
}
const images = [
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=600&h=800&fit=crop',
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=600&h=500&fit=crop',
'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=600&h=700&fit=crop',
'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=600&h=900&fit=crop',
'https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=600&h=550&fit=crop',
'https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=600&h=650&fit=crop',
'https://images.unsplash.com/photo-1501854140801-50d01698950b?w=600&h=750&fit=crop',
'https://images.unsplash.com/photo-1518173946687-a1e4e09e68b7?w=600&h=850&fit=crop',
'https://images.unsplash.com/photo-1472214103451-9374bd1c798e?w=600&h=580&fit=crop',
'https://images.unsplash.com/photo-1505144808419-1957a94ca61e?w=600&h=720&fit=crop',
'https://images.unsplash.com/photo-1465146344425-f00d5f5c8f07?w=600&h=600&fit=crop',
'https://images.unsplash.com/photo-1490730141103-6cac27aaab94?w=600&h=780&fit=crop',
]
const portfolioSets = [
[images[0], images[1], images[4], images[5]],
[images[2], images[3], images[6], images[7]],
[images[8], images[9], images[10], images[11]],
[images[1], images[3], images[8], images[10]],
[images[0], images[2], images[6], images[9]],
[images[4], images[5], images[7], images[11]],
]
const titles = [
'山间晨雾', '海岸余晖', '林深见鹿', '星河滚烫',
'秋日私语', '雪落无声', '荒漠孤烟', '江南水乡',
'云海翻涌', '古城旧梦', '樱花吹雪', '冰川纪行',
]
const descriptions = [
'清晨第一缕阳光穿透薄雾,洒在青翠的山谷间',
'海浪轻抚沙滩,余晖将天际染成珊瑚般的暖色调',
'密林深处,光影斑驳,万物有灵且美',
'浩瀚星空下,篝火噼啪作响,银河横亘天际',
'金黄落叶铺满小径,秋风携来果实成熟的芬芳',
'白雪覆盖大地,万物归于宁静与纯粹',
'大漠无垠,风沙雕刻出千年的纹路',
'小桥流水,烟雨朦胧中白墙黛瓦静静伫立',
'云层如海浪般在山巅之下翻涌奔腾',
'石板路上回响着千年前的足音,城墙斑驳诉说时光',
'粉色花瓣如雪般飘落,短暂而绚烂',
'万年冰层透着幽蓝的光,仿佛地球深处的呼吸',
]
const textContents = [
{ title: '关于灵感', description: '灵感从不拜访懒人。它像一只羞怯的鸟,只在你俯首案前、笔耕不辍时,悄然落在你的肩头。那些看似神来之笔的瞬间,不过是千百次练习后的水到渠成。' },
{ title: '时间之河', description: '时间是一条沉默的河流,我们都是河中的石头。水流磨平了棱角,却让内里的纹理愈发清晰。每一道纹路都是一个故事,每一次冲刷都是一次重生。' },
{ title: '留白之美', description: '最好的设计往往藏在未说之处。一幅山水画的力量不在墨色,而在无墨之间的云雾与山岚。界面如此,人生亦然——空间不是空缺,是呼吸的节拍。' },
{ title: '光与影', description: '没有阴影,光便失去了意义。创作的本质不是消除黑暗,而是让明暗相互成全。一幅好的摄影作品,阴影里藏着比亮处更多的故事。' },
{ title: '旅途随笔', description: '旅行不是从一个地方到另一个地方,而是从一种视角切换到另一种视角。同一轮明月,在不同的经度里,会映出不同的乡愁。' },
{ title: '匠人之心', description: '一把好刀,从锻打到淬火,每一步都不可省略。手艺人知道:没有什么捷径能绕过时间的沉淀。代码与器物,皆是时间的艺术。' },
{ title: '草木人间', description: '每一片叶子都是一个小小的宇宙——脉络如河流,叶肉如大地。阳光穿过叶片的时候,像是神在端详自己的指纹。我们不过是这绿色剧场里的观众。' },
{ title: '夜的独白', description: '凌晨三点,城市终于安静下来。窗外零星灯火像是失眠者彼此交换的暗号。在这万籁俱寂的时刻,思绪反而最清晰——或许黑夜才是思考者真正的白昼。' },
]
const projectTags = [
['Vue', 'TypeScript', 'Nuxt'],
['React', 'Next.js', 'Tailwind'],
['Node.js', 'PostgreSQL', 'Docker'],
['Figma', 'Design System', 'UI/UX'],
['Python', 'FastAPI', 'MongoDB'],
['Swift', 'SwiftUI', 'iOS'],
['Go', 'gRPC', 'Kubernetes'],
['Rust', 'WebAssembly', 'Canvas'],
['Three.js', 'WebGL', 'GLSL'],
['Svelte', 'SvelteKit', 'Prisma'],
['Astro', 'MDX', 'Contentful'],
['Flutter', 'Dart', 'Firebase'],
]
const typeCycle: CardType[] = [
'image-text', 'text', 'image-text', 'portfolio',
'image-text', 'image', 'image-text', 'project',
'text', 'image-text', 'portfolio', 'image-text',
'image', 'image-text', 'project', 'text',
'image-text', 'portfolio', 'image-text', 'image',
]
function genItem(globalIdx: number): CardData {
const type = typeCycle[globalIdx % typeCycle.length]
const idx = globalIdx % 12
const base: Pick<CardData, 'id' | 'type' | 'title' | 'aspectRatio'> = {
id: globalIdx + 1,
type,
title: titles[idx],
aspectRatio: 0.6 + Math.random() * 0.7,
}
switch (type) {
case 'text': {
const tc = textContents[globalIdx % textContents.length]
return { ...base, title: tc.title, description: tc.description, aspectRatio: 0.6 + Math.random() * 0.5 }
}
case 'image':
return { ...base, image: images[idx], description: undefined, aspectRatio: 0.65 + Math.random() * 0.55 }
case 'image-text':
return { ...base, image: images[idx], description: descriptions[idx], aspectRatio: 0.6 + Math.random() * 0.7 }
case 'portfolio': {
const set = portfolioSets[globalIdx % portfolioSets.length]
const ar = 0.85 + Math.random() * 0.15
return { ...base, images: set, description: descriptions[idx], aspectRatio: ar }
}
case 'project':
return {
...base,
image: images[idx],
description: descriptions[idx],
tags: projectTags[globalIdx % projectTags.length],
aspectRatio: 0.65 + Math.random() * 0.45,
}
}
}
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Math.max(1, parseInt(String(query.page || '1')))
const pageSize = Math.min(30, Math.max(1, parseInt(String(query.pageSize || '12'))))
const start = (page - 1) * pageSize
const items = Array.from({ length: pageSize }, (_, i) => genItem(start + i))
const total = 60
const hasMore = start + pageSize < total
return { items, hasMore, page }
})
Loading…
Cancel
Save