feat(upload): recent-uploads gallery + per-image zoom restore
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled

Past uploads now persist to IndexedDB (originalBlob, exif, and — once
deskew has run — the corrected blob, diagnostics, and output scale).
The upload page renders a tile grid for any entry that completed a
deskew; clicking one rehydrates the store from the cached artefacts
and drops the user straight back into Measure with their datums and
measurements intact. The section is hidden entirely when there are no
completed entries, and each tile has a hover-only delete affordance.

Canvas zoom + pan are now per-image, persisted to localStorage with a
short debounce. Re-running the deskew at a different scale clears the
saved zoom (the stale offsets would point off-image at the new
dimensions).

Other tweaks bundled here:
  • Start Over collapses the previous "Start over | New Image" group
    into a single dashed/transparent button.
  • The Measure header gets md:pl-5 so the title clears the fork-me
    ribbon on desktop.
  • Example images on the landing page swap to the IMG_8324 set, with
    a third "Measured" tile spanning the combined width that shows the
    annotation-baked output.
  • Clearing the cache now also wipes the upload + zoom caches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Prevost 2026-04-25 17:05:18 +02:00
parent a5f4bf650c
commit 9032af426e
9 changed files with 546 additions and 23 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 22 KiB

BIN
public/example-measured.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -14,6 +14,7 @@ import type {
import { getDatumColor } from "@/lib/datums" import { getDatumColor } from "@/lib/datums"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { loadMeasurements, saveMeasurements } from "@/lib/measurement-cache" import { loadMeasurements, saveMeasurements } from "@/lib/measurement-cache"
import { loadZoom, saveZoom } from "@/lib/zoom-cache"
const props = defineProps<{ const props = defineProps<{
imageUrl: string imageUrl: string
@ -124,11 +125,45 @@ function loadImg() {
img.value = image img.value = image
imgLoaded.value = true imgLoaded.value = true
fitToContainer() fitToContainer()
// After auto-fit, prefer a previously-saved zoom/pan if the
// values still place the image inside the container protects
// against stale cache entries (different image dims) that would
// otherwise leave the viewer staring at empty canvas.
const hash = store.fileHash
if (hash) {
const cached = loadZoom(hash)
if (cached && isZoomReasonable(cached)) {
viewScale.value = cached.viewScale
viewOffsetX.value = cached.viewOffsetX
viewOffsetY.value = cached.viewOffsetY
}
}
redraw() redraw()
} }
image.src = props.imageUrl image.src = props.imageUrl
} }
// A cached zoom/pan is "reasonable" if the image's bounding box still
// intersects the canvas at all under that transform. Catches the
// degenerate case where the cache outlived an image dimension change.
function isZoomReasonable(z: {
viewScale: number
viewOffsetX: number
viewOffsetY: number
}): boolean {
if (!Number.isFinite(z.viewScale) || z.viewScale <= 0) return false
if (!Number.isFinite(z.viewOffsetX) || !Number.isFinite(z.viewOffsetY))
return false
const c = containerRef.value
const i = img.value
if (!c || !i) return false
const left = z.viewOffsetX
const top = z.viewOffsetY
const right = left + i.naturalWidth * z.viewScale
const bottom = top + i.naturalHeight * z.viewScale
return right > 0 && bottom > 0 && left < c.clientWidth && top < c.clientHeight
}
function fitToContainer() { function fitToContainer() {
const c = containerRef.value const c = containerRef.value
const i = img.value const i = img.value
@ -2250,6 +2285,24 @@ watch(
}, },
{ deep: true }, { deep: true },
) )
// Persist zoom/pan with a small debounce wheel events fire rapidly and
// resize bursts shouldn't each hit localStorage. The debounce is short
// enough that a normal pan-and-pause finishes saving before navigation.
let zoomSaveTimer: ReturnType<typeof setTimeout> | null = null
watch([viewScale, viewOffsetX, viewOffsetY], () => {
if (!imgLoaded.value || !store.fileHash) return
if (zoomSaveTimer) clearTimeout(zoomSaveTimer)
const hash = store.fileHash
zoomSaveTimer = setTimeout(() => {
saveZoom(hash, {
viewScale: viewScale.value,
viewOffsetX: viewOffsetX.value,
viewOffsetY: viewOffsetY.value,
})
zoomSaveTimer = null
}, 250)
})
</script> </script>
<template> <template>

