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,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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