Initial Programm

This commit is contained in:
Joey Pillunat-Klebb | OH5 2025-11-03 15:36:43 +01:00
commit 748128d4d9
17 changed files with 7059 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
}

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# Tauri + Vanilla
This template should help get you started developing with Tauri in vanilla HTML, CSS and Javascript.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

7
src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

5875
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

22
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "OH5_QR_Code_Generator"
version = "0.1.0"
description = "OH5 QR Code Generator"
authors = ["jpk aka Shiro"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
qrcode = { version = "0.14", features = ["image"] }
image = "0.25"
urlencoding = "2.1"
base64 = "0.22"

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

151
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,151 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use serde::{Deserialize, Serialize};
use qrcode::{QrCode, EcLevel};
use image::{DynamicImage, ImageFormat, imageops, Luma};
use base64::Engine;
use urlencoding::encode;
#[derive(Debug, Deserialize)]
struct GeneratePayload {
name: String,
customer_no: String,
machine_from: u32,
machine_to: Option<u32>, // None => Single
text_body: String,
include_icon: bool,
icon_bytes: Option<Vec<u8>>, // PNG/JPG bytes (optional)
to_email: Option<String>,
}
#[derive(Debug, Serialize)]
struct OutputItem {
label: String,
filename: String,
png_base64: String,
}
#[derive(Debug, Serialize)]
struct Output {
items: Vec<OutputItem>,
}
fn build_mailto(
to: Option<&str>,
name: &str,
customer_no: &str,
machine_id: u32,
body: &str,
) -> String {
// Betreff nur Kundennummer + Fixtext
let subject = format!("Kunde: {} hat einen Fehler. QR Code Scan!", customer_no);
// Body enthält Firmenname, Kundennummer, Maschine + freien Text
let full_body = format!(
"Kunde: {}\n\nKundennummer: {}\n\nMaschine: {}\n\n{}",
name,
customer_no,
machine_id,
body
);
let subject_enc = encode(&subject);
let body_enc = encode(&full_body);
if let Some(addr) = to.filter(|s| !s.is_empty()) {
format!("mailto:{}?subject={}&body={}", addr, subject_enc, body_enc)
} else {
format!("mailto:?subject={}&body={}", subject_enc, body_enc)
}
}
fn render_qr_png(data: &str, icon_bytes: Option<&[u8]>) -> anyhow::Result<Vec<u8>> {
// Hohe Fehlerkorrektur, damit Icon robust ist
let code = QrCode::with_error_correction_level(data.as_bytes(), EcLevel::Q)?;
let target = 1024u32;
let qr_luma = code
.render::<Luma<u8>>()
.min_dimensions(target, target)
.quiet_zone(true)
.build();
let mut qr_rgba = DynamicImage::ImageLuma8(qr_luma).to_rgba8();
if let Some(bytes) = icon_bytes {
if let Ok(mut icon) = image::load_from_memory(bytes).map(|i| i.into_rgba8()) {
// Icon auf ~20% der QR-Breite skalieren
let target_w = (qr_rgba.width() as f32 * 0.20) as u32;
let scale = target_w as f32 / icon.width().max(1) as f32;
let target_h = (icon.height() as f32 * scale).round().max(1.0) as u32;
icon = imageops::resize(&icon, target_w, target_h, imageops::FilterType::Lanczos3);
let x = (qr_rgba.width() - icon.width()) / 2;
let y = (qr_rgba.height() - icon.height()) / 2;
imageops::overlay(&mut qr_rgba, &icon, x.into(), y.into());
}
}
let dyn_img = DynamicImage::ImageRgba8(qr_rgba);
let mut buf = Vec::new();
dyn_img.write_to(&mut std::io::Cursor::new(&mut buf), ImageFormat::Png)?;
Ok(buf)
}
#[tauri::command]
fn generate_qr(payload: GeneratePayload) -> Result<Output, String> {
let mut items = Vec::new();
let from = payload.machine_from;
let to = payload.machine_to.unwrap_or(from);
if to < from {
return Err("Ungültiger Bereich: Bis < Von".into());
}
let icon: Option<&[u8]> = if payload.include_icon {
payload.icon_bytes.as_deref()
} else {
None
};
for id in from..=to {
let to_addr = payload.to_email.as_deref().filter(|s| !s.is_empty()).unwrap_or("service@oh5.de");
let data = build_mailto(Some(to_addr), &payload.name, &payload.customer_no, id, &payload.text_body);
let png = render_qr_png(&data, icon).map_err(|e| format!("QR-Fehler: {e}"))?;
let b64 = base64::engine::general_purpose::STANDARD.encode(png);
let label = if from == to {
format!("Kunde: {}\nKundennummer: {}\nID: {}", payload.name, payload.customer_no, id)
} else {
//format!("ID: {}", id)
format!("Kunde: {}\nKundennummer: {}\nID: {}", payload.name, payload.customer_no, id)
};
let filename = format!("qr_{}_{}_{}.png", sanitize(&payload.name), payload.customer_no, id);
items.push(OutputItem { label, filename, png_base64: b64 });
}
Ok(Output { items })
}
// kleine Dateinamen-Helferfunktion
fn sanitize(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else if ch.is_whitespace() {
out.push('_');
}
}
if out.is_empty() { "kunde".to_string() } else { out }
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![generate_qr])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