View File

@ -30,6 +30,8 @@ import {
saveMeasurements, saveMeasurements,
scaleMeasurements, scaleMeasurements,
} from "@/lib/measurement-cache" } from "@/lib/measurement-cache"
import { patchUpload } from "@/lib/upload-cache"
import { clearZoom } from "@/lib/zoom-cache"
const store = useAppStore() const store = useAppStore()
const previewUrl = ref<string | null>(null) const previewUrl = ref<string | null>(null)
@ -238,6 +240,9 @@ async function runDeskew() {
// already cached for this image so they stay anchored to the same // already cached for this image so they stay anchored to the same
// physical features. CorrectedImageViewer reads from cache on // physical features. CorrectedImageViewer reads from cache on
// mount, so writing here is enough; no in-memory state to sync. // mount, so writing here is enough; no in-memory state to sync.
// The cached zoom/pan also no longer makes sense once the image
// dimensions change, so we drop it and let fitToContainer pick
// a fresh default.
if ( if (
oldScale !== null && oldScale !== null &&
oldScale > 0 && oldScale > 0 &&
@ -249,12 +254,28 @@ async function runDeskew() {
const scaled = scaleMeasurements(cached, newScale / oldScale) const scaled = scaleMeasurements(cached, newScale / oldScale)
saveMeasurements(store.fileHash, scaled) saveMeasurements(store.fileHash, scaled)
} }
clearZoom(store.fileHash)
} }
store.setResult(result, newScale) store.setResult(result, newScale)
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = URL.createObjectURL(result.correctedImageBlob) previewUrl.value = URL.createObjectURL(result.correctedImageBlob)
// Persist the deskew artefacts onto the upload record so the
// Recent Uploads gallery can reopen straight into Measure. Best
// effort: an IndexedDB failure shouldn't break the visible flow.
if (store.fileHash) {
try {
await patchUpload(store.fileHash, {
correctedBlob: result.correctedImageBlob,
diagnostics: result.diagnostics,
scalePxPerMm: newScale,
})
} catch {
// ignore gallery just won't include this entry
}
}
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : "Deskew failed" error.value = e instanceof Error ? e.message : "Deskew failed"
} finally { } finally {

View File

@ -1,11 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue" import { ref, computed, onMounted, onUnmounted } from "vue"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { loadImage } from "@/lib/image-loader" import { loadImage } from "@/lib/image-loader"
import { extractExif } from "@/lib/exif" import { extractExif } from "@/lib/exif"
import { hashFile } from "@/lib/file-hash" import { hashFile } from "@/lib/file-hash"
import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache" import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache"
import { clearCache as clearMeasurementCache } from "@/lib/measurement-cache" import {
clearCache as clearMeasurementCache,
loadMeasurements,
} from "@/lib/measurement-cache"
import {
saveUpload,
loadUpload,
listUploads,
deleteUpload,
clearUploads,
type UploadRecord,
} from "@/lib/upload-cache"
import { clearCache as clearZoomCache } from "@/lib/zoom-cache"
import { import {
Card, Card,
CardContent, CardContent,
@ -22,6 +34,8 @@ const error = ref("")
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const cacheCount = ref(0) const cacheCount = ref(0)
const confirmingClear = ref(false) const confirmingClear = ref(false)
const recentUploads = ref<UploadRecord[]>([])
const recentUrls = ref<Map<string, string>>(new Map())
const ACCEPTED = "image/*,.heic,.heif" const ACCEPTED = "image/*,.heic,.heif"
// Auto-revert the "Are you sure?" prompt after this long of inactivity // Auto-revert the "Are you sure?" prompt after this long of inactivity
@ -30,18 +44,73 @@ const CLEAR_CONFIRM_TIMEOUT_MS = 4000
let confirmTimer: ReturnType<typeof setTimeout> | null = null let confirmTimer: ReturnType<typeof setTimeout> | null = null
onMounted(() => { const dateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
})
// Only show entries that have a deskew result those are the ones a
// click can meaningfully drop the user back into Measure with. Uploads
// that never reached deskew remain saved (so re-uploading the same file
// still hits the cache) but don't clutter the gallery.
const completedUploads = computed(() =>
recentUploads.value.filter(
(u) => u.correctedBlob !== undefined && u.diagnostics !== undefined,
),
)
onMounted(async () => {
cacheCount.value = getCacheSize() cacheCount.value = getCacheSize()
await refreshRecentUploads()
}) })
onUnmounted(() => { onUnmounted(() => {
if (confirmTimer) clearTimeout(confirmTimer) if (confirmTimer) clearTimeout(confirmTimer)
for (const url of recentUrls.value.values()) URL.revokeObjectURL(url)
}) })
async function refreshRecentUploads() {
try {
const all = await listUploads()
// Revoke any URLs whose entries no longer exist (e.g. after a
// delete) before refreshing the map.
const nextHashes = new Set(all.map((u) => u.hash))
for (const [hash, url] of recentUrls.value) {
if (!nextHashes.has(hash)) {
URL.revokeObjectURL(url)
recentUrls.value.delete(hash)
}
}
// Mint preview URLs for every entry; we keep them alive for the
// lifetime of this page mount (revoked in onUnmounted).
for (const u of all) {
if (!recentUrls.value.has(u.hash)) {
const preview = u.correctedBlob ?? u.originalBlob
recentUrls.value.set(u.hash, URL.createObjectURL(preview))
}
}
recentUploads.value = all
} catch {
recentUploads.value = []
}
}
function formatDate(ms: number): string {
return dateFormatter.format(new Date(ms))
}
function previewUrlFor(hash: string): string {
return recentUrls.value.get(hash) ?? ""
}
function handleClearCacheClick() { function handleClearCacheClick() {
if (confirmingClear.value) { if (confirmingClear.value) {
clearCache() clearCache()
clearMeasurementCache() clearMeasurementCache()
clearZoomCache()
// IndexedDB clear is async but the UI doesn't depend on its
// completion fire and forget, then reload the gallery.
void clearUploads().then(() => refreshRecentUploads())
cacheCount.value = 0 cacheCount.value = 0
confirmingClear.value = false confirmingClear.value = false
if (confirmTimer) { if (confirmTimer) {
@ -58,6 +127,90 @@ function handleClearCacheClick() {
}, CLEAR_CONFIRM_TIMEOUT_MS) }, CLEAR_CONFIRM_TIMEOUT_MS)
} }
async function handleDeleteUpload(hash: string, ev: Event) {
ev.stopPropagation()
try {
await deleteUpload(hash)
await refreshRecentUploads()
} catch {
// ignore UI will reflect on next refresh
}
}
// Reopen a past upload straight in Measure. Replays the upload pipeline
// from the cached blob (the original image is needed if the user later
// goes back to Datums or Deskew), then stitches the cached deskew
// artefacts and datums onto the store before navigating.
async function restoreUpload(hash: string) {
error.value = ""
store.isProcessing = true
store.processingStatus = "Loading saved image..."
try {
const record = await loadUpload(hash)
if (!record) {
error.value = "Saved upload no longer available"
return
}
if (!record.correctedBlob || !record.diagnostics) {
error.value = "Saved upload has no deskew result"
return
}
const file = new File([record.originalBlob], record.filename, {
type: record.mimeType,
})
const { image, convertedFile } = await loadImage(file, (status) => {
store.processingStatus = status
})
const cachedDatums = loadDatums(hash) ?? []
const cachedMeasurements = loadMeasurements(hash) ?? []
store.setFileHash(hash)
store.setImage(convertedFile, image)
store.setExif(record.exif)
store.datums = cachedDatums
if (record.scalePxPerMm) {
store.scalePxPerMm = record.scalePxPerMm
}
store.setResult(
{
correctedImageBlob: record.correctedBlob,
diagnostics: record.diagnostics,
},
record.scalePxPerMm ?? store.scalePxPerMm,
)
if (cachedMeasurements.length > 0 || cachedDatums.length > 0) {
const parts: string[] = []
if (cachedDatums.length > 0) {
parts.push(
`${String(cachedDatums.length)} datum${cachedDatums.length === 1 ? "" : "s"}`,
)
}
if (cachedMeasurements.length > 0) {
parts.push(
`${String(cachedMeasurements.length)} measurement${cachedMeasurements.length === 1 ? "" : "s"}`,
)
}
store.cacheRestoreMessage = `Restored ${parts.join(" and ")}`
setTimeout(() => {
store.cacheRestoreMessage = ""
}, 4000)
}
// Bump max step so the indicator surfaces every prior step as
// navigable, just like a freshly-completed run would.
store.goToStep(5)
} catch (e) {
error.value =
e instanceof Error ? e.message : "Failed to reopen image"
} finally {
store.isProcessing = false
store.processingStatus = ""
}
}
async function handleFile(file: File) { async function handleFile(file: File) {
error.value = "" error.value = ""
store.isProcessing = true store.isProcessing = true
@ -88,6 +241,27 @@ async function handleFile(file: File) {
}, 4000) }, 4000)
} }
// Persist the upload so it shows up in the recent gallery and
// can be reopened later. We save the post-conversion JPEG (not
// the original HEIC) since that's what the rest of the app
// expects to load. Existing entries are merged so we keep any
// prior deskew artefacts attached to the same hash.
const existing = await loadUpload(hash).catch(() => null)
await saveUpload({
hash,
filename: convertedFile.name,
mimeType: convertedFile.type,
uploadedAt: existing?.uploadedAt ?? Date.now(),
originalBlob: convertedFile,
exif,
correctedBlob: existing?.correctedBlob,
diagnostics: existing?.diagnostics,
scalePxPerMm: existing?.scalePxPerMm,
}).catch(() => {
// IndexedDB unavailable or quota exceeded non-fatal,
// gallery just won't include this upload.
})
store.setImage(convertedFile, image) store.setImage(convertedFile, image)
store.setExif(exif) store.setExif(exif)
store.goToStep(2) store.goToStep(2)
@ -235,13 +409,75 @@ function onFileSelect(e: Event) {
</CardContent> </CardContent>
</Card> </Card>
<!-- Recent uploads only rendered when there's something to
show. Each tile reopens the image directly into Measure
with its datums, measurements, scale and last canvas
zoom restored. -->
<section v-if="completedUploads.length > 0" class="space-y-3">
<p
class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70"
>
Recent uploads
</p>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<button
v-for="upload in completedUploads"
:key="upload.hash"
type="button"
class="group relative overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary/60 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
@click="restoreUpload(upload.hash)"
>
<div
class="aspect-video w-full overflow-hidden bg-muted"
>
<img
:src="previewUrlFor(upload.hash)"
:alt="upload.filename"
class="h-full w-full object-cover transition-transform group-hover:scale-[1.02]"
loading="lazy"
/>
</div>
<div class="space-y-0.5 p-2">
<p class="truncate text-xs font-medium text-foreground">
{{ upload.filename }}
</p>
<p class="truncate font-mono text-[10px] text-muted-foreground">
{{ formatDate(upload.uploadedAt) }}
</p>
</div>
<button
type="button"
class="absolute right-1 top-1 rounded-md bg-background/80 p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100 focus:opacity-100 focus:outline-none"
title="Remove from recent uploads"
aria-label="Remove from recent uploads"
@click="(e) => handleDeleteUpload(upload.hash, e)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</button>
</div>
</section>
<div class="space-y-2 text-left"> <div class="space-y-2 text-left">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">Example</p> <p class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">Example</p>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5"> <div class="space-y-1.5">
<img <img
src="/example-before.jpg" src="/example-before.jpg"
alt="Before: angled photograph of a Pioneer CDJ-1000MK3 top case" alt="Before: angled photograph with reference paper laid flat"
class="w-full rounded-md border border-border object-cover" class="w-full rounded-md border border-border object-cover"
/> />
<p class="text-xs text-muted-foreground">Before &mdash; angled shot</p> <p class="text-xs text-muted-foreground">Before &mdash; angled shot</p>
@ -249,12 +485,20 @@ function onFileSelect(e: Event) {
<div class="space-y-1.5"> <div class="space-y-1.5">
<img <img
src="/example-after.jpg" src="/example-after.jpg"
alt="After: perspective-corrected front-facing view" alt="After: perspective-corrected top-down view"
class="w-full rounded-md border border-border object-cover" class="w-full rounded-md border border-border object-cover"
/> />
<p class="text-xs text-muted-foreground">After &mdash; corrected perspective</p> <p class="text-xs text-muted-foreground">After &mdash; corrected perspective</p>
</div> </div>
</div> </div>
<div class="space-y-1.5">
<img
src="/example-measured.jpg"
alt="Measure: the corrected image with measurement annotations baked in"
class="w-full rounded-md border border-border object-cover"
/>
<p class="text-xs text-muted-foreground">Measured &mdash; annotations baked in</p>
</div>
</div> </div>
<div class="space-y-2 text-left"> <div class="space-y-2 text-left">

View File

@ -211,7 +211,9 @@ async function downloadMeasured(scope: "full" | "view") {
<!-- Break out of <main>'s max-w-7xl so the Measure step spans the full <!-- Break out of <main>'s max-w-7xl so the Measure step spans the full
viewport width annotation work benefits from the extra room. --> viewport width annotation work benefits from the extra room. -->
<div class="relative left-1/2 w-screen -translate-x-1/2 space-y-4 px-4"> <div class="relative left-1/2 w-screen -translate-x-1/2 space-y-4 px-4">
<div> <!-- Padded right of the fork-me ribbon on desktop so the title
text isn't clipped underneath it. -->
<div class="md:pl-5">
<h2 class="text-xl font-semibold">Measure</h2> <h2 class="text-xl font-semibold">Measure</h2>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">
Annotate the corrected image and download. Annotate the corrected image and download.
@ -368,23 +370,15 @@ async function downloadMeasured(scope: "full" | "view") {
</TooltipProvider> </TooltipProvider>
</div> </div>
<!-- "Start over" zone: separator + label make it visually <!-- Dashed transparent styling marks this as a deliberate,
distinct from the action buttons; dashed/transparent destructive-of-state action so it isn't mis-clicked
styling on the button itself adds friction so it isn't while reaching for a download. -->
mis-clicked while reaching for a download. --> <Button
<div class="flex items-center gap-3"> variant="outline"
<div class="h-6 w-px bg-border" /> class="border-dashed bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground"
<span @click="store.reset()"
class="text-xs font-medium uppercase tracking-wider text-muted-foreground/70" >Start Over</Button
>Start over</span >
>
<Button
variant="outline"
class="border-dashed bg-transparent text-muted-foreground hover:bg-muted/40 hover:text-foreground"
@click="store.reset()"
>New Image</Button
>
</div>
</div> </div>
<p v-if="error" class="text-sm text-destructive"> <p v-if="error" class="text-sm text-destructive">

158
src/lib/upload-cache.ts Normal file
View File

@ -0,0 +1,158 @@
import type { DeskewDiagnostics, ExifData } from "@/types"
// Past uploads + their deskew artefacts live in IndexedDB rather than
// localStorage because converted JPEGs routinely exceed localStorage's
// per-origin quota (510MB) within a few photos. IndexedDB stores Blobs
// natively (no base64 inflation) and tolerates GBs, which makes a
// "recent uploads" gallery practical.
const DB_NAME = "skwik-uploads"
const DB_VERSION = 1
const STORE_NAME = "uploads"
/** All persisted state for a single past upload. Filled in two phases:
* upload time captures `originalBlob` + `exif` + identity; deskew
* completion adds `correctedBlob` + `diagnostics` + `scalePxPerMm` so
* the upload can be re-opened straight into the Measure step. */
export interface UploadRecord {
hash: string
filename: string
mimeType: string
uploadedAt: number
originalBlob: Blob
exif: ExifData
correctedBlob?: Blob
diagnostics?: DeskewDiagnostics
scalePxPerMm?: number
}
let dbPromise: Promise<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: "hash",
})
store.createIndex("uploadedAt", "uploadedAt")
}
}
req.onsuccess = () => {
resolve(req.result)
}
req.onerror = () => {
dbPromise = null
reject(req.error ?? new Error("Failed to open uploads DB"))
}
})
return dbPromise
}
/** Insert or replace the record for `hash`. Re-saving with new fields
* (e.g. corrected blob) overwrites the previous entry. */
export async function saveUpload(record: UploadRecord): Promise<void> {
const db = await openDB()
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite")
const req = tx.objectStore(STORE_NAME).put(record)
req.onsuccess = () => {
resolve()
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to save upload"))
}
})
}
/** Read the existing record (if any), apply `patch`, and persist. Used by
* the Deskew step to attach deskew artefacts without losing the upload
* metadata captured earlier. Returns the merged record, or null if no
* record exists for `hash`. */
export async function patchUpload(
hash: string,
patch: Partial<UploadRecord>,
): Promise<UploadRecord | null> {
const existing = await loadUpload(hash)
if (!existing) return null
const merged: UploadRecord = { ...existing, ...patch, hash }
await saveUpload(merged)
return merged
}
export async function loadUpload(hash: string): Promise<UploadRecord | null> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly")
const req = tx.objectStore(STORE_NAME).get(hash)
req.onsuccess = () => {
resolve((req.result as UploadRecord | undefined) ?? null)
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to load upload"))
}
})
}
/** All records, newest-first. Callers filter to entries with the fields
* they care about (e.g. require `correctedBlob` for the gallery). */
export async function listUploads(): Promise<UploadRecord[]> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly")
const req = tx.objectStore(STORE_NAME).getAll()
req.onsuccess = () => {
const all = (req.result as UploadRecord[] | undefined) ?? []
all.sort((a, b) => b.uploadedAt - a.uploadedAt)
resolve(all)
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to list uploads"))
}
})
}
export async function deleteUpload(hash: string): Promise<void> {
const db = await openDB()
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite")
const req = tx.objectStore(STORE_NAME).delete(hash)
req.onsuccess = () => {
resolve()
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to delete upload"))
}
})
}
export async function clearUploads(): Promise<void> {
const db = await openDB()
return new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite")
const req = tx.objectStore(STORE_NAME).clear()
req.onsuccess = () => {
resolve()
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to clear uploads"))
}
})
}
export async function countUploads(): Promise<number> {
const db = await openDB()
return new Promise<number>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly")
const req = tx.objectStore(STORE_NAME).count()
req.onsuccess = () => {
resolve(req.result)
}
req.onerror = () => {
reject(req.error ?? new Error("Failed to count uploads"))
}
})
}

53
src/lib/zoom-cache.ts Normal file
View File

@ -0,0 +1,53 @@
// Per-image zoom + pan state for the corrected-image canvas. Persisted
// so that revisiting the same upload restores the exact view the user
// last left it in. Keyed by file hash, same convention as datums and
// measurements.
const KEY_PREFIX = "skwik-zoom-"
export interface ZoomState {
/** Canvas-px / image-px scale. */
viewScale: number
/** Image origin in canvas px (top-left of the image in canvas space). */
viewOffsetX: number
viewOffsetY: number
}
export function saveZoom(hash: string, zoom: ZoomState): void {
try {
localStorage.setItem(KEY_PREFIX + hash, JSON.stringify(zoom))
} catch {
// localStorage full or unavailable — silently ignore
}
}
export function loadZoom(hash: string): ZoomState | null {
try {
const raw = localStorage.getItem(KEY_PREFIX + hash)
if (!raw) return null
return JSON.parse(raw) as ZoomState
} catch {
return null
}
}
export function clearZoom(hash: string): void {
try {
localStorage.removeItem(KEY_PREFIX + hash)
} catch {
// ignore
}
}
export function clearCache(): void {
const toRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith(KEY_PREFIX)) {
toRemove.push(key)
}
}
for (const key of toRemove) {
localStorage.removeItem(key)
}
}