fix: new user scroll fix, add image lightbox
This commit is contained in:
@@ -89,8 +89,13 @@ export function NewUsersStrip({ users }: { users: UserSummary[] }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bleed to the section edges (-mx-5) but keep a px-5 gutter so the
|
{/* Bleed to the page edges, with a matching inner gutter so the first and
|
||||||
first/last tiles have breathing room as they scroll past. */}
|
last tiles still have breathing room as they scroll past.
|
||||||
|
The negative margin MUST match the feed container's horizontal padding
|
||||||
|
(`main` is `px-4 sm:px-6`). It was `-mx-5` (20px) against a 16px
|
||||||
|
mobile padding, so the row hung 4px past each edge and gave the whole
|
||||||
|
document a horizontal scrollbar. Keep these three in lockstep with the
|
||||||
|
page padding if it ever changes. */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
@@ -98,7 +103,7 @@ export function NewUsersStrip({ users }: { users: UserSummary[] }) {
|
|||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerCancel={onPointerUp}
|
onPointerCancel={onPointerUp}
|
||||||
onClickCapture={onClickCapture}
|
onClickCapture={onClickCapture}
|
||||||
className="-mx-5 flex gap-3 overflow-x-auto px-5 py-1 snap-x scroll-px-5 cursor-grab select-none active:cursor-grabbing [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
className="-mx-4 sm:-mx-6 flex gap-3 overflow-x-auto px-4 sm:px-6 py-1 snap-x scroll-px-4 sm:scroll-px-6 cursor-grab select-none active:cursor-grabbing [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||||
>
|
>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
* posts (or imported data) still scroll to + highlight the text on click.
|
* posts (or imported data) still scroll to + highlight the text on click.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ExternalLink, Repeat2 } from "lucide-react";
|
import { ExternalLink, Repeat2 } from "lucide-react";
|
||||||
@@ -30,6 +31,7 @@ import { UserAvatar } from "@/components/ui/user-avatar";
|
|||||||
import { timeAgo } from "@/lib/time";
|
import { timeAgo } from "@/lib/time";
|
||||||
import { PostActionsFooter } from "./post-actions-footer";
|
import { PostActionsFooter } from "./post-actions-footer";
|
||||||
import { PostCardMenu } from "./post-card-menu";
|
import { PostCardMenu } from "./post-card-menu";
|
||||||
|
import { PostImageLightbox } from "./post-image-lightbox";
|
||||||
|
|
||||||
export function PostCard({
|
export function PostCard({
|
||||||
post,
|
post,
|
||||||
@@ -38,6 +40,11 @@ export function PostCard({
|
|||||||
post: Post;
|
post: Post;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// Which attached photo the full-screen viewer is showing (null = closed).
|
||||||
|
// Owned here rather than inside the lightbox so the thumbnails can open it
|
||||||
|
// at their own index.
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const sourceHref =
|
const sourceHref =
|
||||||
hasHighlight(post.source_url) || !post.caption
|
hasHighlight(post.source_url) || !post.caption
|
||||||
? post.source_url
|
? post.source_url
|
||||||
@@ -125,10 +132,20 @@ export function PostCard({
|
|||||||
: "grid-cols-3"
|
: "grid-cols-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{post.images.map((img) => (
|
{post.images.map((img, i) => (
|
||||||
<div
|
// Each thumbnail opens the full-screen viewer at its own index.
|
||||||
|
// A <button> rather than a click-handled <div> so it's keyboard
|
||||||
|
// reachable and announced — the grid is the only way in.
|
||||||
|
<button
|
||||||
key={img.id}
|
key={img.id}
|
||||||
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10"
|
type="button"
|
||||||
|
onClick={() => setLightboxIndex(i)}
|
||||||
|
aria-label={
|
||||||
|
post.images.length > 1
|
||||||
|
? `View photo ${i + 1} of ${post.images.length}`
|
||||||
|
: "View photo"
|
||||||
|
}
|
||||||
|
className="relative aspect-square rounded-lg overflow-hidden bg-muted ring-1 ring-foreground/10 cursor-zoom-in transition hover:ring-foreground/30 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={img.url}
|
src={img.url}
|
||||||
@@ -138,11 +155,19 @@ export function PostCard({
|
|||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<PostImageLightbox
|
||||||
|
images={post.images ?? []}
|
||||||
|
index={lightboxIndex}
|
||||||
|
caption={post.caption}
|
||||||
|
onIndexChange={setLightboxIndex}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<Link
|
<Link
|
||||||
href={sourceHref}
|
href={sourceHref}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostImageLightbox — full-screen viewer for a post's attached photos.
|
||||||
|
*
|
||||||
|
* Opened by tapping any image on a PostCard. Controlled by the caller: `index`
|
||||||
|
* is the photo being shown (null = closed), so the card owns the state and this
|
||||||
|
* component stays a pure presentation layer.
|
||||||
|
*
|
||||||
|
* Dismiss: tap the backdrop ring around the photo, the ✕, or press Escape.
|
||||||
|
* Navigate a multi-photo post with ← / → or the on-screen chevrons.
|
||||||
|
*
|
||||||
|
* Rendered through a portal onto <body> so it escapes the feed's virtualised,
|
||||||
|
* transformed rows — an overlay nested inside one of those would be clipped by
|
||||||
|
* the row's stacking context and positioned against the transform, not the
|
||||||
|
* viewport.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||||
|
import type { PostImage } from "@/lib/social";
|
||||||
|
|
||||||
|
export function PostImageLightbox({
|
||||||
|
images,
|
||||||
|
index,
|
||||||
|
caption,
|
||||||
|
onIndexChange,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
images: PostImage[];
|
||||||
|
/** Photo currently shown; null closes the viewer. */
|
||||||
|
index: number | null;
|
||||||
|
/** The post's caption, shown in a gradient scrim along the bottom.
|
||||||
|
* Clamped to three lines with an ellipsis so a long passage can't cover
|
||||||
|
* the photo. Omit (or pass empty) to hide the scrim entirely. */
|
||||||
|
caption?: string | null;
|
||||||
|
onIndexChange: (i: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const open = index !== null;
|
||||||
|
const count = images.length;
|
||||||
|
|
||||||
|
// Wrap around at both ends so a long press on → never dead-ends.
|
||||||
|
const goPrev = useCallback(() => {
|
||||||
|
if (index === null || count < 2) return;
|
||||||
|
onIndexChange((index - 1 + count) % count);
|
||||||
|
}, [index, count, onIndexChange]);
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
if (index === null || count < 2) return;
|
||||||
|
onIndexChange((index + 1) % count);
|
||||||
|
}, [index, count, onIndexChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
else if (e.key === "ArrowLeft") goPrev();
|
||||||
|
else if (e.key === "ArrowRight") goNext();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose, goPrev, goNext]);
|
||||||
|
|
||||||
|
// Lock body scroll while open so the feed underneath doesn't drift.
|
||||||
|
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;
|
||||||
|
const img = images[index];
|
||||||
|
if (!img) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={
|
||||||
|
count > 1 ? `Photo ${index + 1} of ${count}` : "Photo"
|
||||||
|
}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 z-100 bg-black/90 backdrop-blur-sm animate-in fade-in-0"
|
||||||
|
>
|
||||||
|
{/* The photo sits inset from the edges, leaving a genuine backdrop ring
|
||||||
|
that closes on tap. `fill` resolves against this box (padding would
|
||||||
|
NOT inset an absolutely-positioned child), hence inset-* rather than
|
||||||
|
p-*. stopPropagation keeps a tap on the photo itself from closing. */}
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="absolute inset-4 sm:inset-12"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
// Keyed so switching photos swaps the element instead of
|
||||||
|
// cross-fading a stale bitmap into the new slot.
|
||||||
|
key={img.id}
|
||||||
|
src={img.url}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
sizes="100vw"
|
||||||
|
className="object-contain"
|
||||||
|
unoptimized
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{count > 1 ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goPrev();
|
||||||
|
}}
|
||||||
|
aria-label="Previous photo"
|
||||||
|
className="fixed left-2 sm:left-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goNext();
|
||||||
|
}}
|
||||||
|
aria-label="Next photo"
|
||||||
|
className="fixed right-2 sm:right-4 top-1/2 -translate-y-1/2 size-10 inline-flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-5" />
|
||||||
|
</button>
|
||||||
|
{/* Counter sits top-left so it never collides with the caption
|
||||||
|
scrim along the bottom. */}
|
||||||
|
<span className="fixed top-5 left-4 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white tabular-nums">
|
||||||
|
{index + 1} / {count}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Caption scrim. `pointer-events-none` so it never intercepts a tap
|
||||||
|
meant for the backdrop — the whole strip stays click-to-close — and
|
||||||
|
the gradient fades upward so the photo's lower edge stays readable
|
||||||
|
rather than being cut by a hard band. */}
|
||||||
|
{caption?.trim() ? (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/60 to-transparent px-4 pt-16 pb-6">
|
||||||
|
<p className="mx-auto max-w-2xl text-center font-(family-name:--font-title) text-sm sm:text-base leading-relaxed text-white/95 line-clamp-3">
|
||||||
|
“{caption.trim()}”
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import { useAuth } from "@/components/auth/auth-provider";
|
|||||||
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
|
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
|
||||||
import { PostActionsFooter } from "./post-actions-footer";
|
import { PostActionsFooter } from "./post-actions-footer";
|
||||||
import { PostCardMenu } from "./post-card-menu";
|
import { PostCardMenu } from "./post-card-menu";
|
||||||
|
import { PostImageLightbox } from "./post-image-lightbox";
|
||||||
import { FeedFollowButton } from "./feed-follow-button";
|
import { FeedFollowButton } from "./feed-follow-button";
|
||||||
import { UpdateBackgroundModal } from "../update-background-modal";
|
import { UpdateBackgroundModal } from "../update-background-modal";
|
||||||
import { MentionsDrawer } from "../mentions-drawer";
|
import { MentionsDrawer } from "../mentions-drawer";
|
||||||
@@ -68,6 +69,8 @@ export function PostScrollCard({
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAuthor = !!user && user.id === post.user_id;
|
const isAuthor = !!user && user.id === post.user_id;
|
||||||
const [bgModal, setBgModal] = useState(false);
|
const [bgModal, setBgModal] = useState(false);
|
||||||
|
// Which attached photo the full-screen viewer is showing (null = closed).
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [mentionsOpen, setMentionsOpen] = useState(false);
|
const [mentionsOpen, setMentionsOpen] = useState(false);
|
||||||
// Live mention count — seeded from the hydrated post, adjusted when the
|
// Live mention count — seeded from the hydrated post, adjusted when the
|
||||||
@@ -176,7 +179,7 @@ export function PostScrollCard({
|
|||||||
{/* Media — image carousel or gradient-with-quote */}
|
{/* Media — image carousel or gradient-with-quote */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{hasImages ? (
|
{hasImages ? (
|
||||||
<ImageCarousel images={images} />
|
<ImageCarousel images={images} onOpen={setLightboxIndex} />
|
||||||
) : (
|
) : (
|
||||||
<QuotePanel post={post} />
|
<QuotePanel post={post} />
|
||||||
)}
|
)}
|
||||||
@@ -225,14 +228,36 @@ export function PostScrollCard({
|
|||||||
onClose={() => setMentionsOpen(false)}
|
onClose={() => setMentionsOpen(false)}
|
||||||
onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))}
|
onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PostImageLightbox
|
||||||
|
images={images}
|
||||||
|
index={lightboxIndex}
|
||||||
|
caption={post.caption}
|
||||||
|
onIndexChange={setLightboxIndex}
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Pieces ──────────────────────────────────────────────────────────────
|
// ─── Pieces ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ImageCarousel({ images }: { images: PostImage[] }) {
|
function ImageCarousel({
|
||||||
|
images,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
images: PostImage[];
|
||||||
|
/** Tapping a slide opens the full-screen viewer at that index. */
|
||||||
|
onOpen: (index: number) => void;
|
||||||
|
}) {
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Where the pointer went down, plus the scroller's position at that moment.
|
||||||
|
// Swiping the carousel and tapping a photo have to stay independent: the
|
||||||
|
// carousel scrolls natively (CSS scroll-snap), and browsers still fire a
|
||||||
|
// click after some swipe gestures. Comparing both the pointer travel AND
|
||||||
|
// whether the strip actually scrolled lets us drop those.
|
||||||
|
const tapStart = useRef({ x: 0, y: 0, scrollLeft: 0 });
|
||||||
|
|
||||||
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
function onScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
@@ -241,9 +266,35 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
|
|||||||
setActive(Math.round(el.scrollLeft / w));
|
setActive(Math.round(el.scrollLeft / w));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: React.PointerEvent) {
|
||||||
|
tapStart.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
scrollLeft: scrollerRef.current?.scrollLeft ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True only for a genuine tap — barely any pointer travel and no scroll. */
|
||||||
|
function wasTap(e: React.MouseEvent) {
|
||||||
|
const moved =
|
||||||
|
Math.abs(e.clientX - tapStart.current.x) > 8 ||
|
||||||
|
Math.abs(e.clientY - tapStart.current.y) > 8;
|
||||||
|
const scrolled =
|
||||||
|
Math.abs(
|
||||||
|
(scrollerRef.current?.scrollLeft ?? 0) - tapStart.current.scrollLeft,
|
||||||
|
) > 4;
|
||||||
|
return !moved && !scrolled;
|
||||||
|
}
|
||||||
|
|
||||||
if (images.length === 1) {
|
if (images.length === 1) {
|
||||||
|
// Single image: no scroller, so every click is unambiguously a tap.
|
||||||
return (
|
return (
|
||||||
<div className="relative aspect-square bg-muted">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(0)}
|
||||||
|
aria-label="View photo"
|
||||||
|
className="relative block w-full aspect-square bg-muted cursor-zoom-in outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
src={images[0].url}
|
src={images[0].url}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -252,13 +303,14 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
|
|||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
|
ref={scrollerRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex overflow-x-auto snap-x snap-mandatory",
|
"flex overflow-x-auto snap-x snap-mandatory",
|
||||||
@@ -266,11 +318,17 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{images.map((img, i) => (
|
{images.map((img, i) => (
|
||||||
<div
|
<button
|
||||||
key={img.id}
|
key={img.id}
|
||||||
className="relative w-full shrink-0 snap-start aspect-square bg-muted"
|
type="button"
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!wasTap(e)) return; // that was a swipe — let it scroll
|
||||||
|
onOpen(i);
|
||||||
|
}}
|
||||||
aria-roledescription="slide"
|
aria-roledescription="slide"
|
||||||
aria-label={`${i + 1} of ${images.length}`}
|
aria-label={`View photo ${i + 1} of ${images.length}`}
|
||||||
|
className="relative block w-full shrink-0 snap-start aspect-square bg-muted cursor-zoom-in outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={img.url}
|
src={img.url}
|
||||||
@@ -280,7 +338,7 @@ function ImageCarousel({ images }: { images: PostImage[] }) {
|
|||||||
className="object-cover"
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
|
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
|
||||||
|
|||||||
Reference in New Issue
Block a user