Add scan and library UI pages

ScanPage (barcode/text search) and DownloadsPage (library + pen sync
status/progress) wired up via bottom-nav tabs in App.tsx.
This commit is contained in:
2026-08-12 11:54:39 +02:00
parent 49786ac54f
commit 30170436fa
4 changed files with 890 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
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>
)
}