Refine workspaces and event publishing; harden uploads and email delivery

This commit is contained in:
2026-09-09 15:44:00 -04:00
parent f5702caaea
commit 574f29a68e
93 changed files with 2885 additions and 535 deletions
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { UploadIcon } from "lucide-react";
import { allowedImageTypeSchema, MAX_PHOTO_BYTES } from "@album/contracts";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
export function BannerUpload({ eventId, selectedId, onSelect }: {
eventId: string;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const input = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [bannerId, setBannerId] = useState<string | null>(selectedId);
const create = api.banners.create.useMutation();
const complete = api.banners.complete.useMutation();
const status = api.banners.status.useQuery({ eventId, bannerId: bannerId ?? "" }, {
enabled: Boolean(bannerId),
refetchInterval: (query) => ["ready", "failed"].includes(query.state.data?.status ?? "") ? false : 1500,
retry: false,
});
async function upload(file: File) {
const mime = file.type || (/\.heic$/i.test(file.name) ? "image/heic" : /\.heif$/i.test(file.name) ? "image/heif" : "");
const parsed = allowedImageTypeSchema.safeParse(mime);
if (!parsed.success || file.size <= 0 || file.size > MAX_PHOTO_BYTES) {
toast.error("Choose a JPEG, PNG, WebP, or HEIC image up to 25 MB.");
return;
}
setUploading(true);
try {
const pending = await create.mutateAsync({ eventId, contentType: parsed.data, byteSize: file.size });
const response = await fetch(pending.uploadUrl, {
method: "PUT", body: file, headers: { "Content-Type": parsed.data }, signal: AbortSignal.timeout(120_000),
});
if (!response.ok) throw new Error("Banner upload failed. Please try again.");
await complete.mutateAsync({ eventId, bannerId: pending.bannerId });
setBannerId(pending.bannerId);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Banner upload failed");
} finally { setUploading(false); }
}
const processing = Boolean(bannerId) && status.data?.status !== "ready" && status.data?.status !== "failed" && !status.isError;
return (
<div className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<input ref={input} type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
className="hidden" aria-label="Upload a dedicated event banner"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void upload(file);
}} />
<Button type="button" variant="outline" disabled={uploading || processing} onClick={() => input.current?.click()}>
<UploadIcon data-icon="inline-start" />{uploading ? "Uploading…" : "Upload Banner"}
</Button>
{status.data?.status === "ready" && bannerId ? (
<Button type="button" variant="secondary" disabled={selectedId === bannerId} onClick={() => onSelect(bannerId)}>
{selectedId === bannerId ? "Banner Selected" : "Use Uploaded Banner"}
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">A separate image just for this event's header. It won't appear in the gallery. Up to 25 MB.</p>
{processing ? <p role="status" className="text-sm text-muted-foreground">Preparing your banner You can keep editing while it processes.</p> : null}
{status.isError || status.data?.status === "failed" ? <p role="alert" className="text-sm text-destructive">Couldn't prepare this banner. Try uploading another image.</p> : null}
{status.data?.url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={status.data.url} alt="Uploaded banner preview" width={1200} height={450} className="aspect-[8/3] w-full rounded-lg object-cover" />
) : null}
</div>
);
}