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,213 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
export interface CompareSeo {
title: string;
description: string;
noindex?: boolean;
}
export interface CompareCta {
label: string;
href: string;
}
export interface CompareHero {
heading: string;
subheading: string;
badges: string[];
}
export interface CompareCompetitor {
name: string;
logo: string;
url: string;
short_description: string;
founded?: number;
headquarters?: string;
}
export interface CompareSummary {
title: string;
intro: string;
one_liner: string;
best_for_openpanel: string[];
best_for_competitor: string[];
}
export interface CompareHighlight {
label: string;
openpanel: string;
competitor: string;
}
export interface CompareHighlights {
title: string;
intro: string;
items: CompareHighlight[];
}
export interface CompareFeature {
name: string;
openpanel: boolean | string;
competitor: boolean | string;
notes?: string | null;
}
export interface CompareFeatureGroup {
group: string;
features: CompareFeature[];
}
export interface CompareFeatureComparison {
title: string;
intro: string;
groups: CompareFeatureGroup[];
}
export interface ComparePricing {
title: string;
intro: string;
openpanel: {
model: string;
description: string;
};
competitor: {
model: string;
description: string;
free_tier?: string;
pricing_url?: string;
};
}
export interface CompareTrust {
data_processing: string;
data_location: string;
self_hosting: boolean;
}
export interface CompareTrustCompliance {
title: string;
intro: string;
openpanel: CompareTrust;
competitor: CompareTrust;
}
export interface CompareUseCase {
title: string;
description: string;
icon?: string;
}
export interface CompareUseCases {
title: string;
intro: string;
items: CompareUseCase[];
}
export interface CompareFaq {
question: string;
answer: string;
}
export interface CompareFaqs {
title: string;
intro: string;
items: CompareFaq[];
}
export interface CompareBenefitsSection {
label?: string;
title: string;
description: string;
cta?: CompareCta;
benefits: string[];
}
export interface CompareTechnicalItem {
label: string;
openpanel: string | string[];
competitor: string | string[];
notes?: string | null;
}
export interface CompareTechnicalComparison {
title: string;
intro: string;
items: CompareTechnicalItem[];
}
export interface CompareMigrationStep {
title: string;
description: string;
}
export interface CompareMigration {
title: string;
intro: string;
difficulty: string;
estimated_time: string;
steps: CompareMigrationStep[];
sdk_compatibility: {
similar_api: boolean;
notes: string;
};
historical_data: {
can_import: boolean;
notes: string;
};
}
export interface CompareData {
url: string;
slug: string;
page_type: 'alternative' | 'vs';
seo: CompareSeo;
hero: CompareHero;
competitor: CompareCompetitor;
summary_comparison: CompareSummary;
highlights: CompareHighlights;
feature_comparison: CompareFeatureComparison;
technical_comparison?: CompareTechnicalComparison;
pricing: ComparePricing;
migration?: CompareMigration;
trust_and_compliance?: CompareTrustCompliance;
use_cases: CompareUseCases;
faqs: CompareFaqs;
benefits_section?: CompareBenefitsSection;
ctas: {
primary: CompareCta;
secondary: CompareCta;
};
}
const contentDir = join(process.cwd(), 'content', 'compare');
export async function getCompareData(
slug: string,
): Promise<CompareData | null> {
try {
const filePath = join(contentDir, `${slug}.json`);
const fileContents = readFileSync(filePath, 'utf8');
const data = JSON.parse(fileContents) as CompareData;
return {
...data,
url: `/compare/${slug}`,
};
} catch (error) {
console.error(`Error loading compare data for ${slug}:`, error);
return null;
}
}
export async function getAllCompareSlugs(): Promise<string[]> {
try {
const files = readdirSync(contentDir);
return files
.filter((file) => file.endsWith('.json'))
.map((file) => file.replace('.json', ''));
} catch (error) {
console.error('Error reading compare directory:', error);
return [];
}
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
export function useIsDarkMode() {
const [isDarkMode, setIsDarkMode] = useState(() => {
if (typeof window === 'undefined') return false;
// Check localStorage first
const savedTheme = window.localStorage.getItem('theme');
if (savedTheme !== null) {
return savedTheme === 'dark';
}
// Fall back to system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
useEffect(() => {
// Check if user prefers dark mode
const htmlElement = document.documentElement;
setIsDarkMode(htmlElement.classList.contains('dark'));
// Create observer to watch for class changes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
setIsDarkMode(htmlElement.classList.contains('dark'));
}
});
});
// Start observing class changes on html element
observer.observe(htmlElement, { attributes: true });
return () => observer.disconnect();
}, []);
return isDarkMode;
}

