Foto-Upload: 413 beheben und Kamera stabilisieren
- nginx client_max_body_size auf 12m (Standard 1 MB loeste beim Bild-Upload einen 413 aus, bevor das Backend den Upload sah). - Backend-Bildlimit auf 8 MB angehoben, bleibt unter dem nginx-Limit, damit Uebergroesse eine klare 400-Meldung statt eines nackten 413 ergibt. - Web: Foto vor dem Upload clientseitig verkleinern (max 1600px, JPEG) - schneller, kleiner in der DB, zuverlaessig unter dem Limit. - iOS: Kamera ueber fullScreenCover statt sheet und Schliessen deterministisch ueber ein Binding - behebt das "Kamera geht direkt wieder zu"-Flackern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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"}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user