feat: full site build — Project/Melody schema (Option A), admin CRUD, public sections, uploads, email+SMTP, internal analytics, legal pages, docs
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user