feat(fc2a): add gallery Pinia store, GalleryItem, and EmptyState
Gallery store handles cursor pagination, date-group merging across pages, tag filter, timeline buckets, and jump-to-month. Stale response detection via an inflightId counter prevents out-of-order page additions when the user toggles filters quickly. GalleryItem is a square thumbnail card with hover/focus accent outline, keyboard activation (Enter/Space → open), video badge for video MIME types, and a broken-image fallback if the thumbnail URL fails to load. EmptyState points operators to /settings?tab=import so the empty path is self-guiding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-empty">
|
||||||
|
<v-icon size="56" icon="mdi-image-off-outline" class="fc-empty__icon" />
|
||||||
|
<h2 class="fc-empty__title">No images yet</h2>
|
||||||
|
<p class="fc-empty__body">
|
||||||
|
Drop files into <code>/import</code> and run a scan from
|
||||||
|
<RouterLink to="/settings?tab=import">Settings → Import</RouterLink>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 96px 24px;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
.fc-empty__icon { color: rgb(var(--v-theme-accent)); margin-bottom: 16px; }
|
||||||
|
.fc-empty__title {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fc-empty__body { color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface))); }
|
||||||
|
code { background: rgb(var(--v-theme-surface-light)); padding: 1px 6px; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<v-card
|
||||||
|
class="fc-gallery-item" elevation="0" @click="$emit('open', image.id)"
|
||||||
|
:aria-label="`Open image ${image.id}`" tabindex="0"
|
||||||
|
@keydown.enter="$emit('open', image.id)"
|
||||||
|
@keydown.space.prevent="$emit('open', image.id)"
|
||||||
|
>
|
||||||
|
<div class="fc-gallery-item__media">
|
||||||
|
<img
|
||||||
|
v-if="!isVideo" :src="image.thumbnail_url" :alt="`Image ${image.id}`"
|
||||||
|
loading="lazy" @error="onThumbError"
|
||||||
|
>
|
||||||
|
<div v-else class="fc-gallery-item__video-thumb">
|
||||||
|
<img :src="image.thumbnail_url" :alt="`Video ${image.id}`" loading="lazy">
|
||||||
|
<v-icon class="fc-gallery-item__video-badge" icon="mdi-play-circle" />
|
||||||
|
</div>
|
||||||
|
<div v-if="thumbError" class="fc-gallery-item__placeholder">
|
||||||
|
<v-icon size="32" icon="mdi-image-broken-variant" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
image: { type: Object, required: true }
|
||||||
|
})
|
||||||
|
defineEmits(['open'])
|
||||||
|
|
||||||
|
const thumbError = ref(false)
|
||||||
|
const isVideo = computed(() => props.image.mime && props.image.mime.startsWith('video/'))
|
||||||
|
|
||||||
|
function onThumbError() { thumbError.value = true }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-gallery-item {
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
.fc-gallery-item:hover, .fc-gallery-item:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
outline: 2px solid rgb(var(--v-theme-accent));
|
||||||
|
}
|
||||||
|
.fc-gallery-item__media {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgb(var(--v-theme-surface-light));
|
||||||
|
}
|
||||||
|
.fc-gallery-item__media img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||||
|
}
|
||||||
|
.fc-gallery-item__video-thumb { position: relative; height: 100%; }
|
||||||
|
.fc-gallery-item__video-badge {
|
||||||
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||||
|
color: rgba(232, 228, 216, 0.85); /* parchment with alpha */
|
||||||
|
font-size: 36px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.fc-gallery-item__placeholder {
|
||||||
|
position: absolute; inset: 0; display: grid; place-items: center;
|
||||||
|
background: rgb(var(--v-theme-surface-light));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
|
||||||
|
export const useGalleryStore = defineStore('gallery', () => {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const images = ref([])
|
||||||
|
const dateGroups = ref([]) // [{year, month, image_ids}]
|
||||||
|
const nextCursor = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const filter = ref({ tag_id: null })
|
||||||
|
|
||||||
|
const timelineBuckets = ref([])
|
||||||
|
const timelineLoading = ref(false)
|
||||||
|
|
||||||
|
let inflightId = 0
|
||||||
|
|
||||||
|
async function loadInitial() {
|
||||||
|
images.value = []
|
||||||
|
dateGroups.value = []
|
||||||
|
nextCursor.value = null
|
||||||
|
await loadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const myId = ++inflightId
|
||||||
|
try {
|
||||||
|
const params = { limit: 50 }
|
||||||
|
if (filter.value.tag_id) params.tag_id = filter.value.tag_id
|
||||||
|
if (nextCursor.value) params.cursor = nextCursor.value
|
||||||
|
const body = await api.get('/api/gallery/scroll', { params })
|
||||||
|
if (myId !== inflightId) return // stale response
|
||||||
|
images.value.push(...body.images)
|
||||||
|
dateGroups.value = mergeGroups(dateGroups.value, body.date_groups)
|
||||||
|
nextCursor.value = body.next_cursor
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
if (myId === inflightId) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTimeline() {
|
||||||
|
timelineLoading.value = true
|
||||||
|
try {
|
||||||
|
const params = filter.value.tag_id ? { tag_id: filter.value.tag_id } : {}
|
||||||
|
timelineBuckets.value = await api.get('/api/gallery/timeline', { params })
|
||||||
|
} finally {
|
||||||
|
timelineLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function jumpTo(year, month) {
|
||||||
|
const params = { year, month }
|
||||||
|
if (filter.value.tag_id) params.tag_id = filter.value.tag_id
|
||||||
|
const body = await api.get('/api/gallery/jump', { params })
|
||||||
|
if (body.cursor) {
|
||||||
|
images.value = []
|
||||||
|
dateGroups.value = []
|
||||||
|
nextCursor.value = body.cursor
|
||||||
|
await loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTagFilter(tagId) {
|
||||||
|
filter.value.tag_id = tagId
|
||||||
|
loadInitial()
|
||||||
|
loadTimeline()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMore = computed(() => nextCursor.value !== null)
|
||||||
|
const isEmpty = computed(() => !loading.value && images.value.length === 0 && error.value === null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
images, dateGroups, hasMore, isEmpty, loading, error,
|
||||||
|
filter, timelineBuckets, timelineLoading,
|
||||||
|
loadInitial, loadMore, loadTimeline, jumpTo, setTagFilter
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function mergeGroups(existing, incoming) {
|
||||||
|
// Merge sequential groups with the same (year, month) instead of duplicating.
|
||||||
|
const merged = [...existing]
|
||||||
|
for (const g of incoming) {
|
||||||
|
const tail = merged[merged.length - 1]
|
||||||
|
if (tail && tail.year === g.year && tail.month === g.month) {
|
||||||
|
tail.image_ids = [...tail.image_ids, ...g.image_ids]
|
||||||
|
} else {
|
||||||
|
merged.push({ ...g, image_ids: [...g.image_ids] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user