Compare commits

..

4 Commits

5 changed files with 197 additions and 30 deletions
+74 -8
View File
File diff suppressed because one or more lines are too long
+13 -5
View File
@@ -52,7 +52,9 @@ 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, storageError, 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, reorderSections, addItem, updateItem, removeItem } = useCarts()
const [dragSectionId, setDragSectionId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
function commitCartRename() { function commitCartRename() {
if (renamingCartId && renameCartValue.trim()) renameCart(renamingCartId, renameCartValue.trim()) if (renamingCartId && renameCartValue.trim()) renameCart(renamingCartId, renameCartValue.trim())
@@ -65,17 +67,17 @@ export default function App() {
else { localStorage.removeItem(THEME_KEY); document.documentElement.removeAttribute('data-theme') } else { localStorage.removeItem(THEME_KEY); document.documentElement.removeAttribute('data-theme') }
} }
function handleCreateFromTemplate(templateId: string) { async function handleCreateFromTemplate(templateId: string) {
const existing = carts.find(c => c.templateId === templateId) const existing = carts.find(c => c.templateId === templateId)
if (existing) { setActiveCart(existing.id); return } if (existing) { setActiveCart(existing.id); return }
const template = catalog?.templates.find(t => t.id === templateId) const template = catalog?.templates.find(t => t.id === templateId)
if (!template) return if (!template) return
createCart(template.name, template) await createCart(template.name, template)
} }
function handleCreateEmpty() { async function handleCreateEmpty() {
if (!newCartName.trim()) return if (!newCartName.trim()) return
createCart(newCartName.trim()) await createCart(newCartName.trim())
setNewCartName('') setNewCartName('')
setShowNewCart(false) setShowNewCart(false)
} }
@@ -219,6 +221,12 @@ export default function App() {
onEditItem={item => updateItem(section.id, item)} onEditItem={item => updateItem(section.id, item)}
onRemoveSection={() => removeSection(section.id)} onRemoveSection={() => removeSection(section.id)}
onRenameSection={label => renameSection(section.id, label)} onRenameSection={label => renameSection(section.id, label)}
isDragging={dragSectionId === section.id}
isDragOver={dragOverId === section.id}
onDragStart={() => setDragSectionId(section.id)}
onDragOver={e => { e.preventDefault(); setDragOverId(section.id) }}
onDrop={() => { if (dragSectionId) reorderSections(dragSectionId, section.id); setDragOverId(null) }}
onDragEnd={() => { setDragSectionId(null); setDragOverId(null) }}
/> />
))} ))}
<button <button
+54 -7
View File
@@ -1,6 +1,6 @@
import { useState } from 'react' import { useEffect, 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' import { useResolvedImage, resolveImageSync, resolveImage } 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)
@@ -25,6 +25,22 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
const [hoverImg, setHoverImg] = useState<string | null>(null) const [hoverImg, setHoverImg] = useState<string | null>(null)
const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
const [lightboxImg, setLightboxImg] = useState<string | null>(null)
useEffect(() => {
if (!lightboxImg) return
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setLightboxImg(null) }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [lightboxImg])
// Preload part images into memCache so resolveImageSync works on hover
useEffect(() => {
if (!isKit) return
for (const p of (item as KitBuild).parts) {
if (p.image) resolveImage(p.image)
}
}, [item])
const priceLabel = isKit const priceLabel = isKit
? kitTotal(item as KitBuild) ? kitTotal(item as KitBuild)
@@ -44,7 +60,12 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
> >
{/* Thumbnail */} {/* Thumbnail */}
{thumbnail ? ( {thumbnail ? (
<img src={thumbnail} 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 cursor-zoom-in"
onClick={() => setLightboxImg(thumbnail)}
/>
) : ( ) : (
<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"
@@ -102,10 +123,11 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
<li <li
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 ? 'zoom-in' : undefined }}
onMouseEnter={p.image ? e => { setHoverImg(resolveImageSync(p.image) ?? null); 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}
onClick={p.image ? () => { const r = resolveImageSync(p.image); if (r) setLightboxImg(r) } : undefined}
> >
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<span className="shrink-0 rounded px-1.5 py-0.5" style={{ background: 'var(--color-bg2)', color: 'var(--color-grey)' }}> <span className="shrink-0 rounded px-1.5 py-0.5" style={{ background: 'var(--color-bg2)', color: 'var(--color-grey)' }}>
@@ -114,7 +136,15 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
<span className="truncate">{p.name}</span> <span className="truncate">{p.name}</span>
{p.image && <span className="shrink-0 text-xs" style={{ color: 'var(--color-bg3)' }}></span>} {p.image && <span className="shrink-0 text-xs" style={{ color: 'var(--color-bg3)' }}></span>}
</div> </div>
<span className="shrink-0">{fmt(p.price.amount, p.price.currency)}</span> <div className="flex shrink-0 items-center gap-2">
<span>{fmt(p.price.amount, p.price.currency)}</span>
<button
onClick={() => navigator.clipboard.writeText(JSON.stringify(p))}
className="text-xs opacity-50 hover:opacity-100"
style={{ color: 'var(--color-grey)' }}
title="Copy part to clipboard"
></button>
</div>
</li> </li>
))} ))}
</ul> </ul>
@@ -125,15 +155,32 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
{/* Hover image preview */} {/* Hover image preview */}
{hoverImg && ( {hoverImg && (
<div <div
className="pointer-events-none fixed z-50 rounded-lg border shadow-lg overflow-hidden" className="fixed z-50 rounded-lg border shadow-lg overflow-hidden cursor-zoom-in"
style={{ style={{
left: mousePos.x + 16, left: mousePos.x + 16,
top: mousePos.y + 16, top: mousePos.y + 16,
borderColor: 'var(--color-bg3)', borderColor: 'var(--color-bg3)',
background: 'var(--color-bg1)', background: 'var(--color-bg1)',
}} }}
onClick={() => setLightboxImg(hoverImg)}
> >
<img src={hoverImg} alt="" className="block h-48 w-48 object-cover" /> <img src={hoverImg} alt="" className="pointer-events-none block h-48 w-48 object-cover" />
</div>
)}
{/* Lightbox */}
{lightboxImg && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 cursor-zoom-out"
style={{ background: 'rgba(0,0,0,0.8)' }}
onClick={() => setLightboxImg(null)}
>
<img
src={lightboxImg}
alt=""
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
style={{ maxHeight: '90vh', maxWidth: '90vw' }}
/>
</div> </div>
)} )}
</div> </div>
+25 -2
View File
@@ -11,9 +11,15 @@ interface Props {
onEditItem: (item: Product | Drone) => void onEditItem: (item: Product | Drone) => void
onRemoveSection: () => void onRemoveSection: () => void
onRenameSection: (label: string) => void onRenameSection: (label: string) => void
onDragStart: () => void
onDragOver: (e: React.DragEvent) => void
onDrop: () => void
onDragEnd: () => void
isDragging: boolean
isDragOver: boolean
} }
export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onRemoveSection, onRenameSection }: Props) { export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onRemoveSection, onRenameSection, onDragStart, onDragOver, onDrop, onDragEnd, isDragging, isDragOver }: Props) {
const [showAdd, setShowAdd] = useState(false) const [showAdd, setShowAdd] = useState(false)
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)
@@ -49,10 +55,27 @@ export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onR
} }
return ( return (
<div className="flex flex-col gap-3 rounded-lg border p-4" style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)' }}> <div
onDragOver={onDragOver}
onDrop={onDrop}
className="flex flex-col gap-3 rounded-lg border p-4 transition-opacity"
style={{
background: 'var(--color-bg1)',
borderColor: isDragOver ? 'var(--color-cyan)' : 'var(--color-bg3)',
opacity: isDragging ? 0.4 : 1,
}}
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
className="cursor-grab select-none text-base leading-none"
style={{ color: 'var(--color-bg3)' }}
title="Drag to reorder"
></span>
{editing ? ( {editing ? (
<form onSubmit={submitRename} className="flex items-center gap-1"> <form onSubmit={submitRename} className="flex items-center gap-1">
<input <input
+27 -4
View File
@@ -20,8 +20,15 @@ function loadActiveId(carts: Cart[]): string | null {
} }
function saveCarts(carts: Cart[]): string | null { function saveCarts(carts: Cart[]): string | null {
const json = JSON.stringify(carts)
try { try {
localStorage.setItem(CARTS_KEY, JSON.stringify(carts)) localStorage.setItem(CARTS_KEY, json)
return null
} catch {
// Some browsers can't replace a large value in one step — free it first, then retry
try {
localStorage.removeItem(CARTS_KEY)
localStorage.setItem(CARTS_KEY, json)
return null return null
} catch (e) { } catch (e) {
if (e instanceof DOMException && e.name === 'QuotaExceededError') { if (e instanceof DOMException && e.name === 'QuotaExceededError') {
@@ -29,6 +36,7 @@ function saveCarts(carts: Cart[]): string | null {
} }
return 'Failed to save carts.' return 'Failed to save carts.'
} }
}
} }
export function useCarts() { export function useCarts() {
@@ -50,7 +58,8 @@ export function useCarts() {
if (!needsMigration) return if (!needsMigration) return
Promise.all(loaded.map(cacheImagesInCart)).then(migrated => { Promise.all(loaded.map(cacheImagesInCart)).then(migrated => {
pruneImageCache(migrated) pruneImageCache(migrated)
saveCarts(migrated) const err = saveCarts(migrated)
setStorageError(err)
setCarts(migrated) setCarts(migrated)
}) })
}, []) }, [])
@@ -74,15 +83,16 @@ export function useCarts() {
// ── Cart management ──────────────────────────────────────────────────────── // ── Cart management ────────────────────────────────────────────────────────
function createCart(name: string, template?: CartTemplate): string { async function createCart(name: string, template?: CartTemplate): Promise<string> {
const id = crypto.randomUUID() const id = crypto.randomUUID()
const cart: Cart = { const raw: Cart = {
id, id,
name, name,
createdAt: new Date().toISOString().slice(0, 10), createdAt: new Date().toISOString().slice(0, 10),
templateId: template?.id, templateId: template?.id,
sections: template ? structuredClone(template.sections) : [], sections: template ? structuredClone(template.sections) : [],
} }
const cart = template ? await cacheImagesInCart(raw) : raw
mutateCarts(prev => [...prev, cart]) mutateCarts(prev => [...prev, cart])
setActiveCart(id) setActiveCart(id)
return id return id
@@ -158,6 +168,18 @@ export function useCarts() {
})) }))
} }
function reorderSections(fromId: string, toId: string) {
mutateActiveCart(cart => {
const sections = [...cart.sections]
const fromIdx = sections.findIndex(s => s.id === fromId)
const toIdx = sections.findIndex(s => s.id === toId)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return cart
const [moved] = sections.splice(fromIdx, 1)
sections.splice(toIdx, 0, moved)
return { ...cart, sections }
})
}
// ── Item management ─────────────────────────────────────────────────────── // ── Item management ───────────────────────────────────────────────────────
function addItem(sectionId: string, item: Product | Drone) { function addItem(sectionId: string, item: Product | Drone) {
@@ -220,6 +242,7 @@ export function useCarts() {
addSection, addSection,
removeSection, removeSection,
renameSection, renameSection,
reorderSections,
addItem, addItem,
updateItem, updateItem,
removeItem, removeItem,