8 changed files with 1133 additions and 5 deletions
@ -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; |
||||
|
} |
||||
|
} |
||||
|
|||||
@ -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"> |
<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> |
</script> |
||||
|
|
||||
<template> |
<template> |
||||
<div class="flex flex-col items-center justify-center min-h-screen"> |
<div class="home"> |
||||
<h1 class="text-4xl font-bold mb-4">Welcome to Nuxt 3!</h1> |
<header class="page-header"> |
||||
<p class="text-lg text-gray-600">This is the index page.</p> |
<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> |
</div> |
||||
</template> |
</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