626 lines
22 KiB
TypeScript
626 lines
22 KiB
TypeScript
import {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type FormEvent,
|
||
type ReactNode,
|
||
} from "react";
|
||
import {
|
||
Download,
|
||
ImagePlus,
|
||
Share2,
|
||
} from "lucide-react";
|
||
import { useParams } from "react-router-dom";
|
||
import { AuthShell } from "../components/auth/AuthShell";
|
||
import { PhotoWallCropModal } from "../components/PhotoWallCropModal";
|
||
import { PhotoWallEventHeader } from "../components/PhotoWallEventHeader";
|
||
import { SharePlatformIcon } from "../components/SharePlatformIcon";
|
||
import {
|
||
getPhotoWallUploadMeta,
|
||
getVisitorLoginPreview,
|
||
uploadPhotoWallSelfie,
|
||
type PhotoWallSharePlatforms,
|
||
type PhotoWallPublicFormField,
|
||
type PhotoWallUploadMeta,
|
||
} from "../lib/api";
|
||
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
|
||
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
|
||
import {
|
||
canUseNativeShareSheet,
|
||
downloadImageFromUrl,
|
||
fetchImageFile,
|
||
ensureShareableImageFile,
|
||
sharePhotoWallImage,
|
||
shareViaNativeSheet,
|
||
} from "../lib/photoWallShare";
|
||
import {
|
||
EB_VISITOR_CONTEXT_KEY,
|
||
setPostAuthReturnPath,
|
||
type EbVisitorContext,
|
||
} from "../types/visitorContext";
|
||
|
||
function fieldKey(f: PhotoWallPublicFormField): string {
|
||
return String(f.name || f.label || "").trim();
|
||
}
|
||
|
||
function WhiteCard({ children }: { children: ReactNode }) {
|
||
return (
|
||
<div className="flex w-full max-w-[354px] flex-col items-center">
|
||
<div className="w-full rounded-xl shadow-[0_8px_12px_6px_rgba(0,0,0,0.15),0_4px_4px_rgba(0,0,0,0.3)]">
|
||
<div className="rounded-xl card-bg text-[var(--theme-card-text-color)] shadow-md px-6 py-6">{children}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type CropSession = {
|
||
imageSrc: string;
|
||
sourceFileName: string;
|
||
};
|
||
|
||
type UploadDoneState = {
|
||
awaitingApproval: boolean;
|
||
previewUrl: string | null;
|
||
/** CORS-safe API URL for download/share */
|
||
fileUrl: string | null;
|
||
framed: boolean;
|
||
};
|
||
|
||
const SHARE_LABELS: Array<{ key: keyof PhotoWallSharePlatforms; label: string }> = [
|
||
{ key: "linkedin", label: "LinkedIn" },
|
||
{ key: "facebook", label: "Facebook" },
|
||
{ key: "instagram", label: "Instagram" },
|
||
{ key: "snapchat", label: "Snapchat" },
|
||
{ key: "x", label: "X" },
|
||
{ key: "whatsapp", label: "WhatsApp" },
|
||
];
|
||
|
||
export function PhotoWallUploadPage() {
|
||
const { uploadToken } = useParams<{ uploadToken: string }>();
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
const resultImgRef = useRef<HTMLImageElement>(null);
|
||
const [meta, setMeta] = useState<PhotoWallUploadMeta | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||
const [uploaderName, setUploaderName] = useState("");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [picking, setPicking] = useState(false);
|
||
const [done, setDone] = useState<UploadDoneState | null>(null);
|
||
const [actionMsg, setActionMsg] = useState<string | null>(null);
|
||
const [cropSession, setCropSession] = useState<CropSession | null>(null);
|
||
/** Cached bytes for share/download — avoids CORS/network fail when opening the OS sheet. */
|
||
const [shareFile, setShareFile] = useState<File | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
if (!uploadToken) return;
|
||
setLoading(true);
|
||
setErr(null);
|
||
try {
|
||
const data = await getPhotoWallUploadMeta(uploadToken);
|
||
setMeta(data);
|
||
} catch (e) {
|
||
setMeta(null);
|
||
setErr(e instanceof Error ? e.message : "Photo wall not available");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [uploadToken]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||
};
|
||
}, [previewUrl]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (cropSession?.imageSrc) URL.revokeObjectURL(cropSession.imageSrc);
|
||
};
|
||
}, [cropSession]);
|
||
|
||
const fields = useMemo(
|
||
() => (meta?.form?.fields || []).filter((f) => f.label?.trim() && f.type !== "section"),
|
||
[meta],
|
||
);
|
||
|
||
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
|
||
const enabledSharePlatforms = useMemo(
|
||
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
|
||
[meta?.sharePlatforms],
|
||
);
|
||
const useNativeShare = useMemo(() => canUseNativeShareSheet(), []);
|
||
|
||
const clearSelectedPhoto = useCallback(() => {
|
||
setFile(null);
|
||
setPreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return null;
|
||
});
|
||
if (fileRef.current) fileRef.current.value = "";
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!done?.fileUrl) return;
|
||
let cancelled = false;
|
||
void fetchImageFile(done.fileUrl, "photo-wall.jpg")
|
||
.then((f) => {
|
||
if (!cancelled) setShareFile(f);
|
||
})
|
||
.catch(() => {
|
||
/* keep any local shareFile from upload */
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [done?.fileUrl]);
|
||
|
||
const resetForNewUpload = useCallback(() => {
|
||
setDone(null);
|
||
setShareFile(null);
|
||
setActionMsg(null);
|
||
setErr(null);
|
||
clearSelectedPhoto();
|
||
}, [clearSelectedPhoto]);
|
||
|
||
const applySelectedFile = useCallback((next: File) => {
|
||
setFile(next);
|
||
setPreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return URL.createObjectURL(next);
|
||
});
|
||
}, []);
|
||
|
||
const handleFileChange = async (selected: File | null) => {
|
||
if (!selected) {
|
||
clearSelectedPhoto();
|
||
return;
|
||
}
|
||
if (!selected.type.startsWith("image/")) {
|
||
setErr("Please choose an image file");
|
||
return;
|
||
}
|
||
setErr(null);
|
||
setPicking(true);
|
||
try {
|
||
const requireCrop = Boolean(meta?.requireUploadCrop);
|
||
if (!requireCrop) {
|
||
applySelectedFile(selected);
|
||
return;
|
||
}
|
||
const maxW = Math.max(1, Number(meta?.maxPhotoWidth) || 1080);
|
||
const maxH = Math.max(1, Number(meta?.maxPhotoHeight) || 1080);
|
||
const natural = await getImageNaturalSize(selected);
|
||
if (ratiosMatch(natural.width, natural.height, maxW, maxH)) {
|
||
applySelectedFile(selected);
|
||
return;
|
||
}
|
||
const src = URL.createObjectURL(selected);
|
||
setCropSession({ imageSrc: src, sourceFileName: selected.name || "photo.jpg" });
|
||
} catch (e) {
|
||
setErr(e instanceof Error ? e.message : "Could not process image");
|
||
} finally {
|
||
setPicking(false);
|
||
if (fileRef.current) fileRef.current.value = "";
|
||
}
|
||
};
|
||
|
||
const onSubmit = async (e: FormEvent) => {
|
||
e.preventDefault();
|
||
if (!uploadToken || !file || !meta) return;
|
||
if (meta.askUploaderName && !uploaderName.trim()) {
|
||
setErr("Please enter your name");
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
setErr(null);
|
||
setActionMsg(null);
|
||
try {
|
||
const localFile = file;
|
||
const result = await uploadPhotoWallSelfie(uploadToken, file, values, {
|
||
displayName: meta.askUploaderName ? uploaderName.trim() : undefined,
|
||
});
|
||
// Keep local bytes immediately so Share works even if the API file proxy fails (CORS/network).
|
||
setShareFile(localFile);
|
||
setDone({
|
||
awaitingApproval: result.awaitingApproval,
|
||
previewUrl: result.previewUrl,
|
||
fileUrl: result.fileUrl,
|
||
framed: result.framed,
|
||
});
|
||
clearSelectedPhoto();
|
||
// Normalize HEIC/etc. in the background so iOS accepts the file in the OS share sheet.
|
||
void ensureShareableImageFile(localFile, "photo-wall.jpg").then((shareable) => {
|
||
setShareFile((prev) => {
|
||
// Don't clobber a framed file already prefetched from the API.
|
||
if (prev && prev !== localFile) return prev;
|
||
return shareable;
|
||
});
|
||
});
|
||
} catch (e2) {
|
||
setErr(e2 instanceof Error ? e2.message : "Upload failed");
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const onDownload = async () => {
|
||
setActionMsg(null);
|
||
try {
|
||
if (shareFile) {
|
||
const objectUrl = URL.createObjectURL(shareFile);
|
||
const a = document.createElement("a");
|
||
a.href = objectUrl;
|
||
a.download = shareFile.name || "photo-wall.jpg";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(objectUrl);
|
||
setActionMsg("Download started");
|
||
return;
|
||
}
|
||
const src = done?.fileUrl || done?.previewUrl;
|
||
if (!src) return;
|
||
await downloadImageFromUrl(src, "photo-wall.jpg");
|
||
setActionMsg("Download started");
|
||
} catch (e) {
|
||
setActionMsg(e instanceof Error ? e.message : "Download failed");
|
||
}
|
||
};
|
||
|
||
const shareCaption = `Check out my photo from ${meta?.eventName || "the event"}!`;
|
||
|
||
const onNativeShare = async () => {
|
||
const src = done?.fileUrl || done?.previewUrl;
|
||
setActionMsg(null);
|
||
try {
|
||
// Pass cached File first so share runs without a prior network await (iOS gesture).
|
||
const result = await shareViaNativeSheet({
|
||
file: shareFile,
|
||
imageUrl: src,
|
||
imgEl: resultImgRef.current,
|
||
title: meta?.eventName || "Photo wall",
|
||
text: shareCaption,
|
||
});
|
||
if (result === "shared") setActionMsg("Shared");
|
||
else setActionMsg(null);
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : "Share failed";
|
||
setActionMsg(
|
||
/load failed|failed to fetch/i.test(msg)
|
||
? "Could not share this photo. Try Download, then share from your photos."
|
||
: msg,
|
||
);
|
||
}
|
||
};
|
||
|
||
const onShare = async (platform: keyof PhotoWallSharePlatforms) => {
|
||
const src = done?.fileUrl || done?.previewUrl;
|
||
if (!src && !shareFile) return;
|
||
setActionMsg(null);
|
||
try {
|
||
const result = await sharePhotoWallImage({
|
||
platform,
|
||
imageUrl: src || "",
|
||
file: shareFile,
|
||
title: meta?.eventName || "Photo wall",
|
||
text: shareCaption,
|
||
});
|
||
if (result === "shared") {
|
||
setActionMsg(
|
||
platform === "whatsapp" || platform === "facebook" || platform === "x" || platform === "linkedin"
|
||
? "Opening app with your photo link"
|
||
: "Shared",
|
||
);
|
||
} else if (result === "cancelled") {
|
||
setActionMsg(null);
|
||
} else {
|
||
setActionMsg("Photo saved and caption copied — paste / attach it in the app");
|
||
}
|
||
} catch (e) {
|
||
setActionMsg(e instanceof Error ? e.message : "Share failed");
|
||
}
|
||
};
|
||
|
||
const goLogin = async () => {
|
||
if (!meta?.eventId) return;
|
||
try {
|
||
const preview = await getVisitorLoginPreview(meta.eventId);
|
||
const ctx: EbVisitorContext = {
|
||
type: "event",
|
||
eventId: meta.eventId,
|
||
event: preview,
|
||
fetchedAt: new Date().toISOString(),
|
||
};
|
||
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
|
||
setPostAuthReturnPath(`/photo-wall/${uploadToken}`);
|
||
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
|
||
} catch {
|
||
setErr("Could not open login");
|
||
}
|
||
};
|
||
|
||
if (!uploadToken) {
|
||
return (
|
||
<AuthShell showLogo={false}>
|
||
<WhiteCard>
|
||
<p className="text-center text-sm text-red-600">Missing upload link</p>
|
||
</WhiteCard>
|
||
</AuthShell>
|
||
);
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<AuthShell showLogo={false}>
|
||
<WhiteCard>
|
||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/70">Loading…</p>
|
||
</WhiteCard>
|
||
</AuthShell>
|
||
);
|
||
}
|
||
|
||
if (err && !meta) {
|
||
return (
|
||
<AuthShell showLogo={false}>
|
||
<WhiteCard>
|
||
<p className="text-center text-sm text-red-600">{err}</p>
|
||
</WhiteCard>
|
||
</AuthShell>
|
||
);
|
||
}
|
||
|
||
if (done) {
|
||
const canDownload = Boolean(meta?.allowFramedDownload && (done.fileUrl || done.previewUrl));
|
||
const showShare = Boolean((done.fileUrl || done.previewUrl) && enabledSharePlatforms.length > 0);
|
||
return (
|
||
<AuthShell showLogo={false}>
|
||
<WhiteCard>
|
||
{meta?.event ? (
|
||
<div className="mb-4">
|
||
<PhotoWallEventHeader event={meta.event} display={meta.eventHeader || null} />
|
||
</div>
|
||
) : null}
|
||
<h1 className="mb-2 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">
|
||
{done.framed ? "Your framed photo" : "Photo uploaded"}
|
||
</h1>
|
||
<p className="mb-4 text-center text-sm text-[var(--theme-card-text-color)]/80">
|
||
{done.awaitingApproval
|
||
? "Thanks! Your photo is waiting for approval."
|
||
: done.framed
|
||
? "Here’s your generated photo. You can edit, download, or share it."
|
||
: "Thanks! Your photo was added to the wall."}
|
||
</p>
|
||
{(done.fileUrl || done.previewUrl) ? (
|
||
<div className="mb-4 overflow-hidden rounded-lg border border-[#e5e5e5] bg-[#f8f8f8]">
|
||
<img
|
||
ref={resultImgRef}
|
||
crossOrigin="anonymous"
|
||
src={done.previewUrl || done.fileUrl || undefined}
|
||
alt={done.framed ? "Your framed photo" : "Uploaded photo"}
|
||
className="mx-auto max-h-[360px] w-full object-contain"
|
||
onError={(e) => {
|
||
const img = e.currentTarget;
|
||
// Retry without CORS mode if CDN blocks anonymous reads.
|
||
if (img.crossOrigin) {
|
||
img.crossOrigin = null;
|
||
img.src = done.previewUrl || done.fileUrl || img.src;
|
||
return;
|
||
}
|
||
if (done.fileUrl && img.src !== done.fileUrl) {
|
||
img.src = done.fileUrl;
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="flex flex-col gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={resetForNewUpload}
|
||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-[#D32F2F theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
|
||
>
|
||
<ImagePlus className="h-4 w-4" />
|
||
Edit / Upload new photo
|
||
</button>
|
||
{canDownload ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => void onDownload()}
|
||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
|
||
>
|
||
<Download className="h-4 w-4" />
|
||
Download photo
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
{showShare ? (
|
||
<div className="mt-4">
|
||
<p className="mb-2 text-center text-xs font-semibold uppercase tracking-wide text-[var(--theme-card-text-color)]/80">
|
||
Share
|
||
</p>
|
||
{useNativeShare ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => void onNativeShare()}
|
||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-4 text-sm font-semibold text-[#333] hover:bg-[#fafafa]"
|
||
>
|
||
<Share2 className="h-4 w-4" />
|
||
Share
|
||
</button>
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{enabledSharePlatforms.map((item) => (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
onClick={() => void onShare(item.key)}
|
||
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-[#ddd] bg-white px-3 text-xs font-semibold text-[#333] hover:bg-[#fafafa]"
|
||
>
|
||
<SharePlatformIcon platform={item.key} />
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
{actionMsg ? <p className="mt-3 text-center text-xs text-[#666]">{actionMsg}</p> : null}
|
||
</WhiteCard>
|
||
</AuthShell>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<AuthShell showLogo={false}>
|
||
<WhiteCard>
|
||
{meta?.event ? (
|
||
<div className="mb-4">
|
||
<PhotoWallEventHeader event={meta.event} display={meta.eventHeader || null} />
|
||
</div>
|
||
) : null}
|
||
<h1 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Upload your photo</h1>
|
||
<p className="mb-5 text-center text-sm text-[var(--theme-card-text-color)]/80">
|
||
{meta?.form?.description || "Add a selfie to the event photo wall."}
|
||
</p>
|
||
|
||
{meta?.accessMode === "login" && !loggedIn ? (
|
||
<div className="space-y-3">
|
||
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to upload your photo for this event.</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => void goLogin()}
|
||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[var(--theme-button-color)] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
|
||
>
|
||
Sign in to continue
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<form onSubmit={(e) => void onSubmit(e)} className="space-y-4">
|
||
<div>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="image/*"
|
||
capture="user"
|
||
className="hidden"
|
||
onChange={(e) => void handleFileChange(e.target.files?.[0] || null)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={picking}
|
||
onClick={() => fileRef.current?.click()}
|
||
className="flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-[#ccc] bg-[#fafafa] px-4 py-8 text-sm text-[#666] hover:border-[#D32F2F] hover:bg-[#fff5f5]"
|
||
>
|
||
{previewUrl ? (
|
||
<img src={previewUrl} alt="Preview" className="max-h-48 rounded-md object-contain" />
|
||
) : (
|
||
<>
|
||
<ImagePlus className="h-8 w-8 text-[#D32F2F]" />
|
||
<span>{picking ? "Preparing…" : "Tap to take or choose a photo"}</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
{file ? (
|
||
<p className="mt-2 truncate text-center text-xs text-[var(--theme-card-text-color)]/80">{file.name}</p>
|
||
) : null}
|
||
</div>
|
||
|
||
{meta?.askUploaderName ? (
|
||
<label className="block text-sm">
|
||
<span className="mb-1 block font-medium text-[var(--theme-card-text-color)]">
|
||
Your name <span className="text-red-500">*</span>
|
||
</span>
|
||
<input
|
||
type="text"
|
||
value={uploaderName}
|
||
onChange={(e) => setUploaderName(e.target.value)}
|
||
className="h-10 w-full rounded-lg border border-[rgba(116,112,112,0.3)] px-3 text-sm transition-colors duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30"
|
||
placeholder="Name shown on the wall"
|
||
required
|
||
/>
|
||
</label>
|
||
) : null}
|
||
|
||
{fields.map((f) => {
|
||
const key = fieldKey(f);
|
||
if (!key) return null;
|
||
const required = Boolean(f.required);
|
||
if (f.type === "textarea") {
|
||
return (
|
||
<label key={key} className="block text-sm">
|
||
<span className="mb-1 block font-medium text-[var(--theme-card-text-color)]">
|
||
{f.label}
|
||
{required ? <span className="text-red-500"> *</span> : null}
|
||
</span>
|
||
<textarea
|
||
value={String(values[key] ?? "")}
|
||
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
|
||
required={required}
|
||
rows={3}
|
||
className="w-full rounded-lg border border-[rgba(116,112,112,0.3)] px-3 py-2 text-sm transition-colors duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30"
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
return (
|
||
<label key={key} className="block text-sm">
|
||
<span className="mb-1 block font-medium text-[var(--theme-card-text-color)]">
|
||
{f.label}
|
||
{required ? <span className="text-red-500"> *</span> : null}
|
||
</span>
|
||
<input
|
||
type={f.type === "email" ? "email" : f.type === "tel" ? "tel" : "text"}
|
||
value={String(values[key] ?? "")}
|
||
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
|
||
required={required}
|
||
className="h-10 w-full rounded-lg border border-[rgba(116,112,112,0.3)] px-3 text-sm transition-colors duration-300 ease-in-out focus:border-[var(--theme-button-color)] focus:outline-none focus:ring-2 focus:ring-[var(--theme-button-color)]/30"
|
||
/>
|
||
</label>
|
||
);
|
||
})}
|
||
|
||
{err ? <p className="text-center text-sm text-red-600">{err}</p> : null}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={!file || submitting || picking}
|
||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[#D32F2F theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[#b71c1c] disabled:opacity-50"
|
||
>
|
||
{submitting ? "Uploading…" : "Upload"}
|
||
</button>
|
||
</form>
|
||
)}
|
||
</WhiteCard>
|
||
|
||
{cropSession && meta ? (
|
||
<PhotoWallCropModal
|
||
imageSrc={cropSession.imageSrc}
|
||
sourceFileName={cropSession.sourceFileName}
|
||
aspect={
|
||
Math.max(1, Number(meta.maxPhotoWidth) || 1080) /
|
||
Math.max(1, Number(meta.maxPhotoHeight) || 1080)
|
||
}
|
||
onCancel={() => {
|
||
URL.revokeObjectURL(cropSession.imageSrc);
|
||
setCropSession(null);
|
||
}}
|
||
onConfirm={(cropped) => {
|
||
URL.revokeObjectURL(cropSession.imageSrc);
|
||
setCropSession(null);
|
||
applySelectedFile(cropped);
|
||
}}
|
||
/>
|
||
) : null}
|
||
</AuthShell>
|
||
);
|
||
}
|