30
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,30 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OH5 QR Code Generator",
"version": "0.1.0",
"identifier": "com.jpk.oh5-qr-generator",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "OH5 QR Code Generator",
"width": 1280,
"height": 700
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/icon.png",
"icons/icon.ico"
]
}
}

121
src/index.html Normal file
View file

@ -0,0 +1,121 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>QR Mailto Generator</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="container">
<!-- Flip-Card um den bisherigen Panel-Inhalt -->
<div class="flip-card" id="main_panel">
<div class="flip-inner">
<!-- Vorderseite: dein aktuelles Formular -->
<div class="flip-front">
<section class="panel active">
<h2>QR Mailto Generator</h2>
<div class="grid">
<div class="row">
<label for="kunde_name">Kundename</label>
<input id="kunde_name" type="text" placeholder="z. B. Max Musterfirma GmbH" />
</div>
<div class="row">
<label for="kunde_nr">Kundennummer (nur Zahlen)</label>
<input id="kunde_nr" type="number" inputmode="numeric" pattern="[0-9]*" placeholder="z. B. 0815" />
</div>
<div class="row">
<label for="machine_from">Maschinen-ID (Von)</label>
<input id="machine_from" type="number" inputmode="numeric" pattern="[0-9]*" placeholder="z. B. 556673" />
</div>
<div class="row">
<label for="machine_to">Maschinen-ID (Bis)</label>
<input id="machine_to" type="number" inputmode="numeric" pattern="[0-9]*" placeholder="z. B. 556680" disabled />
</div>
</div>
<div class="inline" style="margin-top:8px;">
<input id="batch" type="checkbox" />
<label for="batch">Batch-Generation (VonBis aktivieren)</label>
</div>
<div class="row" style="margin-top:12px;">
<label for="custom_text">Texteingabe (Custom)</label>
<textarea id="custom_text" placeholder="Custom Text ..."></textarea>
</div>
<div class="sep"></div>
<div class="inline">
<input id="with_icon" type="checkbox" />
<label for="with_icon">Icon in QR einbetten</label>
<!-- default disabled; JS schaltet frei -->
<input id="icon_file" type="file" accept="image/*" disabled />
</div>
<p class="hint" style="margin-top:6px;">PNG mit Transparenz empfohlen!</p>
<div style="margin-top:16px; display:flex; gap:12px;">
<button id="generate" class="btn" type="button">Generate</button>
<button id="download_all" class="btn" type="button" disabled>Download All</button>
<button id="print_all" class="btn" type="button" disabled>Print All</button>
</div>
<p id="error" class="hint error"></p>
</section>
</div>
<!-- Rückseite: Einstellungen (füll nach Bedarf) -->
<div class="flip-back">
<section class="panel active">
<h2>Einstellungen:</h2>
<div class="row">
<label for="default_email">Standard-E-Mail</label>
<input id="default_email" type="email" value="service@oh5.de" placeholder="z. B. support@firma.de" />
<p class="hint">Wird genutzt, wenn im Formular kein Empfänger angegeben ist.</p>
</div>
<div class="sep"></div>
<h2>Formatierung:</h2>
<div class="row">
<label for="subject_tpl">Mail Betreff (Template)</label>
<input id="subject_tpl" type="text" placeholder="z. B. Kunde: {customer_no}. QR Code Scan Fehler!" />
<p class="hint">Platzhalter: {name}, {customer_no}, {machine_id}</p>
</div>
<div class="row">
<label for="body_tpl">Mail Text (Template)</label>
<textarea id="body_tpl" placeholder="z. B. Kunde: {name} | Nr: {customer_no} | Maschine: {machine_id}&#10;&#10;{custom_text}"></textarea>
<p class="hint">Platzhalter: {name}, {customer_no}, {machine_id}, {custom_text}</p>
</div>
<!-- weitere Einstellungen ... -->
</section>
</div>
</div>
</div>
<div id="results" class="results" aria-live="polite"></div>
</div>
<div class="fab-stack">
<div id="download_fab" class="fab"></div>
<div id="settings_fab" class="fab">⚙️</div>
</div>
<div id="download_list" class="download-list hidden"></div>
<script src="./jszip.min.js"></script>
<script src="./main.js"></script>
<script src="./print.js"></script>
</body>
</html>

