This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
import { allowedImageContentTypes, maxUploadSizeBytes } from "@/lib/validation/upload"
|
||||
|
||||
interface PhotoUploadLabels {
|
||||
title: string
|
||||
description: string
|
||||
current: string
|
||||
choose: string
|
||||
upload: string
|
||||
replace: string
|
||||
uploading: string
|
||||
success: string
|
||||
error: string
|
||||
disabled: string
|
||||
requirements: string
|
||||
}
|
||||
|
||||
export function PhotoUpload({
|
||||
token,
|
||||
labels,
|
||||
disabled,
|
||||
existingFilename,
|
||||
}: {
|
||||
token: string
|
||||
labels: PhotoUploadLabels
|
||||
disabled: boolean
|
||||
existingFilename: string | null
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [filename, setFilename] = useState(existingFilename)
|
||||
const [status, setStatus] = useState<"idle" | "uploading" | "success" | "error">("idle")
|
||||
|
||||
async function uploadSelectedFile() {
|
||||
const file = inputRef.current?.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (
|
||||
!allowedImageContentTypes.includes(
|
||||
file.type as (typeof allowedImageContentTypes)[number],
|
||||
) ||
|
||||
file.size > maxUploadSizeBytes
|
||||
) {
|
||||
setStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
setStatus("uploading")
|
||||
|
||||
try {
|
||||
const presignResponse = await fetch("/api/uploads/presign", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
sizeBytes: file.size,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!presignResponse.ok) throw new Error("Could not presign upload")
|
||||
|
||||
const { uploadUrl, objectKey } = (await presignResponse.json()) as {
|
||||
uploadUrl: string
|
||||
objectKey: string
|
||||
}
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": file.type },
|
||||
body: file,
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) throw new Error("Could not upload file")
|
||||
|
||||
const completeResponse = await fetch("/api/uploads/complete", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
sizeBytes: file.size,
|
||||
objectKey,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!completeResponse.ok) throw new Error("Could not complete upload")
|
||||
|
||||
setFilename(file.name)
|
||||
setStatus("success")
|
||||
if (inputRef.current) inputRef.current.value = ""
|
||||
} catch {
|
||||
setStatus("error")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[2rem] border bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="border-b pb-6">
|
||||
<h2 className="text-2xl font-semibold">{labels.title}</h2>
|
||||
<p className="mt-2 text-stone-600">{labels.description}</p>
|
||||
<p className="mt-2 text-sm text-stone-500">{labels.requirements}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{filename ? (
|
||||
<p className="rounded-2xl bg-stone-100 px-4 py-3 text-sm text-stone-700">
|
||||
{labels.current.replace("{filename}", filename)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{disabled ? (
|
||||
<p className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
{labels.disabled}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<label className="flex-1 space-y-2">
|
||||
<span className="text-sm font-medium">{labels.choose}</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={allowedImageContentTypes.join(",")}
|
||||
className="w-full rounded-xl border bg-white px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={uploadSelectedFile}
|
||||
disabled={status === "uploading"}
|
||||
className="rounded-full bg-stone-950 px-6 py-3 font-medium text-white transition hover:bg-stone-800 disabled:cursor-not-allowed disabled:opacity-60 sm:self-end"
|
||||
>
|
||||
{status === "uploading"
|
||||
? labels.uploading
|
||||
: filename
|
||||
? labels.replace
|
||||
: labels.upload}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "success" ? (
|
||||
<p className="text-sm text-emerald-700">{labels.success}</p>
|
||||
) : null}
|
||||
{status === "error" ? <p className="text-sm text-red-700">{labels.error}</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user