Files
spsays-training/components/social/post-scroll-card.tsx
T

370 lines
12 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 { ImageIcon, Repeat2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Post, PostImage } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu";
import { UpdateBackgroundModal } from "./update-background-modal";
export function PostScrollCard({
post,
onDelete,
onUpdated,
}: {
post: Post;
onDelete?: (id: string) => void;
onUpdated?: (updated: Post) => void;
}) {
const images = post.images ?? [];
const hasImages = images.length > 0;
const sourceHref = postSourceHref(post);
const isMobile = useMediaQuery("(max-width: 767px)");
const [bgModal, setBgModal] = useState(false);
const [expanded, setExpanded] = useState(false);
// Update Background stays as a media-overlay button — it's context-
// specific to the visible card surface and reads more naturally
// positioned over the image itself. Delete has moved into the
// header's three-dot menu (PostCardMenu) where Block / Report also
// live, so the overlay is now Update-Background-only.
const ownerCluster = onUpdated ? (
<div className="absolute top-3 right-3 z-10">
<OwnerButton
label="Update background"
onClick={() => setBgModal(true)}
icon={<ImageIcon className="size-4" />}
/>
</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"
>
{/* 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">
<AuthorAvatar
src={post.author_avatar_url ?? null}
name={post.author_name ?? "Unknown"}
/>
<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>
{post.author_handle ? (
<span className="block text-xs text-muted-foreground truncate">
@{post.author_handle}
</span>
) : null}
</div>
<PostCardMenu post={post} onDelete={onDelete} />
</header>
{/* Media — image carousel or gradient-with-quote */}
<div className="relative">
{hasImages ? (
<ImageCarousel images={images} />
) : (
<QuotePanel post={post} />
)}
{ownerCluster}
</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}
</article>
);
}
// ─── Pieces ──────────────────────────────────────────────────────────────
function ImageCarousel({ images }: { images: PostImage[] }) {
const [active, setActive] = useState(0);
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) {
return (
<div className="relative aspect-square bg-muted">
<Image
src={images[0].url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 640px"
className="object-cover"
unoptimized
/>
</div>
);
}
return (
<div className="relative">
<div
onScroll={onScroll}
className={cn(
"flex overflow-x-auto snap-x snap-mandatory",
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
)}
>
{images.map((img, i) => (
<div
key={img.id}
className="relative w-full shrink-0 snap-start aspect-square bg-muted"
aria-roledescription="slide"
aria-label={`${i + 1} of ${images.length}`}
>
<Image
src={img.url}
alt=""
fill
sizes="(max-width: 768px) 100vw, 640px"
className="object-cover"
unoptimized
/>
</div>
))}
</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}
className={cn(
"font-[family-name:var(--font-title)] break-words",
!expanded && "line-clamp-3",
)}
>
“{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 AuthorAvatar({
src,
name,
}: {
src: string | null;
name: string;
}) {
if (src) {
return (
<Image
src={src}
alt={name}
width={36}
height={36}
className="size-9 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initial = name.charAt(0).toUpperCase() || "?";
return (
<div className="size-9 rounded-full bg-primary/15 text-primary flex items-center justify-center text-sm font-semibold ring-1 ring-foreground/10">
{initial}
</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>
);
}