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) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:33:14 -04:00
parent 899b193085
commit 8358d09348
2 changed files with 160 additions and 5 deletions
+79 -5
View File
@@ -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
}
})