Todos os componentes

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.

Abrir no catálogo

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

client.tsx
"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} />;
};
index.tsx
/* 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;

Componentes parecidos