Tweet Card
Tweet Card é um componente de cartão para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
SmoothUI
MIT
cards
O que é
Tweet Card é um componente de cartão para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Precisa de
npm i react-tweet
Licença
Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
Tweet Card
"use client";
import { type TweetProps, useTweet } from "react-tweet";
import { SmoothTweet, TweetNotFound, TweetSkeleton } from "./index";
export type ClientTweetCardProps = TweetProps & {
className?: string;
userInfoPosition?: "top" | "bottom";
avatarRounded?: string;
};
export const ClientTweetCard = ({
id,
apiUrl,
fallback = <TweetSkeleton />,
components,
fetchOptions,
onError,
...props
}: ClientTweetCardProps) => {
const { data, error, isLoading } = useTweet(id, apiUrl, fetchOptions);
if (isLoading) {
return fallback;
}
if (error || !data) {
const NotFound = components?.TweetNotFound || TweetNotFound;
return <NotFound error={onError ? onError(error) : error} />;
}
return <SmoothTweet tweet={data} {...props} />;
};
/* eslint-disable @next/next/no-img-element */
import { cn } from "@/lib/utils";
import { Suspense } from "react";
import { type EnrichedTweet, enrichTweet, type TweetProps } from "react-tweet";
import { getTweet, type Tweet } from "react-tweet/api";
interface IconProps {
className?: string;
[key: string]: unknown;
}
const ExternalLink = ({ className, ...props }: IconProps) => (
<svg
aria-hidden="true"
className={className}
fill="none"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" x2="21" y1="14" y2="3" />
</svg>
);
export const truncate = (str: string | null, length: number) => {
if (!str || str.length <= length) {
return str;
}
return `${str.slice(0, length - 3)}...`;
};
const Skeleton = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("rounded-md bg-muted", className)} {...props} />
);
export const TweetSkeleton = ({
className,
...props
}: {
className?: string;
[key: string]: unknown;
}) => (
<div
className={cn(
"flex size-full max-h-max min-w-72 flex-col gap-2 rounded-xl border p-4",
className
)}
{...props}
>
<div className="flex flex-row gap-2">
<Skeleton className="size-10 shrink-0 rounded-full" />
<Skeleton className="h-10 w-full" />
</div>
<Skeleton className="h-20 w-full" />
</div>
);
export const TweetNotFound = ({
className,
...props
}: {
className?: string;
[key: string]: unknown;
}) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-2 rounded-lg border p-4",
className
)}
{...props}
>
<h3>Tweet not found</h3>
</div>
);
export const TweetHeader = ({
tweet,
avatarRounded = "rounded",
}: {
tweet: EnrichedTweet;
avatarRounded?: string;
}) => (
<div className="flex items-center gap-2">
<a href={tweet.user.url} rel="noreferrer" target="_blank">
<img
alt={tweet.user.screen_name}
className={cn("size-10 object-cover object-center", avatarRounded)}
draggable={false}
height={40}
loading="eager"
src={tweet.user.profile_image_url_https}
title={`Profile picture of ${tweet.user.name}`}
width={40}
/>
</a>
<div>
<p className="font-semibold text-foreground text-sm tracking-tighter 2xl:text-base">
{tweet.user.name}
</p>
<p className="text-foreground/60 text-xs 2xl:text-sm">
@{truncate(tweet.user.screen_name, 16)}
</p>
</div>
</div>
);
export const TweetBody = ({ tweet }: { tweet: EnrichedTweet }) => (
<blockquote className="flex-1">
<p className="text-balance text-foreground text-sm tracking-tight 2xl:text-base">
{tweet.entities.map((entity, idx) => {
switch (entity.type) {
case "url":
case "symbol":
case "hashtag":
case "mention":
return (
<a
className="ease text-foreground transition-colors duration-200 hover:text-foreground/80"
href={entity.href}
key={`${entity.type}-${idx}-${entity.text.slice(0, 10)}`}
rel="noopener noreferrer"
target="_blank"
>
<span>{entity.text}</span>
</a>
);
case "text":
return (
<span
className="text-foreground"
// biome-ignore lint/suspicious/noArrayIndexKey: Text entities from tweet API have no unique IDs
key={`text-${idx}`}
>
{entity.text}
</span>
);
default:
return null;
}
})}
</p>
</blockquote>
);
export const TweetMedia = ({ tweet }: { tweet: EnrichedTweet }) => {
const hasVideo =
tweet.video &&
Array.isArray(tweet.video.variants) &&
tweet.video.variants.length > 0;
const hasPhotos = tweet.photos && tweet.photos.length > 0;
const hasCardThumbnail =
// @ts-expect-error package doesn't have type definitions
tweet?.card?.binding_values?.thumbnail_image_large?.image_value.url;
if (!(hasVideo || hasPhotos || hasCardThumbnail)) {
return null;
}
return (
<div className="flex w-full flex-col gap-2">
{hasVideo && tweet.video ? (
<video
autoPlay
className="w-full rounded-xl border shadow-sm"
loop
muted
playsInline
poster={tweet.video.poster}
>
<source src={tweet.video.variants[0].src} type="video/mp4" />
Your browser does not support the video tag.
</video>
) : null}
{hasPhotos && tweet.photos
? (() => {
const { photos } = tweet;
return (
<div className="relative grid w-full gap-2">
{photos.length === 1 && (
<img
alt={tweet.text}
className="w-full rounded-xl border object-cover shadow-sm"
draggable={false}
height={photos[0].height}
key={photos[0].url}
src={photos[0].url}
title={`Photo by ${tweet.user.name}`}
width={photos[0].width}
/>
)}
{photos.length === 2 && (
<div className="grid grid-cols-2 gap-2">
{photos.map((photo) => (
<img
alt={tweet.text}
className="w-full rounded-xl border object-cover shadow-sm"
draggable={false}
height={photo.height}
key={photo.url}
src={photo.url}
title={`Photo by ${tweet.user.name}`}
width={photo.width}
/>
))}
</div>
)}
{photos.length >= 3 && (
<div className="grid grid-cols-2 gap-2">
{photos.slice(0, 4).map((photo, index) => (
<img
alt={tweet.text}
className={cn(
"w-full rounded-xl border object-cover shadow-sm",
index === 0 && photos.length > 3 && "row-span-2"
)}
draggable={false}
height={photo.height}
key={photo.url}
src={photo.url}
title={`Photo by ${tweet.user.name}`}
width={photo.width}
/>
))}
</div>
)}
</div>
);
})()
: null}
{!(hasVideo || hasPhotos) && hasCardThumbnail && (
<img
alt={tweet.text}
className="w-full rounded-xl border object-cover shadow-sm"
draggable={false}
src={
// @ts-expect-error package doesn't have type definitions
tweet.card.binding_values.thumbnail_image_large.image_value.url
}
/>
)}
</div>
);
};
export const SmoothTweet = ({
tweet,
className,
userInfoPosition = "bottom",
avatarRounded = "rounded",
...props
}: {
tweet: Tweet;
className?: string;
userInfoPosition?: "top" | "bottom";
avatarRounded?: string;
}) => {
// react-tweet's enrichTweet iterates over each entity array without
// guarding for undefined. The syndication API can omit arrays (e.g.
// symbols/user_mentions) for some tweets, which would throw
// "entities is not iterable" and crash the whole tree. Normalize first.
const safeTweet: Tweet = {
...tweet,
entities: {
hashtags: tweet.entities?.hashtags ?? [],
symbols: tweet.entities?.symbols ?? [],
urls: tweet.entities?.urls ?? [],
user_mentions: tweet.entities?.user_mentions ?? [],
...(tweet.entities?.media ? { media: tweet.entities.media } : {}),
},
};
const enrichedTweet = enrichTweet(safeTweet);
const userInfo = (
<TweetHeader avatarRounded={avatarRounded} tweet={enrichedTweet} />
);
const content = (
<div className="flex w-full flex-col gap-4">
<TweetMedia tweet={enrichedTweet} />
{userInfoPosition === "bottom" && userInfo}
</div>
);
return (
<article
className={cn(
"group !p-6 relative flex flex-col items-start justify-between gap-6 overflow-hidden rounded-xl border bg-background lg:gap-8",
className
)}
data-tweet
{...props}
>
{userInfoPosition === "top" && userInfo}
<TweetBody tweet={enrichedTweet} />
{content}
<button
aria-label="Open tweet in new tab"
className="absolute right-6 bottom-6 z-10 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border bg-primary text-foreground opacity-0 backdrop-blur-sm transition-all duration-200 ease-out group-hover:opacity-100 motion-safe:hover:scale-110"
onClick={() =>
window.open(enrichedTweet.url, "_blank", "noopener,noreferrer")
}
type="button"
>
<ExternalLink className="h-4 w-4" />
</button>
</article>
);
};
/**
* TweetCard (Server Side Only)
*/
export type TweetCardProps = TweetProps & {
className?: string;
userInfoPosition?: "top" | "bottom";
avatarRounded?: string;
};
export const TweetCard = async ({
id,
components,
fallback = <TweetSkeleton />,
onError,
...props
}: TweetCardProps) => {
const tweet = id
? await getTweet(id).catch((err) => {
if (onError) {
onError(err);
} else {
console.error(err);
}
})
: undefined;
if (!tweet) {
const NotFound = components?.TweetNotFound || TweetNotFound;
return <NotFound {...props} />;
}
return (
<Suspense fallback={fallback}>
<SmoothTweet tweet={tweet} {...props} />
</Suspense>
);
};
export { ClientTweetCard } from "./client";
export default TweetCard;