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
+86
View File
@@ -0,0 +1,86 @@
import { createSignal, Show } from 'solid-js'
import ScanPage from './pages/ScanPage'
import DownloadsPage from './pages/DownloadsPage'
import DevMenu from './components/DevMenu'
import './App.css'
type Tab = 'scan' | 'downloads'
// 5 Taps innerhalb von 2 Sekunden auf den Titel öffnen das Dev-Menü
const DEV_TAP_COUNT = 5
const DEV_TAP_WINDOW = 2000
export default function App() {
const [tab, setTab] = createSignal<Tab>('scan')
const [devMenuOpen, setDevMenuOpen] = createSignal(false)
let tapCount = 0
let tapTimer: ReturnType<typeof setTimeout> | undefined
const handleTitleTap = () => {
tapCount++
clearTimeout(tapTimer)
if (tapCount >= DEV_TAP_COUNT) {
tapCount = 0
setDevMenuOpen(true)
} else {
tapTimer = setTimeout(() => { tapCount = 0 }, DEV_TAP_WINDOW)
}
}
return (
<div class="shell">
<header class="topbar">
<span class="topbar__title" onClick={handleTitleTap}>
<Show when={tab() === 'scan'}>Scannen</Show>
<Show when={tab() === 'downloads'}>Bibliothek</Show>
</span>
</header>
<Show when={tab() === 'scan'}>
<ScanPage />
</Show>
<Show when={tab() === 'downloads'}>
<DownloadsPage />
</Show>
<nav class="bottom-nav">
<button
class={`bottom-nav__item${tab() === 'scan' ? ' active' : ''}`}
onClick={() => setTab('scan')}
>
<IconQr />
Scannen
</button>
<button
class={`bottom-nav__item${tab() === 'downloads' ? ' active' : ''}`}
onClick={() => setTab('downloads')}
>
<IconLibrary />
Bibliothek
</button>
</nav>
<Show when={devMenuOpen()}>
<DevMenu onClose={() => setDevMenuOpen(false)} />
</Show>
</div>
)
}
function IconQr() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<path d="M14 14h2v2h-2zM18 14h3M14 18h3M19 18v3M14 21h2"/>
</svg>
)
}
function IconLibrary() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
interface Props {
onScan: (code: string) => void
}
export default function QrScanner(props: Props) {
let videoRef: HTMLVideoElement | undefined
let detectorRef: BarcodeDetector | undefined
let rafId: number
let stream: MediaStream | undefined
const [scanning, setScanning] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [supported, setSupported] = createSignal(true)
onMount(() => {
if (!('BarcodeDetector' in window)) {
setSupported(false)
}
})
const startScan = async () => {
setError(null)
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
})
// Video-Element erst einblenden, dann srcObject setzen
setScanning(true)
// Einen Microtask abwarten damit das DOM aktualisiert wird
await Promise.resolve()
if (!videoRef) return
videoRef.srcObject = stream
await videoRef.play()
detectorRef = new BarcodeDetector({ formats: ['qr_code', 'ean_13', 'code_128'] })
const detect = async () => {
if (!videoRef || videoRef.readyState < 2) {
rafId = requestAnimationFrame(detect)
return
}
try {
const codes = await detectorRef!.detect(videoRef)
if (codes.length > 0) {
stopScan()
props.onScan(codes[0].rawValue)
return
}
} catch {
// detection frame error continue
}
rafId = requestAnimationFrame(detect)
}
rafId = requestAnimationFrame(detect)
} catch (e: unknown) {
setScanning(false)
setError(e instanceof Error ? e.message : 'Kamerafehler')
}
}
const stopScan = () => {
cancelAnimationFrame(rafId)
stream?.getTracks().forEach((t) => t.stop())
stream = undefined
if (videoRef) {
videoRef.srcObject = null
}
setScanning(false)
}
onCleanup(stopScan)
if (!supported()) {
return (
<p class="error">
BarcodeDetector API wird von diesem Browser nicht unterstützt.
</p>
)
}
return (
<div class="scanner">
<Show when={scanning()}>
<video ref={videoRef} class="scanner__video" muted playsinline />
<button class="btn btn--secondary" onClick={stopScan}>
Stopp
</button>
</Show>
<Show when={!scanning()}>
<button class="btn" onClick={startScan}>
QR-Code scannen
</button>
</Show>
{error() && <p class="error">{error()}</p>}
</div>
)
}
+519
View File
@@ -0,0 +1,519 @@
import { batch, createMemo, createSignal, For, onMount, Show } from 'solid-js'
import {
listDownloads, listPenFiles,
detectPen, requestPenAccess,
syncFile, syncAll, ejectPen,
deleteDownload, deletePenFile,
type Book, type PenInfo,
} from '../api'
export default function DownloadsPage() {
// Bibliothek
const [books, setBooks] = createSignal<Book[]>([])
const [booksLoading, setBooksLoading] = createSignal(true)
const [booksError, setBooksError] = createSignal<string | null>(null)
// Stift
const [pen, setPen] = createSignal<PenInfo | null>(null)
const [penFiles, setPenFiles] = createSignal<string[]>([])
const [penDetecting, setPenDetecting] = createSignal(true)
const [penCandidates, setPenCandidates] = createSignal<PenInfo[]>([])
// true wenn eine SAF-URI gespeichert ist (Stift wurde einmal verknüpft)
const [hasSavedPen, setHasSavedPen] = createSignal(false)
// Aktionen
const [syncingAll, setSyncingAll] = createSignal(false)
const [syncingFile, setSyncingFile] = createSignal<string | null>(null)
const [ejecting, setEjecting] = createSignal(false)
const [ejectError, setEjectError] = createSignal<string | null>(null)
const [syncError, setSyncError] = createSignal<string | null>(null)
const [deletingDownload, setDeletingDownload] = createSignal<string | null>(null)
const [deletingPenFile, setDeletingPenFile] = createSignal<string | null>(null)
// Fortschritt: Dateiname → 0..1
const [copyProgress, setCopyProgress] = createSignal<Record<string, number>>({})
// Abgeleitete Werte
const penFileSet = createMemo(() => new Set(penFiles()))
const libraryFileSet = createMemo(() => new Set(books().map(b => b.filename)))
const annotatedBooks = createMemo(() => books().map(b => ({ ...b, onPen: penFileSet().has(b.filename) })))
const penOnlyFiles = createMemo(() => penFiles().filter(f => !libraryFileSet().has(f)))
const missingOnPen = createMemo(() => annotatedBooks().filter(b => !b.onPen).length)
// Laden
const loadBooks = async () => {
try {
setBooks(await listDownloads())
} catch (e) {
setBooksError(e instanceof Error ? e.message : String(e))
} finally {
setBooksLoading(false)
}
}
const runDetect = async () => {
setPenDetecting(true)
setEjectError(null)
try {
const result = await detectPen()
if (result?.uri) {
setHasSavedPen(true)
setPenCandidates(result.candidates)
try {
// Wirft wenn Stift nicht verbunden ist
const files = await listPenFiles(result.uri)
setPen(result)
setPenFiles(files)
} catch {
// URI vorhanden, Stift aber gerade nicht angesteckt
setPen(null)
setPenFiles([])
}
} else {
setPen(null)
setPenFiles([])
setPenCandidates(result?.candidates ?? [])
}
} catch {
setPen(null)
setPenFiles([])
} finally {
setPenDetecting(false)
}
}
const pickPen = async () => {
setPenDetecting(true)
setEjectError(null)
try {
const result = await requestPenAccess()
if (result?.uri) {
setHasSavedPen(true)
setPen(result)
try {
setPenFiles(await listPenFiles(result.uri))
} catch {
setPenFiles([])
}
}
} catch (e) {
setEjectError(e instanceof Error ? e.message : String(e))
} finally {
setPenDetecting(false)
}
}
const selectPen = async (candidate: PenInfo) => {
setPen(candidate)
try {
setPenFiles(await listPenFiles(candidate.uri))
} catch {
setPenFiles([])
}
}
onMount(() => {
loadBooks()
runDetect()
})
const setProgress = (filename: string, fraction: number) => {
setCopyProgress(prev => ({ ...prev, [filename]: fraction }))
}
const clearProgress = (filename: string) => {
setCopyProgress(prev => { const n = { ...prev }; delete n[filename]; return n })
}
const reloadPenFiles = async (uri: string) => {
try { setPenFiles(await listPenFiles(uri)) } catch { setPenFiles([]) }
}
const doSyncAll = async () => {
const p = pen()
if (!p) return
setSyncingAll(true)
setSyncError(null)
try {
await syncAll(p.uri, (filename, fraction) => setProgress(filename, fraction))
await reloadPenFiles(p.uri)
} catch (e) {
setSyncError(e instanceof Error ? e.message : String(e))
} finally {
setSyncingAll(false)
setCopyProgress({})
}
}
const doSyncFile = async (filename: string) => {
const p = pen()
if (!p) return
setSyncingFile(filename)
setSyncError(null)
try {
await syncFile(p.uri, filename, (fraction) => setProgress(filename, fraction))
await reloadPenFiles(p.uri)
} catch (e) {
setSyncError(e instanceof Error ? e.message : String(e))
} finally {
setSyncingFile(null)
clearProgress(filename)
}
}
const doDeleteDownload = async (filename: string) => {
if (!confirm(`"${filename}" aus der Bibliothek löschen?`)) return
setDeletingDownload(filename)
try {
await deleteDownload(filename)
setBooks(b => b.filter(x => x.filename !== filename))
} catch (e) {
setSyncError(e instanceof Error ? e.message : String(e))
} finally {
setDeletingDownload(null)
}
}
const doDeletePenFile = async (filename: string) => {
const p = pen()
if (!p) return
if (!confirm(`"${filename}" vom Stift löschen?`)) return
setDeletingPenFile(filename)
try {
await deletePenFile(p.uri, filename)
setPenFiles(f => f.filter(x => x !== filename))
} catch (e) {
setSyncError(e instanceof Error ? e.message : String(e))
} finally {
setDeletingPenFile(null)
}
}
const doEject = async () => {
const p = pen()
if (!p) return
setEjecting(true)
setEjectError(null)
try {
const result = await ejectPen(p.uri, p.device)
if (result.success) {
batch(() => {
setPen(null)
setPenFiles([])
setPenCandidates([])
})
} else {
setEjectError(result.message)
}
} finally {
setEjecting(false)
}
}
const penLabel = (p: PenInfo) => p.label ?? 'tiptoi-Stift'
return (
<div class="page">
{/* ── Stift-Statusleiste ── */}
<div class="pen-bar">
<Show when={penDetecting()}>
<div class="pen-bar__left">
<span class="spinner" style="width:14px;height:14px;border-width:1.5px" />
<span class="hint">Prüfe Stift</span>
</div>
</Show>
<Show when={!penDetecting() && !pen()}>
<div class="pen-bar__left">
<IconPenOff />
<span class="hint">Kein Stift verbunden</span>
</div>
<Show when={hasSavedPen()} fallback={
<button class="btn pen-bar__action" onClick={pickPen}>
<IconLink /> Verknüpfen
</button>
}>
<div class="pen-bar__actions">
<button class="btn btn--ghost pen-bar__action" onClick={runDetect} title="Stift erneut suchen">
<IconRefresh /> Aktualisieren
</button>
<button class="btn btn--ghost pen-bar__action pen-bar__action--icon" onClick={pickPen} title="Anderes Laufwerk wählen">
<IconLink />
</button>
</div>
</Show>
</Show>
<Show when={!penDetecting() && pen()}>
{(() => {
const p = pen()!
return (
<>
<div class="pen-bar__left">
<IconPenOn />
<div class="pen-bar__info">
<span class="pen-bar__name">{penLabel(p)}</span>
<Show when={missingOnPen() > 0}>
<span class="badge badge--warn">{missingOnPen()} fehlt</span>
</Show>
<Show when={missingOnPen() === 0 && annotatedBooks().length > 0}>
<span class="badge badge--ok">Aktuell</span>
</Show>
</div>
</div>
<div class="pen-bar__actions">
<Show when={missingOnPen() > 0}>
<button
class="btn pen-bar__action"
onClick={doSyncAll}
disabled={syncingAll()}
title="Alle fehlenden Bücher auf Stift kopieren"
>
<Show when={syncingAll()} fallback={<><IconSync /> Alle sync</>}>
<span class="spinner" style="width:14px;height:14px;border-width:1.5px" />
</Show>
</button>
</Show>
<button
class="btn btn--ghost pen-bar__action pen-bar__action--icon"
onClick={doEject}
disabled={ejecting()}
title="Stift trennen"
>
<Show when={ejecting()} fallback={<IconEject />}>
<span class="spinner" style="width:14px;height:14px;border-width:1.5px" />
</Show>
</button>
<button
class="btn btn--ghost pen-bar__action pen-bar__action--icon"
onClick={pickPen}
title="Anderes Laufwerk wählen"
>
<IconLink />
</button>
</div>
</>
)
})()}
</Show>
</div>
{/* Auswerf-Fehler */}
<Show when={ejectError()}>
<p class="status status--error" style="font-size:0.82rem">{ejectError()}</p>
</Show>
{/* Kopier-Fehler */}
<Show when={syncError()}>
<p class="status status--error" style="font-size:0.82rem">Kopierfehler: {syncError()}</p>
</Show>
{/* Mehrere Kandidaten (Server) */}
<Show when={!penDetecting() && !pen() && penCandidates().length > 0}>
<div class="card" style="display:flex;flex-direction:column;gap:0.6rem">
<p class="section-title">Laufwerk wählen</p>
<For each={penCandidates()}>
{c => (
<button class="btn btn--ghost" onClick={() => selectPen(c)}>
{c.label ?? c.uri}
<span style="font-size:0.75rem;color:var(--text-muted);margin-left:0.4rem">{c.uri}</span>
</button>
)}
</For>
</div>
</Show>
{/* ── Bibliothek ── */}
<Show when={booksLoading()}>
<div class="status"><span class="spinner" />Lädt</div>
</Show>
<Show when={booksError()}>
<p class="status status--error">Fehler: {booksError()}</p>
</Show>
<Show when={!booksLoading() && books().length === 0 && !booksError()}>
<div class="empty">
<p style="font-size:2.5rem;margin-bottom:0.5rem">📭</p>
<p>Noch keine Dateien heruntergeladen</p>
</div>
</Show>
<Show when={!booksLoading() && annotatedBooks().length > 0}>
<div>
<p class="section-title" style="margin-bottom:0.6rem">Bibliothek</p>
<div class="downloads-list">
<For each={annotatedBooks()}>
{book => (
<div class="download-item">
<div class="download-item__row">
<Show when={book.coverUrl} fallback={<div class="download-item__icon">GME</div>}>
<img src={book.coverUrl!} class="download-item__cover" alt={book.filename} />
</Show>
<span class="download-item__name">
{book.filename.replace(/\.gme$/, '').replaceAll('_', ' ')}
<Show when={book.size !== null}>
<span class="download-item__size">{formatBytes(book.size!)}</span>
</Show>
</span>
<Show when={pen()}>
<div class="book-status">
<Show when={book.onPen} fallback={
<button
class="btn copy-btn"
onClick={() => doSyncFile(book.filename)}
disabled={syncingFile() === book.filename || syncingAll()}
title="Auf Stift kopieren"
>
<Show when={syncingFile() === book.filename || (syncingAll() && copyProgress()[book.filename] !== undefined)}
fallback={<IconCopy />}
>
<span class="spinner" style="width:13px;height:13px;border-width:1.5px" />
</Show>
</button>
}>
<button
class="btn copy-btn copy-btn--danger"
onClick={() => doDeletePenFile(book.filename)}
disabled={deletingPenFile() === book.filename}
title="Vom Stift löschen"
>
<Show when={deletingPenFile() === book.filename}
fallback={<IconTrash />}
>
<span class="spinner" style="width:13px;height:13px;border-width:1.5px" />
</Show>
</button>
</Show>
</div>
</Show>
<button
class="btn copy-btn copy-btn--ghost"
onClick={() => doDeleteDownload(book.filename)}
disabled={deletingDownload() === book.filename}
title="Aus Bibliothek löschen"
>
<Show when={deletingDownload() === book.filename}
fallback={<IconTrash />}
>
<span class="spinner" style="width:13px;height:13px;border-width:1.5px" />
</Show>
</button>
</div>
<Show when={(copyProgress()[book.filename] ?? -1) >= 0 && (copyProgress()[book.filename] ?? -1) < 1}>
<div class="copy-progress">
<div
class="copy-progress__bar"
style={{ width: `${Math.round((copyProgress()[book.filename] ?? 0) * 100)}%` }}
/>
</div>
</Show>
</div>
)}
</For>
</div>
</div>
</Show>
{/* ── Nur auf Stift ── */}
<Show when={pen() && penOnlyFiles().length > 0}>
<div>
<p class="section-title" style="margin-bottom:0.6rem">Nur auf Stift</p>
<div class="downloads-list">
<For each={penOnlyFiles()}>
{filename => (
<div class="download-item">
<div class="download-item__row">
<div class="download-item__icon" style="background:var(--bg-input);color:var(--text-muted)">GME</div>
<span class="download-item__name" style="color:var(--text-muted)">
{filename.replace(/\.gme$/, '').replaceAll('_', ' ')}
</span>
<button
class="btn copy-btn copy-btn--danger"
onClick={() => doDeletePenFile(filename)}
disabled={deletingPenFile() === filename}
title="Vom Stift löschen"
>
<Show when={deletingPenFile() === filename}
fallback={<IconTrash />}
>
<span class="spinner" style="width:13px;height:13px;border-width:1.5px" />
</Show>
</button>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
</div>
)
}
function IconPenOff() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" style="flex-shrink:0;color:var(--text-muted)">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
<line x1="2" y1="2" x2="22" y2="22"/>
</svg>
)
}
function IconPenOn() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" style="flex-shrink:0;color:var(--ok)">
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
)
}
function IconSync() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="15" height="15">
<path d="M23 4v6h-6"/><path d="M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
)
}
function IconEject() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<polyline points="23 7 12 2 1 7"/><path d="M1 17h22"/><path d="M1 21h22"/>
</svg>
)
}
function IconCopy() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="15" height="15">
<path d="M17 3H7a2 2 0 0 0-2 2v14"/><path d="M19 8v11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V8l5-5h7z"/>
</svg>
)
}
function IconLink() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="15" height="15">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
)
}
function IconTrash() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
)
}
function IconRefresh() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="15" height="15">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
)
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
+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>
)
}