- Added `media-library.php` configuration file for managing media uploads and conversions. - Updated `filesystems.php` to set default visibility for AWS S3 storage. - Implemented `FileUpload` component for handling file uploads with preview and error handling. - Created `ImagePreviewModal` component for displaying image previews in a modal. - Introduced `Attachment` UI components for better file attachment handling. - Added `useFileUpload` hook to manage file upload state and logic. - Implemented utility functions for uploading files to S3 with presigned URLs. - Created API route for generating presigned URLs for file uploads.
167 lines
5.5 KiB
TypeScript
167 lines
5.5 KiB
TypeScript
import { FileImage, Upload, X } from 'lucide-react';
|
|
import { useEffect, useRef, useState } 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;
|
|
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`;
|
|
}
|
|
|
|
export function FileUpload({ value, onChange, folder, accept = 'image/jpeg,image/png,image/webp,image/gif', onUploadingChange, existingUrl, onFileMeta }: FileUploadProps) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
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);
|
|
|
|
useEffect(() => {
|
|
onUploadingChange?.(uploading);
|
|
}, [uploading, onUploadingChange]);
|
|
|
|
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="file-upload"
|
|
/>
|
|
|
|
<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 Bukti</AttachmentTitle>
|
|
<AttachmentDescription>Opsional · JPG, PNG, WebP, GIF · Maks 10MB</AttachmentDescription>
|
|
</>
|
|
)}
|
|
</AttachmentContent>
|
|
|
|
{value && (
|
|
<AttachmentActions>
|
|
<AttachmentAction aria-label="Hapus file" onClick={handleRemove}>
|
|
<X />
|
|
</AttachmentAction>
|
|
</AttachmentActions>
|
|
)}
|
|
</Attachment>
|
|
</>
|
|
);
|
|
}
|