dstpabuaran.com/resources/js/components/file-upload.tsx
Yoga Pangestu 23cc327190 Refactor code for improved readability and consistency across multiple files
- Adjusted indentation and formatting in login, permissions, profile, and security pages for better readability.
- Enhanced the clarity of conditional statements and function calls in permissions and profile components.
- Updated type definitions in vite-env.d.ts for better code structure.
- Cleaned up array mapping syntax in ProductTest.php for consistency.
2026-08-01 10:14:47 +07:00

238 lines
7.1 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>
</>
);
}