13
src/jszip.min.js vendored Normal file

File diff suppressed because one or more lines are too long

353
src/main.js Normal file
View file

@ -0,0 +1,353 @@
// Kein import mehr wir holen invoke aus der globalen Tauri-API
const invoke = (...args) => window.__TAURI__.core.invoke(...args);
const $ = (sel) => document.querySelector(sel);
// Quick sanity log solltest du in DevTools sehen
console.log("[app] JS geladen");
// Inputs
const batchCb = $("#batch");
const machineFrom = $("#machine_from");
const machineTo = $("#machine_to");
const withIcon = $("#with_icon");
const iconFile = $("#icon_file");
const generateBtn = $("#generate");
const downloadAllBtn = $("#download_all");
const printAllBtn = $("#print_all");
const resultsEl = $("#results");
const errorEl = $("#error");
const fab = $("#download_fab");
const downloadList = $("#download_list");
const fabDownload = $("#download_fab");
const fabSettings = $("#settings_fab");
const settingsPanel = $("#settings_panel");
const mainPanel = $("#main_panel");
const flipInner = mainPanel?.querySelector('.flip-inner');
const flipFront = mainPanel?.querySelector('.flip-front');
const flipBack = mainPanel?.querySelector('.flip-back');
// interner Speicher der letzten Ergebnisse (für ZIP)
let lastResults = []; // [{ filename, png_base64, label }, ...]
let downloadHistory = [];
// Batch: Bis-Feld aktivieren/deaktivieren
batchCb?.addEventListener("change", () => {
machineTo.disabled = !batchCb.checked;
});
// Icon: Filefeld aktivieren/deaktivieren
withIcon?.addEventListener("change", () => {
iconFile.disabled = !withIcon.checked;
if (!withIcon.checked) iconFile.value = "";
});
// Beim Laden Zustand korrekt spiegeln
window.addEventListener("DOMContentLoaded", () => {
machineTo.disabled = !batchCb.checked;
iconFile.disabled = !withIcon.checked;
console.log("[app] DOM ready");
});
function onlyDigits(val) {
if (typeof val !== "string") val = String(val ?? "");
return /^\d+$/.test(val);
}
async function fileToUint8Array(file) {
const buf = await file.arrayBuffer();
return new Uint8Array(buf);
}
function downloadBase64(filename, base64, triggerEl = null) {
const a = document.createElement("a");
a.href = "data:image/png;base64," + base64;
a.download = filename;
a.click();
addDownloadToHistory(filename, base64);
animateToFab(triggerEl || a); // <-- Animation starten
}
// Hilfsfunktion: sinnvoller ZIP-Name
function buildZipName() {
const name = ($("#kunde_name")?.value || "").trim().replace(/\s+/g, "_");
const cust = ($("#kunde_nr")?.value || "").trim();
const from = ($("#machine_from")?.value || "").trim();
const to = ($("#machine_to")?.value || "").trim();
let base = "qr_codes";
if (name) base += `_${name}`;
if (cust) base += `_${cust}`;
if (batchCb?.checked && onlyDigits(from) && onlyDigits(to)) {
base += `_IDs_${from}-${to}`;
} else if (onlyDigits(from)) {
base += `_ID_${from}`;
}
return `${base}.zip`;
}
// ZIP-Download für alle generierten PNGs
async function downloadAllAsZip() {
if (!lastResults.length) return;
if (typeof JSZip === "undefined") {
console.error("JSZip nicht geladen prüfe <script src='...jszip.min.js'>");
errorEl.textContent = "JSZip nicht geladen. Bitte Internetverbindung/Script-Tag prüfen.";
return;
}
const zip = new JSZip();
// nur Dateien ins ZIP packen (nicht ins Verlauf eintragen)
lastResults.forEach((item) => {
zip.file(item.filename, item.png_base64, { base64: true });
});
const blob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(blob);
const zipName = buildZipName();
// ZIP herunterladen
const a = document.createElement("a");
a.href = url;
a.download = zipName;
a.click();
// nur ZIP im Verlauf anzeigen
addDownloadLink(zipName, url, /*isObjectUrl*/ true);
animateToFab(downloadAllBtn, 1800); // <-- Button als Startpunkt der Animation
}
// Zeigt/hide FAB
function showFab() {
fab.classList.remove("hidden");
}
function addDownloadLink(filename, href, isObjectUrl = false) {
const item = document.createElement("div");
item.className = "download-item";
const a = document.createElement("a");
a.href = href;
a.download = filename;
a.textContent = filename;
item.appendChild(a);
downloadList?.appendChild(item);
// Optional: ObjectURL später aufräumen (wenn du willst)
if (isObjectUrl) {
a.addEventListener("click", () => {
setTimeout(() => URL.revokeObjectURL(href), 5000);
});
}
showFab();
}
// Fügt einen Eintrag zur Liste hinzu
function addDownloadToHistory(filename, base64) {
addDownloadLink(filename, "data:image/png;base64," + base64);
}
// Klick auf FAB → Liste toggeln
// Download-FAB toggelt Liste
fabDownload.addEventListener("click", () => {
downloadList.classList.toggle("hidden");
settingsPanel.classList.add("hidden");
});
function animateToFab(startEl, duration = 1500) {
const fab = document.querySelector("#download_fab");
if (!fab || !startEl) return;
const fabRect = fab.getBoundingClientRect();
const startRect = startEl.getBoundingClientRect();
const startX = startRect.left;
const startY = startRect.top;
const endX = fabRect.left + fabRect.width / 2;
const endY = fabRect.top + fabRect.height / 2;
// Kontrollpunkt: mittig zwischen Start und Ziel, aber höher für den Bogen
const midX = (startX + endX) / 2;
const midY = Math.min(startY, endY) - 150; // 150px über den beiden
const clone = document.createElement("div");
clone.className = "fly-clone";
clone.textContent = "⬇";
document.body.appendChild(clone);
clone.style.left = startX + "px";
clone.style.top = startY + "px";
// Keyframes als Motion Path (Quadratic Bezier Approximation)
const keyframes = [
{ transform: `translate(0px, 0px) scale(1)`, opacity: 1 },
{
transform: `translate(${midX - startX}px, ${midY - startY}px) scale(1.1)`,
opacity: 0.9,
offset: 0.5,
},
{
transform: `translate(${endX - startX}px, ${endY - startY}px) scale(0.2)`,
opacity: 0,
},
];
clone.animate(keyframes, {
duration: duration,
easing: "ease-in-out",
fill: "forwards",
}).onfinish = () => clone.remove();
}
downloadAllBtn?.addEventListener("click", () => {
downloadAllAsZip().catch((e) => {
console.error("[app] ZIP Fehler:", e);
errorEl.textContent = "ZIP-Erstellung fehlgeschlagen.";
});
});
generateBtn?.addEventListener("click", async () => {
errorEl.textContent = "";
resultsEl.innerHTML = "";
generateBtn.disabled = true;
downloadAllBtn && (downloadAllBtn.disabled = true); // während Generate sperren
printAllBtn && (printAllBtn.disabled = true); // während Generate sperren
lastResults = [];
try {
const name = $("#kunde_name").value.trim();
const cust = $("#kunde_nr").value.trim();
const mFrom = $("#machine_from").value.trim();
const mTo = $("#machine_to").value.trim();
const body = $("#custom_text").value;
if (!name) throw new Error("Kundename fehlt.");
if (!cust || !onlyDigits(cust)) throw new Error("Kundennummer muss aus Ziffern bestehen.");
if (!mFrom || !onlyDigits(mFrom)) throw new Error("Maschinen-ID (Von) muss aus Ziffern bestehen.");
if (batchCb.checked) {
if (!mTo || !onlyDigits(mTo)) throw new Error("Maschinen-ID (Bis) muss aus Ziffern bestehen.");
if (parseInt(mTo, 10) < parseInt(mFrom, 10)) throw new Error("Bereich ungültig: Bis < Von.");
}
let iconBytes = null;
if (withIcon.checked) {
const f = iconFile.files?.[0];
if (!f) throw new Error("Bitte eine Icon-Datei wählen oder Icon deaktivieren.");
const arr = await fileToUint8Array(f);
// Tauri JSON-serialisiert Uint8Array nicht → in normales Array wandeln
iconBytes = Array.from(arr);
}
const payload = {
name,
customer_no: cust,
machine_from: parseInt(mFrom, 10),
machine_to: batchCb.checked ? parseInt(mTo, 10) : null,
text_body: body,
include_icon: withIcon.checked,
icon_bytes: iconBytes,
to_email: document.querySelector("#default_email")?.value || "",
};
console.log("[app] invoke generate_qr", payload);
const res = await invoke("generate_qr", { payload });
console.log("[app] response", res);
if (!res || !Array.isArray(res.items) || res.items.length === 0) {
errorEl.textContent = "Keine QR-Ergebnisse erhalten.";
return;
}
lastResults = res.items; // <-- für ZIP speichern
res.items.forEach((item) => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h4");
h.className = "multiline"; // CSS: white-space: pre-line;
h.textContent = item.label;
const img = document.createElement("img");
img.src = "data:image/png;base64," + item.png_base64;
img.alt = item.label;
const a = document.createElement("a");
a.href = "#";
a.className = "dl";
a.textContent = "Download";
a.addEventListener("click", (e) => {
e.preventDefault();
downloadBase64(item.filename, item.png_base64, a);
});
card.appendChild(h);
card.appendChild(img);
card.appendChild(a);
resultsEl.appendChild(card);
});
// Alles gut -> Download-All freigeben
downloadAllBtn && (downloadAllBtn.disabled = false);
printAllBtn && (printAllBtn.disabled = false);
} catch (err) {
console.error("[app] Fehler:", err);
errorEl.textContent = String(err?.message || err || "Unbekannter Fehler");
} finally {
generateBtn.disabled = false;
// Höhe der Flip-Card nach möglichen Layout-Änderungen neu setzen
syncFlipHeight();
}
});
function syncFlipHeight() {
if (!mainPanel || !flipFront || !flipBack) return;
// kurz sichtbar messen (Back ist evtl. „hinten“)
const frontH = flipFront.scrollHeight;
const backH = flipBack.scrollHeight;
const h = Math.max(frontH, backH);
mainPanel.style.height = h + 'px';
}
if (mainPanel && flipFront && flipBack) {
// initial
syncFlipHeight();
// auf Größenänderungen der Seiten reagieren
const ro = new ResizeObserver(() => syncFlipHeight());
ro.observe(flipFront);
ro.observe(flipBack);
// auch bei Fenster-Resize
window.addEventListener('resize', syncFlipHeight);
}
// Flip an/aus beim Klick auf ⚙️
fabSettings?.addEventListener('click', () => {
mainPanel?.classList.toggle('flipped');
// wenn Settings offen sind, Downloadliste sicher schließen
downloadList?.classList.add('hidden');
});
// Downloadliste toggeln beim Klick auf ⬇
fabDownload?.addEventListener('click', () => {
downloadList?.classList.toggle('hidden');
// wenn Downloadliste offen, die Rückseite nicht erzwingen optional:
// mainPanel?.classList.remove('flipped');
});
// Optional: Klick außerhalb schließt Downloadliste
document.addEventListener('click', (e) => {
const clickedInsideFab = fabDownload?.contains(e.target);
const clickedInsideList = downloadList?.contains(e.target);
if (!clickedInsideFab && !clickedInsideList) {
downloadList?.classList.add('hidden');
}
});

