fix upload image trip
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user