import { ref } from 'vue' import { db } from 'src/db' const THUMB_SIZE = 200 // In-memory URL cache: avoids repeated IndexedDB reads and blob URL creation // Shared across all component instances. Bounded: oldest entries are evicted // and their blob URLs revoked once the cap is reached. const MEMORY_CACHE_MAX = 300 const memoryCache = new Map() // In-flight resolutions: parallel requests for the same URL share one promise // (many dots can mount at once referencing the same image) const inFlight = new Map() function memoryCacheSet(url, blobUrl) { if (memoryCache.size >= MEMORY_CACHE_MAX) { // Map iterates in insertion order → first entry is the oldest const [oldestUrl, oldestBlobUrl] = memoryCache.entries().next().value URL.revokeObjectURL(oldestBlobUrl) memoryCache.delete(oldestUrl) } memoryCache.set(url, blobUrl) } /** * Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob. * Returns a new Blob (JPEG, quality 0.8). */ function createThumbnail(blob) { return new Promise((resolve, reject) => { const img = new Image() const url = URL.createObjectURL(blob) img.onload = () => { const canvas = document.createElement('canvas') canvas.width = THUMB_SIZE canvas.height = THUMB_SIZE const ctx = canvas.getContext('2d') // Cover crop: center the image const scale = Math.max(THUMB_SIZE / img.width, THUMB_SIZE / img.height) const w = img.width * scale const h = img.height * scale const x = (THUMB_SIZE - w) / 2 const y = (THUMB_SIZE - h) / 2 ctx.drawImage(img, x, y, w, h) canvas.toBlob( (thumbBlob) => { URL.revokeObjectURL(url) if (thumbBlob) resolve(thumbBlob) else reject(new Error('Canvas toBlob failed')) }, 'image/jpeg', 0.8 ) } img.onerror = () => { URL.revokeObjectURL(url) reject(new Error('Image load failed')) } img.src = url }) } /** * Fetch an image from URL, cache thumbnail in IndexedDB, return blob URL. */ async function fetchAndCache(imageUrl, eventId) { const response = await fetch(imageUrl) if (!response.ok) throw new Error(`Fetch failed: ${response.status}`) const blob = await response.blob() // Create thumbnail const thumbBlob = await createThumbnail(blob) // Store in IndexedDB await db.imageCache.put({ url: imageUrl, eventId, type: 'thumbnail', blob: thumbBlob, cachedAt: Date.now() }) const blobUrl = URL.createObjectURL(thumbBlob) memoryCacheSet(imageUrl, blobUrl) return blobUrl } /** * Get a cached thumbnail blob URL from IndexedDB. * Returns null if not cached. */ async function getCachedImage(imageUrl) { // Check memory first if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl) try { const entry = await db.imageCache.get(imageUrl) if (entry?.blob) { const blobUrl = URL.createObjectURL(entry.blob) memoryCacheSet(imageUrl, blobUrl) return blobUrl } } catch (e) { console.warn('Image cache read failed:', e) } return null } /** * Resolve an image URL to a displayable src (memory → IndexedDB → fetch). * Concurrent calls for the same URL share a single in-flight promise. * Falls back to the original URL if caching fails. */ export function resolveImage(imageUrl, eventId) { if (!imageUrl) return Promise.resolve(null) if (memoryCache.has(imageUrl)) { return Promise.resolve(memoryCache.get(imageUrl)) } if (inFlight.has(imageUrl)) { return inFlight.get(imageUrl) } const promise = (async () => { const cached = await getCachedImage(imageUrl) if (cached) return cached try { return await fetchAndCache(imageUrl, eventId) } catch (e) { // Fallback: use original URL directly (works when online) console.warn('Image cache failed, using direct URL:', e) return imageUrl } })().finally(() => inFlight.delete(imageUrl)) inFlight.set(imageUrl, promise) return promise } /** * Composable: resolves an event's image to a displayable src. * - Checks memory cache → IndexedDB cache → fetches & caches thumbnail. * - Returns reactive `resolvedSrc` ref. */ export function useImageCache(imageUrl, eventId) { const resolvedSrc = ref(null) const loading = ref(true) resolveImage(imageUrl, eventId) .then((src) => { resolvedSrc.value = src }) .finally(() => { loading.value = false }) return { resolvedSrc, loading } } /** * Resolve full-res image for EventPanel (no thumbnail, just cache check). * Returns the original URL — browser Cache-Control handles caching. * When offline, falls back to cached thumbnail. */ export async function resolveFullRes(imageUrl) { if (!imageUrl) return null // If online, return original URL (browser caches via HTTP headers) if (navigator.onLine) return imageUrl // Offline: try cached thumbnail as fallback const cached = await getCachedImage(imageUrl) return cached || imageUrl } /** * Clear all cached images for a specific event. */ export async function clearEventImages(eventId) { try { const entries = await db.imageCache.where('eventId').equals(eventId).toArray() for (const entry of entries) { if (memoryCache.has(entry.url)) { URL.revokeObjectURL(memoryCache.get(entry.url)) memoryCache.delete(entry.url) } } await db.imageCache.where('eventId').equals(eventId).delete() } catch (e) { console.warn('Clear event images failed:', e) } }