From 8358d09348eff1a7a872a61c92aa33ba48638bc9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 14 May 2026 12:33:14 -0400 Subject: [PATCH] feat(fc2a): real modal store + usePanZoom composable modal store loads image detail (with neighbors) on open, exposes goPrev/ goNext that re-fetch the next image, and implements optimistic tag remove with rollback on API failure. createAndAdd is the bridge between the autocomplete UI and a brand-new tag. usePanZoom is a small composable: click-to-toggle 2x zoom centered on click point, wheel-to-zoom up to 4x, drag-to-pan when zoomed. Pointer events are used (touch + mouse + pen unified). Caller spreads handlers onto the element it wants pannable. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/composables/usePanZoom.js | 81 +++++++++++++++++++++++++ frontend/src/stores/modal.js | 84 ++++++++++++++++++++++++-- 2 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 frontend/src/composables/usePanZoom.js diff --git a/frontend/src/composables/usePanZoom.js b/frontend/src/composables/usePanZoom.js new file mode 100644 index 0000000..b05cc37 --- /dev/null +++ b/frontend/src/composables/usePanZoom.js @@ -0,0 +1,81 @@ +// Click-to-toggle zoom + drag-to-pan when zoomed + wheel-to-zoom. +// Returns reactive state + handlers a component can spread onto its +// root element. + +import { reactive } from 'vue' + +const MIN_SCALE = 1 +const MAX_SCALE = 4 +const WHEEL_SENSITIVITY = 0.0015 + +export function usePanZoom() { + const state = reactive({ + scale: 1, + x: 0, + y: 0, + panning: false + }) + + let dragOriginX = 0 + let dragOriginY = 0 + let panOriginX = 0 + let panOriginY = 0 + + function reset() { + state.scale = 1 + state.x = 0 + state.y = 0 + } + + function toggleZoom(ev) { + if (state.scale > 1) { reset(); return } + state.scale = 2 + // Zoom centered on click point + if (ev && ev.currentTarget) { + const rect = ev.currentTarget.getBoundingClientRect() + const cx = ev.clientX - rect.left - rect.width / 2 + const cy = ev.clientY - rect.top - rect.height / 2 + state.x = -cx + state.y = -cy + } + } + + function onWheel(ev) { + ev.preventDefault() + const delta = -ev.deltaY * WHEEL_SENSITIVITY + const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + delta * state.scale)) + state.scale = next + if (next === MIN_SCALE) { state.x = 0; state.y = 0 } + } + + function onPointerDown(ev) { + if (state.scale === 1) return + state.panning = true + dragOriginX = ev.clientX + dragOriginY = ev.clientY + panOriginX = state.x + panOriginY = state.y + ev.currentTarget.setPointerCapture(ev.pointerId) + } + function onPointerMove(ev) { + if (!state.panning) return + state.x = panOriginX + (ev.clientX - dragOriginX) + state.y = panOriginY + (ev.clientY - dragOriginY) + } + function onPointerUp(ev) { + state.panning = false + try { ev.currentTarget.releasePointerCapture(ev.pointerId) } catch {} + } + + return { + state, + handlers: { + onWheel, + onPointerDown, + onPointerMove, + onPointerUp, + onClick: toggleZoom + }, + reset + } +} diff --git a/frontend/src/stores/modal.js b/frontend/src/stores/modal.js index 7f88020..509c23c 100644 --- a/frontend/src/stores/modal.js +++ b/frontend/src/stores/modal.js @@ -1,10 +1,84 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, computed } from 'vue' +import { useApi } from '../composables/useApi.js' -// Placeholder modal store — real implementation lands in Task 19. export const useModalStore = defineStore('modal', () => { + const api = useApi() + const currentImageId = ref(null) - function open(id) { currentImageId.value = id } - function close() { currentImageId.value = null } - return { currentImageId, open, close } + const current = ref(null) // full image detail from API + const loading = ref(false) + const error = ref(null) + + async function open(id) { + currentImageId.value = id + loading.value = true + error.value = null + try { + current.value = await api.get(`/api/gallery/image/${id}`) + } catch (e) { + error.value = e.message + current.value = null + } finally { + loading.value = false + } + } + + async function close() { + currentImageId.value = null + current.value = null + error.value = null + } + + async function goPrev() { + if (current.value && current.value.neighbors.prev_id) { + await open(current.value.neighbors.prev_id) + } + } + async function goNext() { + if (current.value && current.value.neighbors.next_id) { + await open(current.value.neighbors.next_id) + } + } + + async function reloadTags() { + if (!currentImageId.value) return + const tags = await api.get(`/api/images/${currentImageId.value}/tags`) + if (current.value) current.value.tags = tags + } + + async function removeTag(tagId) { + if (!currentImageId.value) return + // Optimistic UI: remove locally first, restore on error. + const prev = current.value.tags + current.value.tags = current.value.tags.filter(t => t.id !== tagId) + try { + await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`) + } catch (e) { + current.value.tags = prev + throw e + } + } + + async function addExistingTag(tagId) { + if (!currentImageId.value) return + await api.post(`/api/images/${currentImageId.value}/tags`, { + body: { tag_id: tagId, source: 'manual' } + }) + await reloadTags() + } + + async function createAndAdd({ name, kind, fandom_id = null }) { + const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } }) + await addExistingTag(tag.id) + } + + const isOpen = computed(() => currentImageId.value !== null) + const canPrev = computed(() => current.value?.neighbors?.prev_id != null) + const canNext = computed(() => current.value?.neighbors?.next_id != null) + + return { + currentImageId, current, loading, error, isOpen, canPrev, canNext, + open, close, goPrev, goNext, reloadTags, removeTag, addExistingTag, createAndAdd + } })