"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 } 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 ? (
setBgModal(true)} icon={} />
) : null; return (
{/* 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). */}
{post.author_name || "Unknown"} {post.author_handle ? ( @{post.author_handle} ) : null}
{/* Media — image carousel or gradient-with-quote */}
{hasImages ? ( ) : ( )} {ownerCluster}
{/* Caption + citation — reference sits directly under the media so it's read first, with the action row below it. */}
{hasImages ? ( // When images carry the visual, the caption sits as text below. setExpanded((v) => !v)} /> ) : null} {displaySourceLabel(post.source_url, post.source_title)} →
{/* Action footer — like / comment / reshare on the left, add to collection on the right. */}
{onUpdated ? ( setBgModal(false)} post={post} isMobile={isMobile} onUpdated={onUpdated} /> ) : null}
); } // ─── Pieces ────────────────────────────────────────────────────────────── function ImageCarousel({ images }: { images: PostImage[] }) { const [active, setActive] = useState(0); function onScroll(e: React.UIEvent) { const el = e.currentTarget; const w = el.offsetWidth; if (w === 0) return; setActive(Math.round(el.scrollLeft / w)); } if (images.length === 1) { return (
); } return (
{images.map((img, i) => (
))}
{images.map((_, i) => ( ))}
); } 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 (

“{post.caption}”

); } 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

. 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(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 (

“{text}”

{showToggle ? ( ) : null}
); } function AuthorAvatar({ src, name, }: { src: string | null; name: string; }) { if (src) { return ( {name} ); } const initial = name.charAt(0).toUpperCase() || "?"; return (
{initial}
); } function OwnerButton({ label, onClick, icon, }: { label: string; onClick: () => void; icon: React.ReactNode; }) { return ( ); }