feat: new public website

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-12-02 09:17:49 +01:00
parent e2536774b0
commit ac4429d6d9
206 changed files with 18415 additions and 12433 deletions

View File

@@ -0,0 +1,43 @@
import Image from 'next/image';
import Link from 'next/link';
export function ArticleCard({
url,
title,
tag,
cover,
team,
date,
}: {
url: string;
title: string;
tag?: string;
cover: string;
team?: string;
date: Date;
}) {
return (
<Link
href={url}
key={url}
className="border rounded-lg overflow-hidden bg-background-light col hover:scale-105 transition-all duration-300 hover:shadow-lg hover:shadow-background-dark"
>
<Image
src={cover}
alt={title}
width={323}
height={181}
className="w-full"
/>
<span className="p-4 col flex-1">
{tag && <span className="font-mono text-xs mb-2">{tag}</span>}
<span className="flex-1 mb-6">
<h2 className="text-xl font-semibold">{title}</h2>
</span>
<p className="text-sm text-muted-foreground">
{[team, date.toLocaleDateString()].filter(Boolean).join(' · ')}
</p>
</span>
</Link>
);
}

View File

@@ -0,0 +1,28 @@
'use client';
import {
BatteryFullIcon,
BatteryLowIcon,
BatteryMediumIcon,
type LucideProps,
} from 'lucide-react';
import { useEffect, useState } from 'react';
export function BatteryIcon(props: LucideProps) {
const [index, setIndex] = useState(0);
const icons = [BatteryLowIcon, BatteryMediumIcon, BatteryFullIcon];
const Icon = icons[index];
useEffect(() => {
const interval = setInterval(() => {
setIndex((index + 1) % icons.length);
}, 750);
return () => clearInterval(interval);
}, [index]);
if (!Icon) {
return <div className={props.className} />;
}
return <Icon {...props} />;
}

View File

@@ -0,0 +1,7 @@
##### Common options
- `apiUrl` - The url of the openpanel API or your self-hosted instance
- `clientId` - The client id of your application
- `clientSecret` - The client secret of your application (**only required for server-side events**)
- `filter` - A function that will be called before sending an event. If it returns false, the event will not be sent
- `disabled` - If true, the library will not send any events

View File

@@ -0,0 +1,80 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useState } from 'react';
const brandConfig = {
Mixpanel: '#7A59FF',
'Google Analytics': '#E37400',
Amplitude: '#00CF98',
} as const;
const words = Object.keys(brandConfig);
function useWordCycle(words: string[], interval: number, mounted: boolean) {
const [index, setIndex] = useState(0);
const [isInitial, setIsInitial] = useState(true);
useEffect(() => {
if (!mounted) {
return;
}
if (isInitial) {
setIndex(Math.floor(Math.random() * words.length));
setIsInitial(false);
return;
}
const timer = setInterval(() => {
setIndex((current) => (current + 1) % words.length);
}, interval);
return () => clearInterval(timer);
}, [words, interval, isInitial, mounted]);
return words[index];
}
export function Competition() {
const [mounted, setMounted] = useState(false);
const word = useWordCycle(words, 2100, mounted);
const color = brandConfig[word as keyof typeof brandConfig];
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<span className="block truncate leading-tight -mt-1" style={{ color }}>
{word}
</span>
);
}
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={word}
className="block truncate leading-tight -mt-1"
style={{ color }}
>
{word?.split('').map((char, index) => (
<motion.span
key={`${word}-${char}-${index.toString()}`}
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -10, opacity: 0 }}
transition={{
duration: 0.15,
delay: index * 0.015,
ease: 'easeOut',
}}
style={{ display: 'inline-block', whiteSpace: 'pre' }}
>
{char}
</motion.span>
))}
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,12 @@
import { Callout } from 'fumadocs-ui/components/callout';
import Link from 'next/link';
export function DeviceIdWarning() {
return (
<Callout>
Read more about{' '}
<Link href="/docs/device-id">device id and why you might want it</Link>.
**We recommend not to but it&apos;s up to you.**
</Callout>
);
}

View File

