- 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.
157 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|