feat(fc2c-i): showcase view with shortest-column masonry
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-masonry" ref="containerEl">
|
||||||
|
<div class="fc-masonry__cols">
|
||||||
|
<div v-for="(col, ci) in columns" :key="ci" class="fc-masonry__col">
|
||||||
|
<button
|
||||||
|
v-for="item in col" :key="item.id"
|
||||||
|
class="fc-masonry__item" type="button"
|
||||||
|
@click="$emit('open', item.id)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="item.thumbnail_url" :alt="`image ${item.id}`"
|
||||||
|
loading="lazy"
|
||||||
|
:style="aspectStyle(item)"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="fc-masonry__sentinel">
|
||||||
|
<v-progress-circular indeterminate color="accent" size="28" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="hasMore" ref="sentinelEl" class="fc-masonry__sentinel" />
|
||||||
|
<div v-else-if="items.length" class="fc-masonry__end text-caption">
|
||||||
|
End.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { usePolyMasonry } from '../../composables/usePolyMasonry.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
hasMore: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['load-more', 'open'])
|
||||||
|
|
||||||
|
const containerEl = ref(null)
|
||||||
|
const sentinelEl = ref(null)
|
||||||
|
const { columnCount, distribute } = usePolyMasonry(containerEl)
|
||||||
|
|
||||||
|
const columns = computed(() => distribute(props.items, columnCount.value))
|
||||||
|
|
||||||
|
function aspectStyle(item) {
|
||||||
|
const w = Number(item.width)
|
||||||
|
const h = Number(item.height)
|
||||||
|
if (!w || !h) return {}
|
||||||
|
return { aspectRatio: `${w} / ${h}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
let observer = null
|
||||||
|
function attachObserver() {
|
||||||
|
if (observer) observer.disconnect()
|
||||||
|
if (!sentinelEl.value) return
|
||||||
|
observer = new IntersectionObserver(([entry]) => {
|
||||||
|
if (entry.isIntersecting && props.hasMore && !props.loading) {
|
||||||
|
emit('load-more')
|
||||||
|
}
|
||||||
|
}, { rootMargin: '600px' })
|
||||||
|
observer.observe(sentinelEl.value)
|
||||||
|
}
|
||||||
|
watch(sentinelEl, attachObserver)
|
||||||
|
onMounted(attachObserver)
|
||||||
|
onUnmounted(() => observer && observer.disconnect())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-masonry__cols { display: flex; gap: 8px; align-items: flex-start; }
|
||||||
|
.fc-masonry__col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.fc-masonry__item {
|
||||||
|
display: block; padding: 0; border: 0; background: none;
|
||||||
|
cursor: pointer; width: 100%;
|
||||||
|
}
|
||||||
|
.fc-masonry__item img {
|
||||||
|
width: 100%; height: auto; display: block; border-radius: 4px;
|
||||||
|
background: rgb(var(--v-theme-surface-light));
|
||||||
|
}
|
||||||
|
.fc-masonry__sentinel {
|
||||||
|
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
|
||||||
|
}
|
||||||
|
.fc-masonry__end { text-align: center; padding: 32px 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<v-container fluid class="py-6">
|
||||||
|
<div class="fc-showcase__head">
|
||||||
|
<h1 class="fc-h1">Showcase</h1>
|
||||||
|
<v-btn
|
||||||
|
prepend-icon="mdi-shuffle-variant" variant="tonal" color="accent"
|
||||||
|
:loading="store.loading" @click="store.shuffle()"
|
||||||
|
>Shuffle</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
||||||
|
Failed to load: {{ store.error }}
|
||||||
|
</v-alert>
|
||||||
|
<v-alert v-else-if="store.isEmpty" type="info" variant="tonal" class="my-4">
|
||||||
|
No images yet.
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<MasonryGrid
|
||||||
|
:items="store.images"
|
||||||
|
:loading="store.loading"
|
||||||
|
:has-more="store.hasMore"
|
||||||
|
@load-more="store.fetchPage()"
|
||||||
|
@open="openImage"
|
||||||
|
/>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useShowcaseStore } from '../stores/showcase.js'
|
||||||
|
import MasonryGrid from '../components/discovery/MasonryGrid.vue'
|
||||||
|
|
||||||
|
const store = useShowcaseStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => { if (store.images.length === 0) store.fetchPage() })
|
||||||
|
|
||||||
|
function openImage(id) {
|
||||||
|
router.push({ name: 'gallery', query: { image: id } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-h1 {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 32px; font-weight: 500;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
.fc-showcase__head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user