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 { 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() {
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') }
}
function handleCreateFromTemplate(templateId: string) {
async function handleCreateFromTemplate(templateId: string) {
const existing = carts.find(c => c.templateId === templateId)
if (existing) { setActiveCart(existing.id); return }
const template = catalog?.templates.find(t => t.id === templateId)
if (!template) return
createCart(template.name, template)
await createCart(template.name, template)
}
function handleCreateEmpty() {
async function handleCreateEmpty() {
if (!newCartName.trim()) return
createCart(newCartName.trim())
await createCart(newCartName.trim())
setNewCartName('')
setShowNewCart(false)
}
@@ -219,6 +221,12 @@ export default function App() {
onEditItem={item => updateItem(section.id, item)}
onRemoveSection={() => removeSection(section.id)}
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
+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 { useResolvedImage, resolveImageSync } from '../utils/imageCache'
import { useResolvedImage, resolveImageSync, resolveImage } from '../utils/imageCache'
function fmt(amount: number, currency: string) {
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 [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
? kitTotal(item as KitBuild)
@@ -44,7 +60,12 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
>
{/* 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
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
key={p.id}
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}
onMouseMove={p.image ? e => setMousePos({ x: e.clientX, y: e.clientY }) : 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">
<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>
{p.image && <span className="shrink-0 text-xs" style={{ color: 'var(--color-bg3)' }}></span>}
</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>
))}
</ul>
@@ -125,15 +155,32 @@ export function ItemCard({ item, onEdit, onRemove }: Props) {
{/* Hover image preview */}
{hoverImg && (
<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={{
left: mousePos.x + 16,
top: mousePos.y + 16,
borderColor: 'var(--color-bg3)',
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>
+25 -2
View File
@@ -11,9 +11,15 @@ interface Props {
onEditItem: (item: Product | Drone) => void
onRemoveSection: () => 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 [editingItem, setEditingItem] = useState<Product | Drone | null>(null)
const [editing, setEditing] = useState(false)
@@ -49,10 +55,27 @@ export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onR
}
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 */}
<div className="flex items-center justify-between">
<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 ? (
<form onSubmit={submitRename} className="flex items-center gap-1">
<input
+27 -4
View File
@@ -20,8 +20,15 @@ function loadActiveId(carts: Cart[]): string | null {
}
function saveCarts(carts: Cart[]): string | null {
const json = JSON.stringify(carts)
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
} catch (e) {
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
@@ -30,6 +37,7 @@ function saveCarts(carts: Cart[]): string | null {
return 'Failed to save carts.'
}
}
}
export function useCarts() {
const [carts, setCarts] = useState<Cart[]>(() => loadCarts())
@@ -50,7 +58,8 @@ export function useCarts() {
if (!needsMigration) return
Promise.all(loaded.map(cacheImagesInCart)).then(migrated => {
pruneImageCache(migrated)
saveCarts(migrated)
const err = saveCarts(migrated)
setStorageError(err)
setCarts(migrated)
})
}, [])
@@ -74,15 +83,16 @@ export function useCarts() {
// ── Cart management ────────────────────────────────────────────────────────
function createCart(name: string, template?: CartTemplate): string {
async function createCart(name: string, template?: CartTemplate): Promise<string> {
const id = crypto.randomUUID()
const cart: Cart = {
const raw: Cart = {
id,
name,
createdAt: new Date().toISOString().slice(0, 10),
templateId: template?.id,
sections: template ? structuredClone(template.sections) : [],
}
const cart = template ? await cacheImagesInCart(raw) : raw
mutateCarts(prev => [...prev, cart])
setActiveCart(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 ───────────────────────────────────────────────────────
function addItem(sectionId: string, item: Product | Drone) {
@@ -220,6 +242,7 @@ export function useCarts() {
addSection,
removeSection,
renameSection,
reorderSections,
addItem,
updateItem,
removeItem,