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