Initial Programm
This commit is contained in:
commit
748128d4d9
17 changed files with 7059 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
7
README.md
Normal file
7
README.md
Normal 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
7
src-tauri/.gitignore
vendored
Normal 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
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
22
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
10
src-tauri/capabilities/default.json
Normal file
10
src-tauri/capabilities/default.json
Normal 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
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
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
151
src-tauri/src/main.rs
Normal 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
30
src-tauri/tauri.conf.json
Normal 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
121
src/index.html
Normal 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 (Von–Bis 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} {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
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
353
src/main.js
Normal 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
141
src/print.js
Normal 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
299
src/styles.css
Normal 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; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue