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:
parent
0cb9009eaa
commit
11e8013b6a
@ -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 — 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 — 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× or more) to narrow the field of view — 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
48
src/lib/datum-cache.ts
Normal 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
6
src/lib/file-hash.ts
Normal 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
24
src/lib/settings-cache.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user