View File

@@ -0,0 +1,10 @@
export async function getGithubRepoInfo() {
try {
const res = await fetch(
'https://api.github.com/repos/Openpanel-dev/openpanel',
);
return res.json();
} catch (e) {
return null;
}
}

View File

@@ -0,0 +1,39 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const siteName = 'OpenPanel';
export const baseUrl =
process.env.NODE_ENV === 'production'
? 'https://openpanel.dev'
: 'http://localhost:3000';
export const url = (path: string) => {
if (path.startsWith('http')) {
return path;
}
return `${baseUrl}${path}`;
};
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: siteName,
},
links: [],
};
}
export const authors = [
{
name: 'OpenPanel Team',
url: 'https://openpanel.com',
},
{
name: 'Carl-Gerhard Lindesvärd',
url: 'https://openpanel.com',
image: '/twitter-carl.jpg',
},
];
export const getAuthor = (author?: string) => {
return authors.find((a) => a.name === author)!;
};

View File

@@ -0,0 +1,84 @@
import type { Metadata } from 'next';
import { url as baseUrl } from './layout.shared';
const siteName = 'OpenPanel';
const defaultDescription =
'OpenPanel is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.';
const defaultImage = baseUrl('/ogimage.png');
export function getOgImageUrl(url: string): string {
return `/og/${url.replace(baseUrl('/'), '/')}`;
}
export function getRootMetadata(): Metadata {
return getRawMetadata({
url: baseUrl('/'),
title: `${siteName} | An open-source alternative to Mixpanel`,
description: defaultDescription,
image: defaultImage,
});
}
export function getPageMetadata({
url,
title,
description,
image,
}: {
url: string;
title: string;
description: string;
image?: string;
}): Metadata {
return getRawMetadata({
url,
title: `${title} | ${siteName}`,
description,
image: image ?? getOgImageUrl(url),
});
}
export function getRawMetadata(
{
url,
title,
description,
image,
}: { url: string; title: string; description: string; image: string },
meta: Metadata = {},
): Metadata {
return {
title,
description,
alternates: {
canonical: baseUrl(url),
},
icons: {
apple: '/apple-touch-icon.png',
icon: '/favicon.ico',
},
manifest: '/site.webmanifest',
openGraph: {
title,
description,
siteName: siteName,
url: baseUrl(url),
type: 'website',
images: [
{
url: image,
width: 1200,
height: 630,
alt: title,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [image],
},
...meta,
};
}

View File

@@ -0,0 +1,73 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
articleCollection,
docs,
pageCollection,
} from 'fumadocs-mdx:collections/server';
import { type InferPageType, loader } from 'fumadocs-core/source';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';
import type { CompareData } from './compare';
import { url } from './layout.shared';
// See https://fumadocs.dev/docs/headless/source-api for more info
export const source = loader({
baseUrl: '/docs',
source: docs.toFumadocsSource(),
plugins: [lucideIconsPlugin()],
});
export const articleSource = loader({
baseUrl: '/articles',
source: toFumadocsSource(articleCollection, []),
plugins: [lucideIconsPlugin()],
});
export const pageSource = loader({
baseUrl: '/',
source: toFumadocsSource(pageCollection, []),
});
export function getPageImage(page: InferPageType<typeof source>) {
const segments = [...page.slugs, 'image.png'];
return {
segments,
url: `/og/docs/${segments.join('/')}`,
};
}
export async function getLLMText(page: InferPageType<typeof source>) {
const processed = await page.data.getText('processed');
return `# ${page.data.title}
${processed}`;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const contentDir = path.join(__dirname, '../../content/compare');
const files = fs
.readdirSync(contentDir)
.filter((file) => file.endsWith('.json'));
export const compareSource: CompareData[] = files
.map((file) => {
const filePath = path.join(contentDir, file);
const fileContents = fs.readFileSync(filePath, 'utf8');
try {
return JSON.parse(fileContents) as CompareData;
} catch (error) {
console.error(`Error parsing compare data for ${file}:`, error);
return null;
}
})
.flatMap((item) => (item ? [item] : []))
.map((item) => ({
...item,
url: `/compare/${item.slug}`,
}));

View File

@@ -0,0 +1,16 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const formatEventsCount = (value: number) => {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(0)}K`;
}
return value.toString();
};