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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user