@@ -0,0 +1,47 @@
import Script from 'next/script';
import Markdown from 'react-markdown';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from './ui/accordion';
export const Faqs = ({ children }: { children: React.ReactNode }) => (
<Accordion
type="single"
collapsible
className="w-full max-w-screen-md self-center border rounded-3xl [&_button]:px-4 bg-background-dark [&_div.answer]:bg-background-light"
>
{children}
</Accordion>
);
export const FaqItem = ({
question,
children,
}: { question: string; children: string | React.ReactNode }) => (
<AccordionItem
value={question}
itemScope
itemProp="mainEntity"
itemType="https://schema.org/Question"
className="[&_[role=region]]:px-4"
>
<AccordionTrigger className="text-left" itemProp="name">
{question}
</AccordionTrigger>
<AccordionContent
itemProp="acceptedAnswer"
itemScope
itemType="https://schema.org/Answer"
className="prose"
>
{typeof children === 'string' ? (
<Markdown>{children}</Markdown>
) : (
children
)}
</AccordionContent>
</AccordionItem>
);

View File

@@ -0,0 +1,76 @@
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
interface FeatureCardProps {
illustration?: React.ReactNode;
title: string;
description: string;
icon?: LucideIcon;
children?: React.ReactNode;
className?: string;
variant?: 'default' | 'large';
}
interface FeatureCardContainerProps {
children: React.ReactNode;
className?: string;
}
export const FeatureCardBackground = () => (
<div
className={cn(
'pointer-events-none absolute inset-0 bg-linear-to-br opacity-0 blur-2xl transition-opacity duration-300 group-hover:opacity-100',
'dark:from-blue-500/10 dark:via-transparent dark:to-emerald-500/5',
'light:from-blue-800/20 light:via-transparent light:to-emerald-900/10',
)}
/>
);
export function FeatureCardContainer({
children,
className,
}: FeatureCardContainerProps) {
return (
<div
className={cn(
'col gap-8 p-6 rounded-3xl border bg-background group relative overflow-hidden',
className,
)}
>
<FeatureCardBackground />
{children}
</div>
);
}
export function FeatureCard({
illustration,
title,
description,
icon: Icon,
children,
className,
}: FeatureCardProps) {
if (illustration) {
return (
<FeatureCardContainer className={className}>
{illustration}
<div className="col gap-2" data-content>
<h3 className="text-xl font-semibold">{title}</h3>
<p className="text-muted-foreground">{description}</p>
</div>
{children}
</FeatureCardContainer>
);
}
return (
<FeatureCardContainer className={className}>
{Icon && <Icon className="size-6" />}
<div className="col gap-2">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</FeatureCardContainer>
);
}

View File

@@ -0,0 +1,24 @@
import { cn } from '@/lib/utils';
import Image from 'next/image';
export function Figure({
src,
alt,
caption,
className,
}: { src: string; alt: string; caption: string; className?: string }) {
return (
<figure className={cn('-mx-4', className)}>
<Image
src={src}
alt={alt || caption}
width={1200}
height={800}
className="rounded-lg"
/>
<figcaption className="text-center text-sm text-muted-foreground mt-2">
{caption}
</figcaption>
</figure>
);
}

View File

