ScanPage (barcode/text search) and DownloadsPage (library + pen sync status/progress) wired up via bottom-nav tabs in App.tsx.
184 lines
5.6 KiB
TypeScript
184 lines
5.6 KiB
TypeScript
import { createSignal, For, Show } from 'solid-js'
|
||
import QrScanner from '../components/QrScanner'
|
||
import { searchBook, downloadBook, type GmeEntry } from '../api'
|
||
|
||
type State =
|
||
| { type: 'idle' }
|
||
| { type: 'searching' }
|
||
| { type: 'found'; title: string; gmeUrls: GmeEntry[] }
|
||
| { type: 'not_found' }
|
||
| { type: 'downloading' }
|
||
| { type: 'done'; filename: string }
|
||
| { type: 'error'; message: string }
|
||
|
||
export default function ScanPage() {
|
||
const [state, setState] = createSignal<State>({ type: 'idle' })
|
||
const [query, setQuery] = createSignal('')
|
||
|
||
const search = async (q: string) => {
|
||
if (!q.trim()) return
|
||
setState({ type: 'searching' })
|
||
try {
|
||
const data = await searchBook(q.trim())
|
||
if (data.error || !data.gmeUrls?.length) {
|
||
setState({ type: 'not_found' })
|
||
} else {
|
||
setState({ type: 'found', title: data.title!, gmeUrls: data.gmeUrls! })
|
||
}
|
||
} catch (e) {
|
||
setState({ type: 'error', message: e instanceof Error ? e.message : String(e) })
|
||
}
|
||
}
|
||
|
||
const download = async (gmeUrl: string, imageUrl?: string) => {
|
||
setState({ type: 'downloading' })
|
||
try {
|
||
const data = await downloadBook(gmeUrl, imageUrl)
|
||
if (data.filename) {
|
||
setState({ type: 'done', filename: data.filename })
|
||
} else {
|
||
setState({ type: 'error', message: data.message })
|
||
}
|
||
} catch (e) {
|
||
setState({ type: 'error', message: e instanceof Error ? e.message : String(e) })
|
||
}
|
||
}
|
||
|
||
const reset = () => {
|
||
setState({ type: 'idle' })
|
||
setQuery('')
|
||
}
|
||
|
||
const isIdle = () => ['idle', 'not_found', 'done'].includes(state().type)
|
||
|
||
return (
|
||
<div class="page">
|
||
|
||
{/* Suchfeld */}
|
||
<Show when={state().type === 'idle' || state().type === 'not_found'}>
|
||
<div>
|
||
<Show when={state().type === 'not_found'}>
|
||
<p class="hint" style="margin-bottom:0.6rem">
|
||
Kein Buch gefunden – Titel oder Artikelnummer eingeben:
|
||
</p>
|
||
</Show>
|
||
<div class="search-row">
|
||
<input
|
||
class="input"
|
||
type="search"
|
||
placeholder="z. B. Tierkinder oder 55476"
|
||
value={query()}
|
||
onInput={e => setQuery(e.currentTarget.value)}
|
||
onKeyDown={e => e.key === 'Enter' && search(query())}
|
||
/>
|
||
<button class="btn" onClick={() => search(query())}>
|
||
<IconSearch />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Scanner */}
|
||
<Show when={isIdle() && 'BarcodeDetector' in window}>
|
||
<QrScanner onScan={search} />
|
||
</Show>
|
||
|
||
{/* Suche läuft */}
|
||
<Show when={state().type === 'searching'}>
|
||
<div class="status">
|
||
<span class="spinner" />
|
||
Suche läuft…
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Ergebnis */}
|
||
<Show when={state().type === 'found'}>
|
||
{(() => {
|
||
const s = state() as { type: 'found'; title: string; gmeUrls: GmeEntry[] }
|
||
return (
|
||
<div class="result-list">
|
||
<p class="result-card__title">{s.title}</p>
|
||
<For each={s.gmeUrls}>
|
||
{(entry) => (
|
||
<div class="card result-card">
|
||
<Show when={entry.imageUrl}>
|
||
<img src={entry.imageUrl!} class="result-card__image" alt={entry.label} />
|
||
</Show>
|
||
<button class="btn" onClick={() => download(entry.url, entry.imageUrl ?? undefined)}>
|
||
<IconDownload />
|
||
Herunterladen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
)
|
||
})()}
|
||
</Show>
|
||
|
||
{/* Download läuft */}
|
||
<Show when={state().type === 'downloading'}>
|
||
<div class="status">
|
||
<span class="spinner" />
|
||
Download läuft…
|
||
</div>
|
||
</Show>
|
||
|
||
{/* Erfolg */}
|
||
<Show when={state().type === 'done'}>
|
||
{(() => {
|
||
const s = state() as { type: 'done'; filename: string }
|
||
return (
|
||
<div class="card">
|
||
<p class="status status--ok">
|
||
<IconCheck /> {s.filename} wurde gespeichert
|
||
</p>
|
||
</div>
|
||
)
|
||
})()}
|
||
</Show>
|
||
|
||
{/* Fehler */}
|
||
<Show when={state().type === 'error'}>
|
||
{(() => {
|
||
const s = state() as { type: 'error'; message: string }
|
||
return (
|
||
<div class="card">
|
||
<p class="status status--error">Fehler: {s.message}</p>
|
||
</div>
|
||
)
|
||
})()}
|
||
</Show>
|
||
|
||
{/* Zurück */}
|
||
<Show when={state().type !== 'idle'}>
|
||
<button class="btn btn--ghost" onClick={reset}>Neu scannen</button>
|
||
</Show>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function IconSearch() {
|
||
return (
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
|
||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function IconDownload() {
|
||
return (
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
function IconCheck() {
|
||
return (
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
|
||
<polyline points="20 6 9 17 4 12"/>
|
||
</svg>
|
||
)
|
||
}
|