Files
spsays-training/components/social/update-background-modal.tsx
T

241 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* UpdateBackgroundModal — swap or remove the image behind a post card.
*
* Two actions:
* • Upload Image — pick a file → signed-URL upload → PUT to BFF
* → returns the refreshed post.
* • Remove Background — PUT empty image set → post falls back to the
* palette-aware quote-card gradient.
*
* Renders as a centred modal on desktop and a bottom drawer on mobile,
* mirroring the pattern used by AuthModal / ConfirmModal so the UX stays
* consistent across the app.
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ImageIcon, Loader2, Trash2, Upload, X } from "lucide-react";
import { toast } from "sonner";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { posts, SocialError, type Post } from "@/lib/social";
type Busy = "upload" | "remove" | null;
export function UpdateBackgroundModal({
open,
onClose,
post,
isMobile,
onUpdated,
}: {
open: boolean;
onClose: () => void;
post: Post;
isMobile: boolean;
/** Fires with the refreshed post after a successful upload or removal. */
onUpdated?: (updated: Post) => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState<Busy>(null);
const hasImage = (post.images?.length ?? 0) > 0;
// Body-scroll lock + Esc-to-close — desktop modal only. The drawer
// handles both internally.
useEffect(() => {
if (!open || isMobile) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
function onKey(e: KeyboardEvent) {
if (e.key === "Escape" && busy === null) onClose();
}
document.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("keydown", onKey);
};
}, [open, isMobile, busy, onClose]);
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (fileInput.current) fileInput.current.value = "";
if (!file) return;
setBusy("upload");
try {
const url = await posts.uploadImage(file);
const updated = await posts.replaceImages(post.id, [url]);
toast.success("Background updated");
onUpdated?.(updated);
onClose();
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Could not update background.",
);
} finally {
setBusy(null);
}
}
async function onRemove() {
setBusy("remove");
try {
const updated = await posts.replaceImages(post.id, []);
toast.success("Background removed");
onUpdated?.(updated);
onClose();
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Could not remove background.",
);
} finally {
setBusy(null);
}
}
if (!open) return null;
const body = (
<div className="space-y-2">
<input
ref={fileInput}
type="file"
accept="image/*"
hidden
onChange={onUpload}
/>
<button
type="button"
onClick={() => fileInput.current?.click()}
disabled={busy !== null}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-accent transition-colors text-left disabled:opacity-60"
>
<span className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary shrink-0">
{busy === "upload" ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Upload className="size-5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{hasImage ? "Replace Image" : "Upload Image"}
</span>
<span className="block text-xs text-muted-foreground">
JPEG or PNG, recommended ≥ 1080×1080
</span>
</span>
</button>
{hasImage ? (
<button
type="button"
onClick={onRemove}
disabled={busy !== null}
className="w-full flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-destructive/5 transition-colors text-left disabled:opacity-60"
>
<span className="flex items-center justify-center size-10 rounded-lg bg-destructive/10 text-destructive shrink-0">
{busy === "remove" ? (
<Loader2 className="size-5 animate-spin" />
) : (
<Trash2 className="size-5" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
Remove Background
</span>
<span className="block text-xs text-muted-foreground">
Falls back to a theme-coloured gradient
</span>
</span>
</button>
) : null}
</div>
);
if (isMobile) {
return (
<Drawer open={open} onOpenChange={(o) => !o && busy === null && onClose()}>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>Update Background</DrawerTitle>
</DrawerHeader>
<div className="px-4 pb-4">{body}</div>
<div className="p-4 pt-0">
<DrawerClose asChild>
<button
disabled={busy !== null}
className="w-full py-2.5 rounded-lg border border-border text-sm font-medium text-muted-foreground hover:bg-accent disabled:opacity-50"
>
Cancel
</button>
</DrawerClose>
</div>
</DrawerContent>
</Drawer>
);
}
// Desktop: centred modal with portal + backdrop, same pattern as
// ConfirmModal/AuthModal so behaviour stays consistent.
if (typeof document === "undefined") return null;
return createPortal(
<>
<div
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
onClick={() => busy === null && onClose()}
aria-hidden
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="update-bg-title"
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
>
<div className="rounded-2xl border border-border bg-card shadow-xl overflow-hidden">
<div className="flex items-start gap-3 px-5 pt-5 pb-3">
<span
className="shrink-0 size-10 rounded-full bg-primary/10 text-primary flex items-center justify-center"
aria-hidden
>
<ImageIcon className="size-5" />
</span>
<div className="flex-1 min-w-0">
<h2
id="update-bg-title"
className="font-[family-name:var(--font-title)] font-semibold text-lg text-foreground"
>
Update Background
</h2>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
Pick an image to set as the card's background, or remove the
current one.
</p>
</div>
<button
type="button"
onClick={() => busy === null && onClose()}
aria-label="Close"
disabled={busy !== null}
className="shrink-0 size-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted inline-flex items-center justify-center disabled:opacity-50"
>
<X className="size-4" />
</button>
</div>
<div className="px-5 pb-5">{body}</div>
</div>
</div>
</>,
document.body,
);
}