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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
<head>
|
||||
<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>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>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</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 { 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)' }}>
|
||||
|
||||
@@ -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 => ({
|
||||
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,
|
||||
})),
|
||||
})
|
||||
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 ? 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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -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 Product).price.amount, (item as Product).price.currency)
|
||||
|
||||
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 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}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
+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 { 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,
|
||||
|
||||
@@ -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