Add multi-cart UI, image caching, copy/paste, and mobile layout fixes
- Multi-cart system with create, rename, delete, import/export - Sections with copy/paste items via clipboard JSON - IndexedDB image cache with deduplication and unused-image pruning - Migrate existing localStorage base64 images to IndexedDB on load - Mobile-responsive header and cart bar - Template row with pill buttons replacing dropdown select - Cart rename via double-click inline edit - Storage quota error handling with user-facing banner - Updated README and page title/description
This commit is contained in:
+14
-10
@@ -1,13 +1,17 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>fpvshop</title>
|
<title>FPV Shop List</title>
|
||||||
</head>
|
<meta
|
||||||
<body>
|
name="description"
|
||||||
<div id="root"></div>
|
content="Personal shopping list app for FPV drone gear. Organize parts into carts and sections, track prices, and share builds."
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
/>
|
||||||
</body>
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+13
-10
File diff suppressed because one or more lines are too long
+3
-2
@@ -52,7 +52,7 @@ export default function App() {
|
|||||||
const importRef = useRef<HTMLInputElement>(null)
|
const importRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const { catalog, loading: catalogLoading, error: catalogError } = useCatalog()
|
const { catalog, loading: catalogLoading, error: catalogError } = useCatalog()
|
||||||
const { carts, activeCart, activeId, setActiveCart, createCart, deleteCart, renameCart, importCart, exportCart, addSection, removeSection, renameSection, addItem, updateItem, removeItem } = useCarts()
|
const { carts, activeCart, activeId, storageError, setActiveCart, createCart, deleteCart, renameCart, importCart, exportCart, addSection, removeSection, renameSection, addItem, updateItem, removeItem } = useCarts()
|
||||||
|
|
||||||
function commitCartRename() {
|
function commitCartRename() {
|
||||||
if (renamingCartId && renameCartValue.trim()) renameCart(renamingCartId, renameCartValue.trim())
|
if (renamingCartId && renameCartValue.trim()) renameCart(renamingCartId, renameCartValue.trim())
|
||||||
@@ -169,7 +169,7 @@ export default function App() {
|
|||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
file.text().then(json => {
|
file.text().then(json => {
|
||||||
if (!importCart(json)) alert('Invalid cart file.')
|
importCart(json).then(ok => { if (!ok) alert('Invalid cart file.') })
|
||||||
})
|
})
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}}
|
}}
|
||||||
@@ -201,6 +201,7 @@ export default function App() {
|
|||||||
<main className="mx-auto flex max-w-2xl flex-col gap-4 px-4 py-6">
|
<main className="mx-auto flex max-w-2xl flex-col gap-4 px-4 py-6">
|
||||||
{catalogLoading && <p className="py-12 text-center text-sm" style={{ color: 'var(--color-grey)' }}>Loading…</p>}
|
{catalogLoading && <p className="py-12 text-center text-sm" style={{ color: 'var(--color-grey)' }}>Loading…</p>}
|
||||||
{catalogError && <p className="py-12 text-center text-sm" style={{ color: 'var(--color-red)' }}>{catalogError}</p>}
|
{catalogError && <p className="py-12 text-center text-sm" style={{ color: 'var(--color-red)' }}>{catalogError}</p>}
|
||||||
|
{storageError && <p className="rounded-lg border px-4 py-3 text-sm" style={{ color: 'var(--color-red)', borderColor: 'var(--color-red)', background: 'var(--color-bg1)' }}>{storageError}</p>}
|
||||||
|
|
||||||
{!activeCart && !catalogLoading && (
|
{!activeCart && !catalogLoading && (
|
||||||
<p className="py-12 text-center text-sm" style={{ color: 'var(--color-grey)' }}>
|
<p className="py-12 text-center text-sm" style={{ color: 'var(--color-grey)' }}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Product, CompleteDrone, KitBuild, Drone, Category, Currency } from '../types'
|
import type { Product, CompleteDrone, KitBuild, Drone, Category, Currency } from '../types'
|
||||||
|
import { storeImage, resolveImage } from '../utils/imageCache'
|
||||||
|
|
||||||
const CATEGORIES: Category[] = [
|
const CATEGORIES: Category[] = [
|
||||||
'frame', 'flight-controller', 'esc', 'motor', 'camera',
|
'frame', 'flight-controller', 'esc', 'motor', 'camera',
|
||||||
@@ -110,7 +111,7 @@ export function AddItemDialog({ isDroneSection, initialItem, onSubmit, onClose }
|
|||||||
return today()
|
return today()
|
||||||
})
|
})
|
||||||
const [url, setUrl] = useState(() => (initialItem as Product | CompleteDrone | KitBuild)?.url ?? '')
|
const [url, setUrl] = useState(() => (initialItem as Product | CompleteDrone | KitBuild)?.url ?? '')
|
||||||
const [image, setImage] = useState(() => initialItem?.image ?? '')
|
const [image, setImage] = useState('')
|
||||||
const [note, setNote] = useState(() => initialItem?.note ?? '')
|
const [note, setNote] = useState(() => initialItem?.note ?? '')
|
||||||
const [parts, setParts] = useState<PartRow[]>(() => {
|
const [parts, setParts] = useState<PartRow[]>(() => {
|
||||||
if (initialItem && 'buildType' in initialItem && initialItem.buildType === 'kit') {
|
if (initialItem && 'buildType' in initialItem && initialItem.buildType === 'kit') {
|
||||||
@@ -119,6 +120,28 @@ export function AddItemDialog({ isDroneSection, initialItem, onSubmit, onClose }
|
|||||||
return [emptyPart()]
|
return [emptyPart()]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Resolve img: keys from cache when opening in edit mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialItem) return
|
||||||
|
resolveImage(initialItem.image).then(v => setImage(v ?? ''))
|
||||||
|
if ('buildType' in initialItem && initialItem.buildType === 'kit') {
|
||||||
|
Promise.all(initialItem.parts.map(async p => ({
|
||||||
|
...p,
|
||||||
|
image: (p.image ? await resolveImage(p.image) ?? p.image : ''),
|
||||||
|
}))).then(resolved => setParts(resolved.map(p => ({
|
||||||
|
tempId: crypto.randomUUID(),
|
||||||
|
name: p.name,
|
||||||
|
brand: p.brand ?? '',
|
||||||
|
category: p.category,
|
||||||
|
amount: String(p.price.amount),
|
||||||
|
currency: p.price.currency,
|
||||||
|
asOf: p.price.asOf,
|
||||||
|
url: p.url ?? '',
|
||||||
|
image: p.image ?? '',
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Global paste → sets main item image (Ctrl+V anywhere in the dialog)
|
// Global paste → sets main item image (Ctrl+V anywhere in the dialog)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function onPaste(e: ClipboardEvent) {
|
async function onPaste(e: ClipboardEvent) {
|
||||||
@@ -153,24 +176,23 @@ export function AddItemDialog({ isDroneSection, initialItem, onSubmit, onClose }
|
|||||||
updatePart(tempId, { image: await fileToBase64(file) })
|
updatePart(tempId, { image: await fileToBase64(file) })
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const id = initialItem?.id ?? crypto.randomUUID()
|
const id = initialItem?.id ?? crypto.randomUUID()
|
||||||
const common = { image: image || undefined, note: note || undefined, url: url || undefined }
|
const storedImage = image ? await storeImage(image) : undefined
|
||||||
|
const common = { image: storedImage, note: note || undefined, url: url || undefined }
|
||||||
|
|
||||||
if (type === 'kit') {
|
if (type === 'kit') {
|
||||||
onSubmit({
|
const builtParts = await Promise.all(parts.filter(p => p.name).map(async p => ({
|
||||||
id, buildType: 'kit', name, ...common,
|
id: crypto.randomUUID(),
|
||||||
parts: parts.filter(p => p.name).map(p => ({
|
name: p.name,
|
||||||
id: crypto.randomUUID(),
|
brand: p.brand || undefined,
|
||||||
name: p.name,
|
category: p.category,
|
||||||
brand: p.brand || undefined,
|
price: { amount: parseFloat(p.amount) || 0, currency: p.currency, asOf: p.asOf },
|
||||||
category: p.category,
|
url: p.url || undefined,
|
||||||
price: { amount: parseFloat(p.amount) || 0, currency: p.currency, asOf: p.asOf },
|
image: p.image ? await storeImage(p.image) : undefined,
|
||||||
url: p.url || undefined,
|
})))
|
||||||
image: p.image || undefined,
|
onSubmit({ id, buildType: 'kit', name, ...common, parts: builtParts })
|
||||||
})),
|
|
||||||
})
|
|
||||||
} else if (type === 'complete-drone') {
|
} else if (type === 'complete-drone') {
|
||||||
onSubmit({ id, buildType: 'complete', name, brand: brand || undefined, price: { amount: parseFloat(amount) || 0, currency, asOf }, ...common })
|
onSubmit({ id, buildType: 'complete', name, brand: brand || undefined, price: { amount: parseFloat(amount) || 0, currency, asOf }, ...common })
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { Product, Drone, CompleteDrone, KitBuild } from '../types'
|
import type { Product, Drone, CompleteDrone, KitBuild } from '../types'
|
||||||
|
import { useResolvedImage, resolveImageSync } from '../utils/imageCache'
|
||||||
|
|
||||||
function fmt(amount: number, currency: string) {
|
function fmt(amount: number, currency: string) {
|
||||||
return new Intl.NumberFormat('en', { style: 'currency', currency }).format(amount)
|
return new Intl.NumberFormat('en', { style: 'currency', currency }).format(amount)
|
||||||
@@ -31,9 +32,10 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
|
|||||||
? fmt((item as CompleteDrone).price.amount, (item as CompleteDrone).price.currency)
|
? fmt((item as CompleteDrone).price.amount, (item as CompleteDrone).price.currency)
|
||||||
: fmt((item as Product).price.amount, (item as Product).price.currency)
|
: fmt((item as Product).price.amount, (item as Product).price.currency)
|
||||||
|
|
||||||
const brand = (item as CompleteDrone | Product).brand ?? null
|
const brand = (item as CompleteDrone | Product).brand ?? null
|
||||||
const category = (item as Product).category ?? null
|
const category = (item as Product).category ?? null
|
||||||
const url = (item as Product | CompleteDrone | KitBuild).url ?? null
|
const url = (item as Product | CompleteDrone | KitBuild).url ?? null
|
||||||
|
const thumbnail = useResolvedImage(item.image)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -41,8 +43,8 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
|
|||||||
style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)' }}
|
style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)' }}
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
{item.image ? (
|
{thumbnail ? (
|
||||||
<img src={item.image} alt={item.name} className="h-16 w-16 shrink-0 rounded object-cover" />
|
<img src={thumbnail} alt={item.name} className="h-16 w-16 shrink-0 rounded object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded text-2xl"
|
className="flex h-16 w-16 shrink-0 items-center justify-center rounded text-2xl"
|
||||||
@@ -101,7 +103,7 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
|
|||||||
key={p.id}
|
key={p.id}
|
||||||
className="flex items-center justify-between gap-2 text-xs"
|
className="flex items-center justify-between gap-2 text-xs"
|
||||||
style={{ color: 'var(--color-grey)', cursor: p.image ? 'default' : undefined }}
|
style={{ color: 'var(--color-grey)', cursor: p.image ? 'default' : undefined }}
|
||||||
onMouseEnter={p.image ? e => { setHoverImg(p.image!); setMousePos({ x: e.clientX, y: e.clientY }) } : undefined}
|
onMouseEnter={p.image ? e => { setHoverImg(resolveImageSync(p.image) ?? null); setMousePos({ x: e.clientX, y: e.clientY }) } : undefined}
|
||||||
onMouseMove={p.image ? e => setMousePos({ x: e.clientX, y: e.clientY }) : undefined}
|
onMouseMove={p.image ? e => setMousePos({ x: e.clientX, y: e.clientY }) : undefined}
|
||||||
onMouseLeave={p.image ? () => setHoverImg(null) : undefined}
|
onMouseLeave={p.image ? () => setHoverImg(null) : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import type { Section, Product, Drone } from '../types'
|
import type { Section, Product, Drone } from '../types'
|
||||||
import { ItemCard } from './ItemCard'
|
import { ItemCard } from './ItemCard'
|
||||||
import { AddItemDialog } from './AddItemDialog'
|
import { AddItemDialog } from './AddItemDialog'
|
||||||
|
import { cacheItemImages } from '../utils/imageCache'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
section: Section
|
section: Section
|
||||||
@@ -17,6 +18,7 @@ export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onR
|
|||||||
const [editingItem, setEditingItem] = useState<Product | Drone | null>(null)
|
const [editingItem, setEditingItem] = useState<Product | Drone | null>(null)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [labelInput, setLabelInput] = useState(section.label)
|
const [labelInput, setLabelInput] = useState(section.label)
|
||||||
|
const [pasteError, setPasteError] = useState<string | null>(null)
|
||||||
|
|
||||||
function submitRename(e: React.FormEvent) {
|
function submitRename(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -24,6 +26,28 @@ export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onR
|
|||||||
setEditing(false)
|
setEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copySection() {
|
||||||
|
const json = JSON.stringify(section.items, null, 2)
|
||||||
|
navigator.clipboard.writeText(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pasteItems() {
|
||||||
|
setPasteError(null)
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText()
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
const items: (Product | Drone)[] = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.name) continue
|
||||||
|
const cached = await cacheItemImages({ ...structuredClone(item), id: crypto.randomUUID() })
|
||||||
|
onAddItem(cached)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setPasteError('Clipboard does not contain valid item JSON.')
|
||||||
|
setTimeout(() => setPasteError(null), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 rounded-lg border p-4" style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)' }}>
|
<div className="flex flex-col gap-3 rounded-lg border p-4" style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -57,6 +81,22 @@ export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onR
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={copySection}
|
||||||
|
className="rounded px-2 py-1 text-xs"
|
||||||
|
style={{ background: 'var(--color-bg2)', color: 'var(--color-grey)' }}
|
||||||
|
title="Copy section items to clipboard"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={pasteItems}
|
||||||
|
className="rounded px-2 py-1 text-xs"
|
||||||
|
style={{ background: 'var(--color-bg2)', color: 'var(--color-grey)' }}
|
||||||
|
title="Paste items from clipboard"
|
||||||
|
>
|
||||||
|
Paste
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdd(true)}
|
onClick={() => setShowAdd(true)}
|
||||||
className="rounded px-2 py-1 text-xs"
|
className="rounded px-2 py-1 text-xs"
|
||||||
@@ -75,6 +115,10 @@ export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onR
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{pasteError && (
|
||||||
|
<p className="text-xs" style={{ color: 'var(--color-red)' }}>{pasteError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Items */}
|
{/* Items */}
|
||||||
{section.items.length > 0 ? (
|
{section.items.length > 0 ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
+39
-8
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Cart, CartTemplate, Section, Product, Drone } from '../types'
|
import type { Cart, CartTemplate, Section, Product, Drone } from '../types'
|
||||||
|
import { cacheImagesInCart, resolveImagesInCart, pruneImageCache } from '../utils/imageCache'
|
||||||
|
|
||||||
const CARTS_KEY = 'carts'
|
const CARTS_KEY = 'carts'
|
||||||
const ACTIVE_KEY = 'active-cart-id'
|
const ACTIVE_KEY = 'active-cart-id'
|
||||||
@@ -18,8 +19,16 @@ function loadActiveId(carts: Cart[]): string | null {
|
|||||||
return carts[0]?.id ?? null
|
return carts[0]?.id ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveCarts(carts: Cart[]) {
|
function saveCarts(carts: Cart[]): string | null {
|
||||||
localStorage.setItem(CARTS_KEY, JSON.stringify(carts))
|
try {
|
||||||
|
localStorage.setItem(CARTS_KEY, JSON.stringify(carts))
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
|
||||||
|
return 'Storage quota exceeded — images may be too large. Try removing unused images.'
|
||||||
|
}
|
||||||
|
return 'Failed to save carts.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCarts() {
|
export function useCarts() {
|
||||||
@@ -29,12 +38,32 @@ export function useCarts() {
|
|||||||
return loadActiveId(c)
|
return loadActiveId(c)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Migrate any inline base64 images to IndexedDB on first load
|
||||||
|
useEffect(() => {
|
||||||
|
const loaded = loadCarts()
|
||||||
|
const needsMigration = loaded.some(cart =>
|
||||||
|
cart.sections.some(s => s.items.some(item =>
|
||||||
|
(item.image && !item.image.startsWith('img:')) ||
|
||||||
|
('parts' in item && item.parts.some(p => p.image && !p.image.startsWith('img:')))
|
||||||
|
))
|
||||||
|
)
|
||||||
|
if (!needsMigration) return
|
||||||
|
Promise.all(loaded.map(cacheImagesInCart)).then(migrated => {
|
||||||
|
pruneImageCache(migrated)
|
||||||
|
saveCarts(migrated)
|
||||||
|
setCarts(migrated)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
const [storageError, setStorageError] = useState<string | null>(null)
|
||||||
|
|
||||||
const activeCart = carts.find(c => c.id === activeId) ?? null
|
const activeCart = carts.find(c => c.id === activeId) ?? null
|
||||||
|
|
||||||
function mutateCarts(fn: (carts: Cart[]) => Cart[]) {
|
function mutateCarts(fn: (carts: Cart[]) => Cart[]) {
|
||||||
setCarts(prev => {
|
setCarts(prev => {
|
||||||
const next = fn(prev)
|
const next = fn(prev)
|
||||||
saveCarts(next)
|
pruneImageCache(next) // fire-and-forget async
|
||||||
|
const err = saveCarts(next)
|
||||||
|
setStorageError(err)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -81,11 +110,11 @@ export function useCarts() {
|
|||||||
localStorage.setItem(ACTIVE_KEY, id)
|
localStorage.setItem(ACTIVE_KEY, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function importCart(json: string): boolean {
|
async function importCart(json: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const cart = JSON.parse(json) as Cart
|
const cart = JSON.parse(json) as Cart
|
||||||
if (!cart.id || !cart.name || !Array.isArray(cart.sections)) return false
|
if (!cart.id || !cart.name || !Array.isArray(cart.sections)) return false
|
||||||
const imported = { ...cart, id: crypto.randomUUID() }
|
const imported = await cacheImagesInCart({ ...cart, id: crypto.randomUUID() })
|
||||||
mutateCarts(prev => [...prev, imported])
|
mutateCarts(prev => [...prev, imported])
|
||||||
setActiveCart(imported.id)
|
setActiveCart(imported.id)
|
||||||
return true
|
return true
|
||||||
@@ -94,9 +123,10 @@ export function useCarts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCart() {
|
async function exportCart() {
|
||||||
if (!activeCart) return
|
if (!activeCart) return
|
||||||
const blob = new Blob([JSON.stringify(activeCart, null, 2)], { type: 'application/json' })
|
const resolved = await resolveImagesInCart(activeCart)
|
||||||
|
const blob = new Blob([JSON.stringify(resolved, null, 2)], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
@@ -180,6 +210,7 @@ export function useCarts() {
|
|||||||
carts,
|
carts,
|
||||||
activeCart,
|
activeCart,
|
||||||
activeId,
|
activeId,
|
||||||
|
storageError,
|
||||||
setActiveCart,
|
setActiveCart,
|
||||||
createCart,
|
createCart,
|
||||||
deleteCart,
|
deleteCart,
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import type { Cart, Product, Drone } from '../types'
|
||||||
|
|
||||||
|
const DB_NAME = 'fpvshop-images'
|
||||||
|
const STORE_NAME = 'images'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
|
||||||
|
// In-memory mirror for synchronous first-render access
|
||||||
|
const memCache = new Map<string, string>()
|
||||||
|
|
||||||
|
// ── IndexedDB helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBDatabase> | null = null
|
||||||
|
|
||||||
|
function getDB(): Promise<IDBDatabase> {
|
||||||
|
if (!dbPromise) dbPromise = new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME)
|
||||||
|
req.onsuccess = () => resolve(req.result)
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
return dbPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function idbGet(db: IDBDatabase, id: string): Promise<string | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(id)
|
||||||
|
req.onsuccess = () => resolve(req.result as string | undefined)
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function idbPut(db: IDBDatabase, id: string, value: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME).put(value, id)
|
||||||
|
req.onsuccess = () => resolve()
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function idbGetAllKeys(db: IDBDatabase): Promise<string[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).getAllKeys()
|
||||||
|
req.onsuccess = () => resolve(req.result as string[])
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function idbDelete(db: IDBDatabase, id: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = db.transaction(STORE_NAME, 'readwrite').objectStore(STORE_NAME).delete(id)
|
||||||
|
req.onsuccess = () => resolve()
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function storeImage(dataUri: string): Promise<string> {
|
||||||
|
if (!dataUri || dataUri.startsWith('img:')) return dataUri
|
||||||
|
// Dedup via in-memory cache
|
||||||
|
for (const [id, value] of memCache) {
|
||||||
|
if (value === dataUri) return `img:${id}`
|
||||||
|
}
|
||||||
|
const id = crypto.randomUUID().replace(/-/g, '')
|
||||||
|
memCache.set(id, dataUri)
|
||||||
|
const db = await getDB()
|
||||||
|
await idbPut(db, id, dataUri)
|
||||||
|
return `img:${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveImageSync(keyOrUri: string | undefined): string | undefined {
|
||||||
|
if (!keyOrUri) return undefined
|
||||||
|
if (!keyOrUri.startsWith('img:')) return keyOrUri
|
||||||
|
return memCache.get(keyOrUri.slice(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveImage(keyOrUri: string | undefined): Promise<string | undefined> {
|
||||||
|
if (!keyOrUri) return undefined
|
||||||
|
if (!keyOrUri.startsWith('img:')) return keyOrUri
|
||||||
|
const id = keyOrUri.slice(4)
|
||||||
|
if (memCache.has(id)) return memCache.get(id)
|
||||||
|
const db = await getDB()
|
||||||
|
const value = await idbGet(db, id)
|
||||||
|
if (value) memCache.set(id, value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResolvedImage(keyOrUri: string | undefined): string | undefined {
|
||||||
|
const [resolved, setResolved] = useState(() => resolveImageSync(keyOrUri))
|
||||||
|
useEffect(() => {
|
||||||
|
resolveImage(keyOrUri).then(v => setResolved(v))
|
||||||
|
}, [keyOrUri])
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneImageCache(carts: Cart[]): Promise<void> {
|
||||||
|
const used = new Set<string>()
|
||||||
|
for (const cart of carts) {
|
||||||
|
for (const section of cart.sections) {
|
||||||
|
for (const item of section.items) {
|
||||||
|
if (item.image?.startsWith('img:')) used.add(item.image.slice(4))
|
||||||
|
if ('parts' in item) {
|
||||||
|
for (const part of item.parts) {
|
||||||
|
if (part.image?.startsWith('img:')) used.add(part.image.slice(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const db = await getDB()
|
||||||
|
const keys = await idbGetAllKeys(db)
|
||||||
|
for (const id of keys) {
|
||||||
|
if (!used.has(id)) { await idbDelete(db, id); memCache.delete(id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cacheItemImages(item: Product | Drone): Promise<Product | Drone> {
|
||||||
|
const clone = structuredClone(item)
|
||||||
|
if (clone.image && !clone.image.startsWith('img:')) clone.image = await storeImage(clone.image)
|
||||||
|
if ('parts' in clone) {
|
||||||
|
for (const part of clone.parts) {
|
||||||
|
if (part.image && !part.image.startsWith('img:')) part.image = await storeImage(part.image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cacheImagesInCart(cart: Cart): Promise<Cart> {
|
||||||
|
const clone = structuredClone(cart)
|
||||||
|
for (const section of clone.sections) {
|
||||||
|
for (let i = 0; i < section.items.length; i++) {
|
||||||
|
section.items[i] = await cacheItemImages(section.items[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveImagesInCart(cart: Cart): Promise<Cart> {
|
||||||
|
const clone = structuredClone(cart)
|
||||||
|
for (const section of clone.sections) {
|
||||||
|
for (const item of section.items) {
|
||||||
|
if (item.image) item.image = await resolveImage(item.image) ?? item.image
|
||||||
|
if ('parts' in item) {
|
||||||
|
for (const part of item.parts) {
|
||||||
|
if (part.image) part.image = await resolveImage(part.image) ?? part.image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user