Curator list app PoC completed
This commit is contained in:
+43
-8
@@ -1,6 +1,31 @@
|
||||
# fpvshop
|
||||
# FPV Shop List
|
||||
|
||||
A React + TypeScript + Vite project with Tailwind CSS v4.
|
||||
A personal shopping list app for FPV drone gear. Organize parts into carts and
|
||||
sections, track prices, attach images, and share builds with others.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple carts** — create empty carts or seed from curated templates
|
||||
- **Sections** — group items (frame, motors, goggles, batteries, etc.) with an
|
||||
optional required flag
|
||||
- **Item types** — individual products, complete drones, or kit builds with a
|
||||
per-part breakdown
|
||||
- **Kit builds** — add parts with category, price, image, and URL; total is
|
||||
summed automatically
|
||||
- **Images** — attach via file picker, clipboard paste (Ctrl+V), or base64
|
||||
embedded in JSON
|
||||
- **Hover preview** — hover over a kit part to see its image near the cursor
|
||||
- **Import / Export** — share carts as JSON files
|
||||
- **Themes** — 9 named color palettes (Sonokai variants + Axis dark/light) with
|
||||
OS preference fallback
|
||||
- **Persistent** — carts and active selection stored in `localStorage`
|
||||
|
||||
## Curated catalog
|
||||
|
||||
`public/curated.json` contains suggested starter builds (e.g. a Crux3 beginner
|
||||
kit). The app fetches this on load and exposes the templates in the "Load
|
||||
template" dropdown. Templates are deep-copied into a new cart so edits never
|
||||
affect the source.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -11,22 +36,32 @@ A React + TypeScript + Vite project with Tailwind CSS v4.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
Install dependencies
|
||||
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
# Start dev server
|
||||
Start dev server
|
||||
|
||||
```sh
|
||||
bun dev
|
||||
```
|
||||
|
||||
# Build for production
|
||||
Build for production
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
```
|
||||
|
||||
# Preview production build
|
||||
Preview production build
|
||||
|
||||
```sh
|
||||
bun run preview
|
||||
```
|
||||
|
||||
## Scaffolded with
|
||||
|
||||
```bash
|
||||
```sh
|
||||
bun create vite
|
||||
```
|
||||
|
||||
File diff suppressed because one or more lines are too long
+244
-29
@@ -1,4 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useCatalog } from './hooks/useCatalog'
|
||||
import { useCarts } from './hooks/useCarts'
|
||||
import { SectionBlock } from './components/SectionBlock'
|
||||
import { AddSectionDialog } from './components/AddSectionDialog'
|
||||
import type { Product, Drone } from './types'
|
||||
|
||||
const THEMES = [
|
||||
{ value: '', label: 'OS default' },
|
||||
@@ -13,47 +18,257 @@ const THEMES = [
|
||||
{ value: 'axis-light', label: 'Axis Light' },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'theme'
|
||||
const THEME_KEY = 'theme'
|
||||
|
||||
function App() {
|
||||
function cartTotal(sections: { items: (Product | Drone)[] }[]): string {
|
||||
let total = 0
|
||||
let currency = 'SEK'
|
||||
for (const section of sections) {
|
||||
for (const item of section.items) {
|
||||
if ('buildType' in item) {
|
||||
if (item.buildType === 'complete') { total += item.price.amount; currency = item.price.currency }
|
||||
else if (item.parts[0]) { total += item.parts.reduce((s, p) => s + p.price.amount, 0); currency = item.parts[0].price.currency }
|
||||
} else {
|
||||
total += item.price.amount
|
||||
currency = item.price.currency
|
||||
}
|
||||
}
|
||||
}
|
||||
return total === 0 ? '—' : new Intl.NumberFormat('en', { style: 'currency', currency }).format(total)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) ?? ''
|
||||
const stored = localStorage.getItem(THEME_KEY) ?? ''
|
||||
if (stored) document.documentElement.setAttribute('data-theme', stored)
|
||||
return stored
|
||||
})
|
||||
const [showAddSection, setShowAddSection] = useState(false)
|
||||
const [showNewCart, setShowNewCart] = useState(false)
|
||||
const [newCartName, setNewCartName] = useState('')
|
||||
const [cartToDelete, setCartToDelete] = useState<string | null>(null)
|
||||
const [renamingCartId, setRenamingCartId] = useState<string | null>(null)
|
||||
const [renameCartValue, setRenameCartValue] = useState('')
|
||||
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()
|
||||
|
||||
function commitCartRename() {
|
||||
if (renamingCartId && renameCartValue.trim()) renameCart(renamingCartId, renameCartValue.trim())
|
||||
setRenamingCartId(null)
|
||||
}
|
||||
|
||||
function handleThemeChange(value: string) {
|
||||
setTheme(value)
|
||||
if (value) {
|
||||
localStorage.setItem(STORAGE_KEY, value)
|
||||
document.documentElement.setAttribute('data-theme', value)
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
if (value) { localStorage.setItem(THEME_KEY, value); document.documentElement.setAttribute('data-theme', value) }
|
||||
else { localStorage.removeItem(THEME_KEY); document.documentElement.removeAttribute('data-theme') }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function handleCreateEmpty() {
|
||||
if (!newCartName.trim()) return
|
||||
createCart(newCartName.trim())
|
||||
setNewCartName('')
|
||||
setShowNewCart(false)
|
||||
}
|
||||
|
||||
const selectStyle = { background: 'var(--color-bg1)', color: 'var(--color-fg)', borderColor: 'var(--color-bg3)' }
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6">
|
||||
<h1 className="text-4xl font-bold" style={{ color: 'var(--color-fg)' }}>
|
||||
Hello, World!
|
||||
</h1>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={e => handleThemeChange(e.target.value)}
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
style={{
|
||||
background: 'var(--color-bg1)',
|
||||
color: 'var(--color-fg)',
|
||||
borderColor: 'var(--color-grey-dim)',
|
||||
}}
|
||||
>
|
||||
{THEMES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
<div className="min-h-screen" style={{ background: 'var(--color-bg0)' }}>
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-2 border-b px-4 py-3 sm:px-6" style={{ background: 'var(--color-bg0)', borderColor: 'var(--color-bg2)' }}>
|
||||
<h1 className="font-semibold tracking-tight" style={{ color: 'var(--color-fg)' }}>FPV Shop List</h1>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{activeCart && (
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-yellow)' }}>
|
||||
{cartTotal(activeCart.sections)}
|
||||
</span>
|
||||
)}
|
||||
{activeCart && (
|
||||
<button onClick={exportCart} className="rounded px-3 py-1.5 text-sm" style={{ background: 'var(--color-bg2)', color: 'var(--color-cyan)' }}>
|
||||
Export
|
||||
</button>
|
||||
)}
|
||||
<select value={theme} onChange={e => handleThemeChange(e.target.value)} className="rounded border px-2 py-1.5 text-sm" style={selectStyle}>
|
||||
{THEMES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Cart bar */}
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2 overflow-x-auto sm:px-6" style={{ borderColor: 'var(--color-bg2)' }}>
|
||||
{carts.map(cart => (
|
||||
<div key={cart.id} className="flex shrink-0 items-center gap-1 rounded px-2 py-1" style={cart.id === activeId ? { background: 'var(--color-bg2)' } : {}}>
|
||||
{renamingCartId === cart.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameCartValue}
|
||||
onChange={e => setRenameCartValue(e.target.value)}
|
||||
onBlur={commitCartRename}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitCartRename(); if (e.key === 'Escape') setRenamingCartId(null) }}
|
||||
className="rounded border px-1 py-0.5 text-sm outline-none"
|
||||
style={{ background: 'var(--color-bg2)', color: 'var(--color-fg)', borderColor: 'var(--color-bg3)', width: `${Math.max(renameCartValue.length, 4)}ch` }}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setActiveCart(cart.id)}
|
||||
onDoubleClick={() => { setRenamingCartId(cart.id); setRenameCartValue(cart.name) }}
|
||||
className="text-sm"
|
||||
style={cart.id === activeId ? { color: 'var(--color-fg)' } : { color: 'var(--color-grey)' }}
|
||||
title="Double-click to rename"
|
||||
>
|
||||
{cart.name}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCartToDelete(cart.id)}
|
||||
className="text-xs leading-none"
|
||||
style={{ color: 'var(--color-grey)' }}
|
||||
title="Remove cart"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* New cart */}
|
||||
{showNewCart ? (
|
||||
<form onSubmit={e => { e.preventDefault(); handleCreateEmpty() }} className="flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={newCartName}
|
||||
onChange={e => setNewCartName(e.target.value)}
|
||||
onBlur={() => { if (!newCartName.trim()) setShowNewCart(false) }}
|
||||
placeholder="Cart name"
|
||||
className="rounded border px-2 py-1 text-sm outline-none"
|
||||
style={{ background: 'var(--color-bg2)', color: 'var(--color-fg)', borderColor: 'var(--color-bg3)' }}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<button onClick={() => setShowNewCart(true)} className="shrink-0 text-sm" style={{ color: 'var(--color-grey)' }}>
|
||||
+ New cart
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Import cart from file */}
|
||||
<input
|
||||
ref={importRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
file.text().then(json => {
|
||||
if (!importCart(json)) alert('Invalid cart file.')
|
||||
})
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => importRef.current?.click()} className="shrink-0 text-sm" style={{ color: 'var(--color-grey)' }}>
|
||||
Import
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Template row */}
|
||||
{catalog && catalog.templates.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-2 sm:px-6" style={{ borderColor: 'var(--color-bg2)' }}>
|
||||
<span className="text-xs shrink-0" style={{ color: 'var(--color-grey)' }}>Templates:</span>
|
||||
{catalog.templates.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => handleCreateFromTemplate(t.id)}
|
||||
className="shrink-0 rounded border px-2 py-0.5 text-xs"
|
||||
style={{ borderColor: 'var(--color-bg3)', color: 'var(--color-grey)', background: 'var(--color-bg1)' }}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main */}
|
||||
<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>}
|
||||
|
||||
{!activeCart && !catalogLoading && (
|
||||
<p className="py-12 text-center text-sm" style={{ color: 'var(--color-grey)' }}>
|
||||
No cart yet — create one or load a template above
|
||||
</p>
|
||||
)}
|
||||
|
||||
{activeCart && <>
|
||||
{activeCart.sections.map(section => (
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
section={section}
|
||||
onRemoveItem={itemId => removeItem(section.id, itemId)}
|
||||
onAddItem={item => addItem(section.id, item)}
|
||||
onEditItem={item => updateItem(section.id, item)}
|
||||
onRemoveSection={() => removeSection(section.id)}
|
||||
onRenameSection={label => renameSection(section.id, label)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setShowAddSection(true)}
|
||||
className="rounded-lg border py-3 text-sm"
|
||||
style={{ borderColor: 'var(--color-bg3)', color: 'var(--color-grey)', borderStyle: 'dashed' }}
|
||||
>
|
||||
+ Add section
|
||||
</button>
|
||||
</>}
|
||||
</main>
|
||||
|
||||
{showAddSection && (
|
||||
<AddSectionDialog
|
||||
onAdd={(id, label, required) => addSection({ id, label, required })}
|
||||
onClose={() => setShowAddSection(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cartToDelete && (() => {
|
||||
const cart = carts.find(c => c.id === cartToDelete)
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(0,0,0,0.6)' }}
|
||||
>
|
||||
<div className="w-full max-w-sm rounded-lg border p-6 flex flex-col gap-4" style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)' }}>
|
||||
<p className="font-medium" style={{ color: 'var(--color-fg)' }}>Remove cart?</p>
|
||||
<p className="text-sm" style={{ color: 'var(--color-grey)' }}>
|
||||
"<span style={{ color: 'var(--color-fg)' }}>{cart?.name}</span>" will be permanently deleted.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setCartToDelete(null)}
|
||||
className="rounded px-3 py-1.5 text-sm"
|
||||
style={{ background: 'var(--color-bg2)', color: 'var(--color-grey)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { deleteCart(cartToDelete); setCartToDelete(null) }}
|
||||
className="rounded px-3 py-1.5 text-sm font-medium"
|
||||
style={{ background: 'var(--color-filled-red)', color: 'var(--color-black)' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Product, CompleteDrone, KitBuild, Drone, Category, Currency } from '../types'
|
||||
|
||||
const CATEGORIES: Category[] = [
|
||||
'frame', 'flight-controller', 'esc', 'motor', 'camera',
|
||||
'vtx', 'props', 'battery', 'charger', 'radio', 'receiver',
|
||||
'goggles', 'complete-drone', 'accessory',
|
||||
]
|
||||
|
||||
const CURRENCIES: Currency[] = ['USD', 'EUR', 'GBP', 'SEK']
|
||||
|
||||
type ItemType = 'product' | 'complete-drone' | 'kit'
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg2)',
|
||||
color: 'var(--color-fg)',
|
||||
borderColor: 'var(--color-bg3)',
|
||||
}
|
||||
|
||||
interface PartRow {
|
||||
tempId: string
|
||||
name: string
|
||||
brand: string
|
||||
category: Category
|
||||
amount: string
|
||||
currency: Currency
|
||||
asOf: string
|
||||
url: string
|
||||
image: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isDroneSection: boolean
|
||||
initialItem?: Product | Drone
|
||||
onSubmit: (item: Product | CompleteDrone | KitBuild) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function today() { return new Date().toISOString().slice(0, 10) }
|
||||
|
||||
function emptyPart(): PartRow {
|
||||
return { tempId: crypto.randomUUID(), name: '', brand: '', category: 'frame', amount: '', currency: 'SEK', asOf: today(), url: '', image: '' }
|
||||
}
|
||||
|
||||
function partFromProduct(p: Product): PartRow {
|
||||
return {
|
||||
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 ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function initType(item: Product | Drone | undefined, isDroneSection: boolean): ItemType {
|
||||
if (!item) return isDroneSection ? 'complete-drone' : 'product'
|
||||
if ('buildType' in item) return item.buildType === 'kit' ? 'kit' : 'complete-drone'
|
||||
return 'product'
|
||||
}
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
async function readImageFromClipboard(): Promise<string | null> {
|
||||
try {
|
||||
const items = await navigator.clipboard.read()
|
||||
for (const item of items) {
|
||||
const type = item.types.find(t => t.startsWith('image/'))
|
||||
if (type) {
|
||||
const blob = await item.getType(type)
|
||||
return fileToBase64(new File([blob], 'image', { type }))
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
|
||||
export function AddItemDialog({ isDroneSection, initialItem, onSubmit, onClose }: Props) {
|
||||
const editing = !!initialItem
|
||||
|
||||
const [type, setType] = useState<ItemType>(() => initType(initialItem, isDroneSection))
|
||||
const [name, setName] = useState(() => initialItem?.name ?? '')
|
||||
const [brand, setBrand] = useState(() => (initialItem as CompleteDrone | Product)?.brand ?? '')
|
||||
const [category, setCategory] = useState<Category>(() => (initialItem as Product)?.category ?? 'radio')
|
||||
const [amount, setAmount] = useState(() => {
|
||||
if (!initialItem) return ''
|
||||
if ('buildType' in initialItem && initialItem.buildType === 'complete') return String(initialItem.price.amount)
|
||||
if (!('buildType' in initialItem)) return String(initialItem.price.amount)
|
||||
return ''
|
||||
})
|
||||
const [currency, setCurrency] = useState<Currency>(() => {
|
||||
if (!initialItem) return 'SEK'
|
||||
if ('buildType' in initialItem && initialItem.buildType === 'complete') return initialItem.price.currency
|
||||
if (!('buildType' in initialItem)) return initialItem.price.currency
|
||||
return 'SEK'
|
||||
})
|
||||
const [asOf, setAsOf] = useState(() => {
|
||||
if (!initialItem) return today()
|
||||
if ('buildType' in initialItem && initialItem.buildType === 'complete') return initialItem.price.asOf
|
||||
if (!('buildType' in initialItem)) return initialItem.price.asOf
|
||||
return today()
|
||||
})
|
||||
const [url, setUrl] = useState(() => (initialItem as Product | CompleteDrone | KitBuild)?.url ?? '')
|
||||
const [image, setImage] = useState(() => initialItem?.image ?? '')
|
||||
const [note, setNote] = useState(() => initialItem?.note ?? '')
|
||||
const [parts, setParts] = useState<PartRow[]>(() => {
|
||||
if (initialItem && 'buildType' in initialItem && initialItem.buildType === 'kit') {
|
||||
return initialItem.parts.length > 0 ? initialItem.parts.map(partFromProduct) : [emptyPart()]
|
||||
}
|
||||
return [emptyPart()]
|
||||
})
|
||||
|
||||
// Global paste → sets main item image (Ctrl+V anywhere in the dialog)
|
||||
useEffect(() => {
|
||||
async function onPaste(e: ClipboardEvent) {
|
||||
const item = Array.from(e.clipboardData?.items ?? []).find(i => i.type.startsWith('image/'))
|
||||
if (!item) return
|
||||
const file = item.getAsFile()
|
||||
if (!file) return
|
||||
setImage(await fileToBase64(file))
|
||||
}
|
||||
document.addEventListener('paste', onPaste)
|
||||
return () => document.removeEventListener('paste', onPaste)
|
||||
}, [])
|
||||
|
||||
function updatePart(tempId: string, patch: Partial<PartRow>) {
|
||||
setParts(prev => prev.map(p => p.tempId === tempId ? { ...p, ...patch } : p))
|
||||
}
|
||||
|
||||
async function pastePart(tempId: string) {
|
||||
const result = await readImageFromClipboard()
|
||||
if (result) updatePart(tempId, { image: result })
|
||||
}
|
||||
|
||||
async function handleImageFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setImage(await fileToBase64(file))
|
||||
}
|
||||
|
||||
async function handlePartImageFile(tempId: string, e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
updatePart(tempId, { image: await fileToBase64(file) })
|
||||
}
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const id = initialItem?.id ?? crypto.randomUUID()
|
||||
const common = { image: image || undefined, 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,
|
||||
})),
|
||||
})
|
||||
} else if (type === 'complete-drone') {
|
||||
onSubmit({ id, buildType: 'complete', name, brand: brand || undefined, price: { amount: parseFloat(amount) || 0, currency, asOf }, ...common })
|
||||
} else {
|
||||
onSubmit({ id, name, brand: brand || undefined, category, price: { amount: parseFloat(amount) || 0, currency, asOf }, ...common })
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const labelCls = 'block text-xs mb-1'
|
||||
const inputCls = 'w-full rounded border px-2 py-1.5 text-sm outline-none'
|
||||
const fieldCls = 'flex flex-col'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(0,0,0,0.6)' }}
|
||||
onClick={e => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg rounded-lg border p-0"
|
||||
style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)', color: 'var(--color-fg)', maxHeight: '90vh', overflowY: 'auto' }}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3" style={{ borderColor: 'var(--color-bg3)' }}>
|
||||
<h2 className="font-medium">{editing ? 'Edit item' : 'Add item'}</h2>
|
||||
<button type="button" onClick={onClose} style={{ color: 'var(--color-grey)' }}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{isDroneSection && (
|
||||
<div className={fieldCls}>
|
||||
<label className={labelCls} style={{ color: 'var(--color-grey)' }}>Type</label>
|
||||
<select value={type} onChange={e => setType(e.target.value as ItemType)} className={inputCls} style={inputStyle}>
|
||||
<option value="complete-drone">Complete drone</option>
|
||||
<option value="kit">Kit build (parts list)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={fieldCls}>
|
||||
<label className={labelCls} style={{ color: 'var(--color-grey)' }}>Name *</label>
|
||||
<input required value={name} onChange={e => setName(e.target.value)} className={inputCls} style={inputStyle} placeholder="e.g. BetaFPV Cetus X" />
|
||||
</div>
|
||||
|
||||
{type !== 'kit' && (
|
||||
<div className={fieldCls}>
|
||||
<label className={labelCls} style={{ color: 'var(--color-grey)' }}>Brand</label>
|
||||
<input value={brand} onChange={e => setBrand(e.target.value)} className={inputCls} style={inputStyle} placeholder="e.g. BetaFPV" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'product' && (
|
||||
<div className={fieldCls}>
|
||||
<label className={labelCls} style={{ color: 'var(--color-grey)' }}>Category</label>
|
||||
<select value={category} onChange={e => setCategory(e.target.value as Category)} className={inputCls} style={inputStyle}>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type !== 'kit' && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className={fieldCls}>
|
||||
<label className={labelCls} style={{ color: 'var(--color-grey)' }}>Amount</label>
|
||||
<input required value={amount} onChange={e => setAmount(e.target.value)} type="number" min="0" step="0.01" className={inputCls} style={inputStyle} placeholder="0.00" />
|
||||
</div>
|
||||
<div className={fieldCls}>
|
||||
<label className={labelCls} style={{ color: 'var(--color-grey)' }}>Currency</label>
|
||||
<select value={currency} onChange={e => setCurrency(e.target.value as Currency)} className={inputCls} style={inputStyle}>
|
||||
{CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className={fieldCls}>
|
||||
<label className={labelCls} style={{ color: 'var(--color-grey)' }}>Price as of</label>
|
||||
<input value={asOf} onChange={e => setAsOf(e.target.value)} type="date" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main item image */}
|
||||
<div className={fieldCls}>
|
||||
<label className={labelCls} style={{ color: 'var(--color-grey)' }}>
|
||||
Image
|
||||
<span className="ml-2 font-normal" style={{ color: 'var(--color-grey-dim)' }}>— Ctrl+V anywhere to paste</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="file" accept="image/*" onChange={handleImageFile} className="text-sm" style={{ color: 'var(--color-grey)' }} />
|
||||
{image && (
|
||||
<button type="button" onClick={() => setImage('')} className="text-xs" style={{ color: 'var(--color-grey)' }}>
|
||||
✕ clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{image && <img src={image} alt="preview" className="mt-2 h-20 w-20 rounded object-cover" />}
|
||||
</div>
|
||||
|
||||
{type !== 'kit' && (
|
||||
<div className={fieldCls}>
|
||||
<label className={labelCls} style={{ color: 'var(--color-grey)' }}>Store URL</label>
|
||||
<input value={url} onChange={e => setUrl(e.target.value)} className={inputCls} style={inputStyle} placeholder="https://..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'kit' && (
|
||||
<>
|
||||
<div className={fieldCls}>
|
||||
<label className={labelCls} style={{ color: 'var(--color-grey)' }}>Build guide URL</label>
|
||||
<input value={url} onChange={e => setUrl(e.target.value)} className={inputCls} style={inputStyle} placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs" style={{ color: 'var(--color-grey)' }}>Parts</span>
|
||||
<button type="button" onClick={() => setParts(p => [...p, emptyPart()])} className="text-xs" style={{ color: 'var(--color-cyan)' }}>
|
||||
+ Add part
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{parts.map((part, i) => (
|
||||
<div key={part.tempId} className="flex flex-col gap-2 rounded border p-2" style={{ borderColor: 'var(--color-bg3)' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs" style={{ color: 'var(--color-grey)' }}>Part {i + 1}</span>
|
||||
{parts.length > 1 && (
|
||||
<button type="button" onClick={() => setParts(p => p.filter(r => r.tempId !== part.tempId))} className="text-xs" style={{ color: 'var(--color-grey)' }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input value={part.name} onChange={e => updatePart(part.tempId, { name: e.target.value })} placeholder="Name" className={inputCls} style={inputStyle} />
|
||||
<input value={part.brand} onChange={e => updatePart(part.tempId, { brand: e.target.value })} placeholder="Brand" className={inputCls} style={inputStyle} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<select value={part.category} onChange={e => updatePart(part.tempId, { category: e.target.value as Category })} className={inputCls} style={inputStyle}>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<input value={part.amount} onChange={e => updatePart(part.tempId, { amount: e.target.value })} type="number" min="0" step="0.01" placeholder="Price" className={inputCls} style={inputStyle} />
|
||||
<select value={part.currency} onChange={e => updatePart(part.tempId, { currency: e.target.value as Currency })} className={inputCls} style={inputStyle}>
|
||||
{CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<input value={part.url} onChange={e => updatePart(part.tempId, { url: e.target.value })} placeholder="Store URL (optional)" className={inputCls} style={inputStyle} />
|
||||
|
||||
{/* Part image */}
|
||||
<div className="flex items-center gap-2">
|
||||
{part.image
|
||||
? <img src={part.image} alt="part" className="h-10 w-10 rounded object-cover" />
|
||||
: <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded text-xs" style={{ background: 'var(--color-bg3)', color: 'var(--color-grey)' }}>img</div>
|
||||
}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={e => handlePartImageFile(part.tempId, e)}
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--color-grey)' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pastePart(part.tempId)}
|
||||
className="shrink-0 rounded px-2 py-1 text-xs"
|
||||
style={{ background: 'var(--color-bg2)', color: 'var(--color-grey)' }}
|
||||
title="Paste image from clipboard"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
{part.image && (
|
||||
<button type="button" onClick={() => updatePart(part.tempId, { image: '' })} className="text-xs" style={{ color: 'var(--color-grey)' }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={fieldCls}>
|
||||
<label className={labelCls} style={{ color: 'var(--color-grey)' }}>Note</label>
|
||||
<input value={note} onChange={e => setNote(e.target.value)} className={inputCls} style={inputStyle} placeholder="e.g. best beginner choice" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t px-4 py-3" style={{ borderColor: 'var(--color-bg3)' }}>
|
||||
<button type="button" onClick={onClose} className="rounded px-3 py-1.5 text-sm" style={{ background: 'var(--color-bg2)', color: 'var(--color-grey)' }}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="rounded px-3 py-1.5 text-sm font-medium" style={{ background: 'var(--color-yellow)', color: 'var(--color-black)' }}>
|
||||
{editing ? 'Save' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--color-bg2)',
|
||||
color: 'var(--color-fg)',
|
||||
borderColor: 'var(--color-bg3)',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onAdd: (id: string, label: string, required: boolean) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AddSectionDialog({ onAdd, onClose }: Props) {
|
||||
const [label, setLabel] = useState('')
|
||||
const [required, setRequired] = useState(false)
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const id = label.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
onAdd(id, label, required)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const inputCls = 'w-full rounded border px-2 py-1.5 text-sm outline-none'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(0,0,0,0.6)' }}
|
||||
onClick={e => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-lg border p-0"
|
||||
style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)', color: 'var(--color-fg)' }}
|
||||
>
|
||||
<form onSubmit={submit}>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3" style={{ borderColor: 'var(--color-bg3)' }}>
|
||||
<h2 className="font-medium">Add section</h2>
|
||||
<button type="button" onClick={onClose} style={{ color: 'var(--color-grey)' }}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs" style={{ color: 'var(--color-grey)' }}>Label *</label>
|
||||
<input
|
||||
required
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
className={inputCls}
|
||||
style={inputStyle}
|
||||
placeholder="e.g. Goggles"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={required} onChange={e => setRequired(e.target.checked)} />
|
||||
<span style={{ color: 'var(--color-grey)' }}>Required (can't fly without this)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t px-4 py-3" style={{ borderColor: 'var(--color-bg3)' }}>
|
||||
<button type="button" onClick={onClose} className="rounded px-3 py-1.5 text-sm" style={{ background: 'var(--color-bg2)', color: 'var(--color-grey)' }}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="rounded px-3 py-1.5 text-sm font-medium" style={{ background: 'var(--color-yellow)', color: 'var(--color-black)' }}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react'
|
||||
import type { Product, Drone, CompleteDrone, KitBuild } from '../types'
|
||||
|
||||
function fmt(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat('en', { style: 'currency', currency }).format(amount)
|
||||
}
|
||||
|
||||
function kitTotal(kit: KitBuild): string {
|
||||
if (kit.parts.length === 0) return '—'
|
||||
const currency = kit.parts[0].price.currency
|
||||
const total = kit.parts.reduce((sum, p) => sum + p.price.amount, 0)
|
||||
return `~${fmt(total, currency)}`
|
||||
}
|
||||
|
||||
interface Props {
|
||||
item: Product | Drone
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export function ItemCard({ item, onEdit, onRemove }: Props) {
|
||||
const isDrone = 'buildType' in item
|
||||
const isKit = isDrone && (item as Drone).buildType === 'kit'
|
||||
|
||||
const [hoverImg, setHoverImg] = useState<string | null>(null)
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
|
||||
|
||||
const priceLabel = isKit
|
||||
? kitTotal(item as KitBuild)
|
||||
: isDrone
|
||||
? 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
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-3 rounded-md border p-3"
|
||||
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" />
|
||||
) : (
|
||||
<div
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded text-2xl"
|
||||
style={{ background: 'var(--color-bg2)' }}
|
||||
>
|
||||
{isDrone ? '🚁' : '📦'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium" style={{ color: 'var(--color-fg)' }}>{item.name}</span>
|
||||
{brand && (
|
||||
<span className="ml-2 text-sm" style={{ color: 'var(--color-grey)' }}>{brand}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{url && (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="text-xs underline" style={{ color: 'var(--color-cyan)' }}>
|
||||
Buy
|
||||
</a>
|
||||
)}
|
||||
<button onClick={onEdit} className="text-xs" style={{ color: 'var(--color-grey)' }} title="Edit">✎</button>
|
||||
<button onClick={onRemove} className="text-sm" style={{ color: 'var(--color-grey)' }} title="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{category && (
|
||||
<span className="rounded px-1.5 py-0.5 text-xs" style={{ background: 'var(--color-bg2)', color: 'var(--color-grey)' }}>
|
||||
{category}
|
||||
</span>
|
||||
)}
|
||||
{isDrone && (
|
||||
<span className="rounded px-1.5 py-0.5 text-xs" style={{ background: 'var(--color-bg2)', color: 'var(--color-blue)' }}>
|
||||
{(item as Drone).buildType}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-yellow)' }}>{priceLabel}</span>
|
||||
</div>
|
||||
|
||||
{item.note && (
|
||||
<p className="text-xs" style={{ color: 'var(--color-grey)' }}>{item.note}</p>
|
||||
)}
|
||||
|
||||
{isKit && (item as KitBuild).parts.length > 0 && (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-xs" style={{ color: 'var(--color-grey)' }}>
|
||||
{(item as KitBuild).parts.length} parts
|
||||
</summary>
|
||||
<ul className="mt-1 space-y-0.5 pl-2">
|
||||
{(item as KitBuild).parts.map(p => (
|
||||
<li
|
||||
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}
|
||||
onMouseMove={p.image ? e => setMousePos({ x: e.clientX, y: e.clientY }) : undefined}
|
||||
onMouseLeave={p.image ? () => setHoverImg(null) : 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)' }}>
|
||||
{p.category}
|
||||
</span>
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover image preview */}
|
||||
{hoverImg && (
|
||||
<div
|
||||
className="pointer-events-none fixed z-50 rounded-lg border shadow-lg overflow-hidden"
|
||||
style={{
|
||||
left: mousePos.x + 16,
|
||||
top: mousePos.y + 16,
|
||||
borderColor: 'var(--color-bg3)',
|
||||
background: 'var(--color-bg1)',
|
||||
}}
|
||||
>
|
||||
<img src={hoverImg} alt="" className="block h-48 w-48 object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react'
|
||||
import type { Section, Product, Drone } from '../types'
|
||||
import { ItemCard } from './ItemCard'
|
||||
import { AddItemDialog } from './AddItemDialog'
|
||||
|
||||
interface Props {
|
||||
section: Section
|
||||
onRemoveItem: (itemId: string) => void
|
||||
onAddItem: (item: Product | Drone) => void
|
||||
onEditItem: (item: Product | Drone) => void
|
||||
onRemoveSection: () => void
|
||||
onRenameSection: (label: string) => void
|
||||
}
|
||||
|
||||
export function SectionBlock({ section, onRemoveItem, onAddItem, onEditItem, onRemoveSection, onRenameSection }: Props) {
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<Product | Drone | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [labelInput, setLabelInput] = useState(section.label)
|
||||
|
||||
function submitRename(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (labelInput.trim()) onRenameSection(labelInput.trim())
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-4" style={{ background: 'var(--color-bg1)', borderColor: 'var(--color-bg3)' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{editing ? (
|
||||
<form onSubmit={submitRename} className="flex items-center gap-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={labelInput}
|
||||
onChange={e => setLabelInput(e.target.value)}
|
||||
onBlur={submitRename}
|
||||
className="rounded border px-2 py-0.5 text-sm outline-none"
|
||||
style={{ background: 'var(--color-bg2)', color: 'var(--color-fg)', borderColor: 'var(--color-bg3)' }}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
className="font-medium"
|
||||
style={{ color: 'var(--color-fg)' }}
|
||||
onClick={() => { setLabelInput(section.label); setEditing(true) }}
|
||||
title="Click to rename"
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
)}
|
||||
{section.required && (
|
||||
<span className="rounded px-1.5 py-0.5 text-xs" style={{ background: 'var(--color-bg-red)', color: 'var(--color-red)' }}>
|
||||
required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="rounded px-2 py-1 text-xs"
|
||||
style={{ background: 'var(--color-bg2)', color: 'var(--color-cyan)' }}
|
||||
>
|
||||
+ Add item
|
||||
</button>
|
||||
<button
|
||||
onClick={onRemoveSection}
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--color-grey)' }}
|
||||
title="Remove section"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{section.items.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{section.items.map(item => (
|
||||
<ItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onEdit={() => setEditingItem(item)}
|
||||
onRemove={() => onRemoveItem(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-4 text-center text-sm" style={{ color: 'var(--color-grey)' }}>
|
||||
No items yet — add one above
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<AddItemDialog
|
||||
isDroneSection={section.id === 'drone'}
|
||||
onSubmit={onAddItem}
|
||||
onClose={() => setShowAdd(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingItem && (
|
||||
<AddItemDialog
|
||||
isDroneSection={section.id === 'drone'}
|
||||
initialItem={editingItem}
|
||||
onSubmit={item => { onEditItem(item); setEditingItem(null) }}
|
||||
onClose={() => setEditingItem(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState } from 'react'
|
||||
import type { Cart, CartTemplate, Section, Product, Drone } from '../types'
|
||||
|
||||
const CARTS_KEY = 'carts'
|
||||
const ACTIVE_KEY = 'active-cart-id'
|
||||
|
||||
function loadCarts(): Cart[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CARTS_KEY)
|
||||
if (raw) return JSON.parse(raw) as Cart[]
|
||||
} catch {}
|
||||
return []
|
||||
}
|
||||
|
||||
function loadActiveId(carts: Cart[]): string | null {
|
||||
const stored = localStorage.getItem(ACTIVE_KEY)
|
||||
if (stored && carts.find(c => c.id === stored)) return stored
|
||||
return carts[0]?.id ?? null
|
||||
}
|
||||
|
||||
function saveCarts(carts: Cart[]) {
|
||||
localStorage.setItem(CARTS_KEY, JSON.stringify(carts))
|
||||
}
|
||||
|
||||
export function useCarts() {
|
||||
const [carts, setCarts] = useState<Cart[]>(() => loadCarts())
|
||||
const [activeId, setActiveId] = useState<string | null>(() => {
|
||||
const c = loadCarts()
|
||||
return loadActiveId(c)
|
||||
})
|
||||
|
||||
const activeCart = carts.find(c => c.id === activeId) ?? null
|
||||
|
||||
function mutateCarts(fn: (carts: Cart[]) => Cart[]) {
|
||||
setCarts(prev => {
|
||||
const next = fn(prev)
|
||||
saveCarts(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function mutateActiveCart(fn: (cart: Cart) => Cart) {
|
||||
mutateCarts(carts => carts.map(c => c.id === activeId ? fn(c) : c))
|
||||
}
|
||||
|
||||
// ── Cart management ────────────────────────────────────────────────────────
|
||||
|
||||
function createCart(name: string, template?: CartTemplate): string {
|
||||
const id = crypto.randomUUID()
|
||||
const cart: Cart = {
|
||||
id,
|
||||
name,
|
||||
createdAt: new Date().toISOString().slice(0, 10),
|
||||
templateId: template?.id,
|
||||
sections: template ? structuredClone(template.sections) : [],
|
||||
}
|
||||
mutateCarts(prev => [...prev, cart])
|
||||
setActiveCart(id)
|
||||
return id
|
||||
}
|
||||
|
||||
function deleteCart(id: string) {
|
||||
mutateCarts(prev => {
|
||||
const next = prev.filter(c => c.id !== id)
|
||||
if (activeId === id) {
|
||||
const newActive = next[0]?.id ?? null
|
||||
setActiveId(newActive)
|
||||
if (newActive) localStorage.setItem(ACTIVE_KEY, newActive)
|
||||
else localStorage.removeItem(ACTIVE_KEY)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function renameCart(id: string, name: string) {
|
||||
mutateCarts(carts => carts.map(c => c.id === id ? { ...c, name } : c))
|
||||
}
|
||||
|
||||
function setActiveCart(id: string) {
|
||||
setActiveId(id)
|
||||
localStorage.setItem(ACTIVE_KEY, id)
|
||||
}
|
||||
|
||||
function importCart(json: string): 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() }
|
||||
mutateCarts(prev => [...prev, imported])
|
||||
setActiveCart(imported.id)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function exportCart() {
|
||||
if (!activeCart) return
|
||||
const blob = new Blob([JSON.stringify(activeCart, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${activeCart.name.toLowerCase().replace(/\s+/g, '-')}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ── Section management ────────────────────────────────────────────────────
|
||||
|
||||
function addSection(section: Omit<Section, 'items'>) {
|
||||
mutateActiveCart(cart => ({
|
||||
...cart,
|
||||
sections: [...cart.sections, { ...section, items: [] }],
|
||||
}))
|
||||
}
|
||||
|
||||
function removeSection(sectionId: string) {
|
||||
mutateActiveCart(cart => ({
|
||||
...cart,
|
||||
sections: cart.sections.filter(s => s.id !== sectionId),
|
||||
}))
|
||||
}
|
||||
|
||||
function renameSection(sectionId: string, label: string) {
|
||||
mutateActiveCart(cart => ({
|
||||
...cart,
|
||||
sections: cart.sections.map(s => s.id === sectionId ? { ...s, label } : s),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Item management ───────────────────────────────────────────────────────
|
||||
|
||||
function addItem(sectionId: string, item: Product | Drone) {
|
||||
mutateActiveCart(cart => ({
|
||||
...cart,
|
||||
sections: cart.sections.map(s =>
|
||||
s.id === sectionId ? { ...s, items: [...s.items, item] } : s
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function updateItem(sectionId: string, item: Product | Drone) {
|
||||
mutateActiveCart(cart => ({
|
||||
...cart,
|
||||
sections: cart.sections.map(s =>
|
||||
s.id === sectionId
|
||||
? { ...s, items: s.items.map(i => i.id === item.id ? item : i) }
|
||||
: s
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeItem(sectionId: string, itemId: string) {
|
||||
mutateActiveCart(cart => ({
|
||||
...cart,
|
||||
sections: cart.sections.map(s =>
|
||||
s.id === sectionId
|
||||
? { ...s, items: s.items.filter(i => i.id !== itemId) }
|
||||
: s
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function copyItemToCart(item: Product | Drone, targetCartId: string, targetSectionId: string) {
|
||||
const copy = { ...structuredClone(item), id: crypto.randomUUID() }
|
||||
mutateCarts(carts => carts.map(c => {
|
||||
if (c.id !== targetCartId) return c
|
||||
const hasSection = c.sections.some(s => s.id === targetSectionId)
|
||||
if (!hasSection) return c
|
||||
return {
|
||||
...c,
|
||||
sections: c.sections.map(s =>
|
||||
s.id === targetSectionId ? { ...s, items: [...s.items, copy] } : s
|
||||
),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
carts,
|
||||
activeCart,
|
||||
activeId,
|
||||
setActiveCart,
|
||||
createCart,
|
||||
deleteCart,
|
||||
renameCart,
|
||||
importCart,
|
||||
exportCart,
|
||||
addSection,
|
||||
removeSection,
|
||||
renameSection,
|
||||
addItem,
|
||||
updateItem,
|
||||
removeItem,
|
||||
copyItemToCart,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { CuratedCatalog } from '../types'
|
||||
|
||||
export function useCatalog() {
|
||||
const [catalog, setCatalog] = useState<CuratedCatalog | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/curated.json')
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`Failed to load curated.json (${r.status})`)
|
||||
return r.json() as Promise<CuratedCatalog>
|
||||
})
|
||||
.then(data => setCatalog(data))
|
||||
.catch(err => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return { catalog, loading, error }
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// ── Primitives ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Currency = 'USD' | 'EUR' | 'GBP' | 'SEK'
|
||||
|
||||
export interface Price {
|
||||
amount: number
|
||||
currency: Currency
|
||||
asOf: string // ISO date — prices are a point-in-time snapshot
|
||||
}
|
||||
|
||||
// ── Categories ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Category =
|
||||
| 'frame'
|
||||
| 'flight-controller'
|
||||
| 'esc'
|
||||
| 'motor'
|
||||
| 'camera'
|
||||
| 'vtx'
|
||||
| 'props'
|
||||
| 'battery'
|
||||
| 'charger'
|
||||
| 'radio'
|
||||
| 'receiver'
|
||||
| 'goggles'
|
||||
| 'complete-drone'
|
||||
| 'accessory'
|
||||
|
||||
// ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
name: string
|
||||
brand?: string
|
||||
category: Category
|
||||
price: Price
|
||||
url?: string
|
||||
image?: string // base64 data URI
|
||||
note?: string
|
||||
}
|
||||
|
||||
// ── Drone builds ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompleteDrone {
|
||||
id: string
|
||||
buildType: 'complete'
|
||||
name: string
|
||||
brand?: string
|
||||
price: Price
|
||||
url?: string
|
||||
image?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface KitBuild {
|
||||
id: string
|
||||
buildType: 'kit'
|
||||
name: string
|
||||
url?: string // build guide
|
||||
image?: string
|
||||
note?: string
|
||||
parts: Product[] // total price is derived
|
||||
}
|
||||
|
||||
export type Drone = CompleteDrone | KitBuild
|
||||
|
||||
// ── Sections ──────────────────────────────────────────────────────────────────
|
||||
// Shared by both templates and carts.
|
||||
|
||||
export interface Section {
|
||||
id: string
|
||||
label: string
|
||||
required: boolean
|
||||
description?: string
|
||||
items: (Product | Drone)[]
|
||||
}
|
||||
|
||||
// ── Curated catalog (fetched from /curated.json) ──────────────────────────────
|
||||
|
||||
export interface CuratedCatalog {
|
||||
version: string
|
||||
updatedAt: string
|
||||
templates: CartTemplate[]
|
||||
}
|
||||
|
||||
export interface CartTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
currency: Currency
|
||||
sections: Section[]
|
||||
}
|
||||
|
||||
// ── User carts (stored in localStorage) ──────────────────────────────────────
|
||||
|
||||
export interface Cart {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
templateId?: string // which template it was seeded from
|
||||
sections: Section[]
|
||||
}
|
||||
Reference in New Issue
Block a user