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:
2026-04-20 01:00:48 +02:00
parent 1f21d857e4
commit 70315a3fd1
8 changed files with 310 additions and 51 deletions
+39 -8
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import type { Cart, CartTemplate, Section, Product, Drone } from '../types'
import { cacheImagesInCart, resolveImagesInCart, pruneImageCache } from '../utils/imageCache'
const CARTS_KEY = 'carts'
const ACTIVE_KEY = 'active-cart-id'
@@ -18,8 +19,16 @@ function loadActiveId(carts: Cart[]): string | null {
return carts[0]?.id ?? null
}
function saveCarts(carts: Cart[]) {
localStorage.setItem(CARTS_KEY, JSON.stringify(carts))
function saveCarts(carts: Cart[]): string | null {
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() {
@@ -29,12 +38,32 @@ export function useCarts() {
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
function mutateCarts(fn: (carts: Cart[]) => Cart[]) {
setCarts(prev => {
const next = fn(prev)
saveCarts(next)
pruneImageCache(next) // fire-and-forget async
const err = saveCarts(next)
setStorageError(err)
return next
})
}
@@ -81,11 +110,11 @@ export function useCarts() {
localStorage.setItem(ACTIVE_KEY, id)
}
function importCart(json: string): boolean {
async function importCart(json: string): Promise<boolean> {
try {
const cart = JSON.parse(json) as Cart
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])
setActiveCart(imported.id)
return true
@@ -94,9 +123,10 @@ export function useCarts() {
}
}
function exportCart() {
async function exportCart() {
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 a = document.createElement('a')
a.href = url
@@ -180,6 +210,7 @@ export function useCarts() {
carts,
activeCart,
activeId,
storageError,
setActiveCart,
createCart,
deleteCart,