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

160 lines
5.9 KiB
TypeScript

"use client";
/**
* PostGridCard — square card used in the 2-per-row grid view.
*
* • With image: first attached image fills the card; a bottom-up dark
* gradient makes the caption legible without dropping the image's
* atmosphere.
* • Without image: a palette-aware Tailwind gradient stands in; the
* caption sits as a serif quote bottom-left in the foreground colour.
*
* `onDelete` shows a small X in the corner for owner views. Used both for
* actual post deletion (on /u/me) and for removing from a collection
* (on /u/me/collections/[id]); the parent decides which.
*/
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { ImageIcon, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Post } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { postSourceHref, quoteCardBgStyle, quoteSize } from "./post-card-shared";
import { UpdateBackgroundModal } from "./update-background-modal";
export function PostGridCard({
post,
onDelete,
onUpdated,
href: hrefProp,
}: {
post: Post;
onDelete?: (id: string) => void;
/** Owner-only: shown when defined so the user can swap/remove the
* card's background image. */
onUpdated?: (updated: Post) => void;
/** Override the click target. Defaults to the source-passage URL.
* Profile grids pass a `/u/<handle>/posts#post-<id>` anchor so the
* thumbnail jumps to the Instagram-style scroll feed instead of the
* knowledgebase page. */
href?: string;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
const [bgModal, setBgModal] = useState(false);
const firstImage = post.images?.[0]?.url;
const href = hrefProp ?? postSourceHref(post);
const size = quoteSize(post.caption, "grid");
// Image variant always anchors text bottom-left over the image's dark
// overlay. No-image variant lets `size.alignment` decide — short quotes
// sit centred so the gradient frames them; long quotes fall back to
// bottom-left so they don't cover the whole card.
const bottomLeft = !!firstImage || size.alignment === "bottom-left";
return (
<div className="relative group select-none">
<Link
href={href}
className={cn(
"block aspect-square rounded-2xl overflow-hidden ring-1 ring-foreground/10",
"hover:ring-foreground/25 hover:shadow-md transition-all relative",
)}
>
{firstImage ? (
<>
<Image
src={firstImage}
alt=""
fill
sizes="(max-width: 640px) 50vw, 320px"
className="object-cover"
unoptimized
/>
{/* Full-coverage scrim — even the top of the card carries enough
darkness (~40%) so white text overlaid anywhere stays
legible without looking blended with the image. */}
<div className="absolute inset-0 bg-gradient-to-t from-black/95 via-black/70 to-black/40" />
</>
) : (
<div className="absolute inset-0" style={quoteCardBgStyle()} />
)}
<div
className={cn(
"absolute inset-0 flex flex-col p-4 sm:p-5",
// Text color: white on the dark image overlay, theme
// foreground on the flat quote-card background.
firstImage ? "text-white" : "text-foreground",
bottomLeft
? "justify-end items-start text-left"
: "justify-center items-center text-center",
)}
>
<p
className={cn(
"font-[family-name:var(--font-title)] leading-snug break-words",
size.fontClass,
size.clampClass,
// Drop-shadow only helps when text sits on imagery; on the
// flat bg it just blurs the type.
firstImage && "drop-shadow-sm",
// Centred quotes need a soft horizontal cap so a long line
// doesn't kiss the card edge — looks better with margins.
!bottomLeft ? "max-w-[90%]" : "w-full",
)}
>
“{post.caption}”
</p>
<p
className={cn(
"mt-2 text-[0.65rem] uppercase tracking-[0.2em] font-medium truncate",
firstImage ? "text-white/80" : "text-muted-foreground",
bottomLeft ? "w-full" : "max-w-[90%]",
)}
>
{displaySourceLabel(post.source_url, post.source_title)}
</p>
</div>
</Link>
{onUpdated || onDelete ? (
<div className="absolute top-2 right-2 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
{onUpdated ? (
<button
type="button"
onClick={() => setBgModal(true)}
aria-label="Update background"
title="Update background"
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
>
<ImageIcon className="size-4" />
</button>
) : null}
{onDelete ? (
<button
type="button"
onClick={() => onDelete(post.id)}
aria-label="Remove"
className="size-8 rounded-full inline-flex items-center justify-center bg-black/40 text-white hover:bg-black/60 transition-colors"
>
<X className="size-4" />
</button>
) : null}
</div>
) : null}
{onUpdated ? (
<UpdateBackgroundModal
open={bgModal}
onClose={() => setBgModal(false)}
post={post}
isMobile={isMobile}
onUpdated={onUpdated}
/>
) : null}
</div>
);
}