|
|
|
@@ -1,155 +1,76 @@
|
|
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
import { useRef, useState } from "react"
|
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
|
|
|
|
|
|
|
|
import { allowedImageContentTypes, maxUploadSizeBytes } from "@/lib/validation/upload"
|
|
|
|
|
import { PhotoPreview } from "@/components/invite/photo-preview"
|
|
|
|
|
import { allowedImageContentTypes, allowedImageExtensions, getUploadContentType, 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
|
|
|
|
|
title: string; description: string; current: string; choose: string; upload: string
|
|
|
|
|
replace: string; uploading: string; success: string; error: string; disabled: string
|
|
|
|
|
requirements: string; saved: string; selected: string; invalid: string; refresh: string
|
|
|
|
|
previewUnavailable: string
|
|
|
|
|
}
|
|
|
|
|
type Photo = { filename: string | null; url: string }
|
|
|
|
|
|
|
|
|
|
export function PhotoUpload({
|
|
|
|
|
token,
|
|
|
|
|
labels,
|
|
|
|
|
disabled,
|
|
|
|
|
existingFilename,
|
|
|
|
|
}: {
|
|
|
|
|
token: string
|
|
|
|
|
labels: PhotoUploadLabels
|
|
|
|
|
disabled: boolean
|
|
|
|
|
existingFilename: string | null
|
|
|
|
|
export function PhotoUpload({ token, labels, disabled, existingPhoto }: {
|
|
|
|
|
token: string; labels: PhotoUploadLabels; disabled: boolean; existingPhoto: Photo | null
|
|
|
|
|
}) {
|
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
const [filename, setFilename] = useState(existingFilename)
|
|
|
|
|
const [savedPhoto, setSavedPhoto] = useState(existingPhoto)
|
|
|
|
|
const [selected, setSelected] = useState<{ file: File; url: string } | null>(null)
|
|
|
|
|
const [status, setStatus] = useState<"idle" | "uploading" | "success" | "error">("idle")
|
|
|
|
|
|
|
|
|
|
async function uploadSelectedFile() {
|
|
|
|
|
const file = inputRef.current?.files?.[0]
|
|
|
|
|
if (!file) return
|
|
|
|
|
useEffect(() => () => { if (selected) URL.revokeObjectURL(selected.url) }, [selected])
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!allowedImageContentTypes.includes(
|
|
|
|
|
file.type as (typeof allowedImageContentTypes)[number],
|
|
|
|
|
) ||
|
|
|
|
|
file.size > maxUploadSizeBytes
|
|
|
|
|
) {
|
|
|
|
|
function chooseFile(file: File | undefined) {
|
|
|
|
|
if (!file) return
|
|
|
|
|
if (!getUploadContentType(file) || file.size > maxUploadSizeBytes || file.size === 0) {
|
|
|
|
|
setSelected((previous) => {
|
|
|
|
|
if (previous) URL.revokeObjectURL(previous.url)
|
|
|
|
|
return null
|
|
|
|
|
})
|
|
|
|
|
setStatus("error")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
setStatus("idle")
|
|
|
|
|
setSelected((previous) => {
|
|
|
|
|
if (previous) URL.revokeObjectURL(previous.url)
|
|
|
|
|
return { file, url: URL.createObjectURL(file) }
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function uploadSelectedFile() {
|
|
|
|
|
if (!selected) 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")
|
|
|
|
|
const contentType = getUploadContentType(selected.file)
|
|
|
|
|
if (!contentType) throw new Error("invalid-file")
|
|
|
|
|
const input = { token, filename: selected.file.name, contentType, sizeBytes: selected.file.size }
|
|
|
|
|
const presign = await fetch("/api/uploads/presign", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) })
|
|
|
|
|
if (!presign.ok) throw new Error("presign")
|
|
|
|
|
const { uploadUrl, objectKey } = await presign.json() as { uploadUrl: string; objectKey: string }
|
|
|
|
|
const upload = await fetch(uploadUrl, { method: "PUT", headers: { "content-type": contentType }, body: selected.file })
|
|
|
|
|
if (!upload.ok) throw new Error("upload")
|
|
|
|
|
const complete = await fetch("/api/uploads/complete", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...input, objectKey }) })
|
|
|
|
|
if (!complete.ok) throw new Error("complete")
|
|
|
|
|
const result = await complete.json() as { photo: Photo }
|
|
|
|
|
setSavedPhoto(result.photo)
|
|
|
|
|
setSelected(null)
|
|
|
|
|
if (inputRef.current) inputRef.current.value = ""
|
|
|
|
|
} catch {
|
|
|
|
|
setStatus("error")
|
|
|
|
|
}
|
|
|
|
|
setStatus("success")
|
|
|
|
|
} catch { setStatus("error") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section className="border-t border-wedding-line pt-10">
|
|
|
|
|
<div className="grid gap-4 max-w-3xl mx-auto">
|
|
|
|
|
<p className="wedding-section-label">04</p>
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="font-wedding-display text-wedding-medium">{labels.title}</h2>
|
|
|
|
|
<p className="mt-2 font-wedding-ui text-sm leading-wedding-body">{labels.description}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="font-wedding-ui text-sm">{labels.requirements}</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="mt-6 max-w-3xl mx-auto">
|
|
|
|
|
{filename ? (
|
|
|
|
|
<p className="bg-wedding-cream px-4 py-3 font-wedding-ui text-sm text-wedding-ink">
|
|
|
|
|
{labels.current.replace("{filename}", filename)}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{disabled ? (
|
|
|
|
|
<p className="border border-wedding-line bg-wedding-cream px-4 py-3 font-wedding-ui text-sm">
|
|
|
|
|
{labels.disabled}
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="mt-4 flex flex-col gap-4 wedding-md:flex-row wedding-md:items-end">
|
|
|
|
|
<label className="flex-1 font-wedding-ui text-sm">
|
|
|
|
|
<span>{labels.choose}</span>
|
|
|
|
|
<input
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
type="file"
|
|
|
|
|
accept={allowedImageContentTypes.join(",")}
|
|
|
|
|
className="mt-2 w-full border border-wedding-line bg-wedding-ivory p-3"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={uploadSelectedFile}
|
|
|
|
|
disabled={status === "uploading"}
|
|
|
|
|
className="wedding-primary-cta disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
>
|
|
|
|
|
{status === "uploading"
|
|
|
|
|
? labels.uploading
|
|
|
|
|
: filename
|
|
|
|
|
? labels.replace
|
|
|
|
|
: labels.upload}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{status === "success" ? (
|
|
|
|
|
<p className="mt-4 font-wedding-ui text-sm text-wedding-olive">{labels.success}</p>
|
|
|
|
|
) : null}
|
|
|
|
|
{status === "error" ? <p className="mt-4 font-wedding-ui text-sm text-wedding-terracotta">{labels.error}</p> : null}
|
|
|
|
|
<div className="mx-auto grid max-w-3xl gap-4"><p className="wedding-section-label">04</p><div><h2 className="font-wedding-display text-wedding-medium">{labels.title}</h2><p className="mt-2 font-wedding-ui text-sm leading-wedding-body">{labels.description}</p></div><p className="font-wedding-ui text-sm">{labels.requirements}</p></div>
|
|
|
|
|
<div className="mx-auto mt-6 max-w-3xl space-y-5">
|
|
|
|
|
{savedPhoto ? <div><p className="mb-2 font-wedding-ui text-sm">{labels.saved}</p><PhotoPreview photo={{ ...savedPhoto, fallback: labels.refresh }} alt={labels.current.replace("{filename}", savedPhoto.filename ?? "")} /></div> : null}
|
|
|
|
|
{selected ? <div><p className="mb-2 font-wedding-ui text-sm">{labels.selected}</p><PhotoPreview photo={{ url: selected.url, fallback: labels.previewUnavailable }} alt={labels.selected} /></div> : null}
|
|
|
|
|
{disabled ? <p className="border border-wedding-line bg-wedding-cream px-4 py-3 font-wedding-ui text-sm">{labels.disabled}</p> : <div className="flex flex-col gap-4 wedding-md:flex-row wedding-md:items-end"><label className="flex-1 font-wedding-ui text-sm"><span>{labels.choose}</span><input ref={inputRef} type="file" accept={[...allowedImageContentTypes, ...allowedImageExtensions].join(",")} disabled={status === "uploading"} onChange={(event) => chooseFile(event.target.files?.[0])} className="mt-2 w-full border border-wedding-line bg-wedding-ivory p-3 disabled:opacity-60" /></label><button type="button" onClick={uploadSelectedFile} disabled={!selected || status === "uploading"} className="wedding-primary-cta disabled:cursor-not-allowed disabled:opacity-60">{status === "uploading" ? labels.uploading : savedPhoto ? labels.replace : labels.upload}</button></div>}
|
|
|
|
|
{status === "success" ? <p aria-live="polite" className="font-wedding-ui text-sm text-wedding-olive">{labels.success}</p> : null}
|
|
|
|
|
{status === "error" ? <p aria-live="polite" className="font-wedding-ui text-sm text-wedding-terracotta">{selected ? labels.error : labels.invalid}</p> : null}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)
|
|
|
|
|