mirror of
https://github.com/rybbit-io/rybbit.git
synced 2025-05-10 20:05:38 +02:00
Merge branch 'master' of github.com:goldflag/frogstats
This commit is contained in:
commit
417849f42c
9 changed files with 391 additions and 62 deletions
|
@ -9,6 +9,7 @@ import {
|
|||
} from "../../../../components/ui/dropdown-menu";
|
||||
import { userStore } from "../../../../lib/userStore";
|
||||
import { resetStore, useStore } from "../../../../lib/store";
|
||||
import { Favicon } from "../../../../components/Favicon";
|
||||
|
||||
function SiteSelectorContent() {
|
||||
const { data: sites } = useGetSites();
|
||||
|
@ -33,11 +34,7 @@ function SiteSelectorContent() {
|
|||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
className="w-4 h-4"
|
||||
src={`https://www.google.com/s2/favicons?domain=${site.domain}&sz=64`}
|
||||
alt={site.domain}
|
||||
/>
|
||||
<Favicon domain={site.domain} className="w-4 h-4" />
|
||||
<span>{site.domain}</span>
|
||||
</div>
|
||||
{isSelected && <Check size={16} />}
|
||||
|
@ -60,11 +57,7 @@ export function SiteSelector() {
|
|||
<DropdownMenuTrigger unstyled>
|
||||
{site && (
|
||||
<div className="flex gap-2 border border-neutral-800 rounded-lg py-1.5 px-3 justify-start cursor-pointer hover:bg-neutral-800/50 transition-colors h-[36px]">
|
||||
<img
|
||||
className="w-5 h-5"
|
||||
src={`https://www.google.com/s2/favicons?domain=${site.domain}&sz=64`}
|
||||
alt={site.domain}
|
||||
/>
|
||||
<Favicon domain={site.domain} className="w-5 h-5" />
|
||||
<div className="text-white truncate text-sm">{site.domain}</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { Card, CardContent } from "../../../../../components/ui/card";
|
|||
import { StandardSection } from "../../../components/shared/StandardSection/StandardSection";
|
||||
import { Button } from "../../../../../components/ui/button";
|
||||
import { Expand } from "lucide-react";
|
||||
import { Favicon } from "../../../../../components/Favicon";
|
||||
|
||||
type Tab =
|
||||
| "referrers"
|
||||
|
@ -62,10 +63,7 @@ export function Referrers() {
|
|||
getLink={(e) => `https://${e.value}`}
|
||||
getLabel={(e) => (
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
className="w-4 mr-2"
|
||||
src={`https://www.google.com/s2/favicons?domain=${e.value}&sz=32`}
|
||||
/>
|
||||
<Favicon domain={e.value} className="w-4 mr-2" />
|
||||
{e.value ? e.value : "Direct"}
|
||||
</div>
|
||||
)}
|
||||
|
|
33
client/src/components/Favicon.tsx
Normal file
33
client/src/components/Favicon.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useState } from "react";
|
||||
|
||||
export function Favicon({
|
||||
domain,
|
||||
className,
|
||||
}: {
|
||||
domain: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const firstLetter = domain.charAt(0).toUpperCase();
|
||||
|
||||
if (imageError) {
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
className ?? "w-4 h-4"
|
||||
} bg-neutral-700 rounded-full flex items-center justify-center text-xs font-medium text-white`}
|
||||
>
|
||||
{firstLetter}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`https://icons.duckduckgo.com/ip3/${domain}.ico`}
|
||||
className={className ?? "w-4 h-4"}
|
||||
alt={`Favicon for ${domain}`}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -14,6 +14,7 @@ import {
|
|||
|
||||
import { Menu } from "lucide-react";
|
||||
import { VisuallyHidden } from "radix-ui";
|
||||
import { Favicon } from "./Favicon";
|
||||
|
||||
export function MobileSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
@ -36,13 +37,7 @@ export function MobileSidebar() {
|
|||
<Sidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{site && (
|
||||
<img
|
||||
className="w-6 h-6"
|
||||
src={`https://www.google.com/s2/favicons?domain=${site.domain}&sz=64`}
|
||||
alt={site.domain}
|
||||
/>
|
||||
)}
|
||||
{site && <Favicon domain={site.domain} className="w-6 h-6" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { SiteSessionChart } from "./SiteSessionChart";
|
|||
import { SiteSettings } from "./SiteSettings/SiteSettings";
|
||||
import { Button } from "./ui/button";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
import { Favicon } from "./Favicon";
|
||||
|
||||
interface SiteCardProps {
|
||||
siteId: number;
|
||||
|
@ -71,17 +72,7 @@ export function SiteCard({ siteId, domain }: SiteCardProps) {
|
|||
href={`/${siteId}`}
|
||||
className="group flex gap-3 items-center duration-200"
|
||||
>
|
||||
<img
|
||||
className="w-6 h-6"
|
||||
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=64`}
|
||||
alt={domain}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src =
|
||||
"https://placehold.co/48/374151/FFFFFF?text=" +
|
||||
domain.charAt(0).toUpperCase();
|
||||
}}
|
||||
/>
|
||||
<Favicon domain={domain} className="w-6 h-6" />
|
||||
<span className="text-lg font-medium truncate group-hover:underline transition-all">
|
||||
{domain}
|
||||
</span>
|
||||
|
|
40
docs/package-lock.json
generated
40
docs/package-lock.json
generated
|
@ -23,6 +23,7 @@
|
|||
"postcss": "^8.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-tweet": "^3.2.2",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.4"
|
||||
},
|
||||
|
@ -2148,7 +2149,7 @@
|
|||
"version": "19.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
|
||||
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
@ -2522,7 +2523,7 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/cytoscape": {
|
||||
"version": "3.31.2",
|
||||
|
@ -5903,6 +5904,20 @@
|
|||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-tweet": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-tweet/-/react-tweet-3.2.2.tgz",
|
||||
"integrity": "sha512-hIkxAVPpN2RqWoDEbo3TTnN/pDcp9/Jb6pTgiA4EbXa9S+m2vHIvvZKHR+eS0PDIsYqe+zTmANRa5k6+/iwGog==",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.3",
|
||||
"clsx": "^2.0.0",
|
||||
"swr": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reading-time": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz",
|
||||
|
@ -6638,6 +6653,18 @@
|
|||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
|
||||
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz",
|
||||
"integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/system-architecture": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz",
|
||||
|
@ -6757,7 +6784,6 @@
|
|||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
@ -6935,6 +6961,14 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
"postcss": "^8.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-tweet": "^3.2.2",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.4"
|
||||
},
|
||||
|
|
287
docs/src/app/components/Tweet.tsx
Normal file
287
docs/src/app/components/Tweet.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-normal">
|
||||
{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 bg-neutral-900",
|
||||
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>
|
||||
);
|
||||
};
|
|
@ -18,6 +18,7 @@ import { UserSessions } from "./components/Cards/UserSessions";
|
|||
import { Integrations } from "./components/integrations";
|
||||
import { Logo } from "./components/Logo";
|
||||
import { PricingSection } from "./components/PricingSection";
|
||||
import { TweetCard } from "./components/Tweet";
|
||||
|
||||
const tilt_wrap = Tilt_Warp({
|
||||
subsets: ["latin"],
|
||||
|
@ -120,41 +121,37 @@ export default function IndexPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<Integrations />
|
||||
|
||||
{/* Testimonial Section */}
|
||||
<section className="py-10 md:py-16 w-full">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="relative bg-neutral-800/20 backdrop-blur-sm border border-neutral-700 rounded-xl shadow-lg overflow-hidden">
|
||||
{/* Background glow effects - toned down */}
|
||||
<div className="absolute -right-40 -top-40 w-60 h-60 bg-emerald-500/10 rounded-full blur-3xl"></div>
|
||||
<div className="absolute -left-20 -bottom-20 w-40 h-40 bg-indigo-500/10 rounded-full blur-3xl"></div>
|
||||
|
||||
{/* Quote mark - smaller */}
|
||||
<div className="absolute top-4 left-4 md:top-6 md:left-6 text-6xl md:text-7xl leading-none font-serif text-emerald-600/25">
|
||||
"
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="text-center mb-10 md:mb-16">
|
||||
<div className="inline-block bg-emerald-900/30 text-emerald-400 px-3 py-1 rounded-full text-sm font-medium mb-4">
|
||||
User Testimonials
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-5xl font-bold tracking-tight">
|
||||
What People Are Saying
|
||||
</h2>
|
||||
<p className="mt-4 text-base md:text-xl text-neutral-300 max-w-2xl mx-auto">
|
||||
See what others think about Rybbit Analytics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Testimonial content */}
|
||||
<div className="relative z-10 p-6 md:p-10 text-center">
|
||||
<p className="text-lg md:text-2xl font-medium mb-6 text-white mx-auto max-w-2xl leading-relaxed">
|
||||
Rybbit has completely transformed how we understand our users.
|
||||
The real-time data is incredible, and I've finally ditched
|
||||
Google Analytics for something that respects privacy.
|
||||
</p>
|
||||
|
||||
<div className="inline-block relative">
|
||||
<div className="absolute inset-0 bg-emerald-500/10 blur-sm rounded-full"></div>
|
||||
<div className="relative bg-neutral-900/60 backdrop-blur-sm border border-neutral-700 rounded-full px-5 py-2">
|
||||
<p className="font-semibold text-white">Chris Weaver</p>
|
||||
<p className="text-sm text-neutral-400">CEO at Onyx AI</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 justify-center">
|
||||
<TweetCard id="1920470706761929048" />
|
||||
<TweetCard id="1920379817113088341" />
|
||||
<TweetCard id="1920318739335033226" />
|
||||
<TweetCard id="1920258312177594500" />
|
||||
<TweetCard id="1919793785384386576" />
|
||||
<TweetCard id="1920316582875496449" />
|
||||
<TweetCard id="1920425974954381456" />
|
||||
<TweetCard id="1919290867451404670" />
|
||||
<TweetCard id="1920192156960239683" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Integrations />
|
||||
|
||||
{/* Pricing Section */}
|
||||
<PricingSection />
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue