feat: avatar opener
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
Reference in New Issue
Block a user