diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx index 9ac8e673..4b62908e 100644 --- a/components/profile/profile-header.tsx +++ b/components/profile/profile-header.tsx @@ -17,14 +17,20 @@ * centred-row popovers tend to spill off the left edge on narrow * viewports. * + * Clicking the avatar (when there's an uploaded image) opens a portal + * modal showing the full-size image inside a circular crop. Tap the + * backdrop or press Escape to close. + * * Auth users on someone else's profile reach reading settings via the * sidebar rail on desktop. Anonymous visitors use SiteHeader's mobile * gear. */ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; import Image from "next/image"; import Link from "next/link"; -import { Settings } from "lucide-react"; +import { Settings, X } from "lucide-react"; import { cn } from "@/lib/utils"; export function ProfileHeader({ @@ -51,69 +57,103 @@ export function ProfileHeader({ const showReadingSettings = !!editHref; const showActions = !!editHref; + const [zoomOpen, setZoomOpen] = useState(false); + return ( -
- -
-

- {name} -

-

@{handle}

- {typeof postCount === "number" || joinedAt ? ( -

- {typeof postCount === "number" ? ( - <> - {postCount} post - {postCount === 1 ? "" : "s"} - - ) : null} - {typeof postCount === "number" && joinedAt ? " · " : null} - {joinedAt ? <>Joined {new Date(joinedAt).toLocaleDateString()} : null} -

- ) : null} -
- {showActions ? ( -
- {editHref ? ( - - Edit profile - - ) : null} - {showReadingSettings ? ( - - - + <> +
+ setZoomOpen(true) : undefined} + /> +
+

+ {name} +

+

@{handle}

+ {typeof postCount === "number" || joinedAt ? ( +

+ {typeof postCount === "number" ? ( + <> + {postCount} post + {postCount === 1 ? "" : "s"} + + ) : null} + {typeof postCount === "number" && joinedAt ? " · " : null} + {joinedAt ? <>Joined {new Date(joinedAt).toLocaleDateString()} : null} +

) : null}
+ {showActions ? ( +
+ {editHref ? ( + + Edit profile + + ) : null} + {showReadingSettings ? ( + + + + ) : null} +
+ ) : null} +
+ {avatarURL ? ( + setZoomOpen(false)} + /> ) : null} -
+ ); } -function Avatar({ src, name }: { src: string | null; name: string }) { +function Avatar({ + src, + name, + onZoom, +}: { + src: string | null; + name: string; + /** Provided when the avatar should be interactive — the parent shows + * the zoom modal on click. Omit (or pass undefined) to render the + * initials placeholder as a plain non-clickable element. */ + onZoom?: () => void; +}) { if (src) { return ( - {name} + ); } const initials = @@ -129,3 +169,83 @@ function Avatar({ src, name }: { src: string | null; name: string }) { ); } + +/** + * AvatarZoomModal — portaled full-screen overlay with the avatar shown + * inside a large circular crop. Click the backdrop or press Escape to + * close. Body scroll is locked while open so the page underneath + * doesn't jitter when the modal mounts. + */ +function AvatarZoomModal({ + src, + name, + open, + onClose, +}: { + src: string; + name: string; + open: boolean; + onClose: () => void; +}) { + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, onClose]); + + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + if (!open || typeof document === "undefined") return null; + + return createPortal( +
+ {/* Stop-propagation so clicks on the image itself don't dismiss + the modal — only the surrounding backdrop closes. */} +
e.stopPropagation()} + className="relative rounded-full overflow-hidden ring-1 ring-white/20 shadow-2xl" + style={{ + // Square box capped at 80% of the smaller viewport dimension — + // keeps the circle large but always fully visible with a + // comfortable margin on tiny screens. + width: "min(80vw, 80vh, 28rem)", + height: "min(80vw, 80vh, 28rem)", + }} + > + {name} +
+ +
, + document.body, + ); +} diff --git a/components/selection-toolbar/collections-drawer.tsx b/components/selection-toolbar/collections-drawer.tsx index a820bbab..81359f8b 100644 --- a/components/selection-toolbar/collections-drawer.tsx +++ b/components/selection-toolbar/collections-drawer.tsx @@ -15,6 +15,7 @@ */ import { useEffect, useState } from "react"; +import Image from "next/image"; import { FolderPlus, Check, Plus, Loader2 } from "lucide-react"; import { Drawer, @@ -245,8 +246,22 @@ export function CollectionsDrawer({ className="flex items-center justify-between gap-3 w-full px-3 py-3 rounded-lg text-left hover:bg-white/10 transition-colors disabled:hover:bg-transparent disabled:cursor-not-allowed disabled:opacity-60" >
-
- + {/* Cover thumb — falls back to the FolderPlus glyph when + the collection has no cover_url. `overflow-hidden` + so the Image's fill respects the rounded slot. */} +
+ {c.cover_url ? ( + + ) : ( + + )}

diff --git a/components/social/collection-grid-card.tsx b/components/social/collection-grid-card.tsx index 55185b20..f480f469 100644 --- a/components/social/collection-grid-card.tsx +++ b/components/social/collection-grid-card.tsx @@ -52,10 +52,12 @@ export function CollectionGridCard({ {/* Visibility badge — black pill works on both image overlay and flat surface (white or dark). */} -

- {c.is_public ? : } - {c.is_public ? "Public" : "Private"} -
+ {c.is_public ? null : ( +
+ + {"Private"} +
+ )} {/* Title + count — text colour follows the surface beneath. */}