dstpabuaran.com/resources/js/components/file-upload-multiple.tsx
Yoga Pangestu 10db7bc5a2 Refactor product variant photo handling to support multiple images
- Updated ProductVariantService to handle multiple photo keys and URLs.
- Introduced FileUploadMultiple component for uploading multiple images.
- Modified product creation and editing forms to accommodate multiple photos.
- Adjusted data structures in product drafts and tests to reflect changes in photo handling.
- Enhanced image preview modal to navigate through multiple images.
2026-08-01 15:49:38 +07:00

157 lines
5.2 KiB
TypeScript

import { Plus, Upload, X } from 'lucide-react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { uploadFile, UploadError, getTemporaryUrl } from '@/lib/upload';
type FileUploadMultipleItem = {
key: string;
url: string | null;
};
type FileUploadMultipleProps = {
value: FileUploadMultipleItem[];
onChange: (items: FileUploadMultipleItem[]) => void;
maxItems?: number;
folder?: string;
accept?: string;
onUploadingChange?: (uploading: boolean) => void;
};
export function FileUploadMultiple({
value,
onChange,
maxItems = 5,
folder,
accept = 'image/jpeg,image/png,image/webp,image/gif',
onUploadingChange,
}: FileUploadMultipleProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState<string | null>(null);
const [uploadingCount, setUploadingCount] = useState(0);
const onUploadingChangeRef = useRef(onUploadingChange);
useEffect(() => {
onUploadingChangeRef.current = onUploadingChange;
});
useEffect(() => {
onUploadingChangeRef.current?.(uploadingCount > 0);
}, [uploadingCount]);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
if (value.length >= maxItems) {
setError(`Maksimal ${maxItems} gambar.`);
return;
}
setError(null);
setUploadingCount((prev) => prev + 1);
try {
const key = await uploadFile(file, folder);
const newItem: FileUploadMultipleItem = {
key,
url: getTemporaryUrl(key),
};
onChange([...value, newItem]);
} catch (err) {
const message =
err instanceof UploadError
? err.message
: 'Gagal mengunggah file.';
setError(message);
} finally {
setUploadingCount((prev) => prev - 1);
if (inputRef.current) {
inputRef.current.value = '';
}
}
},
[value, maxItems, folder, onChange],
);
function handleRemove(index: number) {
const updated = value.filter((_, i) => i !== index);
onChange(updated);
setError(null);
}
const canAdd = value.length < maxItems;
return (
<div className="grid gap-2">
<div className="flex flex-wrap gap-3">
{value.map((item, index) => (
<div
key={item.key}
className="group relative h-24 w-24 overflow-hidden rounded-lg border bg-muted"
>
<img
src={item.url ?? undefined}
alt={`Foto ${index + 1}`}
className="h-full w-full object-cover"
/>
{index === 0 && (
<span className="absolute left-1 top-1 rounded bg-primary px-1.5 py-0.5 text-[10px] font-medium text-primary-foreground">
Utama
</span>
)}
<button
type="button"
onClick={() => handleRemove(index)}
className="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white opacity-0 transition-opacity hover:bg-black/80 group-hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</div>
))}
{canAdd && (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex h-24 w-24 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed bg-muted/50 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-muted"
>
{uploadingCount > 0 ? (
<Upload className="h-5 w-5 animate-pulse" />
) : (
<Plus className="h-5 w-5" />
)}
<span className="text-[10px]">
{uploadingCount > 0
? 'Mengunggah...'
: 'Tambah Foto'}
</span>
</button>
)}
</div>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
/>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<p className="text-xs text-muted-foreground">
{value.length}/{maxItems} foto · Klik foto pertama sebagai
thumbnail utama
</p>
</div>
);
}