Improve banner drafts and event settings assistant
This commit is contained in:
@@ -1,27 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, 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 }: {
|
||||
export function BannerUpload({ eventId, selectedId, onSelect, onBusyChange }: {
|
||||
eventId: string;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [bannerId, setBannerId] = useState<string | null>(selectedId);
|
||||
const [bannerId, setBannerId] = useState<string | null>(null);
|
||||
const [startedAt, setStartedAt] = useState<number | null>(null);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
const selectRef = useRef(onSelect);
|
||||
useEffect(() => { selectRef.current = onSelect; }, [onSelect]);
|
||||
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,
|
||||
refetchInterval: (query) => timedOut || ["ready", "failed"].includes(query.state.data?.status ?? "") ? false : 1500,
|
||||
retry: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!timedOut && bannerId && status.data?.status === "ready") {
|
||||
selectRef.current(bannerId);
|
||||
setBannerId(null);
|
||||
setStartedAt(null);
|
||||
toast.success("Banner ready to preview. Save changes to apply it.");
|
||||
}
|
||||
}, [bannerId, status.data?.status, timedOut]);
|
||||
useEffect(() => {
|
||||
if (!startedAt) return;
|
||||
const timer = setTimeout(() => setTimedOut(true), 120_000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [startedAt]);
|
||||
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);
|
||||
@@ -30,6 +48,9 @@ export function BannerUpload({ eventId, selectedId, onSelect }: {
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setTimedOut(false);
|
||||
setBannerId(null);
|
||||
setStartedAt(null);
|
||||
try {
|
||||
const pending = await create.mutateAsync({ eventId, contentType: parsed.data, byteSize: file.size });
|
||||
const response = await fetch(pending.uploadUrl, {
|
||||
@@ -38,11 +59,14 @@ export function BannerUpload({ eventId, selectedId, onSelect }: {
|
||||
if (!response.ok) throw new Error("Banner upload failed. Please try again.");
|
||||
await complete.mutateAsync({ eventId, bannerId: pending.bannerId });
|
||||
setBannerId(pending.bannerId);
|
||||
setStartedAt(Date.now());
|
||||
} 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;
|
||||
const processing = Boolean(bannerId) && status.data?.status !== "failed" && !status.isError && !timedOut;
|
||||
useEffect(() => { onBusyChange(uploading || processing); }, [uploading, processing, onBusyChange]);
|
||||
useEffect(() => () => onBusyChange(false), [onBusyChange]);
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -54,21 +78,12 @@ export function BannerUpload({ eventId, selectedId, onSelect }: {
|
||||
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"}
|
||||
<UploadIcon data-icon="inline-start" />{uploading ? "Uploading…" : processing ? "Preparing banner…" : selectedId ? "Replace uploaded banner" : "Upload a 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}
|
||||
{status.isError || status.data?.status === "failed" || timedOut ? <p role="alert" className="text-sm text-destructive">{timedOut ? "Processing is taking longer than expected. Your previous banner is unchanged; try another upload." : "Couldn't prepare this banner. Your previous banner is unchanged. Try uploading another image."}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user