feat: heic support
This commit is contained in:
@@ -1,11 +1,20 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import convert from "heic-convert"
|
||||
|
||||
import { getInvitationByToken } from "@/lib/db/queries/invitations"
|
||||
import { getPhotoUploadByInvitationId, upsertPhotoUpload } from "@/lib/db/queries/uploads"
|
||||
import { isRsvpClosed } from "@/lib/rsvp-deadline"
|
||||
import { getUploadBucket } from "@/lib/storage/s3"
|
||||
import { deleteUploadObject, isObjectKeyForInvitation } from "@/lib/storage/uploads"
|
||||
import { completeUploadSchema } from "@/lib/validation/upload"
|
||||
import {
|
||||
createInvitationUploadKey,
|
||||
createPresignedDownloadUrl,
|
||||
deleteUploadObject,
|
||||
downloadUploadObject,
|
||||
getUploadedObjectMetadata,
|
||||
isObjectKeyForInvitation,
|
||||
putUploadObject,
|
||||
} from "@/lib/storage/uploads"
|
||||
import { completeUploadSchema, maxUploadSizeBytes } from "@/lib/validation/upload"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
@@ -14,7 +23,13 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ message: "Upload deadline has passed" }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = completeUploadSchema.safeParse(await request.json())
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ message: "Invalid upload completion" }, { status: 400 })
|
||||
}
|
||||
const parsed = completeUploadSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ message: "Invalid upload completion" }, { status: 400 })
|
||||
@@ -30,19 +45,62 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ message: "Invalid object key" }, { status: 400 })
|
||||
}
|
||||
|
||||
let metadata: Awaited<ReturnType<typeof getUploadedObjectMetadata>>
|
||||
try {
|
||||
metadata = await getUploadedObjectMetadata(parsed.data.objectKey)
|
||||
} catch {
|
||||
return NextResponse.json({ message: "Uploaded object not found" }, { status: 400 })
|
||||
}
|
||||
if (metadata.contentType !== parsed.data.contentType || metadata.sizeBytes !== parsed.data.sizeBytes) {
|
||||
return NextResponse.json({ message: "Uploaded object does not match" }, { status: 400 })
|
||||
}
|
||||
|
||||
const isHeic = parsed.data.contentType === "image/heic" || parsed.data.contentType === "image/heif"
|
||||
let objectKey = parsed.data.objectKey
|
||||
let filename = parsed.data.filename
|
||||
let contentType = parsed.data.contentType
|
||||
let sizeBytes = parsed.data.sizeBytes
|
||||
|
||||
if (isHeic) {
|
||||
try {
|
||||
const input = await downloadUploadObject(parsed.data.objectKey, maxUploadSizeBytes)
|
||||
const jpeg = await convert({ buffer: input, format: "JPEG", quality: 0.92 })
|
||||
if (jpeg.length === 0 || jpeg.length > maxUploadSizeBytes) {
|
||||
return NextResponse.json({ message: "Converted image has an invalid size" }, { status: 400 })
|
||||
}
|
||||
filename = `${parsed.data.filename.replace(/\.[^.]*$/, "") || "upload"}.jpg`
|
||||
objectKey = createInvitationUploadKey(invitation.invitation.id, filename)
|
||||
await putUploadObject({ objectKey, contentType: "image/jpeg", body: jpeg })
|
||||
const finalMetadata = await getUploadedObjectMetadata(objectKey)
|
||||
if (finalMetadata.contentType !== "image/jpeg" || finalMetadata.sizeBytes !== jpeg.length) {
|
||||
throw new Error("Converted object does not match")
|
||||
}
|
||||
contentType = "image/jpeg"
|
||||
sizeBytes = jpeg.length
|
||||
} catch {
|
||||
return NextResponse.json({ message: "Unable to convert uploaded image" }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const previousUpload = await getPhotoUploadByInvitationId(invitation.invitation.id)
|
||||
const upload = await upsertPhotoUpload({
|
||||
invitationId: invitation.invitation.id,
|
||||
bucket: getUploadBucket(),
|
||||
objectKey: parsed.data.objectKey,
|
||||
originalFilename: parsed.data.filename,
|
||||
contentType: parsed.data.contentType,
|
||||
sizeBytes: parsed.data.sizeBytes,
|
||||
objectKey,
|
||||
originalFilename: filename,
|
||||
contentType,
|
||||
sizeBytes,
|
||||
})
|
||||
|
||||
if (previousUpload && previousUpload.object_key !== parsed.data.objectKey) {
|
||||
if (isHeic) {
|
||||
await deleteUploadObject(parsed.data.objectKey).catch(() => undefined)
|
||||
}
|
||||
if (previousUpload && previousUpload.object_key !== objectKey) {
|
||||
await deleteUploadObject(previousUpload.object_key).catch(() => undefined)
|
||||
}
|
||||
|
||||
return NextResponse.json({ upload })
|
||||
return NextResponse.json({
|
||||
upload,
|
||||
photo: { filename: upload.original_filename, url: await createPresignedDownloadUrl(upload.object_key) },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { getInvitationByToken } from "@/lib/db/queries/invitations"
|
||||
import { getPhotoUploadByInvitationId } from "@/lib/db/queries/uploads"
|
||||
import { createPresignedDownloadUrl } from "@/lib/storage/uploads"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get("token")
|
||||
if (!token) return NextResponse.json({ message: "Invitation token is required" }, { status: 400 })
|
||||
|
||||
const invitation = await getInvitationByToken(token)
|
||||
if (!invitation) return NextResponse.json({ message: "Invitation not found" }, { status: 404 })
|
||||
|
||||
const upload = await getPhotoUploadByInvitationId(invitation.invitation.id)
|
||||
if (!upload) return NextResponse.json({ photo: null }, { headers: { "cache-control": "private, no-store" } })
|
||||
|
||||
return NextResponse.json(
|
||||
{ photo: { filename: upload.original_filename, url: await createPresignedDownloadUrl(upload.object_key) } },
|
||||
{ headers: { "cache-control": "private, no-store" } },
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,13 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ message: "Upload deadline has passed" }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = presignUploadSchema.safeParse(await request.json())
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ message: "Invalid upload request" }, { status: 400 })
|
||||
}
|
||||
const parsed = presignUploadSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ message: "Invalid upload request" }, { status: 400 })
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getGuestDictionary } from "@/lib/i18n/get-dictionary"
|
||||
import { isSupportedLanguage } from "@/lib/i18n/config"
|
||||
import { getInvitePageContent } from "@/lib/i18n/invite-page/content"
|
||||
import { isRsvpClosed } from "@/lib/rsvp-deadline"
|
||||
import { createPresignedDownloadUrl } from "@/lib/storage/uploads"
|
||||
|
||||
import { submitRsvpAction } from "./actions"
|
||||
|
||||
@@ -31,6 +32,9 @@ export default async function InvitePage({
|
||||
getInvitationInteractions(detail.invitation.id),
|
||||
getPhotoUploadByInvitationId(detail.invitation.id),
|
||||
])
|
||||
const photo = photoUpload
|
||||
? { filename: photoUpload.original_filename, url: await createPresignedDownloadUrl(photoUpload.object_key) }
|
||||
: null
|
||||
|
||||
return <WeddingInvite
|
||||
action={submitRsvpAction.bind(null, token)}
|
||||
@@ -40,7 +44,7 @@ export default async function InvitePage({
|
||||
content={getInvitePageContent(language)}
|
||||
closed={isRsvpClosed()}
|
||||
toastRequest={interactions.toastRequests[0]}
|
||||
filename={photoUpload?.original_filename ?? null}
|
||||
photo={photo}
|
||||
submitted={submitted}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
export function PhotoPreview({ photo, alt }: { photo: { url: string; fallback?: string }; alt: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
// Signed storage URLs must bypass Next's image optimizer and remote-host allowlist.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <div className="relative flex aspect-video items-center justify-center overflow-hidden border border-wedding-line bg-wedding-cream"><img src={photo.url} alt={alt} onError={() => setFailed(true)} className={`size-full object-contain ${failed ? "hidden" : ""}`} referrerPolicy="no-referrer" />{failed ? <p className="px-6 text-center font-wedding-ui text-sm text-wedding-ink">{photo.fallback ?? alt}</p> : null}</div>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ export function RsvpForm({
|
||||
dictionary,
|
||||
closed,
|
||||
toastRequest,
|
||||
filename,
|
||||
photo,
|
||||
}: {
|
||||
action: (formData: FormData) => void | Promise<void>
|
||||
detail: InvitationDetail
|
||||
@@ -22,14 +22,14 @@ export function RsvpForm({
|
||||
dictionary: GuestDictionary
|
||||
closed: boolean
|
||||
toastRequest: ToastRequest | undefined
|
||||
filename: string | null
|
||||
photo: { filename: string | null; url: string } | null
|
||||
}) {
|
||||
return (
|
||||
<form action={action} className="mt-12">
|
||||
<RsvpGuestFields members={detail.members} dictionary={dictionary} disabled={closed} />
|
||||
<RsvpTravelFields response={detail.response} dictionary={dictionary} disabled={closed} />
|
||||
<RsvpToastFields members={detail.members} toastRequest={toastRequest} dictionary={dictionary} disabled={closed} />
|
||||
<div className="py-10"><PhotoUpload token={token} labels={dictionary.upload} disabled={closed} existingFilename={filename} /></div>
|
||||
<div className="py-10"><PhotoUpload token={token} labels={dictionary.upload} disabled={closed} existingPhoto={photo} /></div>
|
||||
{!closed ? <div className="max-w-3xl mx-auto"><button type="submit" className="wedding-primary-cta w-full">{dictionary.form.submit} →</button></div> : null}
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ export function RsvpSection(props: {
|
||||
content: InvitePageContent["rsvp"]
|
||||
closed: boolean
|
||||
toastRequest: ToastRequest | undefined
|
||||
filename: string | null
|
||||
photo: { filename: string | null; url: string } | null
|
||||
submitted?: string
|
||||
error?: string
|
||||
}) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export function WeddingInvite({
|
||||
content,
|
||||
closed,
|
||||
toastRequest,
|
||||
filename,
|
||||
photo,
|
||||
submitted,
|
||||
error,
|
||||
}: {
|
||||
@@ -29,7 +29,7 @@ export function WeddingInvite({
|
||||
content: InvitePageContent
|
||||
closed: boolean
|
||||
toastRequest: ToastRequest | undefined
|
||||
filename: string | null
|
||||
photo: { filename: string | null; url: string } | null
|
||||
submitted?: string
|
||||
error?: string
|
||||
}) {
|
||||
@@ -41,7 +41,7 @@ export function WeddingInvite({
|
||||
<DetailsSection content={content.details} />
|
||||
<ScheduleSection content={content.schedule} />
|
||||
<FaqSection content={content.faq} />
|
||||
<RsvpSection action={action} detail={detail} token={token} dictionary={dictionary} content={content.rsvp} closed={closed} toastRequest={toastRequest} filename={filename} submitted={submitted} error={error} />
|
||||
<RsvpSection action={action} detail={detail} token={token} dictionary={dictionary} content={content.rsvp} closed={closed} toastRequest={toastRequest} photo={photo} submitted={submitted} error={error} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,11 @@ export const de = {
|
||||
success: "Foto erfolgreich hochgeladen.",
|
||||
error: "Das Foto konnte nicht hochgeladen werden. Bitte versuche es erneut.",
|
||||
disabled: "Foto-Uploads sind geschlossen, da die RSVP-Frist abgelaufen ist.",
|
||||
requirements: "JPG, PNG oder WebP. Maximal 10 MB.",
|
||||
requirements: "JPG, JPEG, PNG, WebP, HEIC oder HEIF. Maximal 10 MB.",
|
||||
saved: "Aktuell hochgeladenes Foto",
|
||||
selected: "Ausgewähltes Foto — noch nicht hochgeladen",
|
||||
invalid: "Bitte wähle ein JPG-, JPEG-, PNG-, WebP-, HEIC- oder HEIF-Bild bis 10 MB.",
|
||||
refresh: "Das gespeicherte Foto konnte nicht geladen werden. Bitte aktualisiere die Seite.",
|
||||
previewUnavailable: "Dieses Format kann hier nicht angezeigt werden, wird aber nach dem Hochladen in JPEG umgewandelt.",
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -75,6 +75,11 @@ export const en = {
|
||||
success: "Photo uploaded successfully.",
|
||||
error: "We could not upload the photo. Please try again.",
|
||||
disabled: "Photo uploads are closed because the RSVP deadline has passed.",
|
||||
requirements: "JPG, PNG, or WebP. Maximum 10 MB.",
|
||||
requirements: "JPG, JPEG, PNG, WebP, HEIC, or HEIF. Maximum 10 MB.",
|
||||
saved: "Currently uploaded photo",
|
||||
selected: "Selected photo — not uploaded yet",
|
||||
invalid: "Please choose a JPG, JPEG, PNG, WebP, HEIC, or HEIF image up to 10 MB.",
|
||||
refresh: "The saved photo could not be loaded. Try refreshing the page.",
|
||||
previewUnavailable: "This format can't be previewed here, but it will be converted to a JPEG once uploaded.",
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -75,6 +75,11 @@ export const es = {
|
||||
success: "Foto subida correctamente.",
|
||||
error: "No pudimos subir la foto. Inténtalo de nuevo.",
|
||||
disabled: "La subida de fotos está cerrada porque ya pasó la fecha límite.",
|
||||
requirements: "JPG, PNG o WebP. Máximo 10 MB.",
|
||||
requirements: "JPG, JPEG, PNG, WebP, HEIC o HEIF. Máximo 10 MB.",
|
||||
saved: "Foto subida actualmente",
|
||||
selected: "Foto seleccionada — todavía no subida",
|
||||
invalid: "Elige una imagen JPG, JPEG, PNG, WebP, HEIC o HEIF de hasta 10 MB.",
|
||||
refresh: "No se pudo cargar la foto guardada. Intenta actualizar la página.",
|
||||
previewUnavailable: "Este formato no se puede previsualizar aquí, pero se convertirá a JPEG al subirlo.",
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -75,6 +75,11 @@ export const pl = {
|
||||
success: "Zdjęcie zostało przesłane.",
|
||||
error: "Nie udało się przesłać zdjęcia. Spróbuj ponownie.",
|
||||
disabled: "Przesyłanie zdjęć jest zamknięte, ponieważ termin odpowiedzi już minął.",
|
||||
requirements: "JPG, PNG lub WebP. Maksymalnie 10 MB.",
|
||||
requirements: "JPG, JPEG, PNG, WebP, HEIC lub HEIF. Maksymalnie 10 MB.",
|
||||
saved: "Aktualnie przesłane zdjęcie",
|
||||
selected: "Wybrane zdjęcie — jeszcze nieprzesłane",
|
||||
invalid: "Wybierz zdjęcie JPG, JPEG, PNG, WebP, HEIC lub HEIF o rozmiarze do 10 MB.",
|
||||
refresh: "Nie udało się załadować zapisanego zdjęcia. Odśwież stronę.",
|
||||
previewUnavailable: "Tego formatu nie można tu wyświetlić, ale po przesłaniu zostanie przekonwertowany na JPEG.",
|
||||
},
|
||||
} as const
|
||||
|
||||
+53
-1
@@ -1,4 +1,4 @@
|
||||
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
|
||||
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||
import { nanoid } from "nanoid"
|
||||
|
||||
@@ -61,6 +61,58 @@ export async function createPresignedDownloadUrl(objectKey: string) {
|
||||
)
|
||||
}
|
||||
|
||||
export async function downloadUploadObject(objectKey: string, maxSizeBytes: number) {
|
||||
const response = await getS3Client().send(new GetObjectCommand({
|
||||
Bucket: getUploadBucket(),
|
||||
Key: objectKey,
|
||||
}))
|
||||
const body = response.Body
|
||||
if (!body) throw new Error("Uploaded object has no body")
|
||||
|
||||
if ("transformToByteArray" in body && typeof body.transformToByteArray === "function") {
|
||||
const bytes = await body.transformToByteArray()
|
||||
if (bytes.byteLength > maxSizeBytes) throw new Error("Uploaded object is too large")
|
||||
return Buffer.from(bytes)
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
for await (const chunk of body as AsyncIterable<Uint8Array | string>) {
|
||||
const buffer = Buffer.from(chunk)
|
||||
size += buffer.length
|
||||
if (size > maxSizeBytes) throw new Error("Uploaded object is too large")
|
||||
chunks.push(buffer)
|
||||
}
|
||||
return Buffer.concat(chunks, size)
|
||||
}
|
||||
|
||||
export async function putUploadObject({
|
||||
objectKey,
|
||||
contentType,
|
||||
body,
|
||||
}: {
|
||||
objectKey: string
|
||||
contentType: string
|
||||
body: Buffer
|
||||
}) {
|
||||
await getS3Client().send(new PutObjectCommand({
|
||||
Bucket: getUploadBucket(),
|
||||
Key: objectKey,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
ContentLength: body.length,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getUploadedObjectMetadata(objectKey: string) {
|
||||
const response = await getS3Client().send(new HeadObjectCommand({
|
||||
Bucket: getUploadBucket(),
|
||||
Key: objectKey,
|
||||
}))
|
||||
|
||||
return { contentType: response.ContentType, sizeBytes: response.ContentLength }
|
||||
}
|
||||
|
||||
export async function deleteUploadObject(objectKey: string) {
|
||||
const client = getS3Client()
|
||||
const bucket = getUploadBucket()
|
||||
|
||||
@@ -1,8 +1,33 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const allowedImageContentTypes = ["image/jpeg", "image/png", "image/webp"] as const
|
||||
export const allowedImageContentTypes = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
] as const
|
||||
export const allowedImageExtensions = [".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"] as const
|
||||
export const maxUploadSizeBytes = 10 * 1024 * 1024
|
||||
|
||||
export type AllowedImageContentType = (typeof allowedImageContentTypes)[number]
|
||||
|
||||
export function getUploadContentType(file: File): AllowedImageContentType | null {
|
||||
const type = file.type.toLowerCase()
|
||||
if (type === "image/jpg") return "image/jpeg"
|
||||
if (allowedImageContentTypes.includes(type as AllowedImageContentType)) {
|
||||
return type as AllowedImageContentType
|
||||
}
|
||||
|
||||
const extension = `.${file.name.split(".").pop()?.toLowerCase() ?? ""}`
|
||||
if ([".jpg", ".jpeg"].includes(extension)) return "image/jpeg"
|
||||
if (extension === ".png") return "image/png"
|
||||
if (extension === ".webp") return "image/webp"
|
||||
if (extension === ".heic") return "image/heic"
|
||||
if (extension === ".heif") return "image/heif"
|
||||
return null
|
||||
}
|
||||
|
||||
export const presignUploadSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
filename: z.string().trim().min(1).max(255),
|
||||
|
||||
Generated
+51
@@ -12,6 +12,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.940.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"heic-convert": "^2.1.0",
|
||||
"kysely": "^0.28.8",
|
||||
"lucide-react": "^1.17.0",
|
||||
"nanoid": "^5.1.6",
|
||||
@@ -6460,6 +6461,32 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/heic-convert": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
|
||||
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"heic-decode": "^2.0.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"pngjs": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/heic-decode": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
||||
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"libheif-js": "^1.19.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
@@ -7031,6 +7058,12 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-js": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
||||
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -7252,6 +7285,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libheif-js": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.23.2.tgz",
|
||||
"integrity": "sha512-qvHIXtggEsw1lCNCWBYKloL2Z36DJBm0R9ThGiH2JnhKYdeZFLPFkP30Lw4yMskxxhx0bKg1gLrBHX1D2w2pSw==",
|
||||
"license": "LGPL-3.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -8271,6 +8313,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
|
||||
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.940.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"heic-convert": "^2.1.0",
|
||||
"kysely": "^0.28.8",
|
||||
"lucide-react": "^1.17.0",
|
||||
"nanoid": "^5.1.6",
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
declare module "heic-convert" {
|
||||
type ConvertOptions = {
|
||||
buffer: Buffer
|
||||
format: "JPEG" | "PNG"
|
||||
quality?: number
|
||||
}
|
||||
|
||||
function convert(options: ConvertOptions): Promise<Buffer>
|
||||
|
||||
export default convert
|
||||
}
|
||||
Reference in New Issue
Block a user