176 lines
5.6 KiB
TypeScript
176 lines
5.6 KiB
TypeScript
"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>
|
|
);
|
|
}
|