141
src/print.js Normal file
View file

@ -0,0 +1,141 @@
// --- Hilfen: Array in 6er Seiten splitten
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
function ensurePrintStyles() {
if (document.getElementById("qr-print-style")) return;
const css = `
@page { size: A4 portrait; margin: 10mm; }
@media print {
/* Alles außer dem Print-Container vollständig aus dem Layout entfernen */
body > *:not(#print_container) { display: none !important; }
html, body { margin: 0; padding: 0; }
#print_container { display: block !important; position: static !important; width: auto !important; }
}
#print_container {
width: calc(210mm - 20mm); /* A4 - Seitenränder */
margin: 0 auto;
box-sizing: border-box;
}
/* 3 x 3 Raster (9 pro Seite) — feste mm-Sizes */
#print_container > .print-page {
width: 100%;
height: calc(297mm - 20mm); /* A4 - Top/Bottom-Rand */
display: grid;
grid-template-columns: repeat(3, 55mm);
grid-auto-rows: 80mm; /* 50mm Bild + ca. 30mm Text/Abstand */
column-gap: 6mm;
row-gap: 6mm;
justify-content: center;
break-inside: avoid;
box-sizing: border-box;
}
/* Seitenumbruch NUR zwischen Seiten, nicht nach der letzten */
#print_container > .print-page:not(:last-child) { break-after: page; }
.print-item {
box-sizing: border-box;
width: 55mm;
height: 80mm;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
overflow: hidden;
}
/* QR 5cm x 5cm (wegen 0.5cm weißem Rand im PNG) */
.print-item img {
width: 50mm;
height: 50mm;
flex: 0 0 auto;
object-fit: contain;
display: block;
}
.print-item h4 {
margin: 2mm 0 0;
font: 9pt system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
text-align: center;
white-space: pre-line;
line-height: 1.1;
overflow: hidden;
}
`;
const style = document.createElement("style");
style.id = "qr-print-style";
style.textContent = css;
document.head.appendChild(style);
}
// --- Print-All: Seiten bauen und drucken
function buildAndPrintAll() {
if (!lastResults.length) return;
ensurePrintStyles();
// alten Container entfernen falls vorhanden
const old = document.getElementById("print_container");
if (old) old.remove();
// neuen Container erstellen
const container = document.createElement("div");
container.id = "print_container";
// in 6er-Seiten gruppieren (2x2)
const pages = chunk(lastResults, 9);
pages.forEach(group => {
const page = document.createElement("div");
page.className = "print-page";
group.forEach(item => {
const wrap = document.createElement("div");
wrap.className = "print-item";
const img = document.createElement("img");
img.src = "data:image/png;base64," + item.png_base64;
img.alt = item.label;
const h = document.createElement("h4");
h.textContent = item.label;
wrap.appendChild(img);
wrap.appendChild(h);
page.appendChild(wrap);
});
// falls weniger als 16, optional leere Plätze auffüllen, damit Raster sauber bleibt
for (let i = group.length; i < 9; i++) {
const filler = document.createElement("div");
filler.className = "print-item";
page.appendChild(filler);
}
container.appendChild(page);
});
document.body.appendChild(container);
// nach dem Drucken Container wieder entfernen
const cleanup = () => {
container.remove();
window.removeEventListener("afterprint", cleanup);
};
window.addEventListener("afterprint", cleanup);
// Druckdialog öffnen
window.print();
}
// Button-Handler
printAllBtn?.addEventListener("click", buildAndPrintAll);

