"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 ? (
setBgModal(true)}
icon={}
/>
) : null;
return (
{/* 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 ? (
Reshared from{" "}
@{post.original_author_handle}
) : 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). */}
{/* 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 (