Compare commits
4 Commits
70315a3fd1
...
78a200c6ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 78a200c6ee | |||
| 7d648c4313 | |||
| a771f3993e | |||
| bcda0e7315 |
+74
-8
File diff suppressed because one or more lines are too long
+13
-5
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user