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,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