feat: integrate react-tweet library and add AvatarCircles and TweetImages components to enhance MagicuiPage
This commit is contained in:
parent
d0b3e069d9
commit
0a314a22f1
@ -102,6 +102,7 @@
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-remove-scroll": "^2.6.3",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-tweet": "^3.2.2",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^2.15.1",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
|
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@ -263,6 +263,9 @@ importers:
|
||||
react-resizable-panels:
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
react-tweet:
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
react-use-measure:
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
@ -4976,6 +4979,12 @@ packages:
|
||||
react: '>=16.6.0'
|
||||
react-dom: '>=16.6.0'
|
||||
|
||||
react-tweet@3.2.2:
|
||||
resolution: {integrity: sha512-hIkxAVPpN2RqWoDEbo3TTnN/pDcp9/Jb6pTgiA4EbXa9S+m2vHIvvZKHR+eS0PDIsYqe+zTmANRa5k6+/iwGog==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-use-measure@2.1.7:
|
||||
resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
|
||||
peerDependencies:
|
||||
@ -10660,6 +10669,14 @@ snapshots:
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
|
||||
react-tweet@3.2.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.15
|
||||
clsx: 2.1.1
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
swr: 2.3.2(react@19.0.0)
|
||||
|
||||
react-use-measure@2.1.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
react: 19.0.0
|
||||
|
@ -3,6 +3,7 @@ import { AnimatedGridPatternDemo } from '@/components/magicui/example/animated-g
|
||||
import { AnimatedListDemo } from '@/components/magicui/example/animated-list-example';
|
||||
import { AnimatedShinyTextDemo } from '@/components/magicui/example/animated-shiny-text-example';
|
||||
import { AnimatedSubscribeButtonDemo } from '@/components/magicui/example/animated-subscribe-button-example';
|
||||
import { AvatarCirclesDemo } from '@/components/magicui/example/avatar-circles-example';
|
||||
import { BentoDemo } from '@/components/magicui/example/bento-grid-example';
|
||||
import { BlurFadeDemo } from '@/components/magicui/example/blur-fade-example';
|
||||
import { DotPatternDemo } from '@/components/magicui/example/dot-pattern-example';
|
||||
@ -18,6 +19,7 @@ import { RainbowButtonDemo } from '@/components/magicui/example/rainbow-button-e
|
||||
import { RippleDemo } from '@/components/magicui/example/ripple-example';
|
||||
import { ShimmerButtonDemo } from '@/components/magicui/example/shimmer-button-example';
|
||||
import { ShinyButtonDemo } from '@/components/magicui/example/shiny-button-example';
|
||||
import { TweetImages } from '@/components/magicui/example/twitter-card-example';
|
||||
import { WordRotateDemo } from '@/components/magicui/example/word-rotate-example';
|
||||
|
||||
/**
|
||||
@ -29,12 +31,14 @@ export default async function MagicuiPage() {
|
||||
return (
|
||||
<div className="mx-auto space-y-8">
|
||||
<RippleDemo />
|
||||
<BentoDemo />
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<MarqueeDemoVertical />
|
||||
<AnimatedListDemo />
|
||||
</div>
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
<div className="flex items-center justify-center">
|
||||
<AvatarCirclesDemo />
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<AnimatedGradientTextDemo />
|
||||
</div>
|
||||
@ -69,7 +73,9 @@ export default async function MagicuiPage() {
|
||||
<WordRotateDemo />
|
||||
</div>
|
||||
</div>
|
||||
<TweetImages />
|
||||
<BlurFadeDemo />
|
||||
<BentoDemo />
|
||||
<DotPatternDemo />
|
||||
<GridPatternDemo />
|
||||
<AnimatedGridPatternDemo />
|
||||
|
49
src/components/magicui/avatar-circles.tsx
Normal file
49
src/components/magicui/avatar-circles.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Avatar {
|
||||
imageUrl: string;
|
||||
profileUrl: string;
|
||||
}
|
||||
interface AvatarCirclesProps {
|
||||
className?: string;
|
||||
numPeople?: number;
|
||||
avatarUrls: Avatar[];
|
||||
}
|
||||
|
||||
export const AvatarCircles = ({
|
||||
numPeople,
|
||||
className,
|
||||
avatarUrls,
|
||||
}: AvatarCirclesProps) => {
|
||||
return (
|
||||
<div className={cn("z-10 flex -space-x-4 rtl:space-x-reverse", className)}>
|
||||
{avatarUrls.map((url, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={url.profileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
key={index}
|
||||
className="h-10 w-10 rounded-full border-2 border-white dark:border-gray-800"
|
||||
src={url.imageUrl}
|
||||
width={40}
|
||||
height={40}
|
||||
alt={`Avatar ${index + 1}`}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
{(numPeople ?? 0) > 0 && (
|
||||
<a
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-white bg-black text-center text-xs font-medium text-white hover:bg-gray-600 dark:border-gray-800 dark:bg-white dark:text-black"
|
||||
href=""
|
||||
>
|
||||
+{numPeople}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
32
src/components/magicui/example/avatar-circles-example.tsx
Normal file
32
src/components/magicui/example/avatar-circles-example.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { AvatarCircles } from "@/components/magicui/avatar-circles";
|
||||
|
||||
const avatars = [
|
||||
{
|
||||
imageUrl: "https://avatars.githubusercontent.com/u/16860528",
|
||||
profileUrl: "https://github.com/dillionverma",
|
||||
},
|
||||
{
|
||||
imageUrl: "https://avatars.githubusercontent.com/u/20110627",
|
||||
profileUrl: "https://github.com/tomonarifeehan",
|
||||
},
|
||||
{
|
||||
imageUrl: "https://avatars.githubusercontent.com/u/106103625",
|
||||
profileUrl: "https://github.com/BankkRoll",
|
||||
},
|
||||
{
|
||||
imageUrl: "https://avatars.githubusercontent.com/u/59228569",
|
||||
profileUrl: "https://github.com/safethecode",
|
||||
},
|
||||
{
|
||||
imageUrl: "https://avatars.githubusercontent.com/u/59442788",
|
||||
profileUrl: "https://github.com/sanjay-mali",
|
||||
},
|
||||
{
|
||||
imageUrl: "https://avatars.githubusercontent.com/u/89768406",
|
||||
profileUrl: "https://github.com/itsarghyadas",
|
||||
},
|
||||
];
|
||||
|
||||
export function AvatarCirclesDemo() {
|
||||
return <AvatarCircles numPeople={99} avatarUrls={avatars} />;
|
||||
}
|
5
src/components/magicui/example/twitter-card-example.tsx
Normal file
5
src/components/magicui/example/twitter-card-example.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { ClientTweetCard } from "@/components/magicui/twitter-card-client";
|
||||
|
||||
export function TweetImages() {
|
||||
return <ClientTweetCard id="1678577280489234432" className="shadow-2xl" />;
|
||||
}
|
29
src/components/magicui/twitter-card-client.tsx
Normal file
29
src/components/magicui/twitter-card-client.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { TweetProps, useTweet } from "react-tweet";
|
||||
|
||||
import {
|
||||
MagicTweet,
|
||||
TweetNotFound,
|
||||
TweetSkeleton,
|
||||
} from "@/components/magicui/twitter-card";
|
||||
|
||||
export const ClientTweetCard = ({
|
||||
id,
|
||||
apiUrl,
|
||||
fallback = <TweetSkeleton />,
|
||||
components,
|
||||
fetchOptions,
|
||||
onError,
|
||||
...props
|
||||
}: TweetProps & { className?: string }) => {
|
||||
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 <MagicTweet tweet={data} components={components} {...props} />;
|
||||
};
|
287
src/components/magicui/twitter-card.tsx
Normal file
287
src/components/magicui/twitter-card.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
enrichTweet,
|
||||
type EnrichedTweet,
|
||||
type TweetProps,
|
||||
type TwitterComponents,
|
||||
} from "react-tweet";
|
||||
import { getTweet, type Tweet } from "react-tweet/api";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TwitterIconProps {
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
const Twitter = ({ className, ...props }: TwitterIconProps) => (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
viewBox="0 0 24 24"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<g>
|
||||
<path fill="none" d="M0 0h24v24H0z"></path>
|
||||
<path d="M22.162 5.656a8.384 8.384 0 0 1-2.402.658A4.196 4.196 0 0 0 21.6 4c-.82.488-1.719.83-2.656 1.015a4.182 4.182 0 0 0-7.126 3.814 11.874 11.874 0 0 1-8.62-4.37 4.168 4.168 0 0 0-.566 2.103c0 1.45.738 2.731 1.86 3.481a4.168 4.168 0 0 1-1.894-.523v.052a4.185 4.185 0 0 0 3.355 4.101 4.21 4.21 0 0 1-1.89.072A4.185 4.185 0 0 0 7.97 16.65a8.394 8.394 0 0 1-6.191 1.732 11.83 11.83 0 0 0 6.41 1.88c7.693 0 11.9-6.373 11.9-11.9 0-.18-.005-.362-.013-.54a8.496 8.496 0 0 0 2.087-2.165z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const Verified = ({ className, ...props }: TwitterIconProps) => (
|
||||
<svg
|
||||
aria-label="Verified Account"
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<g fill="currentColor">
|
||||
<path d="M22.5 12.5c0-1.58-.875-2.95-2.148-3.6.154-.435.238-.905.238-1.4 0-2.21-1.71-3.998-3.818-3.998-.47 0-.92.084-1.336.25C14.818 2.415 13.51 1.5 12 1.5s-2.816.917-3.437 2.25c-.415-.165-.866-.25-1.336-.25-2.11 0-3.818 1.79-3.818 4 0 .494.083.964.237 1.4-1.272.65-2.147 2.018-2.147 3.6 0 1.495.782 2.798 1.942 3.486-.02.17-.032.34-.032.514 0 2.21 1.708 4 3.818 4 .47 0 .92-.086 1.335-.25.62 1.334 1.926 2.25 3.437 2.25 1.512 0 2.818-.916 3.437-2.25.415.163.865.248 1.336.248 2.11 0 3.818-1.79 3.818-4 0-.174-.012-.344-.033-.513 1.158-.687 1.943-1.99 1.943-3.484zm-6.616-3.334l-4.334 6.5c-.145.217-.382.334-.625.334-.143 0-.288-.04-.416-.126l-.115-.094-2.415-2.415c-.293-.293-.293-.768 0-1.06s.768-.294 1.06 0l1.77 1.767 3.825-5.74c.23-.345.696-.436 1.04-.207.346.23.44.696.21 1.04z" />
|
||||
</g>
|
||||
</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>) => {
|
||||
return (
|
||||
<div className={cn("rounded-md bg-primary/10", 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-lg 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 }: { tweet: EnrichedTweet }) => (
|
||||
<div className="flex flex-row justify-between tracking-tight">
|
||||
<div className="flex items-center space-x-2">
|
||||
<a href={tweet.user.url} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
title={`Profile picture of ${tweet.user.name}`}
|
||||
alt={tweet.user.screen_name}
|
||||
height={48}
|
||||
width={48}
|
||||
src={tweet.user.profile_image_url_https}
|
||||
className="overflow-hidden rounded-full border border-transparent"
|
||||
/>
|
||||
</a>
|
||||
<div>
|
||||
<a
|
||||
href={tweet.user.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center whitespace-nowrap font-semibold"
|
||||
>
|
||||
{truncate(tweet.user.name, 20)}
|
||||
{tweet.user.verified ||
|
||||
(tweet.user.is_blue_verified && (
|
||||
<Verified className="ml-1 inline size-4 text-blue-500" />
|
||||
))}
|
||||
</a>
|
||||
<div className="flex items-center space-x-1">
|
||||
<a
|
||||
href={tweet.user.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-gray-500 transition-all duration-75"
|
||||
>
|
||||
@{truncate(tweet.user.screen_name, 16)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href={tweet.url} target="_blank" rel="noreferrer">
|
||||
<span className="sr-only">Link to tweet</span>
|
||||
<Twitter className="size-5 items-start text-[#3BA9EE] transition-all ease-in-out hover:scale-105" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const TweetBody = ({ tweet }: { tweet: EnrichedTweet }) => (
|
||||
<div className="break-words leading-normal tracking-tighter">
|
||||
{tweet.entities.map((entity, idx) => {
|
||||
switch (entity.type) {
|
||||
case "url":
|
||||
case "symbol":
|
||||
case "hashtag":
|
||||
case "mention":
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={entity.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-normal text-gray-500"
|
||||
>
|
||||
<span>{entity.text}</span>
|
||||
</a>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-sm font-normal"
|
||||
dangerouslySetInnerHTML={{ __html: entity.text }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const TweetMedia = ({ tweet }: { tweet: EnrichedTweet }) => {
|
||||
if (!tweet.video && !tweet.photos) return null;
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
{tweet.video && (
|
||||
<video
|
||||
poster={tweet.video.poster}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="rounded-xl border shadow-sm"
|
||||
>
|
||||
<source src={tweet.video.variants[0].src} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)}
|
||||
{tweet.photos && (
|
||||
<div className="relative flex transform-gpu snap-x snap-mandatory gap-4 overflow-x-auto">
|
||||
<div className="shrink-0 snap-center sm:w-2" />
|
||||
{tweet.photos.map((photo) => (
|
||||
<img
|
||||
key={photo.url}
|
||||
src={photo.url}
|
||||
title={"Photo by " + tweet.user.name}
|
||||
alt={tweet.text}
|
||||
className="h-64 w-5/6 shrink-0 snap-center snap-always rounded-xl border object-cover shadow-sm"
|
||||
/>
|
||||
))}
|
||||
<div className="shrink-0 snap-center sm:w-2" />
|
||||
</div>
|
||||
)}
|
||||
{!tweet.video &&
|
||||
!tweet.photos &&
|
||||
// @ts-ignore
|
||||
tweet?.card?.binding_values?.thumbnail_image_large?.image_value.url && (
|
||||
<img
|
||||
src={
|
||||
// @ts-ignore
|
||||
tweet.card.binding_values.thumbnail_image_large.image_value.url
|
||||
}
|
||||
className="h-64 rounded-xl border object-cover shadow-sm"
|
||||
alt={tweet.text}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MagicTweet = ({
|
||||
tweet,
|
||||
components,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
tweet: Tweet;
|
||||
components?: TwitterComponents;
|
||||
className?: string;
|
||||
}) => {
|
||||
const enrichedTweet = enrichTweet(tweet);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex size-full max-w-lg flex-col gap-2 overflow-hidden rounded-lg border p-4 backdrop-blur-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<TweetHeader tweet={enrichedTweet} />
|
||||
<TweetBody tweet={enrichedTweet} />
|
||||
<TweetMedia tweet={enrichedTweet} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* TweetCard (Server Side Only)
|
||||
*/
|
||||
export const TweetCard = async ({
|
||||
id,
|
||||
components,
|
||||
fallback = <TweetSkeleton />,
|
||||
onError,
|
||||
...props
|
||||
}: TweetProps & {
|
||||
className?: string;
|
||||
}) => {
|
||||
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}>
|
||||
<MagicTweet tweet={tweet} {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user