fix upload image trip

This commit is contained in:
2026-05-22 14:52:22 +07:00
parent 9022f983a2
commit 4c449a572a
18 changed files with 721 additions and 118 deletions
@@ -0,0 +1,175 @@
"use client";
import { useRef, useState } from "react";
import Image from "next/image";
import { ImagePlus, X, Loader2 } from "lucide-react";
import { LIMITS } from "@/lib/limits";
interface TripImageUploadProps {
/** URL gambar yang sudah terunggah (path `/api/trip-images/...`). */
value: string[];
onChange: (urls: string[]) => void;
/** Lapor error ke form (mis. file terlalu besar / gagal upload). */
onError?: (msg: string) => void;
}
const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
/** Sinkron dengan MAX_TRIP_IMAGE_UPLOAD_BYTES di lib/trip-image-storage.ts. */
const MAX_BYTES = 12 * 1024 * 1024;
/**
* Pengganti input URL foto: user memilih file dari perangkatnya, tiap file
* langsung di-upload & dikompres server-side. Form hanya menyimpan URL hasil.
*/
export function TripImageUpload({
value,
onChange,
onError,
}: TripImageUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploadingCount, setUploadingCount] = useState(0);
const max = LIMITS.MAX_IMAGE_URLS;
const usedSlots = value.length + uploadingCount;
const remaining = max - usedSlots;
async function uploadOne(file: File): Promise<string | null> {
if (!ACCEPT_MIME.split(",").includes(file.type)) {
onError?.(`"${file.name}" harus JPG, PNG, atau WebP`);
return null;
}
if (file.size > MAX_BYTES) {
onError?.(`"${file.name}" melebihi 12MB`);
return null;
}
const fd = new FormData();
fd.set("file", file);
try {
const res = await fetch("/api/upload/trip-image", {
method: "POST",
body: fd,
});
const json = await res.json();
if (!res.ok) {
onError?.(json.error ?? `Gagal mengunggah "${file.name}"`);
return null;
}
return json.url as string;
} catch {
onError?.(`Gagal mengunggah "${file.name}"`);
return null;
}
}
async function handlePick(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (files.length === 0) return;
if (remaining <= 0) {
onError?.(`Maksimal ${max} foto`);
return;
}
const picked = files.slice(0, remaining);
if (files.length > remaining) {
onError?.(`Hanya ${remaining} foto pertama diunggah (maks ${max})`);
}
setUploadingCount((c) => c + picked.length);
// `value` di-snapshot saat handler dibuat; upload sekuensial supaya urutan
// foto stabil, lalu hasil yang berhasil ditambahkan sekali di akhir.
const uploaded: string[] = [];
for (const file of picked) {
const url = await uploadOne(file);
setUploadingCount((c) => c - 1);
if (url) uploaded.push(url);
}
if (uploaded.length > 0) onChange([...value, ...uploaded]);
}
function removeAt(index: number) {
onChange(value.filter((_, i) => i !== index));
}
return (
<div>
<label className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-neutral-700">
Foto Trip
</span>
<span className="text-xs text-neutral-400">
{value.length}/{max}
</span>
</label>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{value.map((url, i) => (
<div
key={url}
className="group relative aspect-square overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100"
>
<Image
src={url}
alt={i === 0 ? "Foto cover" : `Foto ${i + 1}`}
fill
className="object-cover"
sizes="(max-width: 640px) 33vw, 160px"
/>
{i === 0 && (
<span className="absolute left-1 top-1 rounded-md bg-primary-600/90 px-1.5 py-0.5 text-[10px] font-bold text-white">
Cover
</span>
)}
<button
type="button"
onClick={() => removeAt(i)}
aria-label={`Hapus foto ${i + 1}`}
className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-black/55 text-white transition-colors hover:bg-red-600"
>
<X size={13} strokeWidth={2.5} aria-hidden />
</button>
</div>
))}
{Array.from({ length: uploadingCount }).map((_, i) => (
<div
key={`uploading-${i}`}
className="flex aspect-square items-center justify-center rounded-xl border border-dashed border-neutral-300 bg-neutral-50"
>
<Loader2
size={20}
strokeWidth={2}
aria-hidden
className="animate-spin text-neutral-400"
/>
</div>
))}
{remaining > 0 && (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex aspect-square flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-neutral-300 bg-neutral-50/60 text-neutral-500 transition-colors hover:border-primary-400 hover:text-primary-600"
>
<ImagePlus size={20} strokeWidth={1.75} aria-hidden />
<span className="text-[11px] font-semibold">Tambah</span>
</button>
)}
</div>
<input
ref={inputRef}
type="file"
accept={ACCEPT_MIME}
multiple
onChange={handlePick}
className="sr-only"
/>
<p className="mt-1.5 text-xs text-neutral-400">
Unggah langsung dari galeri/kamera JPG, PNG, atau WebP, maks 12MB per
foto. Foto pertama jadi cover. Gambar besar otomatis dikompres tanpa
mengorbankan kualitas.
</p>
</div>
);
}