899b193085
GalleryView wires the grid + timeline + modal together. The URL is the source of truth for which image (if any) is open — clicking an item pushes ?image=N; closing the modal pops it. This makes deep links shareable and the back button work intuitively. Below 900px viewport, the timeline drops below the grid as a horizontal strip so it doesn't compete with thumbnail space on mobile. Modal store + ImageViewer are placeholders here; real implementations land in Batch 5 (Tasks 19–22). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
89 lines
2.7 KiB
Vue
89 lines
2.7 KiB
Vue
<template>
|
|
<aside class="fc-timeline" aria-label="Gallery timeline">
|
|
<h3 class="fc-timeline__title">Timeline</h3>
|
|
<ul v-if="!store.timelineLoading" class="fc-timeline__list">
|
|
<li v-for="group in grouped" :key="group.year">
|
|
<div class="fc-timeline__year">{{ group.year }}</div>
|
|
<ul class="fc-timeline__months">
|
|
<li v-for="bucket in group.buckets" :key="`${bucket.year}-${bucket.month}`">
|
|
<button class="fc-timeline__bucket" @click="store.jumpTo(bucket.year, bucket.month)">
|
|
<span class="fc-timeline__month">{{ monthShort(bucket.month) }}</span>
|
|
<span class="fc-timeline__count">{{ bucket.count }}</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
</ul>
|
|
<div v-else class="text-caption">Loading…</div>
|
|
</aside>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted } from 'vue'
|
|
import { useGalleryStore } from '../../stores/gallery.js'
|
|
|
|
const store = useGalleryStore()
|
|
onMounted(() => store.loadTimeline())
|
|
|
|
const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
function monthShort(m) { return MONTHS_SHORT[m - 1] }
|
|
|
|
const grouped = computed(() => {
|
|
const out = []
|
|
let current = null
|
|
for (const b of store.timelineBuckets) {
|
|
if (!current || current.year !== b.year) {
|
|
current = { year: b.year, buckets: [] }
|
|
out.push(current)
|
|
}
|
|
current.buckets.push(b)
|
|
}
|
|
return out
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-timeline {
|
|
position: sticky;
|
|
top: 16px;
|
|
width: 160px;
|
|
max-height: calc(100vh - 96px);
|
|
overflow-y: auto;
|
|
padding: 12px;
|
|
background: rgb(var(--v-theme-surface));
|
|
border-radius: 8px;
|
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
|
}
|
|
.fc-timeline__title {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 14px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
font-weight: 500;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
margin-bottom: 8px;
|
|
}
|
|
.fc-timeline__list { list-style: none; padding: 0; margin: 0; }
|
|
.fc-timeline__year {
|
|
font-family: 'Fraunces', Georgia, serif;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
color: rgb(var(--v-theme-accent));
|
|
margin: 12px 0 4px;
|
|
}
|
|
.fc-timeline__months { list-style: none; padding: 0; margin: 0; }
|
|
.fc-timeline__bucket {
|
|
display: flex; justify-content: space-between; width: 100%;
|
|
padding: 4px 6px;
|
|
background: transparent; border: none; color: rgb(var(--v-theme-on-surface));
|
|
font: inherit; cursor: pointer; border-radius: 4px;
|
|
transition: background 100ms ease;
|
|
}
|
|
.fc-timeline__bucket:hover { background: rgb(var(--v-theme-surface-light)); }
|
|
.fc-timeline__count {
|
|
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
|
font-size: 12px;
|
|
}
|
|
</style>
|