From 30170436fafade5a4654ca6c27104286b838e25c Mon Sep 17 00:00:00 2001 From: arnef Date: Wed, 12 Aug 2026 11:46:31 +0200 Subject: [PATCH] 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. --- client/src/App.tsx | 86 +++++ client/src/components/QrScanner.tsx | 102 ++++++ client/src/pages/DownloadsPage.tsx | 519 ++++++++++++++++++++++++++++ client/src/pages/ScanPage.tsx | 183 ++++++++++ 4 files changed, 890 insertions(+) create mode 100644 client/src/App.tsx create mode 100644 client/src/components/QrScanner.tsx create mode 100644 client/src/pages/DownloadsPage.tsx create mode 100644 client/src/pages/ScanPage.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..6788a71 --- /dev/null +++ b/client/src/App.tsx @@ -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('scan') + const [devMenuOpen, setDevMenuOpen] = createSignal(false) + let tapCount = 0 + let tapTimer: ReturnType | undefined + + const handleTitleTap = () => { + tapCount++ + clearTimeout(tapTimer) + if (tapCount >= DEV_TAP_COUNT) { + tapCount = 0 + setDevMenuOpen(true) + } else { + tapTimer = setTimeout(() => { tapCount = 0 }, DEV_TAP_WINDOW) + } + } + + return ( +
+
+ + Scannen + Bibliothek + +
+ + + + + + + + + + + + setDevMenuOpen(false)} /> + +
+ ) +} + +function IconQr() { + return ( + + + + + + ) +} + +function IconLibrary() { + return ( + + + + ) +} diff --git a/client/src/components/QrScanner.tsx b/client/src/components/QrScanner.tsx new file mode 100644 index 0000000..604f043 --- /dev/null +++ b/client/src/components/QrScanner.tsx @@ -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(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 ( +

+ BarcodeDetector API wird von diesem Browser nicht unterstützt. +

+ ) + } + + return ( +
+ + + + + + {error() &&

{error()}

} +
+ ) +} diff --git a/client/src/pages/DownloadsPage.tsx b/client/src/pages/DownloadsPage.tsx new file mode 100644 index 0000000..9bd3b78 --- /dev/null +++ b/client/src/pages/DownloadsPage.tsx @@ -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([]) + const [booksLoading, setBooksLoading] = createSignal(true) + const [booksError, setBooksError] = createSignal(null) + + // Stift + const [pen, setPen] = createSignal(null) + const [penFiles, setPenFiles] = createSignal([]) + const [penDetecting, setPenDetecting] = createSignal(true) + const [penCandidates, setPenCandidates] = createSignal([]) + // 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(null) + const [ejecting, setEjecting] = createSignal(false) + const [ejectError, setEjectError] = createSignal(null) + const [syncError, setSyncError] = createSignal(null) + const [deletingDownload, setDeletingDownload] = createSignal(null) + const [deletingPenFile, setDeletingPenFile] = createSignal(null) + // Fortschritt: Dateiname → 0..1 + const [copyProgress, setCopyProgress] = createSignal>({}) + + // 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 ( +
+ + {/* ── Stift-Statusleiste ── */} +
+ +
+ + Prüfe Stift… +
+
+ + +
+ + Kein Stift verbunden +
+ + Verknüpfen + + }> +
+ + +
+
+
+ + + {(() => { + const p = pen()! + return ( + <> +
+ +
+ {penLabel(p)} + 0}> + {missingOnPen()} fehlt + + 0}> + Aktuell + +
+
+
+ 0}> + + + + +
+ + ) + })()} +
+
+ + {/* Auswerf-Fehler */} + +

{ejectError()}

+
+ + {/* Kopier-Fehler */} + +

Kopierfehler: {syncError()}

+
+ + {/* Mehrere Kandidaten (Server) */} + 0}> +
+

Laufwerk wählen

+ + {c => ( + + )} + +
+
+ + {/* ── Bibliothek ── */} + +
Lädt…
+
+ +

Fehler: {booksError()}

+
+ +
+

📭

+

Noch keine Dateien heruntergeladen

+
+
+ + 0}> +
+

Bibliothek

+
+ + {book => ( +
+
+ GME
}> + {book.filename} + + + {book.filename.replace(/\.gme$/, '').replaceAll('_', ' ')} + + {formatBytes(book.size!)} + + + +
+ doSyncFile(book.filename)} + disabled={syncingFile() === book.filename || syncingAll()} + title="Auf Stift kopieren" + > + } + > + + + + }> + + +
+
+ +
+ = 0 && (copyProgress()[book.filename] ?? -1) < 1}> +
+
+
+ +
+ )} +
+
+
+
+ + {/* ── Nur auf Stift ── */} + 0}> +
+

Nur auf Stift

+
+ + {filename => ( +
+
+
GME
+ + {filename.replace(/\.gme$/, '').replaceAll('_', ' ')} + + +
+
+ )} +
+
+
+
+
+ ) +} + +function IconPenOff() { + return ( + + + + + ) +} +function IconPenOn() { + return ( + + + + ) +} +function IconSync() { + return ( + + + + + ) +} +function IconEject() { + return ( + + + + ) +} +function IconCopy() { + return ( + + + + ) +} +function IconLink() { + return ( + + + + + ) +} + +function IconTrash() { + return ( + + + + ) +} + +function IconRefresh() { + return ( + + + + + ) +} + +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` +} diff --git a/client/src/pages/ScanPage.tsx b/client/src/pages/ScanPage.tsx new file mode 100644 index 0000000..11210e8 --- /dev/null +++ b/client/src/pages/ScanPage.tsx @@ -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({ 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 ( +
+ + {/* Suchfeld */} + +
+ +

+ Kein Buch gefunden – Titel oder Artikelnummer eingeben: +

+
+
+ setQuery(e.currentTarget.value)} + onKeyDown={e => e.key === 'Enter' && search(query())} + /> + +
+
+
+ + {/* Scanner */} + + + + + {/* Suche läuft */} + +
+ + Suche läuft… +
+
+ + {/* Ergebnis */} + + {(() => { + const s = state() as { type: 'found'; title: string; gmeUrls: GmeEntry[] } + return ( +
+

{s.title}

+ + {(entry) => ( +
+ + {entry.label} + + +
+ )} +
+
+ ) + })()} +
+ + {/* Download läuft */} + +
+ + Download läuft… +
+
+ + {/* Erfolg */} + + {(() => { + const s = state() as { type: 'done'; filename: string } + return ( +
+

+ {s.filename} wurde gespeichert +

+
+ ) + })()} +
+ + {/* Fehler */} + + {(() => { + const s = state() as { type: 'error'; message: string } + return ( +
+

Fehler: {s.message}

+
+ ) + })()} +
+ + {/* Zurück */} + + + +
+ ) +} + +function IconSearch() { + return ( + + + + ) +} + +function IconDownload() { + return ( + + + + ) +} + +function IconCheck() { + return ( + + + + ) +}