Files
spsays-training/components/social/post/post-scroll-card.tsx
T
2026-08-09 11:11:35 +05:30

452 lines
16 KiB
TypeScript

"use client";
/**
* PostScrollCard — Instagram-style post used on the scroll feed.
*
* Layout:
*
* ┌───────────────────────────────────────┐
* │ ◯ name @handle ⋮ │ ← author header (TBD)
* ├───────────────────────────────────────┤
* │ │
* │ image carousel │ ← square, swipeable
* │ (or quote) │ (no-image variant
* │ │ fills with gradient
* │ • ○ ○ ○ │ panel + serif quote)
* ├───────────────────────────────────────┤
* │ "Quoted passage shown as caption…" │ ← truncates with "more"
* │ ŚRĪMAD-BHĀGAVATAM — CANTO 4, CH 5 │ ← citation
* └───────────────────────────────────────┘
*
* • Multi-image carousel uses native CSS scroll-snap with dot indicators
* below; the same primitive CollectionScrollCard uses for posts.
* • Long captions clamp to 3 lines until the user expands via "more".
* • Owner action cluster (Update background / Delete) sits in the
* top-right of the media area, fading in on hover (desktop) and
* always visible on touch (mobile).
*/
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { AtSign, ImageIcon, Repeat2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Post, PostImage } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { timeAgo } from "@/lib/time";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { UserAvatar } from "@/components/ui/user-avatar";
import { useAuth } from "@/components/auth/auth-provider";
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu";
import { PostImageLightbox } from "./post-image-lightbox";
import { useTapGuard } from "./use-tap-guard";
import { FeedFollowButton } from "./feed-follow-button";
import { UpdateBackgroundModal } from "../update-background-modal";
import { MentionsDrawer } from "../mentions-drawer";
export function PostScrollCard({
post,
onDelete,
onUpdated,
onHiddenChange,
enableFollow = false,
}: {
post: Post;
onDelete?: (id: string) => void;
onUpdated?: (updated: Post) => void;
/** Owner-only: forwarded to the overflow menu so a parent can react to a
* hide/unhide (e.g. drop the post from the Hidden tab on unhide). */
onHiddenChange?: (id: string, hidden: boolean) => void;
/** Home-feed only: show a Follow / Follow back button in the header for
* authors the viewer doesn't already follow. Off everywhere else. */
enableFollow?: boolean;
}) {
const images = post.images ?? [];
const hasImages = images.length > 0;
const sourceHref = postSourceHref(post);
const isMobile = useMediaQuery("(max-width: 767px)");
const { user } = useAuth();
const isAuthor = !!user && user.id === post.user_id;
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 [mentionsOpen, setMentionsOpen] = useState(false);
// Live mention count — seeded from the hydrated post, adjusted when the
// author edits tags in the drawer, so the icon appears/disappears in place.
const [mentionCount, setMentionCount] = useState(post.mentions_count ?? 0);
// The author always gets the mentions entry point (to add/remove); everyone
// else sees it only when the post actually tags someone.
const showMentions = mentionCount > 0 || isAuthor;
// Top-right media overlay: a Mentions icon and the owner-only
// Update-background button. Delete lives in the header menu.
const topRight =
showMentions || onUpdated ? (
<div className="absolute top-3 right-3 z-10 flex items-center gap-2">
{showMentions ? (
<OwnerButton
label="See who's mentioned"
onClick={() => setMentionsOpen(true)}
icon={<AtSign className="size-4" />}
/>
) : null}
{onUpdated ? (
<OwnerButton
label="Update background"
onClick={() => setBgModal(true)}
icon={<ImageIcon className="size-4" />}
/>
) : null}
</div>
) : null;
return (
<article
id={`post-${post.id}`}
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24 select-none"
>
{/* Reshare attribution — only on rows that are themselves
reshares. Tappable link → original author's profile. The card
body below shows the resharer as the author (this row IS the
resharer's own post — likes, comments, background all belong
to them, decoupled from the original). */}
{post.reshared_from_post_id && post.original_author_handle ? (
<Link
href={`/u/${post.original_author_handle}`}
className="inline-flex items-center gap-1.5 px-4 pt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Repeat2 className="size-3.5" />
<span>
Reshared from{" "}
<span className="font-medium">@{post.original_author_handle}</span>
</span>
</Link>
) : null}
{/* Author header — identity on the left, three-dot overflow menu
on the right (Delete for author, Block/Report for everyone
else; PostCardMenu hides itself when there are no items). */}
<header className="flex items-center gap-3 px-4 py-3">
{post.author_handle ? (
<Link
href={`/u/${post.author_handle}`}
aria-label={`View ${post.author_name || post.author_handle}'s profile`}
className="shrink-0"
>
<UserAvatar
src={post.author_avatar_url}
name={post.author_name ?? "Unknown"}
className="size-9"
/>
</Link>
) : (
<UserAvatar
src={post.author_avatar_url}
name={post.author_name ?? "Unknown"}
className="size-9"
/>
)}
<div className="min-w-0 flex-1">
<Link
href={
post.author_handle ? `/u/${post.author_handle}` : "#"
}
className="block text-sm font-semibold text-foreground truncate hover:underline"
>
{post.author_name || "Unknown"}
</Link>
<span className="block text-xs text-muted-foreground truncate">
{post.author_handle ? <>@{post.author_handle} · </> : null}
{timeAgo(post.created_at)}
</span>
</div>
{enableFollow && post.author_handle && !post.viewer_follows_author ? (
<FeedFollowButton
handle={post.author_handle}
followsYou={!!post.author_follows_viewer}
requested={!!post.viewer_follow_requested}
/>
) : null}
<PostCardMenu
post={post}
onDelete={onDelete}
onHiddenChange={onHiddenChange}
/>
</header>
{/* Media — image carousel or gradient-with-quote */}
<div className="relative">
{hasImages ? (
<ImageCarousel images={images} onOpen={setLightboxIndex} />
) : (
<QuotePanel post={post} />
)}
{topRight}
</div>
{/* Caption + citation — reference sits directly under the media
so it's read first, with the action row below it. */}
<div className="px-4 pt-4 pb-3 space-y-2">
{hasImages ? (
// When images carry the visual, the caption sits as text below.
<Caption
text={post.caption}
expanded={expanded}
onToggle={() => setExpanded((v) => !v)}
/>
) : null}
<Link
href={sourceHref}
className="block max-w-full truncate text-[0.7rem] uppercase tracking-[0.2em] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{displaySourceLabel(post.source_url, post.source_title)} →
</Link>
</div>
{/* Action footer — like / comment / reshare on the left, add to
collection on the right. */}
<div className="pt-1 pb-2 border-t border-border">
<PostActionsFooter post={post} />
</div>
{onUpdated ? (
<UpdateBackgroundModal
open={bgModal}
onClose={() => setBgModal(false)}
post={post}
isMobile={isMobile}
onUpdated={onUpdated}
/>
) : null}
<MentionsDrawer
open={mentionsOpen}
postId={post.id}
authorId={post.user_id}
onClose={() => setMentionsOpen(false)}
onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))}
/>
<PostImageLightbox
images={images}
index={lightboxIndex}
caption={post.caption}
onIndexChange={setLightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
</article>
);
}
// ─── Pieces ──────────────────────────────────────────────────────────────
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 scrollerRef = useRef<HTMLDivElement>(null);
// Swiping the carousel and tapping a photo have to stay independent — the
// strip scrolls natively, and browsers still fire a click after some swipes.
const { onPointerDown, wasTap } = useTapGuard(scrollerRef);
function onScroll(e: React.UIEvent<HTMLDivElement>) {
const el = e.currentTarget;
const w = el.offsetWidth;
if (w === 0) return;
setActive(Math.round(el.scrollLeft / w));
}
if (images.length === 1) {
// Single image: no scroller, so every click is unambiguously a tap.
return (
<button
type="button"
onClick={() => onOpen(0)}
aria-label="View photo"
className="relative block w-full aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<Image
src={images[0].url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 640px"
className="object-cover"
unoptimized
/>
</button>
);
}
return (
<div className="relative">
<div
ref={scrollerRef}
onScroll={onScroll}
className={cn(
"flex overflow-x-auto snap-x snap-mandatory",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
{images.map((img, i) => (
<button
key={img.id}
type="button"
onPointerDown={onPointerDown}
onClick={(e) => {
if (!wasTap(e)) return; // that was a swipe — let it scroll
onOpen(i);
}}
aria-roledescription="slide"
aria-label={`View photo ${i + 1} of ${images.length}`}
className="relative block w-full shrink-0 snap-start aspect-square bg-muted cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<Image
src={img.url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 640px"
className="object-cover"
unoptimized
/>
</button>
))}
</div>
<div className="absolute inset-x-0 bottom-3 flex items-center justify-center gap-1.5 pointer-events-none">
{images.map((_, i) => (
<span
key={i}
aria-hidden
className={cn(
"size-1.5 rounded-full transition-all",
i === active ? "bg-white" : "bg-white/50",
)}
/>
))}
</div>
</div>
);
}
function QuotePanel({ post }: { post: Post }) {
// No-image post: the entire square is a flat palette-aware surface
// (pure white on light themes, palette-specific dark on dark themes)
// with the quote in serif inside. Text uses --foreground so it inverts
// automatically with the theme.
return (
<div
className="relative aspect-square overflow-hidden select-none"
style={quoteCardBgStyle()}
>
<div className="absolute inset-0 flex flex-col justify-center items-center p-8 sm:p-10 text-center text-foreground">
<p className="font-[family-name:var(--font-title)] text-xl sm:text-2xl leading-relaxed max-w-md line-clamp-[8] break-words">
“{post.caption}”
</p>
</div>
</div>
);
}
function Caption({
text,
expanded,
onToggle,
}: {
text: string;
expanded: boolean;
onToggle: () => void;
}) {
// We clamp to 3 lines collapsed and expose a "More" toggle only when
// the clamp is actually hiding something. A char-length heuristic gets
// this wrong: a 200-char caption can fit cleanly in 3 lines on a wide
// card (no clipping → "More" would be a false promise). Instead measure
// scrollHeight vs clientHeight on the clamped <p>. Once we've observed
// overflow we latch it on so "Show less" stays visible after expansion
// (where scrollHeight would equal clientHeight and we'd otherwise lose
// the way back).
const pRef = useRef<HTMLParagraphElement | null>(null);
const [overflowed, setOverflowed] = useState(false);
useEffect(() => {
if (expanded) return;
const el = pRef.current;
if (!el) return;
const measure = () => {
// Sub-pixel rounding can leave a 1px gap even when content fits;
// require a real difference before flagging overflow.
setOverflowed(el.scrollHeight - el.clientHeight > 1);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [text, expanded]);
const showToggle = overflowed || expanded;
return (
<div className="text-sm text-foreground leading-relaxed">
<p
ref={pRef}
onClick={
showToggle
? (e) => {
e.stopPropagation();
onToggle();
}
: undefined
}
className={cn(
"font-[family-name:var(--font-title)] break-words",
!expanded && "line-clamp-3",
showToggle && "cursor-pointer",
)}
>
“{text}”
</p>
{showToggle ? (
<button
type="button"
onClick={onToggle}
className="mt-1 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? "Show less" : "More"}
</button>
) : null}
</div>
);
}
function OwnerButton({
label,
onClick,
icon,
}: {
label: string;
onClick: () => void;
icon: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={cn(
"size-9 rounded-full inline-flex items-center justify-center",
"bg-black/40 text-white hover:bg-black/60 transition-colors backdrop-blur-sm",
)}
>
{icon}
</button>
);
}