feat(fc2a): add TimelineSidebar and assemble GalleryView
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>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<v-dialog :model-value="true" max-width="600" @update:model-value="$emit('close')">
|
||||
<v-card>
|
||||
<v-card-title>Image viewer</v-card-title>
|
||||
<v-card-text>Modal viewer lands in Batch 5 (Tasks 19–22).</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
<script setup>defineEmits(['close'])</script>
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import PlaceholderView from './views/PlaceholderView.vue'
|
||||
import SettingsView from './views/SettingsView.vue'
|
||||
import GalleryView from './views/GalleryView.vue'
|
||||
|
||||
const routes = [
|
||||
// FC-2: image backbone
|
||||
{ path: '/', name: 'gallery', component: PlaceholderView, meta: { title: 'Gallery' } },
|
||||
{ path: '/', name: 'gallery', component: GalleryView, meta: { title: 'Gallery' } },
|
||||
{ path: '/showcase', name: 'showcase', component: PlaceholderView, meta: { title: 'Showcase' } },
|
||||
{ path: '/tags', name: 'tags', component: PlaceholderView, meta: { title: 'Tags' } },
|
||||
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Placeholder modal store — real implementation lands in Task 19.
|
||||
export const useModalStore = defineStore('modal', () => {
|
||||
const currentImageId = ref(null)
|
||||
function open(id) { currentImageId.value = id }
|
||||
function close() { currentImageId.value = null }
|
||||
return { currentImageId, open, close }
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<h1 class="fc-h1 mb-4">Gallery</h1>
|
||||
<div class="fc-gallery-layout">
|
||||
<div class="fc-gallery-layout__main">
|
||||
<EmptyState v-if="store.isEmpty" />
|
||||
<GalleryGrid v-else @open="openImage" />
|
||||
</div>
|
||||
<TimelineSidebar v-if="store.images.length > 0" class="fc-gallery-layout__sidebar" />
|
||||
</div>
|
||||
|
||||
<ImageViewer
|
||||
v-if="modal.currentImageId !== null"
|
||||
@close="closeImage"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useGalleryStore } from '../stores/gallery.js'
|
||||
import { useModalStore } from '../stores/modal.js'
|
||||
import GalleryGrid from '../components/gallery/GalleryGrid.vue'
|
||||
import TimelineSidebar from '../components/gallery/TimelineSidebar.vue'
|
||||
import EmptyState from '../components/gallery/EmptyState.vue'
|
||||
import ImageViewer from '../components/modal/ImageViewer.vue'
|
||||
|
||||
const store = useGalleryStore()
|
||||
const modal = useModalStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadInitial()
|
||||
await store.loadTimeline()
|
||||
// Open modal if URL has ?image=N
|
||||
const initial = parseInt(route.query.image, 10)
|
||||
if (!isNaN(initial)) modal.open(initial)
|
||||
})
|
||||
|
||||
watch(() => route.query.image, (q) => {
|
||||
const id = parseInt(q, 10)
|
||||
if (!isNaN(id) && id !== modal.currentImageId) modal.open(id)
|
||||
else if (isNaN(id) && modal.currentImageId !== null) modal.close()
|
||||
})
|
||||
|
||||
function openImage(id) {
|
||||
router.push({ query: { ...route.query, image: id } })
|
||||
}
|
||||
function closeImage() {
|
||||
const q = { ...route.query }
|
||||
delete q.image
|
||||
router.push({ query: q })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-h1 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 32px;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-gallery-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.fc-gallery-layout__main { flex: 1; min-width: 0; }
|
||||
.fc-gallery-layout__sidebar { flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fc-gallery-layout { flex-direction: column-reverse; }
|
||||
.fc-gallery-layout__sidebar { width: 100%; max-height: 200px; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user