8 changed files with 1133 additions and 5 deletions
@ -1 +1,41 @@ |
|||
@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; |
|||
} |
|||
} |
|||
|
|||
@ -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">📷</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> |
|||
@ -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">📷</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> |
|||
@ -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> |
|||
@ -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">📷</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> |
|||
@ -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> |
|||
@ -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> |
|||
</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> |
|||
|
|||
@ -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…
Reference in new issue