feat(cache): persist datums per file hash and user settings

- Add file-hash.ts: SHA-256 hash of uploaded files via Web Crypto API
- Add datum-cache.ts: localStorage save/load/clear for datums by hash
- Add settings-cache.ts: persist scalePxPerMm and includeScaleBar
- Restore datums from cache on re-upload of same file
- Discreet "Clear cache" button on upload page
- Store fileHash and cacheRestoreMessage in Pinia store
- Auto-save datums on every change via deep watcher
- Track maxStepReached for clickable step navigation

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Samuel Prevost 2026-04-14 23:19:34 +02:00
parent 0cb9009eaa
commit 11e8013b6a
5 changed files with 340 additions and 20 deletions

View File

@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref } from "vue"
import { ref, onMounted } from "vue"
import { useAppStore } from "@/stores/app"
import { loadImage } from "@/lib/image-loader"
import { extractExif } from "@/lib/exif"
import { hashFile } from "@/lib/file-hash"
import { loadDatums, clearCache, getCacheSize } from "@/lib/datum-cache"
import {
Card,
CardContent,
@ -10,33 +12,62 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
const store = useAppStore()
const isDragging = ref(false)
const error = ref("")
const fileInput = ref<HTMLInputElement | null>(null)
const cacheCount = ref(0)
const ACCEPTED = ".jpg,.jpeg,.heic,.heif"
onMounted(() => {
cacheCount.value = getCacheSize()
})
function handleClearCache() {
clearCache()
cacheCount.value = 0
}
async function handleFile(file: File) {
error.value = ""
store.isProcessing = true
store.processingStatus = "Reading file..."
try {
const { image, convertedFile } = await loadImage(file, (status) => {
store.processingStatus = status
})
const { image, convertedFile } = await loadImage(
file,
(status) => {
store.processingStatus = status
},
)
store.processingStatus = "Extracting EXIF data..."
const exif = await extractExif(file)
store.processingStatus = "Computing file hash..."
const hash = await hashFile(file)
store.setFileHash(hash)
const cached = loadDatums(hash)
if (cached && cached.length > 0) {
store.datums = cached
store.cacheRestoreMessage =
`Restored ${String(cached.length)} datum${cached.length === 1 ? "" : "s"} from cache`
setTimeout(() => {
store.cacheRestoreMessage = ""
}, 4000)
}
store.setImage(convertedFile, image)
store.setExif(exif)
store.goToStep(2)
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load image"
error.value =
e instanceof Error ? e.message : "Failed to load image"
} finally {
store.isProcessing = false
store.processingStatus = ""
@ -57,16 +88,17 @@ function onFileSelect(e: Event) {
</script>
<template>
<div class="flex min-h-[60vh] items-center justify-center">
<Card class="w-full max-w-lg">
<CardHeader class="text-center">
<CardTitle class="text-2xl">Upload an Image</CardTitle>
<CardDescription>
Drop a JPG or HEIC image, or click to browse. HEIC files
will be converted automatically.
</CardDescription>
</CardHeader>
<CardContent>
<div class="flex min-h-[60vh] items-start justify-center pt-12">
<div class="w-full max-w-2xl space-y-6">
<Card>
<CardHeader class="text-center">
<CardTitle class="text-lg">Load Source Image</CardTitle>
<CardDescription>
Drop a JPG or HEIC file, or click to browse. HEIC
is converted automatically.
</CardDescription>
</CardHeader>
<CardContent>
<div
class="relative flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors"
:class="
@ -112,8 +144,10 @@ function onFileSelect(e: Event) {
>browse</span
>
</p>
<p class="mt-1 text-xs text-muted-foreground/70">
JPG, JPEG, HEIC, HEIF
<p
class="mt-1 font-mono text-xs text-muted-foreground/60"
>
.jpg .jpeg .heic .heif
</p>
</template>
@ -132,7 +166,193 @@ function onFileSelect(e: Event) {
>
{{ error }}
</p>
</CardContent>
</Card>
</CardContent>
</Card>
<div
v-if="cacheCount > 0"
class="flex justify-end"
>
<Button
variant="ghost"
size="sm"
class="h-7 gap-1.5 text-xs text-muted-foreground/60 hover:text-destructive"
@click="handleClearCache"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 6h18" />
<path
d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"
/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
Clear cache ({{ cacheCount }})
</Button>
</div>
<div class="space-y-2 text-left">
<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="space-y-1.5">
<img
src="/example-before.jpg"
alt="Before: angled photograph of a Pioneer CDJ-1000MK3 top case"
class="w-full rounded-md border border-border object-cover"
/>
<p class="text-xs text-muted-foreground">Before &mdash; angled shot</p>
</div>
<div class="space-y-1.5">
<img
src="/example-after.jpg"
alt="After: perspective-corrected front-facing view"
class="w-full rounded-md border border-border object-cover"
/>
<p class="text-xs text-muted-foreground">After &mdash; corrected perspective</p>
</div>
</div>
</div>
<div class="space-y-2 text-left">
<p class="text-sm leading-relaxed text-muted-foreground">
Correct perspective distortion in photographs using
known reference dimensions. Useful when you need to
use a photo as a scale reference for design work,
measure objects from photographs, or recover accurate
geometry from angled shots.
</p>
</div>
<div class="space-y-4 text-left">
<h3 class="text-sm font-medium text-foreground">How it works</h3>
<p class="text-sm leading-relaxed text-muted-foreground">
Place an object with known dimensions (a ruler, credit card, or A4 sheet) next to the subject
you want to photograph. Take the picture from any angle. Skwik uses the reference object to
compute a perspective transform and produce a corrected, front-facing image with accurate
proportions.
</p>
<p class="text-sm leading-relaxed text-muted-foreground">
This is especially handy for reverse-engineering enclosure cutouts, measuring parts you
can't easily reach, or getting a dimensionally accurate top-down view without a tripod.
</p>
<h3 class="mt-8 text-sm font-medium text-foreground">Tips for best results</h3>
<ul class="list-disc space-y-2 pl-5 text-sm leading-relaxed text-muted-foreground">
<li>
<strong class="text-foreground/80">Use a large, rigid rectangle with precise dimensions.</strong>
An A4 magazine cover works well. Plain paper is acceptable but can bend or curl,
which degrades accuracy.
</li>
<li>
<strong class="text-foreground/80">Lay everything on a flat surface.</strong>
Both the subject and the reference object must sit on the same plane.
</li>
<li>
<strong class="text-foreground/80">Shoot from as high up as possible and zoom in.</strong>
Stand on a chair or stool and hold your phone at arm's length above the scene.
Use optical zoom (2&times; or more) to narrow the field of view &mdash; this compresses
perspective closer to an orthogonal projection and makes the correction more accurate.
</li>
</ul>
<!-- Side-view illustration: person on chair photographing downward -->
<div class="mt-4 flex justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 360 260"
class="w-full max-w-xl"
role="img"
aria-label="Side-view illustration: a person standing on a chair holds a phone high, zooming in on an object on the ground below"
>
<!-- Ground line -->
<line x1="0" y1="240" x2="360" y2="240" stroke="currentColor" stroke-width="1.5" class="text-border" />
<!-- Object on the ground directly below phone (centered at x=180) -->
<rect x="145" y="230" width="70" height="10" rx="2"
class="text-primary" fill="currentColor" opacity="0.18"
stroke="currentColor" stroke-width="1.2" />
<rect x="165" y="224" width="30" height="16" rx="1"
fill="none" stroke="currentColor" stroke-width="0.8"
stroke-dasharray="3 2" class="text-muted-foreground" />
<text x="170" y="245" font-size="7" class="text-muted-foreground" fill="currentColor">ref</text>
<!-- Chair (simple side-view) -->
<g class="text-muted-foreground" stroke="currentColor" stroke-width="1.5" fill="none">
<!-- seat -->
<line x1="150" y1="170" x2="190" y2="170" />
<!-- legs -->
<line x1="153" y1="170" x2="150" y2="240" />
<line x1="187" y1="170" x2="190" y2="240" />
<!-- back rest -->
<line x1="150" y1="170" x2="147" y2="125" />
<line x1="147" y1="125" x2="157" y2="125" />
</g>
<!-- Person (stick figure standing on the chair) -->
<g class="text-foreground" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round">
<!-- head -->
<circle cx="170" cy="102" r="9" />
<!-- body -->
<line x1="170" y1="111" x2="170" y2="150" />
<!-- legs on chair seat -->
<line x1="170" y1="150" x2="160" y2="170" />
<line x1="170" y1="150" x2="180" y2="170" />
<!-- arms reaching forward/down holding phone -->
<polyline points="170,125 183,115 180,108" />
<!-- other arm at side for balance -->
<line x1="170" y1="125" x2="152" y2="140" />
</g>
<!-- Phone in hand (held out, pointing straight down) -->
<rect x="175" y="100" width="10" height="16" rx="2"
class="text-foreground" fill="currentColor" opacity="0.85" />
<!-- small lens dot -->
<circle cx="180" cy="116" r="1.5" class="text-background" fill="currentColor" />
<!-- Camera FOV cone wide angle (faded, dashed) pointing straight down -->
<polygon points="180,116 124,238 236,238"
fill="currentColor" class="text-muted-foreground" opacity="0.06" />
<line x1="180" y1="116" x2="124" y2="238"
stroke="currentColor" stroke-width="0.8" stroke-dasharray="4 3"
class="text-muted-foreground" opacity="0.3" />
<line x1="180" y1="116" x2="236" y2="238"
stroke="currentColor" stroke-width="0.8" stroke-dasharray="4 3"
class="text-muted-foreground" opacity="0.3" />
<!-- Camera FOV cone zoomed in (narrow, stronger) pointing straight down -->
<polygon points="180,116 155,230 205,230"
fill="currentColor" class="text-primary" opacity="0.10" />
<line x1="180" y1="116" x2="155" y2="230"
stroke="currentColor" stroke-width="1.2"
class="text-primary" opacity="0.6" />
<line x1="180" y1="116" x2="205" y2="230"
stroke="currentColor" stroke-width="1.2"
class="text-primary" opacity="0.6" />
<!-- Labels -->
<text x="238" y="230" font-size="8" class="text-muted-foreground" fill="currentColor">wide angle</text>
<text x="238" y="240" font-size="7" class="text-muted-foreground" fill="currentColor">(more distortion)</text>
<text x="207" y="215" font-size="8" class="text-primary" fill="currentColor" font-weight="600">zoomed in</text>
<!-- Small arrow showing "higher = better" -->
<g class="text-muted-foreground" stroke="currentColor" stroke-width="1" opacity="0.5">
<line x1="50" y1="230" x2="50" y2="105" />
<polyline points="45,112 50,105 55,112" fill="none" />
</g>
<text x="28" y="168" font-size="7" class="text-muted-foreground" fill="currentColor"
transform="rotate(-90 40 168)">higher is better</text>
</svg>
</div>
</div>
</div>
</div>
</template>

48
src/lib/datum-cache.ts Normal file
View File

@ -0,0 +1,48 @@
import type { Datum } from "@/types"
const KEY_PREFIX = "skwik-datums-"
export function saveDatums(hash: string, datums: Datum[]): void {
try {
localStorage.setItem(
KEY_PREFIX + hash,
JSON.stringify(datums),
)
} catch {
// localStorage full or unavailable — silently ignore
}
}
export function loadDatums(hash: string): Datum[] | null {
try {
const raw = localStorage.getItem(KEY_PREFIX + hash)
if (!raw) return null
return JSON.parse(raw) as Datum[]
} catch {
return null
}
}
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)
}
}
export function getCacheSize(): number {
let count = 0
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key?.startsWith(KEY_PREFIX)) {
count++
}
}
return count
}

6
src/lib/file-hash.ts Normal file
View File

@ -0,0 +1,6 @@
export async function hashFile(file: File): Promise<string> {
const buffer = await file.arrayBuffer()
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
}

24
src/lib/settings-cache.ts Normal file
View File

@ -0,0 +1,24 @@
export interface SkwikSettings {
scalePxPerMm: number
includeScaleBar: boolean
}
const KEY = "skwik-settings"
export function saveSettings(settings: SkwikSettings): void {
try {
localStorage.setItem(KEY, JSON.stringify(settings))
} catch {
// localStorage full or unavailable — silently ignore
}
}
export function loadSettings(): SkwikSettings | null {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return null
return JSON.parse(raw) as SkwikSettings
} catch {
return null
}
}

