116 lines
3.2 KiB
TypeScript
116 lines
3.2 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
uploadedFileSchema,
|
|
UPLOAD_RULES,
|
|
type UploadKind,
|
|
type UploadedFileMetadata,
|
|
} from "@/lib/validations/upload";
|
|
|
|
type FileUploadProps = {
|
|
kind: UploadKind;
|
|
onUploaded: (files: UploadedFileMetadata[]) => void;
|
|
id?: string;
|
|
multiple?: boolean;
|
|
label?: string;
|
|
helperText?: string;
|
|
};
|
|
|
|
const defaultLabels: Record<UploadKind, string> = {
|
|
image: "Image",
|
|
audio: "Audio",
|
|
};
|
|
|
|
export default function FileUpload({
|
|
kind,
|
|
onUploaded,
|
|
id,
|
|
multiple = false,
|
|
label,
|
|
helperText,
|
|
}: FileUploadProps) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const rules = UPLOAD_RULES[kind];
|
|
const fieldId = id ?? `file-upload-${kind}`;
|
|
|
|
async function handleFiles(selectedFiles: File[]) {
|
|
setError(null);
|
|
setIsUploading(true);
|
|
|
|
try {
|
|
const uploaded: UploadedFileMetadata[] = [];
|
|
|
|
for (const file of selectedFiles) {
|
|
if (!rules.mimeTypes.includes(file.type) || file.size > rules.maxBytes) {
|
|
throw new Error(`Unsupported or oversized ${kind} file.`);
|
|
}
|
|
|
|
const body = new FormData();
|
|
body.set("kind", kind);
|
|
body.set("file", file);
|
|
|
|
const response = await fetch("/api/upload", { method: "POST", body });
|
|
const payload: unknown = await response.json();
|
|
|
|
if (!response.ok) {
|
|
const message =
|
|
typeof payload === "object" && payload !== null && "error" in payload
|
|
? String(payload.error)
|
|
: "The file could not be uploaded.";
|
|
throw new Error(message);
|
|
}
|
|
|
|
const parsed = uploadedFileSchema.safeParse(payload);
|
|
if (!parsed.success) {
|
|
throw new Error("The upload response was invalid.");
|
|
}
|
|
|
|
uploaded.push(parsed.data);
|
|
}
|
|
|
|
onUploaded(uploaded);
|
|
} catch (uploadError) {
|
|
setError(uploadError instanceof Error ? uploadError.message : "The file could not be uploaded.");
|
|
} finally {
|
|
setIsUploading(false);
|
|
if (inputRef.current) {
|
|
inputRef.current.value = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2" dir="auto">
|
|
<Label htmlFor={fieldId}>{label ?? defaultLabels[kind]}</Label>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<Input
|
|
ref={inputRef}
|
|
id={fieldId}
|
|
type="file"
|
|
accept={rules.accept}
|
|
multiple={multiple}
|
|
disabled={isUploading}
|
|
onChange={(event) => {
|
|
void handleFiles(Array.from(event.target.files ?? []));
|
|
}}
|
|
/>
|
|
{isUploading ? (
|
|
<Button type="button" variant="secondary" disabled>
|
|
Uploading…
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{helperText ?? `Maximum ${Math.round(rules.maxBytes / (1024 * 1024))} MB.`}
|
|
</p>
|
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
</div>
|
|
);
|
|
}
|