159 lines
5.8 KiB
TypeScript
159 lines
5.8 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
|
|
/>
|
|
{/* Bottom-up gradient for caption legibility — keeps the image
|
|
visible up top while text sits over solid darkness below. */}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent" />
|
|
</>
|
|
) : (
|
|
<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>
|
|
);
|
|
}
|