Merge branch 'master' of github.com:goldflag/frogstats

This commit is contained in:
Bill Yang 2025-05-08 21:14:00 -07:00
commit 417849f42c
9 changed files with 391 additions and 62 deletions

View file

@ -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>
)}

View file

@ -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>
)}

View 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)}
/>
);
}

View file

@ -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>
);
}

View file

@ -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
View file

@ -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",

View file

@ -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"
},

View 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>
);
};

View file

@ -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 />