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