View File

@ -2,9 +2,13 @@ import { defineStore } from "pinia"
import { ref, computed } from "vue"
import type { AppStep, Datum, DeskewResult, ExifData } from "@/types"
import { DEFAULT_SCALE_PX_PER_MM } from "@/types"
import { loadSettings } from "@/lib/settings-cache"
export const useAppStore = defineStore("app", () => {
const cached = loadSettings()
const currentStep = ref<AppStep>(1)
const maxStepReached = ref<AppStep>(1)
const originalFile = ref<File | null>(null)
const loadedImage = ref<HTMLImageElement | null>(null)
const exifData = ref<ExifData>({})
@ -13,7 +17,11 @@ export const useAppStore = defineStore("app", () => {
const isProcessing = ref(false)
const processingStatus = ref("")
const selectedDatumId = ref<string | null>(null)
const scalePxPerMm = ref(DEFAULT_SCALE_PX_PER_MM)
const scalePxPerMm = ref(
cached?.scalePxPerMm ?? DEFAULT_SCALE_PX_PER_MM,
)
const fileHash = ref<string | null>(null)
const cacheRestoreMessage = ref("")
const canProceedToStep2 = computed(() => loadedImage.value !== null)
const canProceedToStep3 = computed(() => canProceedToStep2.value)
@ -36,6 +44,9 @@ export const useAppStore = defineStore("app", () => {
function goToStep(step: AppStep) {
currentStep.value = step
if (step > maxStepReached.value) {
maxStepReached.value = step
}
}
function addDatum(datum: Datum) {
@ -65,8 +76,13 @@ export const useAppStore = defineStore("app", () => {
deskewResult.value = result
}
function setFileHash(hash: string) {
fileHash.value = hash
}
function reset() {
currentStep.value = 1
maxStepReached.value = 1
originalFile.value = null
loadedImage.value = null
exifData.value = {}
@ -76,10 +92,13 @@ export const useAppStore = defineStore("app", () => {
processingStatus.value = ""
selectedDatumId.value = null
scalePxPerMm.value = DEFAULT_SCALE_PX_PER_MM
fileHash.value = null
cacheRestoreMessage.value = ""
}
return {
currentStep,
maxStepReached,
originalFile,
loadedImage,
exifData,
@ -89,6 +108,8 @@ export const useAppStore = defineStore("app", () => {
processingStatus,
selectedDatumId,
scalePxPerMm,
fileHash,
cacheRestoreMessage,
canProceedToStep2,
canProceedToStep3,
canProceedToStep4,
@ -99,6 +120,7 @@ export const useAppStore = defineStore("app", () => {
updateDatum,
removeDatum,
setResult,
setFileHash,
reset,
}
})