299
src/styles.css Normal file
View file

@ -0,0 +1,299 @@
/* ===== iOS 26 Glass / Vibrancy Theme ===== */
:root{
/* Akzent + Glasfarben */
--acc: #4f8cff;
/* Light-Mode Defaults */
--bg-grad-1: #e9ecf5;
--bg-grad-2: #d9e0ef;
--text: #0b0f1a;
--muted: #5a6475;
/* Glas-Schichten (hell) */
--glass: rgba(255,255,255,0.55);
--glass-strong: rgba(255,255,255,0.7);
--glass-border: rgba(255,255,255,0.35);
--glass-shadow: rgba(15,17,21,0.12);
/* Inputs */
--field: rgba(255,255,255,0.45);
--field-border: rgba(255,255,255,0.35);
/* Download-Liste (heller Text auf dunklem Glas) */
--sheet-bg: rgba(20,22,28,0.5);
--sheet-border: rgba(255,255,255,0.18);
--sheet-text: #eef2ff;
/* Transitions */
--t: 200ms;
}
/* Dark Mode Anpassungen */
@media (prefers-color-scheme: dark) {
:root{
--bg-grad-1: #0b0e13;
--bg-grad-2: #141821;
--text: #e7eaf0;
--muted: #9aa3b2;
--glass: rgba(20,24,32,0.55);
--glass-strong: rgba(20,24,32,0.7);
--glass-border: rgba(255,255,255,0.12);
--glass-shadow: rgba(0,0,0,0.35);
--field: rgba(20,24,32,0.55);
--field-border: rgba(255,255,255,0.12);
--sheet-bg: rgba(15,17,21,0.6);
--sheet-border: rgba(255,255,255,0.12);
--sheet-text: #eef2ff;
}
}
*{ box-sizing: border-box; }
html,body{ height:100%; }
body{
margin:0;
font-family: ui-rounded, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color: var(--text);
/* iOS-like Wallpaper Gradient */
background:
radial-gradient(1200px 800px at 20% 0%, rgba(79,140,255,0.12), transparent 60%),
radial-gradient(1000px 700px at 80% 20%, rgba(255, 86, 146,0.10), transparent 60%),
linear-gradient(180deg, var(--bg-grad-1), var(--bg-grad-2));
}
/* Container */
.container{ max-width: 980px; margin: 40px auto; padding: 0 16px; }
h1{ font-size: 24px; margin: 0 0 16px; letter-spacing:.2px; }
/* ======= Glass Primitives ======= */
.glass{
background: var(--glass);
border: 1px solid var(--glass-border);
box-shadow: 0 10px 30px var(--glass-shadow);
backdrop-filter: saturate(140%) blur(22px);
-webkit-backdrop-filter: saturate(140%) blur(22px);
}
/* Panels / Cards */
.panel{
border-radius: 18px;
padding: 16px;
}
.panel{ background: var(--glass); border:1px solid var(--glass-border); box-shadow:0 10px 30px var(--glass-shadow); backdrop-filter:saturate(140%) blur(22px); -webkit-backdrop-filter:saturate(140%) blur(22px); }
.grid{ display:grid; gap:12px; grid-template-columns: 1fr 1fr; }
.row{ display:flex; flex-direction: column; gap:6px; }
label{ font-size:13px; color: var(--muted); }
/* Inputs (glasig) */
input[type="text"], input[type="number"], textarea, input[type="file"]{
background: var(--field);
border: 1px solid var(--field-border);
border-radius: 14px;
padding: 12px 14px;
color: var(--text);
outline: none;
transition: border-color var(--t) ease, box-shadow var(--t) ease, transform var(--t) ease;
backdrop-filter: saturate(150%) blur(10px);
-webkit-backdrop-filter: saturate(150%) blur(10px);
}
input[type="file"]{ padding: 10px; }
input[disabled]{ opacity:.6; cursor:not-allowed; }
textarea{ min-height:120px; resize: vertical; }
input:focus, textarea:focus{
border-color: rgba(79,140,255,0.6);
box-shadow: 0 0 0 6px rgba(79,140,255,0.15);
transform: translateY(-1px);
}
/* Hint */
.hint{ font-size:12px; color: var(--muted); }
.hint.error{ color:#ff9aa2; margin-top:10px; }
/* Buttons glasig + iOS lift */
.btn{
position: relative;
background: linear-gradient(180deg, rgba(79,140,255,0.85), rgba(79,140,255,0.75));
color: #fff;
border: 1px solid rgba(255,255,255,0.28);
border-radius: 14px;
padding: 12px 16px;
font-weight: 600;
cursor: pointer;
transition: transform .15s ease, box-shadow .2s ease, filter .2s ease, opacity .2s ease;
box-shadow: 0 8px 20px rgba(79,140,255,0.28), inset 0 1px 0 rgba(255,255,255,0.25);
backdrop-filter: saturate(120%) blur(6px);
-webkit-backdrop-filter: saturate(120%) blur(6px);
}
.btn:hover{ transform: scale(1.08); box-shadow: 0 10px 24px rgba(79,140,255,0.35); }
.btn:active{ transform: scale(0.98); filter: brightness(.98); }
.btn[disabled]{ opacity:.55; cursor:not-allowed; }
.dl {
display:inline-block;
color: var(--acc); /* <- Akzent-Blau */
text-decoration:none;
transition: transform .2s ease, box-shadow .2s ease, color .2s ease;
padding: 6px 8px;
border-radius: 10px;
font-weight: 600;
}
.dl:hover {
transform: scale(1.08);
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
color: #6fa8ff; /* etwas helleres Blau beim Hover */
}
/* Floating Action Button (glasig) */
.fab-stack {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 14px; /* Abstand zwischen Buttons */
z-index: 1000;
}
.fab {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(180deg, rgba(79,140,255,0.9), rgba(79,140,255,0.75));
border: 1px solid rgba(255,255,255,0.28);
color: #fff;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(79,140,255,0.35);
cursor: pointer;
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.fab:hover {
transform: scale(1.15);
box-shadow: 0 12px 28px rgba(79,140,255,0.45);
}
/* Settings Panel kann noch hübscher gemacht werden */
.settings-panel {
position: fixed;
bottom: 90px;
right: 90px;
width: 280px;
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: 16px;
padding: 16px;
box-shadow: 0 12px 30px var(--glass-shadow);
backdrop-filter: saturate(140%) blur(20px);
-webkit-backdrop-filter: saturate(140%) blur(20px);
z-index: 999;
}
.settings-panel.hidden { display: none; }
/* Download-Sheet */
.download-list{
position: fixed; bottom: 90px; right:24px;
width: 280px; max-height: 320px; overflow-y:auto;
background: var(--sheet-bg);
color: var(--sheet-text);
border: 1px solid var(--sheet-border);
border-radius: 16px;
box-shadow: 0 12px 30px var(--glass-shadow);
padding: 10px;
backdrop-filter: saturate(140%) blur(20px);
-webkit-backdrop-filter: saturate(140%) blur(20px);
z-index: 999;
}
.download-list.hidden{ display:none; }
.download-item{ font-size:14px; padding: 6px 6px; border-bottom: 1px solid rgba(255,255,255,0.08); }
.download-item:last-child{ border-bottom: none; }
.download-item a{ color:#dbe7ff; text-decoration:none; }
.download-item a:hover{ text-decoration:underline; }
/* Cards (QR-Previews) */
.results{ margin-top:20px; display:grid; gap:16px; grid-template-columns: repeat(auto-fill, minmax(220px,1fr)); }
.card{
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: 18px;
padding: 12px;
text-align:center;
box-shadow: 0 10px 26px var(--glass-shadow);
backdrop-filter: saturate(140%) blur(22px);
-webkit-backdrop-filter: saturate(140%) blur(22px);
}
.card h4{ margin:6px 0 10px; font-size:14px; color: var(--muted); }
.card img{ width:100%; height:auto; border-radius:12px; background: rgba(255,255,255,0.8); }
/* Divider */
.sep{ height:1px; margin:16px 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.28), transparent); }
/* Fly-to-FAB Clone */
.fly-clone{
position: fixed; z-index: 2000; pointer-events:none;
font-size: 14px; background: var(--acc); color:#fff;
padding: 4px 8px; border-radius: 8px; opacity:1;
transform: translate(0,0) scale(1);
box-shadow: 0 8px 20px rgba(79,140,255,0.35);
}
/* Misc */
.multiline{ white-space: pre-line; }
/* Flip-Container */
.flip-card {
perspective: 1500px; /* Tiefe für 3D-Effekt */
position: relative;
transition: height 0.35s ease;
}
/* Innenleben, das gedreht wird */
.flip-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.8s ease;
transform-style: preserve-3d;
}
/* Zustand: umgedreht */
.flip-card.flipped .flip-inner {
transform: rotateY(180deg);
}
/* Vorder- und Rückseite */
.flip-front,
.flip-back {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.flip-front {
transform: rotateY(0deg);
}
.flip-back {
transform: rotateY(180deg);
}
/* Helper */
.hidden {
display: none !important;
}
@media (max-width: 720px){
.grid{ grid-template-columns: 1fr; }
}