diff --git a/backend/app/services/images.py b/backend/app/services/images.py index e3b9e3d..0ee1628 100644 --- a/backend/app/services/images.py +++ b/backend/app/services/images.py @@ -18,9 +18,11 @@ from sqlalchemy.orm import Session from ..models import Product, ProductImage -# Grosszuegig genug fuer ein Produktfoto, eng genug, dass niemand die Datenbank -# mit einer versehentlich verlinkten Datei volllaeuft. -MAX_BYTES = 2 * 1024 * 1024 +# Grosszuegig genug fuer ein Produktfoto (auch ein Handy-Foto), eng genug, dass +# niemand die Datenbank mit einer versehentlich verlinkten Datei volllaeuft. +# Bleibt unter dem nginx-Limit (client_max_body_size, siehe web/nginx.conf), +# damit Uebergroesse eine klare Meldung statt eines nackten 413 ergibt. +MAX_BYTES = 8 * 1024 * 1024 ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} diff --git a/ios/Sources/ProductPhotoView.swift b/ios/Sources/ProductPhotoView.swift index 2801d1b..63c1231 100644 --- a/ios/Sources/ProductPhotoView.swift +++ b/ios/Sources/ProductPhotoView.swift @@ -39,8 +39,8 @@ struct ProductPhotoSection: View { if busy { ProgressView() } if let error { Text(error).foregroundStyle(.red).font(.callout) } } - .sheet(isPresented: $showCamera) { - CameraPicker { image in Task { await upload(image) } } + .fullScreenCover(isPresented: $showCamera) { + CameraPicker(isPresented: $showCamera) { image in Task { await upload(image) } } .ignoresSafeArea() } .onChange(of: pickerItem) { item in @@ -108,8 +108,8 @@ struct ProductPhotoSection: View { /// Kamera über UIImagePickerController – SwiftUI hat keine eigene Kamera-Ansicht. /// Ohne Kamera (Simulator) fällt es auf die Fotoauswahl zurück. struct CameraPicker: UIViewControllerRepresentable { + @Binding var isPresented: Bool var onImage: (UIImage) -> Void - @Environment(\.dismiss) private var dismiss func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() @@ -123,6 +123,9 @@ struct CameraPicker: UIViewControllerRepresentable { func makeCoordinator() -> Coordinator { Coordinator(self) } + // Schließen deterministisch über das Binding statt über die Umgebung – + // das behebt das Flackern (Kamera geht direkt wieder zu), wenn die Ansicht + // während der Präsentation neu ausgewertet wird. final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { let parent: CameraPicker init(_ parent: CameraPicker) { self.parent = parent } @@ -130,10 +133,10 @@ struct CameraPicker: UIViewControllerRepresentable { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let image = info[.originalImage] as? UIImage { parent.onImage(image) } - parent.dismiss() + parent.isPresented = false } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - parent.dismiss() + parent.isPresented = false } } } diff --git a/web/nginx.conf b/web/nginx.conf index 03de95e..58719b3 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -4,6 +4,11 @@ server { root /usr/share/nginx/html; index index.html; + # Foto-Uploads (Artikelbild, Logo/Favicon) brauchen mehr als die 1 MB, die + # nginx sonst zulaesst - sonst antwortet der Proxy mit 413, bevor das Backend + # den Upload ueberhaupt sieht. + client_max_body_size 12m; + # SPA-Routing: unbekannte Pfade auf index.html mappen location / { try_files $uri $uri/ /index.html; diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index 4dd9e94..7bc134e 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -31,6 +31,33 @@ const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter const KIND_LABEL = { count: "Anzahl", weight: "Gewicht", volume: "Volumen" }; const KIND_ORDER = ["count", "weight", "volume"]; +// Foto vor dem Hochladen verkleinern: kleiner in der DB, schneller im Upload und +// zuverlässig unter dem Server-Limit. Bei Problemen fällt es aufs Original zurück. +function verkleinereBild(datei, maxDim = 1600, quality = 0.85) { + return new Promise((resolve) => { + if (!datei || !datei.type?.startsWith("image/")) return resolve(datei); + const url = URL.createObjectURL(datei); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + const faktor = Math.min(1, maxDim / Math.max(img.width, img.height)); + if (faktor >= 1 && datei.size <= 1_500_000) return resolve(datei); + const w = Math.round(img.width * faktor); + const h = Math.round(img.height * faktor); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + canvas.getContext("2d").drawImage(img, 0, 0, w, h); + canvas.toBlob( + (blob) => resolve(blob ? new File([blob], "foto.jpg", { type: "image/jpeg" }) : datei), + "image/jpeg", quality, + ); + }; + img.onerror = () => { URL.revokeObjectURL(url); resolve(datei); }; + img.src = url; + }); +} + export default function ProductForm() { const confirm = useConfirm(); const toast = useToast(); @@ -142,7 +169,8 @@ export default function ProductForm() { if (!file || isNew) return; setError(null); try { - await api.uploadProductImage(id, file); + const foto = await verkleinereBild(file); + await api.uploadProductImage(id, foto); setBildVersion((v) => v + 1); toast("Foto gespeichert."); } catch (err) {