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:
2026-05-14 12:31:53 -04:00
parent 968a9e14e3
commit da89fe6be2
3 changed files with 194 additions and 0 deletions
@@ -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>