10-04-2026
This commit is contained in:
parent
70a7776da5
commit
761b1156c1
50 changed files with 11997 additions and 150 deletions
175
frontend/_src/composables/useImageCache.js
Normal file
175
frontend/_src/composables/useImageCache.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
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
|
||||
const memoryCache = new Map()
|
||||
|
||||
/**
|
||||
* 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)
|
||||
memoryCache.set(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)
|
||||
memoryCache.set(imageUrl, blobUrl)
|
||||
return blobUrl
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Image cache read failed:', e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(false)
|
||||
|
||||
async function resolve() {
|
||||
if (!imageUrl) {
|
||||
resolvedSrc.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Memory cache (instant)
|
||||
if (memoryCache.has(imageUrl)) {
|
||||
resolvedSrc.value = memoryCache.get(imageUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. IndexedDB cache
|
||||
const cached = await getCachedImage(imageUrl)
|
||||
if (cached) {
|
||||
resolvedSrc.value = cached
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Fetch, create thumbnail, cache
|
||||
loading.value = true
|
||||
try {
|
||||
const blobUrl = await fetchAndCache(imageUrl, eventId)
|
||||
resolvedSrc.value = blobUrl
|
||||
} catch (e) {
|
||||
// Fallback: use original URL directly (works when online)
|
||||
console.warn('Image cache failed, using direct URL:', e)
|
||||
resolvedSrc.value = imageUrl
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
resolve()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
137
frontend/_src/composables/usePanelDrag.js
Normal file
137
frontend/_src/composables/usePanelDrag.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { ref, onBeforeUnmount } from 'vue'
|
||||
|
||||
/**
|
||||
* Composable for draggable bottom-sheet panels with snap points.
|
||||
*
|
||||
* Snap stops (in dvh): 100, 75, 50
|
||||
* Close threshold: below 25dvh
|
||||
*
|
||||
* @param {Function} onClose - called when panel is dragged below threshold
|
||||
* @returns {{ panelHeight, handleListeners, resetHeight }}
|
||||
*/
|
||||
export function usePanelDrag(onClose) {
|
||||
const SNAP_POINTS = [100, 75, 50, 25] // dvh values
|
||||
const CLOSE_THRESHOLD = 15 // below this → close
|
||||
|
||||
// Current panel height in dvh (null = use CSS default)
|
||||
const panelHeight = ref(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
let dragging = false
|
||||
let startY = 0
|
||||
let startHeight = 0
|
||||
|
||||
function getViewportHeight() {
|
||||
return window.innerHeight
|
||||
}
|
||||
|
||||
function pxToDvh(px) {
|
||||
return (px / getViewportHeight()) * 100
|
||||
}
|
||||
|
||||
function findNearestSnap(dvh) {
|
||||
let nearest = SNAP_POINTS[0]
|
||||
let minDist = Infinity
|
||||
for (const snap of SNAP_POINTS) {
|
||||
const dist = Math.abs(dvh - snap)
|
||||
if (dist < minDist) {
|
||||
minDist = dist
|
||||
nearest = snap
|
||||
}
|
||||
}
|
||||
return nearest
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
// Only primary button / single touch
|
||||
if (e.button && e.button !== 0) return
|
||||
dragging = true
|
||||
isDragging.value = true
|
||||
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY
|
||||
startY = clientY
|
||||
|
||||
// Current height: if panelHeight is set use it, else measure from CSS
|
||||
const currentDvh = panelHeight.value ?? 75
|
||||
startHeight = currentDvh
|
||||
|
||||
document.addEventListener('pointermove', onPointerMove, { passive: false })
|
||||
document.addEventListener('pointerup', onPointerUp)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
|
||||
// Prevent text selection
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!dragging) return
|
||||
const clientY = e.clientY
|
||||
handleMove(clientY)
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (!dragging) return
|
||||
if (e.touches.length !== 1) return
|
||||
handleMove(e.touches[0].clientY)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function handleMove(clientY) {
|
||||
const deltaY = clientY - startY
|
||||
const deltaDvh = pxToDvh(deltaY)
|
||||
const newHeight = Math.max(10, Math.min(100, startHeight - deltaDvh))
|
||||
panelHeight.value = newHeight
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
finishDrag()
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
finishDrag()
|
||||
}
|
||||
|
||||
function finishDrag() {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
isDragging.value = false
|
||||
|
||||
cleanup()
|
||||
|
||||
const currentHeight = panelHeight.value ?? 75
|
||||
if (currentHeight < CLOSE_THRESHOLD) {
|
||||
panelHeight.value = null
|
||||
onClose()
|
||||
} else {
|
||||
// Snap to nearest point
|
||||
panelHeight.value = findNearestSnap(currentHeight)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
document.removeEventListener('pointermove', onPointerMove)
|
||||
document.removeEventListener('pointerup', onPointerUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
|
||||
function resetHeight() {
|
||||
panelHeight.value = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(cleanup)
|
||||
|
||||
// Event listeners to bind on the handle element
|
||||
const handleListeners = {
|
||||
pointerdown: onPointerDown,
|
||||
touchstart: onPointerDown,
|
||||
}
|
||||
|
||||
return {
|
||||
panelHeight,
|
||||
isDragging,
|
||||
handleListeners,
|
||||
resetHeight,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue