- 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>
363 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|