- TimelineView: Shader-Event-Mapping in computed ausgelagert, deep-Watch entfernt, natives Wheel-Scrolling statt @wheel.prevent - Events-Store: shallowRef statt tiefer Reaktivität, String-Sortierung statt Date-Objekten, getGlowColor memoized, DB-Writes debounced - Edits werden jetzt dedupliziert als 'update' in die SyncQueue geschrieben (vorher gingen Änderungen beim Sync verloren) - useImageCache: In-Flight-Deduping, Cache-Limit mit revokeObjectURL - FloatingLines: Antialiasing aus (Fullscreen-Quad), deep-Watches entfernt - Ungenutzte Seiten/Komponenten entfernt (~6.700 Zeilen, nicht geroutet) - implementation-plan.md: Hinweis zu update-Action im Sync ergänzt
192 lines
5.4 KiB
JavaScript
192 lines
5.4 KiB
JavaScript
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)
|
|
}
|
|
}
|