Added qr scanner
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
2026-05-27 00:46:52 +02:00
parent 623b7f0256
commit b66f59eedc
5 changed files with 76 additions and 55 deletions

View File

@@ -1,58 +1,78 @@
import { Controller } from "@hotwired/stimulus";
import QrScanner from "qr-scanner"; // Importiert das saubere, einzelne Modul
export default class extends Controller {
static targets = ["input", "preview", "modal"];
connect() {
this.html5QrCode = null;
this.qrScanner = null;
}
// Öffnet das Modal und startet den Kamera-Stream
startCamera(event) {
event.preventDefault();
// Modal anzeigen
// 1. Modal anzeigen
this.modalTarget.classList.remove("hidden");
// Neue Instanz auf dem Preview-Div mit der ID des Elements erzeugen
this.html5QrCode = new Html5Qrcode(this.previewTarget.id);
// 2. Ein HTML5 <video> Element holen oder erstellen (wird für den Stream gebraucht)
const videoElement = this.previewTarget.querySelector("video") || this.createVideoElement();
// FPS-Rate und Scan-Rahmen (250x250px) festlegen
const config = { fps: 10, qrbox: { width: 250, height: 250 } };
// 3. Scanner initialisieren
this.qrScanner = new QrScanner(
videoElement,
(result) => {
// SUCCESS: Code erkannt!
this.handleScanSuccess(result.data);
},
{
onDecodeError: (error) => { /* Loop-Fehler während der Suche ignorieren */ },
highlightScanRegion: true, // Zeichnet einen schicken gelben/grünen Scan-Rahmen ins Bild
highlightCodeOutline: true,
maxScansPerSecond: 10
}
);
this.html5QrCode
.start(
{ facingMode: "environment" }, // Erzwingt die rückseitige Hauptkamera bei Handys
config,
(decodedText, decodedResult) => {
// SUCCESS: Code erkannt!
this.inputTarget.value = decodedText; // Trägt die ID (z.B. 10024) ins Textfeld ein
// NEU: Simuliert das Tippen, damit dein search-form Controller die Live-Suche sofort startet!
this.inputTarget.dispatchEvent(new Event("input", { bubbles: true }));
this.stopCamera(); // Stoppt die Kamera und schließt das Fenster
},
(errorMessage) => {
// Kontinuierlicher Scan-Loop (Fehler ignorieren, wenn kein QR-Code im Bild ist)
},
)
.catch((err) => {
console.error("Kamera-Zugriff verweigert oder blockiert:", err);
});
// 4. Kamera starten
this.qrScanner.start().catch((err) => {
alert("Kamera-Zugriff verweigert, blockiert oder unverschlüsselte Verbindung!");
console.error("Kamera-Fehler:", err);
this.modalTarget.classList.add("hidden");
});
}
// Schließt das Modal und beendet den Stream sauber
// Verarbeitet die gescannte ID und startet das search-form
handleScanSuccess(decodedText) {
this.inputTarget.value = decodedText;
// Simuliert das Tippen für deinen Live-Filter
this.inputTarget.dispatchEvent(new Event("input", { bubbles: true }));
this.stopCamera();
}
// Beendet den Stream sauber und schließt das Fenster
stopCamera() {
if (this.html5QrCode && this.html5QrCode.isScanning) {
this.html5QrCode
.stop()
.then(() => {
this.modalTarget.classList.add("hidden");
})
.catch((err) => console.error("Fehler beim Beenden des Streams:", err));
} else {
this.modalTarget.classList.add("hidden");
if (this.qrScanner) {
this.qrScanner.stop();
this.qrScanner.destroy();
this.qrScanner = null;
}
this.modalTarget.classList.add("hidden");
}
// Hilfsmethode: Erstellt das Video-Tag im Vorschau-Fenster
createVideoElement() {
const video = document.createElement("video");
video.autoplay = true;
video.muted = true;
video.playsInline = true;
video.className = "w-full h-full object-cover rounded-xl";
this.previewTarget.appendChild(video);
return video;
}
disconnect() {
this.stopCamera();
}
}

View File

@@ -40,7 +40,10 @@
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
<div id="search-reader" data-scanner-target="preview" class="w-full aspect-square rounded-xl overflow-hidden border border-gray-200 bg-gray-50"></div>
<!-- DAS VORSCHAUFENSTER FÜR DIE KAMERA IN DEINER _search_bar.html.erb -->
<div data-scanner-target="preview" class="w-full aspect-square rounded-xl overflow-hidden border border-gray-200 bg-gray-50 flex items-center justify-center">
<!-- Das <video>-Tag wird von Stimulus hier automatisch injiziert -->
</div>
<p class="text-xs text-gray-500 text-center">Halte den QR-Code des Geräts ruhig in den Scan-Rahmen.</p>
</div>
</div>

View File

@@ -4,7 +4,7 @@
<title><%= content_for(:title) || "Vault171" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="application-name" content="Vault17">
<meta name="application-name" content="Vault171">
<meta name="mobile-web-app-capable" content="yes">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
@@ -75,9 +75,10 @@
<svg class="h-5 w-5 shrink-0 ml-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>
<span class="collapse-text ml-3 transition-all duration-300 ease-in-out max-w-[180px] opacity-100 overflow-hidden whitespace-nowrap">Wareneingang</span>
<% end %>
<%= link_to categories_path, class: nav_link_class("categories") do %>
<svg class="h-5 w-5 shrink-0 ml-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621 Clyde5.04 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>
<svg class="h-5 w-5 shrink-0 ml-0.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-19.5 0A2.25 2.25 0 0 0 2.25 15v2.25m0-4.5A2.25 2.25 0 0 1 4.5 12h15a2.25 2.25 0 0 1 2.25 2.25m-19.5 0v4.5A2.25 2.25 0 0 0 4.5 21h15a2.25 2.25 0 0 0 2.25-2.25V15M2.25 12V6a2.25 2.25 0 0 1 2.25-2.25h2.25c.59 0 1.157.234 1.576.652L9.75 5.85a2.25 2.25 0 0 0 1.576.652h6.924a2.25 2.25 0 0 1 2.25 2.25v3.25" />
</svg>
<span class="collapse-text ml-3 transition-all duration-300 ease-in-out max-w-[180px] opacity-100 overflow-hidden whitespace-nowrap">Kategorien</span>
<% end %>
</nav>
@@ -110,11 +111,11 @@
</aside>
<!-- HAUPTBEREICH (Inhaltsfläche rückt bei verkleinerter Sidebar nach) -->
<div class="main-content flex-1 flex flex-col min-h-screen w-full transition-all duration-300 ease-in-out pl-0 md:pl-64">
<!-- HAUPTBEREICH (Fixiert die Gesamthöhe auf den Bildschirm und steuert das Scrollen intern) -->
<div class="main-content flex-1 flex flex-col h-screen w-full transition-all duration-300 ease-in-out pl-0 md:pl-64 overflow-hidden">
<!-- OBERE LEISTE (Top Bar) -->
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 md:px-6 sticky top-0 z-30">
<!-- OBERE LEISTE (Bleibt durch 'sticky' oben, da der umschließende Container nicht mehr mitwächst) -->
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-4 md:px-6 sticky top-0 z-30 shrink-0">
<div class="flex items-center gap-3">
<!-- BURGER BUTTON (Nur mobil sichtbar) -->
<label for="mobile-sidebar-toggle" class="md:hidden p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition cursor-pointer">
@@ -127,21 +128,13 @@
</div>
</header>
<!-- INHALT DER JEWEILIGEN VIEW -->
<main class="p-4 md:p-6 flex-1 w-full mx-auto px-4 md:px-8">
<!-- Platzhalter für Flash-Meldungen (z.B. erfolgreiches Speichern)
<% flash.each do |type, message| %>
<div class="mb-4 p-4 text-sm rounded-lg border <%= type == 'notice' ? 'bg-green-50 text-green-800 border-green-200' : 'bg-red-50 text-red-800 border-red-200' %>">
<%= message %>
</div>
<% end %>
-->
<%= render "layouts/flash" %>
<%= render "layouts/flash" %>
<!-- INHALTSFLÄCHE (Nur dieser Bereich scrollt jetzt flexibel nach unten weg!) -->
<main class="p-4 md:p-6 flex-1 w-full mx-auto px-4 md:px-8 overflow-y-auto bg-gray-50/30">
<%= yield %>
</main>
</div>
</div>
<!-- Globaler Platzhalter für das optionale Turbo-Frame-Modal (z.B. neue Personen anlegen) -->
<%= turbo_frame_tag "modal" %>

View File

@@ -5,3 +5,4 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
pin "qr-scanner" # @1.4.2

4
vendor/javascript/qr-scanner.js vendored Normal file

File diff suppressed because one or more lines are too long