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
+5 -1
View File
@@ -4,7 +4,11 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fpvshop</title>
<title>FPV Shop List</title>
<meta
name="description"
content="Personal shopping list app for FPV drone gear. Organize parts into carts and sections, track prices, and share builds."
/>
</head>
<body>
<div id="root"></div>
+13 -10
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -52,7 +52,7 @@ export default function App() {
const importRef = useRef<HTMLInputElement>(null)
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() {
if (renamingCartId && renameCartValue.trim()) renameCart(renamingCartId, renameCartValue.trim())
@@ -169,7 +169,7 @@ export default function App() {
const file = e.target.files?.[0]
if (!file) return
file.text().then(json => {
if (!importCart(json)) alert('Invalid cart file.')
importCart(json).then(ok => { if (!ok) alert('Invalid cart file.') })
})
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">
{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>}
{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 && (
<p className="py-12 text-center text-sm" style={{ color: 'var(--color-grey)' }}>
+31 -9
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import type { Product, CompleteDrone, KitBuild, Drone, Category, Currency } from '../types'
import { storeImage, resolveImage } from '../utils/imageCache'
const CATEGORIES: Category[] = [
'frame', 'flight-controller', 'esc', 'motor', 'camera',
@@ -110,7 +111,7 @@ export function AddItemDialog({ isDroneSection, initialItem, onSubmit, onClose }
return today()
})
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 [parts, setParts] = useState<PartRow[]>(() => {
if (initialItem && 'buildType' in initialItem && initialItem.buildType === 'kit') {
@@ -119,6 +120,28 @@ export function AddItemDialog({ isDroneSection, initialItem, onSubmit, onClose }
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)
useEffect(() => {
async function onPaste(e: ClipboardEvent) {
@@ -153,24 +176,23 @@ export function AddItemDialog({ isDroneSection, initialItem, onSubmit, onClose }
updatePart(tempId, { image: await fileToBase64(file) })
}
function submit(e: React.FormEvent) {
async function submit(e: React.FormEvent) {
e.preventDefault()
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') {
onSubmit({
id, buildType: 'kit', name, ...common,
parts: parts.filter(p => p.name).map(p => ({
const builtParts = await Promise.all(parts.filter(p => p.name).map(async p => ({
id: crypto.randomUUID(),
name: p.name,
brand: p.brand || undefined,
category: p.category,
price: { amount: parseFloat(p.amount) || 0, currency: p.currency, asOf: p.asOf },
url: p.url || undefined,
image: p.image || undefined,
})),
})
image: p.image ? await storeImage(p.image) : undefined,
})))
onSubmit({ id, buildType: 'kit', name, ...common, parts: builtParts })
} else if (type === 'complete-drone') {
onSubmit({ id, buildType: 'complete', name, brand: brand || undefined, price: { amount: parseFloat(amount) || 0, currency, asOf }, ...common })
} else {
+5 -3
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import type { Product, Drone, CompleteDrone, KitBuild } from '../types'
import { useResolvedImage, resolveImageSync } from '../utils/imageCache'
function fmt(amount: number, currency: string) {
return new Intl.NumberFormat('en', { style: 'currency', currency }).format(amount)
@@ -34,6 +35,7 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
const brand = (item as CompleteDrone | Product).brand ?? null
const category = (item as Product).category ?? null
const url = (item as Product | CompleteDrone | KitBuild).url ?? null
const thumbnail = useResolvedImage(item.image)
return (
<div
@@ -41,8 +43,8 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)' }}
>
{/* Thumbnail */}
{item.image ? (
<img src={item.image} alt={item.name} className="h-16 w-16 shrink-0 rounded object-cover" />
{thumbnail ? (
<img src={thumbnail} alt={item.name} className="h-16 w-16 shrink-0 rounded object-cover" />
) : (
<div
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}
className="flex items-center justify-between gap-2 text-xs"
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}
onMouseLeave={p.image ? () => setHoverImg(null) : undefined}
>
+44
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import type { Section, Product, Drone } from '../types'
import { ItemCard } from './ItemCard'
import { AddItemDialog } from './AddItemDialog'
import { cacheItemImages } from '../utils/imageCache'
interface Props {
section: Section
@@ -17,6 +18,7 @@ export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onR
const [editingItem, setEditingItem] = useState<Product | Drone | null>(null)
const [editing, setEditing] = useState(false)
const [labelInput, setLabelInput] = useState(section.label)
const [pasteError, setPasteError] = useState<string | null>(null)
function submitRename(e: React.FormEvent) {
e.preventDefault()
@@ -24,6 +26,28 @@ export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onR
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 (
<div className="flex flex-col gap-3 rounded-lg border p-4" style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)' }}>
{/* Header */}
@@ -57,6 +81,22 @@ export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onR
)}
</div>
<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
onClick={() => setShowAdd(true)}
className="rounded px-2 py-1 text-xs"
@@ -75,6 +115,10 @@ export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onR
</div>
</div>
{pasteError && (
<p className="text-xs" style={{ color: 'var(--color-red)' }}>{pasteError}</p>
)}
{/* Items */}
{section.items.length > 0 ? (
<div className="flex flex-col gap-2">
+38 -7
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[]) {
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,
+152
View File
@@ -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
}