Files
petfeed/src/components/post-creation/MultiImageUpload.tsx
T
adminandClaude Opus 5.5 1bf0f9282a fix: HEIF/HEIC-Fotos (Apple) für Beiträge und Storys annehmen
- App: post-new.tsx sortierte HEIC/HEIF still aus, obwohl resizeForUpload()
  ohnehin in JPEG umwandelt — jetzt erlaubt, plus Hinweis bei übersprungenen
  Fotos
- Web: neues src/lib/heic-to-jpeg.ts wandelt HEIC vor dem Upload im Browser
  in JPEG um (Safari kann HEIC nativ dekodieren); eingebaut in Beiträge,
  Storys, Meilensteine und Tierbild, accept um HEIC ergänzt. Browser ohne
  HEIC-Dekodierung bekommen eine klare Meldung statt "falsches Format".
- Server-Whitelist (JPEG/PNG/WebP) bleibt unverändert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:26:24 +02:00

363 lines
12 KiB
TypeScript

"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { HEIC_ACCEPT, HeicUnsupportedError, normalizeImageFile } from "@/lib/heic-to-jpeg";
import { ImagePlus, X, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { useMutation } from "@tanstack/react-query";
import { uploadFileWithProgress } from "@/lib/upload-xhr";
import { computeBlurhash } from "@/lib/blurhash";
/**
* MultiImageUpload — multi-file upload zone for post images (up to 10).
*
* Adapts AvatarUpload to multiple files. For each selected file:
* 1. Validate type (jpg/png/webp) + size (<10MB) with exact UI-SPEC error copy
* 2. Reject >10 total: "You can upload up to 10 photos per post."
* 3. posts.getPresignedUrl → XHR PUT to Supabase signed URL with exact Content-Type (Pitfall 4)
* 4. Collect returned storage keys
*
* Pitfall 4 prevention: Content-Type header in XHR PUT MUST exactly match the contentType
* passed to getPresignedUrl (Supabase returns 400 SignatureDoesNotMatch if mismatched).
*
* UI-SPEC copy:
* Upload zone: "Add photos" / "Drag photos here or tap to browse — up to 10"
* File too large: "Photo must be under 10 MB."
* Wrong format: "Upload a JPG, PNG, or WebP image."
* Too many images: "You can upload up to 10 photos per post."
*/
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] as const;
type AllowedContentType = (typeof ALLOWED_TYPES)[number];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const MAX_IMAGES = 10;
interface UploadedImage {
/** Local preview URL (object URL) */
previewUrl: string;
/** Supabase storage key — only set after successful upload */
storageKey: string | null;
/** Blurhash placeholder computed client-side from the selected file (Gitea #1) */
blurhash: string | null;
/** File size for display */
fileName: string;
/** Upload state */
status: "pending" | "uploading" | "done" | "error";
uploadProgress: number;
}
interface MultiImageUploadProps {
petId: string;
/** Called with all successfully uploaded storage keys + their blurhash placeholders */
onImagesChange: (images: { key: string; blurhash: string | null }[]) => void;
/** Max number of images (default 10) */
maxImages?: number;
}
export function MultiImageUpload({
petId,
onImagesChange,
maxImages = MAX_IMAGES,
}: MultiImageUploadProps) {
const trpc = useTRPC();
const fileInputRef = useRef<HTMLInputElement>(null);
const [images, setImages] = useState<UploadedImage[]>([]);
const [isDragging, setIsDragging] = useState(false);
// Notify parent of done images whenever they change — must be outside setImages updater
// to avoid "Cannot update a component while rendering a different component" warning.
useEffect(() => {
const done = images
.filter((img) => img.status === "done" && img.storageKey)
.map((img) => ({ key: img.storageKey as string, blurhash: img.blurhash }));
onImagesChange(done);
}, [images, onImagesChange]);
const getPresignedUrlMutation = useMutation(
trpc.posts.getPresignedUrl.mutationOptions()
);
const uploadFile = useCallback(
async (originalFile: File, index: number) => {
// Apple HEIC/HEIF → JPEG im Browser (Server nimmt nur JPEG/PNG/WebP an)
let file: File;
try {
file = await normalizeImageFile(originalFile);
} catch (err) {
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "error" };
return updated;
});
toast.error(
err instanceof HeicUnsupportedError
? "This browser cannot read HEIC photos. Please use Safari or export the photo as JPG."
: "Upload a JPG, PNG, or WebP image."
);
return null;
}
// Validate size
if (file.size > MAX_FILE_SIZE) {
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "error" };
return updated;
});
toast.error("Photo must be under 10 MB.");
return null;
}
// Validate type
if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) {
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "error" };
return updated;
});
toast.error("Upload a JPG, PNG, or WebP image.");
return null;
}
// Mark as uploading
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "uploading", uploadProgress: 0 };
return updated;
});
try {
// Step 1: Get presigned URL, and in parallel compute the blurhash placeholder
// client-side (Gitea #1) — both run concurrently since neither depends on the other.
const [{ presignedUrl, key }, blurhash] = await Promise.all([
getPresignedUrlMutation.mutateAsync({
petId,
contentType: file.type as AllowedContentType,
fileSize: file.size,
}),
computeBlurhash(file),
]);
// Step 2: PUT file directly to Supabase Storage, tracking progress
await uploadFileWithProgress(presignedUrl, file, {
contentType: file.type,
onProgress: (percent) => {
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], uploadProgress: percent };
return updated;
});
},
});
// Step 3: Mark as done with storage key + blurhash
setImages((prev) => {
const updated = [...prev];
updated[index] = {
...updated[index],
status: "done",
storageKey: key,
blurhash,
uploadProgress: 100,
};
return updated;
});
return key;
} catch {
setImages((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], status: "error" };
return updated;
});
toast.error("Upload failed — please try again.");
return null;
}
},
[petId, getPresignedUrlMutation]
);
const processFiles = useCallback(
async (files: File[]) => {
const currentCount = images.length;
const available = maxImages - currentCount;
if (available <= 0) {
toast.error("You can upload up to 10 photos per post.");
return;
}
const toUpload = files.slice(0, available);
if (files.length > available) {
toast.error("You can upload up to 10 photos per post.");
}
// Create preview entries immediately
const newImages: UploadedImage[] = toUpload.map((file) => ({
previewUrl: URL.createObjectURL(file),
storageKey: null,
blurhash: null,
fileName: file.name,
status: "pending",
uploadProgress: 0,
}));
setImages((prev) => {
const updated = [...prev, ...newImages];
return updated;
});
// Upload each file
const startIndex = currentCount;
await Promise.all(
toUpload.map((file, i) => uploadFile(file, startIndex + i))
);
},
[images.length, maxImages, uploadFile]
);
const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
if (files.length > 0) {
void processFiles(files);
}
event.target.value = "";
},
[processFiles]
);
const handleRemove = useCallback((index: number) => {
setImages((prev) => {
URL.revokeObjectURL(prev[index].previewUrl);
return prev.filter((_, i) => i !== index);
});
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback(() => {
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
void processFiles(files);
}
},
[processFiles]
);
const canAddMore = images.length < maxImages;
return (
<div className="space-y-3">
{/* Upload zone — shown when can add more images */}
{canAddMore && (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragging
? "border-orange-500 bg-orange-50/50"
: "border-muted hover:border-muted-foreground/50"
}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
fileInputRef.current?.click();
}
}}
aria-label="Add photos"
>
<ImagePlus className="h-8 w-8 text-muted-foreground mx-auto mb-2" aria-hidden="true" />
<p className="text-sm font-medium text-foreground">Add photos</p>
<p className="text-xs text-muted-foreground mt-1">
Drag photos here or tap to browse — up to 10
</p>
</div>
)}
{/* Hidden file input */}
<input
type="file"
accept={`image/jpeg,image/png,image/webp,${HEIC_ACCEPT}`}
multiple
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
{/* Image thumbnails grid */}
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map((img, i) => (
<div key={i} className="relative aspect-square rounded-md overflow-hidden bg-muted">
{/* Preview image */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.previewUrl}
alt={`Image ${i + 1}`}
className="w-full h-full object-cover"
/>
{/* Uploading overlay */}
{img.status === "uploading" && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Loader2 className="h-6 w-6 text-white animate-spin" />
</div>
)}
{/* Error overlay */}
{img.status === "error" && (
<div className="absolute inset-0 bg-red-500/40 flex items-center justify-center">
<span className="text-xs text-white font-medium">Error</span>
</div>
)}
{/* Progress bar at bottom */}
{img.status === "uploading" && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-muted">
<div
className="h-full bg-orange-500 transition-all"
style={{ width: `${img.uploadProgress}%` }}
/>
</div>
)}
{/* Remove button */}
{img.status !== "uploading" && (
<button
type="button"
onClick={() => handleRemove(i)}
className="absolute top-1 right-1 w-5 h-5 bg-black/60 rounded-full flex items-center justify-center hover:bg-black/80 transition-colors"
aria-label={`Remove image ${i + 1}`}
>
<X className="h-3 w-3 text-white" />
</button>
)}
{/* Order badge */}
<span className="absolute bottom-1 left-1 text-xs text-white bg-black/50 rounded px-1 select-none">
{i + 1}
</span>
</div>
))}
</div>
)}
</div>
);
}