feat: avatar opener

This commit is contained in:
gosangam
2026-06-03 00:49:58 +05:30
parent fd333159f6
commit ddc6d3c7bf
4 changed files with 204 additions and 61 deletions
+175 -55
View File
@@ -17,14 +17,20 @@
* centred-row popovers tend to spill off the left edge on narrow * centred-row popovers tend to spill off the left edge on narrow
* viewports. * 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 * Auth users on someone else's profile reach reading settings via the
* sidebar rail on desktop. Anonymous visitors use SiteHeader's mobile * sidebar rail on desktop. Anonymous visitors use SiteHeader's mobile
* gear. * gear.
*/ */
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Settings } from "lucide-react"; import { Settings, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function ProfileHeader({ export function ProfileHeader({
@@ -51,69 +57,103 @@ export function ProfileHeader({
const showReadingSettings = !!editHref; const showReadingSettings = !!editHref;
const showActions = !!editHref; const showActions = !!editHref;
const [zoomOpen, setZoomOpen] = useState(false);
return ( return (
<header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8"> <>
<Avatar src={avatarURL} name={name} /> <header className="flex flex-col items-center text-center gap-4 pt-4 pb-6 sm:pt-6 sm:pb-8">
<div className="space-y-1"> <Avatar
<h1 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground leading-tight"> src={avatarURL}
{name} name={name}
</h1> onZoom={avatarURL ? () => setZoomOpen(true) : undefined}
<p className="text-sm text-muted-foreground">@{handle}</p> />
{typeof postCount === "number" || joinedAt ? ( <div className="space-y-1">
<p className="text-xs text-muted-foreground"> <h1 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground leading-tight">
{typeof postCount === "number" ? ( {name}
<> </h1>
<span className="tabular-nums">{postCount}</span> post <p className="text-sm text-muted-foreground">@{handle}</p>
{postCount === 1 ? "" : "s"} {typeof postCount === "number" || joinedAt ? (
</> <p className="text-xs text-muted-foreground">
) : null} {typeof postCount === "number" ? (
{typeof postCount === "number" && joinedAt ? " · " : null} <>
{joinedAt ? <>Joined {new Date(joinedAt).toLocaleDateString()}</> : null} <span className="tabular-nums">{postCount}</span> post
</p> {postCount === 1 ? "" : "s"}
) : null} </>
</div> ) : null}
{showActions ? ( {typeof postCount === "number" && joinedAt ? " · " : null}
<div className="flex items-center gap-2"> {joinedAt ? <>Joined {new Date(joinedAt).toLocaleDateString()}</> : null}
{editHref ? ( </p>
<Link
href={editHref}
className="inline-flex items-center justify-center h-9 px-5 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Edit profile
</Link>
) : null}
{showReadingSettings ? (
<Link
href="/settings"
aria-label="Settings"
title="Settings"
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"border border-border text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Settings className="size-4" />
</Link>
) : null} ) : null}
</div> </div>
{showActions ? (
<div className="flex items-center gap-2">
{editHref ? (
<Link
href={editHref}
className="inline-flex items-center justify-center h-9 px-5 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
Edit profile
</Link>
) : null}
{showReadingSettings ? (
<Link
href="/settings"
aria-label="Settings"
title="Settings"
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"border border-border text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Settings className="size-4" />
</Link>
) : null}
</div>
) : null}
</header>
{avatarURL ? (
<AvatarZoomModal
src={avatarURL}
name={name}
open={zoomOpen}
onClose={() => setZoomOpen(false)}
/>
) : null} ) : null}
</header> </>
); );
} }
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) { if (src) {
return ( return (
<Image <button
src={src} type="button"
alt={name} onClick={onZoom}
width={112} aria-label={`View ${name}'s profile picture`}
height={112} className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
className="size-24 sm:size-28 rounded-full object-cover ring-1 ring-foreground/10" >
unoptimized <Image
/> src={src}
alt={name}
width={112}
height={112}
className="size-24 sm:size-28 rounded-full object-cover ring-1 ring-foreground/10 hover:ring-foreground/30 transition"
unoptimized
/>
</button>
); );
} }
const initials = const initials =
@@ -129,3 +169,83 @@ function Avatar({ src, name }: { src: string | null; name: string }) {
</div> </div>
); );
} }
/**
* 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(
<div
role="dialog"
aria-modal="true"
aria-label={`${name}'s profile picture`}
onClick={onClose}
className="fixed inset-0 z-100 bg-black/80 backdrop-blur-sm flex items-center justify-center p-6 animate-in fade-in-0"
>
{/* Stop-propagation so clicks on the image itself don't dismiss
the modal — only the surrounding backdrop closes. */}
<div
onClick={(e) => 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)",
}}
>
<Image
src={src}
alt={name}
fill
sizes="(max-width: 640px) 80vw, 28rem"
className="object-cover"
unoptimized
/>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="fixed top-4 right-4 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
>
<X className="size-5" />
</button>
</div>,
document.body,
);
}
@@ -15,6 +15,7 @@
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Image from "next/image";
import { FolderPlus, Check, Plus, Loader2 } from "lucide-react"; import { FolderPlus, Check, Plus, Loader2 } from "lucide-react";
import { import {
Drawer, 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" 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"
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div className="flex items-center justify-center size-9 rounded-lg bg-white/10 shrink-0"> {/* Cover thumb — falls back to the FolderPlus glyph when
<FolderPlus className="size-4 text-white/60" /> the collection has no cover_url. `overflow-hidden`
so the Image's fill respects the rounded slot. */}
<div className="relative flex items-center justify-center size-9 rounded-lg bg-white/10 shrink-0 overflow-hidden">
{c.cover_url ? (
<Image
src={c.cover_url}
alt=""
fill
sizes="36px"
className="object-cover"
unoptimized
/>
) : (
<FolderPlus className="size-4 text-white/60" />
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-white/90 truncate"> <p className="text-sm font-medium text-white/90 truncate">
+6 -4
View File
@@ -52,10 +52,12 @@ export function CollectionGridCard({
{/* Visibility badge — black pill works on both image overlay and {/* Visibility badge — black pill works on both image overlay and
flat surface (white or dark). */} flat surface (white or dark). */}
<div className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-black/40 backdrop-blur-sm px-2.5 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white"> {c.is_public ? null : (
{c.is_public ? <Globe className="size-3" /> : <Lock className="size-3" />} <div className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-black/40 backdrop-blur-sm px-2.5 py-1 text-[0.6rem] uppercase tracking-[0.18em] font-medium text-white">
{c.is_public ? "Public" : "Private"} <Lock className="size-3" />
</div> {"Private"}
</div>
)}
{/* Title + count — text colour follows the surface beneath. */} {/* Title + count — text colour follows the surface beneath. */}
<div <div
+6
View File
@@ -16,6 +16,12 @@ export function Toaster() {
<SonnerToaster <SonnerToaster
theme={(resolvedTheme as "light" | "dark") || "light"} theme={(resolvedTheme as "light" | "dark") || "light"}
position="bottom-right" position="bottom-right"
// SiteBottomNav sits at the viewport bottom on small screens
// (md:hidden). Without this, sonner stacks toasts right on top of
// the nav. 5rem clears the ~64px nav with a little breathing room.
// Desktop is unaffected — the regular `offset` prop is left at
// its default.
mobileOffset={{ bottom: "5rem" }}
richColors={false} richColors={false}
closeButton closeButton
toastOptions={{ toastOptions={{