diff --git a/frontend/src/components/gallery/EmptyState.vue b/frontend/src/components/gallery/EmptyState.vue new file mode 100644 index 0000000..355133f --- /dev/null +++ b/frontend/src/components/gallery/EmptyState.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/gallery/GalleryItem.vue b/frontend/src/components/gallery/GalleryItem.vue new file mode 100644 index 0000000..a532285 --- /dev/null +++ b/frontend/src/components/gallery/GalleryItem.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/src/stores/gallery.js b/frontend/src/stores/gallery.js new file mode 100644 index 0000000..10d3208 --- /dev/null +++ b/frontend/src/stores/gallery.js @@ -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 +}