199 lines
6.5 KiB
TypeScript
199 lines
6.5 KiB
TypeScript
import { FileImage, Upload, X } from 'lucide-react';
|
|
import { useEffect, useRef, useState, useId } from 'react';
|
|
import {
|
|
Attachment,
|
|
AttachmentAction,
|
|
AttachmentActions,
|
|
AttachmentContent,
|
|
AttachmentDescription,
|
|
AttachmentMedia,
|
|
AttachmentTitle,
|
|
AttachmentTrigger,
|
|
} from '@/components/ui/attachment';
|
|
import { uploadFile, UploadError } from '@/lib/upload';
|
|
|
|
type FileUploadProps = {
|
|
value: string | null;
|
|
onChange: (key: string | null) => void;
|
|
folder?: string;
|
|
accept?: string;
|
|
maxSize?: number;
|
|
onUploadingChange?: (uploading: boolean) => void;
|
|
existingUrl?: string | null;
|
|
onFileMeta?: (meta: { size: number; type: string } | null) => void;
|
|
};
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) {
|
|
return `${bytes} B`;
|
|
}
|
|
|
|
if (bytes < 1024 * 1024) {
|
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
}
|
|
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function acceptToLabels(accept: string): string[] {
|
|
const mimeMap: Record<string, string> = {
|
|
'image/jpeg': 'JPG',
|
|
'image/png': 'PNG',
|
|
'image/webp': 'WebP',
|
|
'image/gif': 'GIF',
|
|
'image/svg+xml': 'SVG',
|
|
'application/pdf': 'PDF',
|
|
'video/mp4': 'MP4',
|
|
'application/zip': 'ZIP',
|
|
};
|
|
|
|
return accept
|
|
.split(',')
|
|
.map((mime) => mimeMap[mime.trim()] || mime.trim().split('/').pop()?.toUpperCase() || 'File')
|
|
.filter((v, i, a) => a.indexOf(v) === i);
|
|
}
|
|
|
|
function formatMaxSize(bytes: number): string {
|
|
if (bytes < 1024 * 1024) {
|
|
return `${(bytes / 1024).toFixed(0)}KB`;
|
|
}
|
|
return `${(bytes / (1024 * 1024)).toFixed(0)}MB`;
|
|
}
|
|
|
|
export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image/png,image/webp,image/gif', maxSize = 10 * 1024 * 1024, onUploadingChange, existingUrl, onFileMeta }: FileUploadProps) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const uploadId = useId();
|
|
const [uploading, setUploading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [fileName, setFileName] = useState<string | null>(null);
|
|
const [fileSize, setFileSize] = useState<number | null>(null);
|
|
const [preview, setPreview] = useState<string | null>(null);
|
|
|
|
const onUploadingChangeRef = useRef(onUploadingChange);
|
|
onUploadingChangeRef.current = onUploadingChange;
|
|
|
|
useEffect(() => {
|
|
onUploadingChangeRef.current?.(uploading);
|
|
}, [uploading]);
|
|
|
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
setUploading(true);
|
|
setFileName(file.name);
|
|
setFileSize(file.size);
|
|
|
|
const objectUrl = URL.createObjectURL(file);
|
|
setPreview(objectUrl);
|
|
|
|
try {
|
|
const key = await uploadFile(file, folder);
|
|
onChange(key);
|
|
onFileMeta?.({ size: file.size, type: file.type });
|
|
} catch (err) {
|
|
const message = err instanceof UploadError ? err.message : 'Gagal mengunggah file.';
|
|
setError(message);
|
|
setPreview(null);
|
|
setFileName(null);
|
|
setFileSize(null);
|
|
onFileMeta?.(null);
|
|
|
|
} finally {
|
|
setUploading(false);
|
|
|
|
if (inputRef.current) {
|
|
inputRef.current.value = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleRemove(e: React.MouseEvent) {
|
|
e.stopPropagation();
|
|
onChange(null);
|
|
setFileName(null);
|
|
setFileSize(null);
|
|
setPreview(null);
|
|
setError(null);
|
|
onFileMeta?.(null);
|
|
|
|
if (inputRef.current) {
|
|
inputRef.current.value = '';
|
|
}
|
|
}
|
|
|
|
const state = uploading ? 'uploading' : error ? 'error' : value ? 'done' : 'idle';
|
|
|
|
return (
|
|
<>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept={accept}
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
id={uploadId}
|
|
/>
|
|
|
|
<Attachment state={state} orientation="horizontal" className='w-full'>
|
|
<AttachmentTrigger
|
|
onClick={() => inputRef.current?.click()}
|
|
aria-label={value ? 'Ganti file' : 'Pilih file untuk diunggah'}
|
|
/>
|
|
|
|
<AttachmentMedia variant={preview || existingUrl ? 'image' : 'icon'}>
|
|
{preview ? (
|
|
<img src={preview} alt={fileName ?? 'Preview'} />
|
|
) : existingUrl && value ? (
|
|
<img src={existingUrl} alt="Bukti" />
|
|
) : uploading ? (
|
|
<Upload className="animate-pulse" />
|
|
) : (
|
|
<FileImage />
|
|
)}
|
|
</AttachmentMedia>
|
|
|
|
<AttachmentContent>
|
|
{value ? (
|
|
<>
|
|
<AttachmentTitle>{fileName}</AttachmentTitle>
|
|
<AttachmentDescription>
|
|
{fileSize ? formatFileSize(fileSize) : 'Terupload'}
|
|
</AttachmentDescription>
|
|
</>
|
|
) : uploading ? (
|
|
<>
|
|
<AttachmentTitle>Mengunggah...</AttachmentTitle>
|
|
<AttachmentDescription>Memproses file</AttachmentDescription>
|
|
</>
|
|
) : error ? (
|
|
<>
|
|
<AttachmentTitle>Gagal</AttachmentTitle>
|
|
<AttachmentDescription>{error}</AttachmentDescription>
|
|
</>
|
|
) : (
|
|
<>
|
|
<AttachmentTitle>Unggah File</AttachmentTitle>
|
|
<AttachmentDescription>
|
|
{`${acceptToLabels(accept).join(', ')} · Maks ${formatMaxSize(maxSize)}`}
|
|
</AttachmentDescription>
|
|
</>
|
|
)}
|
|
</AttachmentContent>
|
|
|
|
{value && (
|
|
<AttachmentActions>
|
|
<AttachmentAction aria-label="Hapus file" onClick={handleRemove}>
|
|
<X />
|
|
</AttachmentAction>
|
|
</AttachmentActions>
|
|
)}
|
|
</Attachment>
|
|
</>
|
|
);
|
|
}
|