284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
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' },
|
|
{ value: 'sonokai-default', label: 'Sonokai Default' },
|
|
{ value: 'sonokai-default-darker', label: 'Sonokai Default Darker' },
|
|
{ value: 'sonokai-shusia', label: 'Sonokai Shusia' },
|
|
{ value: 'sonokai-andromeda', label: 'Sonokai Andromeda' },
|
|
{ value: 'sonokai-atlantis', label: 'Sonokai Atlantis' },
|
|
{ value: 'sonokai-maia', label: 'Sonokai Maia' },
|
|
{ value: 'sonokai-espresso', label: 'Sonokai Espresso' },
|
|
{ value: 'axis-dark', label: 'Axis Dark' },
|
|
{ value: 'axis-light', label: 'Axis Light' },
|
|
]
|
|
|
|
const THEME_KEY = 'theme'
|
|
|
|
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(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, storageError, setActiveCart, createCart, deleteCart, renameCart, importCart, exportCart, addSection, removeSection, renameSection, reorderSections, addItem, updateItem, removeItem } = useCarts()
|
|
const [dragSectionId, setDragSectionId] = useState<string | null>(null)
|
|
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
|
|
|
function commitCartRename() {
|
|
if (renamingCartId && renameCartValue.trim()) renameCart(renamingCartId, renameCartValue.trim())
|
|
setRenamingCartId(null)
|
|
}
|
|
|
|
function handleThemeChange(value: string) {
|
|
setTheme(value)
|
|
if (value) { localStorage.setItem(THEME_KEY, value); document.documentElement.setAttribute('data-theme', value) }
|
|
else { localStorage.removeItem(THEME_KEY); document.documentElement.removeAttribute('data-theme') }
|
|
}
|
|
|
|
async function handleCreateFromTemplate(templateId: string) {
|
|
const existing = carts.find(c => c.templateId === templateId)
|
|
if (existing) { setActiveCart(existing.id); return }
|
|
const template = catalog?.templates.find(t => t.id === templateId)
|
|
if (!template) return
|
|
await createCart(template.name, template)
|
|
}
|
|
|
|
async function handleCreateEmpty() {
|
|
if (!newCartName.trim()) return
|
|
await createCart(newCartName.trim())
|
|
setNewCartName('')
|
|
setShowNewCart(false)
|
|
}
|
|
|
|
const selectStyle = { background: 'var(--color-bg1)', color: 'var(--color-fg)', borderColor: 'var(--color-bg3)' }
|
|
|
|
return (
|
|
<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 => {
|
|
importCart(json).then(ok => { if (!ok) 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>}
|
|
{storageError && <p className="rounded-lg border px-4 py-3 text-sm" style={{ color: 'var(--color-red)', borderColor: 'var(--color-red)', background: 'var(--color-bg1)' }}>{storageError}</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)}
|
|
isDragging={dragSectionId === section.id}
|
|
isDragOver={dragOverId === section.id}
|
|
onDragStart={() => setDragSectionId(section.id)}
|
|
onDragOver={e => { e.preventDefault(); setDragOverId(section.id) }}
|
|
onDrop={() => { if (dragSectionId) reorderSections(dragSectionId, section.id); setDragOverId(null) }}
|
|
onDragEnd={() => { setDragSectionId(null); setDragOverId(null) }}
|
|
/>
|
|
))}
|
|
<button
|
|
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>
|
|
)
|
|
}
|