392 lines
14 KiB
TypeScript
392 lines
14 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 { 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 { 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);
|
|
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">
|
|
<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>
|
|
{post.author_handle ? (
|
|
<span className="block text-xs text-muted-foreground truncate">
|
|
@{post.author_handle}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{enableFollow && post.author_handle && !post.viewer_follows_author ? (
|
|
<FeedFollowButton
|
|
handle={post.author_handle}
|
|
followsYou={!!post.author_follows_viewer}
|
|
/>
|
|
) : null}
|
|
<PostCardMenu
|
|
post={post}
|
|
onDelete={onDelete}
|
|
onHiddenChange={onHiddenChange}
|
|
/>
|
|
</header>
|
|
|
|
{/* Media — image carousel or gradient-with-quote */}
|
|
<div className="relative">
|
|
{hasImages ? (
|
|
<ImageCarousel images={images} />
|
|
) : (
|
|
<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))}
|
|
/>
|
|
</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 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>
|
|
);
|
|
}
|