feat: avatar opener
This commit is contained in:
@@ -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 (
|
||||
<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} />
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground leading-tight">
|
||||
{name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">@{handle}</p>
|
||||
{typeof postCount === "number" || joinedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{typeof postCount === "number" ? (
|
||||
<>
|
||||
<span className="tabular-nums">{postCount}</span> post
|
||||
{postCount === 1 ? "" : "s"}
|
||||
</>
|
||||
) : null}
|
||||
{typeof postCount === "number" && joinedAt ? " · " : null}
|
||||
{joinedAt ? <>Joined {new Date(joinedAt).toLocaleDateString()}</> : null}
|
||||
</p>
|
||||
) : null}
|
||||
</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>
|
||||
<>
|
||||
<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}
|
||||
onZoom={avatarURL ? () => setZoomOpen(true) : undefined}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-[family-name:var(--font-title)] font-semibold text-xl sm:text-2xl text-foreground leading-tight">
|
||||
{name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">@{handle}</p>
|
||||
{typeof postCount === "number" || joinedAt ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{typeof postCount === "number" ? (
|
||||
<>
|
||||
<span className="tabular-nums">{postCount}</span> post
|
||||
{postCount === 1 ? "" : "s"}
|
||||
</>
|
||||
) : null}
|
||||
{typeof postCount === "number" && joinedAt ? " · " : null}
|
||||
{joinedAt ? <>Joined {new Date(joinedAt).toLocaleDateString()}</> : null}
|
||||
</p>
|
||||
) : null}
|
||||
</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}
|
||||
</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) {
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={name}
|
||||
width={112}
|
||||
height={112}
|
||||
className="size-24 sm:size-28 rounded-full object-cover ring-1 ring-foreground/10"
|
||||
unoptimized
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onZoom}
|
||||
aria-label={`View ${name}'s profile picture`}
|
||||
className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<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 =
|
||||
@@ -129,3 +169,83 @@ function Avatar({ src, name }: { src: string | null; name: string }) {
|
||||
</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 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"
|
||||
>
|
||||
<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">
|
||||
<FolderPlus className="size-4 text-white/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. */}
|
||||
<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 className="min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 truncate">
|
||||
|
||||
@@ -52,10 +52,12 @@ export function CollectionGridCard({
|
||||
|
||||
{/* Visibility badge — black pill works on both image overlay and
|
||||
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 ? <Globe className="size-3" /> : <Lock className="size-3" />}
|
||||
{c.is_public ? "Public" : "Private"}
|
||||
</div>
|
||||
{c.is_public ? null : (
|
||||
<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">
|
||||
<Lock className="size-3" />
|
||||
{"Private"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title + count — text colour follows the surface beneath. */}
|
||||
<div
|
||||
|
||||
@@ -16,6 +16,12 @@ export function Toaster() {
|
||||
<SonnerToaster
|
||||
theme={(resolvedTheme as "light" | "dark") || "light"}
|
||||
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}
|
||||
closeButton
|
||||
toastOptions={{
|
||||
|
||||
Reference in New Issue
Block a user