Inital commit
This commit is contained in:
+274
@@ -0,0 +1,274 @@
|
||||
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, 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(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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user