Curator list app PoC completed

This commit is contained in:
2026-04-20 00:22:08 +02:00
parent 0d829e0387
commit 8fbd137932
10 changed files with 1457 additions and 49 deletions
+43 -8
View File
@@ -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 ## Stack
@@ -11,22 +36,32 @@ A React + TypeScript + Vite project with Tailwind CSS v4.
## Setup ## Setup
```bash Install dependencies
# Install dependencies
```sh
bun install bun install
```
# Start dev server Start dev server
```sh
bun dev bun dev
```
# Build for production Build for production
```sh
bun run build bun run build
```
# Preview production build Preview production build
```sh
bun run preview bun run preview
``` ```
## Scaffolded with ## Scaffolded with
```bash ```sh
bun create vite bun create vite
``` ```
File diff suppressed because one or more lines are too long
+244 -29
View File
@@ -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 = [ const THEMES = [
{ value: '', label: 'OS default' }, { value: '', label: 'OS default' },
@@ -13,47 +18,257 @@ const THEMES = [
{ value: 'axis-light', label: 'Axis Light' }, { 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 [theme, setTheme] = useState(() => {
const stored = localStorage.getItem(STORAGE_KEY) ?? '' const stored = localStorage.getItem(THEME_KEY) ?? ''
if (stored) document.documentElement.setAttribute('data-theme', stored) if (stored) document.documentElement.setAttribute('data-theme', stored)
return 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) { function handleThemeChange(value: string) {
setTheme(value) setTheme(value)
if (value) { if (value) { localStorage.setItem(THEME_KEY, value); document.documentElement.setAttribute('data-theme', value) }
localStorage.setItem(STORAGE_KEY, value) else { localStorage.removeItem(THEME_KEY); document.documentElement.removeAttribute('data-theme') }
document.documentElement.setAttribute('data-theme', value)
} else {
localStorage.removeItem(STORAGE_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 ( return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6"> <div className="min-h-screen" style={{ background: 'var(--color-bg0)' }}>
<h1 className="text-4xl font-bold" style={{ color: 'var(--color-fg)' }}> {/* Header */}
Hello, World! <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> <h1 className="font-semibold tracking-tight" style={{ color: 'var(--color-fg)' }}>FPV Shop List</h1>
<select <div className="flex items-center gap-2 flex-wrap">
value={theme} {activeCart && (
onChange={e => handleThemeChange(e.target.value)} <span className="text-sm font-medium" style={{ color: 'var(--color-yellow)' }}>
className="rounded border px-3 py-2 text-sm" {cartTotal(activeCart.sections)}
style={{ </span>
background: 'var(--color-bg1)', )}
color: 'var(--color-fg)', {activeCart && (
borderColor: 'var(--color-grey-dim)', <button onClick={exportCart} className="rounded px-3 py-1.5 text-sm" style={{ background: 'var(--color-bg2)', color: 'var(--color-cyan)' }}>
}} Export
> </button>
{THEMES.map(t => ( )}
<option key={t.value} value={t.value}>{t.label}</option> <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> </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> </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
+365
View File
@@ -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>
)
}
+139
View File
@@ -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>
)
}
+114
View File
@@ -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>
)
}
+197
View File
@@ -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,
}
}
+21
View File
@@ -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 }
}
+102
View File
@@ -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[]
}