@@ -0,0 +1,79 @@
import { CheckCircle, CreditCard, Globe, Server, User } from 'lucide-react';
import type { ReactNode } from 'react';
interface FlowStepProps {
step: number;
actor: string;
description: string;
children?: ReactNode;
icon?: 'visitor' | 'website' | 'backend' | 'payment' | 'success';
isLast?: boolean;
}
const iconMap = {
visitor: User,
website: Globe,
backend: Server,
payment: CreditCard,
success: CheckCircle,
};
const iconColorMap = {
visitor: 'text-blue-500',
website: 'text-green-500',
backend: 'text-purple-500',
payment: 'text-yellow-500',
success: 'text-green-600',
};
const iconBorderColorMap = {
visitor: 'border-blue-500',
website: 'border-green-500',
backend: 'border-purple-500',
payment: 'border-yellow-500',
success: 'border-green-600',
};
export function FlowStep({
step,
actor,
description,
children,
icon = 'visitor',
isLast = false,
}: FlowStepProps) {
const Icon = iconMap[icon];
return (
<div className="relative flex gap-4 mb-4 min-w-0">
{/* Step number and icon */}
<div className="flex flex-col items-center shrink-0">
<div className="relative z-10 bg-background">
<div className="flex items-center justify-center size-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm shadow-sm">
{step}
</div>
<div
className={`absolute -bottom-2 -right-2 flex items-center justify-center w-6 h-6 rounded-full bg-background border shadow-sm ${iconBorderColorMap[icon] || 'border-primary'}`}
>
<Icon
className={`size-3.5 ${iconColorMap[icon] || 'text-primary'}`}
/>
</div>
</div>
{/* Connector line - extends from badge through content to next step */}
{!isLast && (
<div className="w-0.5 bg-border mt-2 flex-1 min-h-[2rem]" />
)}
</div>
{/* Content */}
<div className="flex-1 pt-1 min-w-0">
<div className="mb-2">
<span className="font-semibold text-foreground mr-2">{actor}:</span>{' '}
<span className="text-muted-foreground">{description}</span>
</div>
{children && <div className="mt-3 min-w-0">{children}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,175 @@
import { baseOptions } from '@/lib/layout.shared';
import { articleSource, compareSource } from '@/lib/source';
import { MailIcon } from 'lucide-react';
import Link from 'next/link';
import { Logo } from './logo';
export async function Footer() {
const articles = (await articleSource.getPages()).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
);
const year = new Date().getFullYear();
return (
<div>
<footer className="text-sm relative overflow-hidden pt-32">
<div className="absolute -bottom-20 md:-bottom-32 left-0 right-0 center-center opacity-5 pointer-events-none">
<div className="absolute inset-0 bg-linear-to-b from-background to-transparent" />
<Logo className="w-[900px] shrink-0" />
</div>
<div className="container grid grid-cols-1 md:grid-cols-4 gap-12 md:gap-8 relative">
<div className="col gap-3">
<h3 className="font-medium">Useful links</h3>
<Links
data={[
{ title: 'About', url: '/about' },
{ title: 'Contact', url: '/contact' },
{ title: 'Become a supporter', url: '/supporter' },
]}
/>
</div>
<div className="col gap-3">
<h3 className="font-medium">Resources</h3>
<Links
data={[
{ title: 'Pricing', url: '/pricing' },
{ title: 'Documentation', url: '/docs' },
{ title: 'SDKs', url: '/docs/sdks' },
{ title: 'Articles', url: '/articles' },
]}
/>
</div>
<div className="col gap-3">
<h3 className="font-medium">Compare</h3>
<Links
data={compareSource.map((item) => ({
url: item.url,
title: item?.hero?.heading,
}))}
/>
</div>
<div className="col gap-3">
<h3 className="font-medium">Latest articles</h3>
<Links
data={articles.slice(0, 10).map((article) => ({
title: article.data.title,
url: article.url,
}))}
/>
</div>
</div>
<div className="col text-muted-foreground border-t pt-8 mt-16 gap-8 relative bg-background/70 pb-32">
<div className="container col md:row justify-between gap-8">
<div>
<Link href="/" className="row items-center font-medium -ml-3">
<Logo className="h-6" />
{baseOptions().nav?.title}
</Link>
</div>
<Social />
</div>
<div className="container flex flex-col-reverse md:row justify-between gap-8">
<div>Copyright © {year} OpenPanel. All rights reserved.</div>
<div className="col lg:row gap-2 md:gap-4">
<Link href="/sitemap.xml">Sitemap</Link>
<Link href="/privacy">Privacy Policy</Link>
<Link href="/terms">Terms of Service</Link>
<Link href="/cookies">Cookie Policy (just kidding)</Link>
</div>
</div>
</div>
</footer>
</div>
);
}
function Links({ data }: { data: { title: string; url: string }[] }) {
return (
<ul className="gap-2 col text-muted-foreground">
{data.map((item) => (
<li key={item.url} className="truncate">
<Link
href={item.url}
className="hover:text-foreground transition-colors"
title={item.title}
>
{item.title}
</Link>
</li>
))}
</ul>
);
}
function Social() {
return (
<div className="md:items-end col gap-4">
<div className="[&_svg]:size-6 row gap-4">
<Link
title="Go to GitHub"
href="https://github.com/Openpanel-dev/openpanel"
rel="noreferrer noopener nofollow"
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="fill-current"
>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
</Link>
<Link
title="Go to X"
href="https://x.com/openpaneldev"
rel="noreferrer noopener nofollow"
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="fill-current"
>
<title>X</title>
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H3.298Z" />
</svg>
</Link>
<Link
title="Join Discord"
href="https://go.openpanel.dev/discord"
rel="noreferrer noopener nofollow"
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="fill-current"
>
<title>Discord</title>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
</Link>
<Link
title="Send an email"
href="mailto:hello@openpanel.dev"
rel="noreferrer noopener nofollow"
>
<MailIcon className="size-6" />
</Link>
<a
target="_blank"
href="https://status.openpanel.dev"
className="row gap-2 items-center border rounded-full px-2 py-1 max-md:self-start max-md:ml-auto"
rel="noreferrer noopener nofollow"
>
<span>Operational</span>
<div className="size-2 bg-emerald-500 rounded-full" />
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { cn } from '@/lib/utils';
import { ChevronRightIcon } from 'lucide-react';
import Link from 'next/link';
import { Button } from './ui/button';
export function GetStartedButton({
text,
href = 'https://dashboard.openpanel.dev/onboarding',
className,
}: {
text?: React.ReactNode;
className?: string;
href?: string;
}) {
return (
<Button size="lg" asChild className={cn('group', className)}>
<Link href={href}>
{text ?? 'Get started now'}
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
</Link>
</Button>
);
}

View File

@@ -0,0 +1,42 @@
import { getGithubRepoInfo } from '@/lib/github';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { Button } from './ui/button';
function formatStars(stars: number) {
if (stars >= 1000) {
const k = stars / 1000;
return `${k.toFixed(k >= 10 ? 0 : 1)}k`;
}
return stars.toString();
}
export function GithubButton() {
const [stars, setStars] = useState(4_800);
useEffect(() => {
getGithubRepoInfo().then((res) => {
if (res?.stargazers_count) {
setStars(res.stargazers_count);
}
});
}, []);
return (
<Button variant={'secondary'} asChild>
<Link href="https://git.new/openpanel" className="hidden md:flex">
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
{formatStars(stars)} stars
</Link>
</Button>
);
}

View File

@@ -0,0 +1,99 @@
// Thank you: https://ui.aceternity.com/components/infinite-moving-cards
import { cn } from '@/lib/utils';
import React, { useEffect, useState } from 'react';
export const InfiniteMovingCards = <T,>({
items,
direction = 'left',
speed = 'fast',
pauseOnHover = true,
className,
renderItem,
}: {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
direction?: 'left' | 'right';
speed?: 'fast' | 'normal' | 'slow';
pauseOnHover?: boolean;
className?: string;
}) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const scrollerRef = React.useRef<HTMLUListElement>(null);
useEffect(() => {
addAnimation();
}, []);
const [start, setStart] = useState(false);
function addAnimation() {
if (containerRef.current && scrollerRef.current) {
const scrollerContent = Array.from(scrollerRef.current.children);
scrollerContent.forEach((item) => {
const duplicatedItem = item.cloneNode(true);
if (scrollerRef.current) {
scrollerRef.current.appendChild(duplicatedItem);
}
});
getDirection();
getSpeed();
setStart(true);
}
}
const getDirection = () => {
if (containerRef.current) {
if (direction === 'left') {
containerRef.current.style.setProperty(
'--animation-direction',
'forwards',
);
} else {
containerRef.current.style.setProperty(
'--animation-direction',
'reverse',
);
}
}
};
const getSpeed = () => {
if (containerRef.current) {
if (speed === 'fast') {
containerRef.current.style.setProperty('--animation-duration', '20s');
} else if (speed === 'normal') {
containerRef.current.style.setProperty('--animation-duration', '40s');
} else {
containerRef.current.style.setProperty('--animation-duration', '80s');
}
}
};
return (
<div
ref={containerRef}
className={cn(
'scroller relative z-20 overflow-hidden -ml-4 md:-ml-[1200px] w-screen md:w-[calc(100vw+1400px)]',
className,
)}
>
<ul
ref={scrollerRef}
className={cn(
'flex min-w-full shrink-0 gap-8 py-4 w-max flex-nowrap items-start',
start && 'animate-scroll',
pauseOnHover && 'hover:[animation-play-state:paused]',
)}
>
{items.map((item, idx) => (
<li
className="w-[310px] max-w-full relative shrink-0 md:w-[400px]"
key={idx.toString()}
>
{renderItem(item, idx)}
</li>
))}
</ul>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import { cn } from '@/lib/utils';
export function Logo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 61 35"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={cn('text-black dark:text-white w-16', className)}
>
<rect
x="34.0269"
y="0.368164"
width="10.3474"
height="34.2258"
rx="5.17372"
/>
<rect
x="49.9458"
y="0.368164"
width="10.3474"
height="17.5109"
rx="5.17372"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.212 0C6.36293 0 0 6.36293 0 14.212V20.02C0 27.8691 6.36293 34.232 14.212 34.232C22.0611 34.232 28.424 27.8691 28.424 20.02V14.212C28.424 6.36293 22.0611 0 14.212 0ZM14.2379 8.35999C11.3805 8.35999 9.06419 10.6763 9.06419 13.5337V20.6971C9.06419 23.5545 11.3805 25.8708 14.2379 25.8708C17.0953 25.8708 19.4116 23.5545 19.4116 20.6971V13.5337C19.4116 10.6763 17.0953 8.35999 14.2379 8.35999Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,216 @@
'use client';
import { baseOptions } from '@/lib/layout.shared';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
import { MenuIcon, MoonIcon, SunIcon, XIcon } from 'lucide-react';
import { useTheme } from 'next-themes';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { FeatureCardContainer } from './feature-card';
import { GithubButton } from './github-button';
import { Logo } from './logo';
import { Button } from './ui/button';
const LINKS = [
{
text: 'Home',
url: '/',
},
{
text: 'Pricing',
url: '/pricing',
},
{
text: 'Supporter',
url: '/supporter',
},
{
text: 'Docs',
url: '/docs',
},
{
text: 'Articles',
url: '/articles',
},
];
const Navbar = () => {
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const navbarRef = useRef<HTMLDivElement>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
// If click outside of the menu, close it
const handleClick = (e: MouseEvent) => {
if (isMobileMenuOpen && !navbarRef.current?.contains(e.target as Node)) {
setIsMobileMenuOpen(false);
}
};
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, [isMobileMenuOpen]);
return (
<nav
className={cn(
'fixed top-0 z-50 w-full py-4 border-b transition-colors duration-500',
isScrolled
? 'bg-background border-border'
: 'bg-background/0 border-border/0',
)}
ref={navbarRef}
>
<div className="container">
<div className={cn('flex justify-between items-center')}>
{/* Logo */}
<div className="shrink-0">
<Link href="/" className="row items-center font-medium">
<Logo className="h-6" />
<span className="hidden [@media(min-width:850px)]:block">
{baseOptions().nav?.title}
</span>
</Link>
</div>
<div className="row items-center gap-8">
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-8 text-sm">
{LINKS?.map((link) => {
return (
<Link
key={link.url}
href={link.url!}
className="text-foreground/80 hover:text-foreground font-medium"
>
{link.text}
</Link>
);
})}
</div>
{/* Right side buttons */}
<div className="flex items-center gap-2">
<GithubButton />
{/* Sign in button */}
<Button asChild>
<Link
className="hidden md:flex"
href="https://dashboard.openpanel.dev/onboarding"
>
Sign up
</Link>
</Button>
<ThemeToggle />
<Button
className="md:hidden -my-2"
size="icon"
variant="ghost"
onClick={() => {
setIsMobileMenuOpen((p) => !p);
}}
>
<MenuIcon className="w-4 h-4" />
</Button>
</div>
</div>
</div>
{/* Mobile menu */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
ref={mobileMenuRef}
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden bg-background/20 md:hidden backdrop-blur-lg fixed inset-0 p-4"
onClick={(e) => {
const target = e.target as HTMLElement;
if (target === mobileMenuRef.current) {
setIsMobileMenuOpen(false);
}
}}
>
<FeatureCardContainer className="col text-sm divide-y divide-foreground/10 gap-0 mt-12 py-4">
{LINKS?.map((link) => {
return (
<Link
key={link.url}
href={link.url!}
className="text-foreground/80 hover:text-foreground text-lg font-semibold p-4 px-0"
onClick={() => setIsMobileMenuOpen(false)}
>
{link.text}
</Link>
);
})}
</FeatureCardContainer>
<div className="row gap-2 absolute top-3 right-4 items-center">
<ThemeToggle className="flex!" />
<Button
size="icon"
variant="outline"
onClick={() => setIsMobileMenuOpen(false)}
>
<XIcon className="size-4" />
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</nav>
);
};
export default Navbar;
function ThemeToggle({ className }: { className?: string }) {
const theme = useTheme();
return (
<Button
size="icon"
variant="naked"
onClick={() => theme.setTheme(theme.theme === 'dark' ? 'light' : 'dark')}
className={cn(
'relative overflow-hidden size-8 cursor-pointer hidden md:inline',
className,
)}
suppressHydrationWarning
>
<AnimatePresence mode="wait" initial={false}>
{theme.theme === 'dark' ||
(!theme.theme && theme.resolvedTheme === 'dark') ? (
<motion.div
key="moon"
initial={{ rotate: -90, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
exit={{ rotate: 90, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<MoonIcon className="size-4" suppressHydrationWarning />
</motion.div>
) : (
<motion.div
key="sun"
initial={{ rotate: 90, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
exit={{ rotate: -90, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<SunIcon className="size-4" suppressHydrationWarning />
</motion.div>
)}
</AnimatePresence>
<span className="sr-only">Toggle theme</span>
</Button>
);
}

View File

@@ -0,0 +1,18 @@
import { cn } from '@/lib/utils';
import type { LucideIcon } from 'lucide-react';
export function Perks({
perks,
className,
}: { perks: { text: string; icon: LucideIcon }[]; className?: string }) {
return (
<ul className={cn('grid grid-cols-2 gap-2', className)}>
{perks.map((perk) => (
<li key={perk.text} className="text-sm text-muted-foreground">
<perk.icon className="size-4 inline-block mr-2 relative -top-px" />
{perk.text}
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,10 @@
import { Callout } from 'fumadocs-ui/components/callout';
export function PersonalDataWarning() {
return (
<Callout>
Keep in mind that this is considered personal data. Make sure you have the
users consent before calling this!
</Callout>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import NumberFlow from '@number-flow/react';
import { cn } from '@/lib/utils';
import { PRICING } from '@openpanel/payments/prices';
import { useState } from 'react';
import { Slider } from './ui/slider';
export function PricingSlider() {
const [index, setIndex] = useState(2);
const match = PRICING[index];
const formatNumber = (value: number) => value.toLocaleString();
return (
<>
<Slider
value={[index]}
max={PRICING.length}
step={1}
tooltip={
match
? `${formatNumber(match.events)} events per month`
: `More than ${formatNumber(PRICING[PRICING.length - 1].events)} events`
}
onValueChange={(value) => setIndex(value[0])}
/>
{match ? (
<div>
<div>
<NumberFlow
className="text-5xl"
value={match.price}
format={{
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}}
locales={'en-US'}
/>
<span className="text-sm text-muted-foreground ml-2">/ month</span>
</div>
<span
className={cn(
'text-sm text-muted-foreground italic opacity-100',
match.price === 0 && 'opacity-0',
)}
>
+ VAT if applicable
</span>
</div>
) : (
<div className="text-lg">
Contact us at{' '}
<a className="underline" href="mailto:hello@openpanel.dev">
hello@openpanel.dev
</a>{' '}
to get a custom quote.
</div>
)}
</>
);
}

View File

@@ -0,0 +1,81 @@
import { cn } from '@/lib/utils';
export function Section({
children,
className,
id,
...props
}: {
children: React.ReactNode;
className?: string;
id?: string;
}) {
return (
<section id={id} className={cn('my-32 col', className)} {...props}>
{children}
</section>
);
}
const variants = {
default: 'text-3xl md:text-5xl font-semibold',
sm: 'text-3xl md:text-4xl font-semibold',
};
export function SectionHeader({
label,
title,
description,
className,
align,
as = 'h2',
variant = 'default',
}: {
label?: string;
title: string | React.ReactNode;
description?: string;
className?: string;
align?: 'center' | 'left';
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
variant?: keyof typeof variants;
}) {
const Heading = as;
return (
<div
className={cn(
'col gap-4',
align === 'center'
? 'center-center text-center'
: 'items-start text-left',
className,
)}
>
{label && <SectionLabel>{label}</SectionLabel>}
<Heading className={cn(variants[variant], 'max-w-3xl leading-tight')}>
{title}
</Heading>
{description && (
<p className={cn('text-muted-foreground max-w-3xl')}>{description}</p>
)}
</div>
);
}
export function SectionLabel({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<span
className={cn(
'text-xs uppercase tracking-wider text-muted-foreground font-medium',
className,
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,70 @@
import { useMemo } from 'react';
interface SimpleChartProps {
width?: number;
height?: number;
points?: number[];
strokeWidth?: number;
strokeColor?: string;
className?: string;
}
export function SimpleChart({
width = 300,
height = 100,
points = [0, 10, 5, 8, 12, 4, 7],
strokeWidth = 2,
strokeColor = '#2563eb',
className,
}: SimpleChartProps) {
// Skip if no points
if (!points.length) return null;
// Calculate scaling factors
const maxValue = Math.max(...points);
const xStep = width / (points.length - 1);
const yScale = height / maxValue;
// Generate path commands
const pathCommands = points
.map((point, index) => {
const x = index * xStep;
const y = height - point * yScale;
return `${index === 0 ? 'M' : 'L'} ${x},${y}`;
})
.join(' ');
// Create area path by adding bottom corners
const areaPath = `${pathCommands} L ${width},${height} L 0,${height} Z`;
// Generate unique gradient ID
const gradientId = `gradient-${strokeColor
.replace('#', '')
.replaceAll('(', '')
.replaceAll(')', '')}`;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className={`w-full ${className ?? ''}`}
>
<defs>
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0" />
</linearGradient>
</defs>
{/* Area fill */}
{/* <path d={areaPath} fill={`url(#${gradientId})`} /> */}
{/* Stroke line */}
<path
d={pathCommands}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
/>
</svg>
);
}

View File

@@ -0,0 +1,30 @@
import { cn } from '@/lib/utils';
import { type VariantProps, cva } from 'class-variance-authority';
const tagVariants = cva(
'shadow-sm px-4 gap-2 center-center border self-auto text-xs rounded-full h-7',
{
variants: {
variant: {
light:
'bg-background-light dark:bg-background-dark text-muted-foreground',
dark: 'bg-foreground-light dark:bg-foreground-dark text-muted border-background/10 shadow-background/5',
},
},
defaultVariants: {
variant: 'light',
},
},
);
interface TagProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof tagVariants> {}
export function Tag({ children, className, variant, ...props }: TagProps) {
return (
<span className={cn(tagVariants({ variant, className }))} {...props}>
{children}
</span>
);
}

View File

@@ -0,0 +1,34 @@
import type { TableOfContents } from 'fumadocs-core/toc';
import { ArrowRightIcon } from 'lucide-react';
import Link from 'next/link';
import { FeatureCardContainer } from './feature-card';
interface Props {
toc: TableOfContents;
}
export const Toc: React.FC<Props> = ({ toc }) => {
return (
<FeatureCardContainer className="gap-2">
<span className="text-lg font-semibold">Table of contents</span>
<ul>
{toc.map((item) => (
<li
key={item.url}
className="py-1"
style={{ marginLeft: `${(item.depth - 2) * (4 * 4)}px` }}
>
<Link
href={item.url}
className="hover:underline row gap-2 items-center group/toc-item"
title={item.title?.toString() ?? ''}
>
<ArrowRightIcon className="shrink-0 w-4 h-4 opacity-30 group-hover/toc-item:opacity-100 transition-opacity" />
<span className="truncate text-sm">{item.title}</span>
</Link>
</li>
))}
</ul>
</FeatureCardContainer>
);
};

View File

@@ -0,0 +1,92 @@
import {
BadgeIcon,
CheckCheckIcon,
CheckIcon,
HeartIcon,
MessageCircleIcon,
RefreshCwIcon,
} from 'lucide-react';
import Image from 'next/image';
interface TwitterCardProps {
avatarUrl?: string;
name: string;
handle: string;
content: React.ReactNode;
replies?: number;
retweets?: number;
likes?: number;
verified?: boolean;
}
export function TwitterCard({
avatarUrl,
name,
handle,
content,
replies = 0,
retweets = 0,
likes = 0,
verified = false,
}: TwitterCardProps) {
const renderContent = () => {
if (typeof content === 'string') {
return <p className="text-sm">{content}</p>;
}
if (Array.isArray(content) && typeof content[0] === 'string') {
return content.map((line) => (
<p key={line} className="text-sm">
{line}
</p>
));
}
return <div className="text-sm">{content}</div>;
};
return (
<div className="border rounded-3xl p-8 col gap-4 bg-background-light">
<div className="row gap-4">
<div className="size-12 rounded-full bg-muted overflow-hidden shrink-0">
{avatarUrl && (
<Image src={avatarUrl} alt={name} width={48} height={48} />
)}
</div>
<div className="col gap-4">
<div className="col gap-2">
<div className="">
<span className="font-medium">{name}</span>
{verified && (
<div className="relative inline-block top-0.5 ml-1">
<BadgeIcon className="size-4 fill-[#1D9BF0] text-[#1D9BF0]" />
<div className="absolute inset-0 center-center">
<CheckIcon className="size-2 text-white" strokeWidth={3} />
</div>
</div>
)}
</div>
<span className="text-muted-foreground text-sm leading-0">
@{handle}
</span>
</div>
{renderContent()}
<div className="row gap-4 text-muted-foreground text-sm">
<div className="row gap-2">
<MessageCircleIcon className="transition-all size-4 fill-background hover:fill-blue-500 hover:text-blue-500" />
{/* <span>{replies}</span> */}
</div>
<div className="row gap-2">
<RefreshCwIcon className="transition-all size-4 fill-background hover:text-blue-500" />
{/* <span>{retweets}</span> */}
</div>
<div className="row gap-2">
<HeartIcon className="transition-all size-4 fill-background hover:fill-rose-500 hover:text-rose-500" />
{/* <span>{likes}</span> */}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
import { FeatureCardBackground } from '../feature-card';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Item>>;
}) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b last:border-b-0', className)}
{...props}
/>
);
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Trigger>>;
}) => (
<AccordionPrimitive.Header className="flex not-prose">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'group relative overflow-hidden flex flex-1 items-center justify-between py-4 font-medium transition-all [&[data-state=open]>svg]:rotate-180 cursor-pointer',
className,
)}
{...props}
>
<FeatureCardBackground />
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> & {
ref?: React.RefObject<React.ElementRef<typeof AccordionPrimitive.Content>>;
}) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down text-muted-foreground"
{...props}
>
<div
className={cn(
'pb-4 pt-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
className,
)}
>
{children}
</div>
</AccordionPrimitive.Content>
);
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,65 @@
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps, cva } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'hover:-translate-y-px inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-foreground text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
naked:
'bg-transparent hover:bg-transparent ring-0 border-none !px-0 !py-0 shadow-none',
},
size: {
default: 'h-8 px-4',
sm: 'h-6 px-2',
lg: 'h-12 px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = ({
ref,
className,
variant,
size,
asChild = false,
...props
}: ButtonProps & {
ref?: React.RefObject<HTMLButtonElement>;
}) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
};
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,69 @@
import * as SliderPrimitive from '@radix-ui/react-slider';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(false);
React.useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
}, [query]);
return matches;
}
const Slider = ({
ref,
className,
tooltip,
...props
}: {
ref?: any;
className?: string;
tooltip?: string;
value: number[];
max: number;
step: number;
onValueChange: (value: number[]) => void;
}) => {
const isMobile = useMediaQuery('(max-width: 768px)');
return (
<>
{isMobile && (
<div className="text-sm text-muted-foreground mb-4">{tooltip}</div>
)}
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-white/10">
<SliderPrimitive.Range className="absolute h-full bg-white/90" />
</SliderPrimitive.Track>
{tooltip && !isMobile ? (
<Tooltip open disableHoverableContent>
<TooltipTrigger asChild>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-white bg-black ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</TooltipTrigger>
<TooltipContent
side="top"
sideOffset={10}
className="rounded-full bg-black text-white/70 py-1 text-xs border-white/30"
>
{tooltip}
</TooltipContent>
</Tooltip>
) : (
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-white bg-black ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
)}
</SliderPrimitive.Root>
</>
);
};
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,51 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipPortal = TooltipPrimitive.Portal;
const TooltipContent = ({
ref,
className,
sideOffset = 4,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
ref?: React.RefObject<React.ElementRef<typeof TooltipPrimitive.Content>>;
}) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
);
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
export const Tooltiper = ({
children,
content,
delayDuration = 0,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
delayDuration?: number;
}) => (
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipPortal>
<TooltipContent {...props}>{content}</TooltipContent>
</TooltipPortal>
</Tooltip>
);

View File

@@ -0,0 +1,6 @@
##### Web options
- `trackScreenViews` - If true, the library will automatically track screen views (default: false)
- `trackOutgoingLinks` - If true, the library will automatically track outgoing links (default: false)
- `trackAttributes` - If true, you can trigger events by using html attributes (`<button type="button" data-track="your_event" />`) (default: false)

View File

@@ -0,0 +1,73 @@
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { FeatureCardContainer } from './feature-card';
interface WindowImageProps {
src?: string;
srcDark?: string;
srcLight?: string;
alt: string;
className?: string;
caption?: string;
}
export function WindowImage({
src,
srcDark,
srcLight,
alt,
caption,
className,
}: WindowImageProps) {
// If src is provided, use it for both (backward compatibility)
// Otherwise, use srcDark and srcLight
const darkSrc = srcDark || src;
const lightSrc = srcLight || src;
if (!darkSrc || !lightSrc) {
throw new Error(
'WindowImage requires either src or both srcDark and srcLight',
);
}
return (
<FeatureCardContainer
className={cn([
'overflow-hidden rounded-lg border border-border bg-background shadow-lg/5 relative z-10 [@media(min-width:1100px)]:-mx-16 p-4 md:p-16',
className,
])}
>
<div className="rounded-lg overflow-hidden p-2 bg-card/80 border col gap-2 relative">
{/* Window controls */}
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="size-2 rounded-full bg-red-500" />
<div className="size-2 rounded-full bg-yellow-500" />
<div className="size-2 rounded-full bg-green-500" />
</div>
</div>
<div className="relative w-full border rounded-md overflow-hidden">
<Image
src={darkSrc}
alt={alt}
width={1200}
height={800}
className="hidden dark:block w-full h-auto"
/>
<Image
src={lightSrc}
alt={alt}
width={1200}
height={800}
className="dark:hidden w-full h-auto"
/>
</div>
</div>
{caption && (
<figcaption className="text-center text-sm text-muted-foreground max-w-lg mx-auto">
{caption}
</figcaption>
)}
</FeatureCardContainer>
);
}