Added gitea-mirror

This commit is contained in:
2026-01-19 08:34:20 +01:00
parent b956b07d1e
commit e19aff248d
385 changed files with 81357 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
---
// Click button, get confetti!
// Styled by Tailwind :)
---
<button
class="appearance-none py-2 px-4 bg-purple-500 text-white font-semibold rounded-lg shadow-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-400 focus:ring-opacity-75"
>
<slot />
</button>
<script>
import confetti from 'canvas-confetti';
const button = document.body.querySelector('button');
if (button) {
button.addEventListener('click', () => confetti());
}
</script>

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { Button } from './ui/button';
import { ArrowRight } from 'lucide-react';
import { GitHubStats } from './GitHubStats';
export function CTA() {
return (
<section className="py-16 sm:py-24 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="relative overflow-hidden rounded-2xl sm:rounded-3xl bg-card/80 backdrop-blur-sm border border-primary/10 p-6 sm:p-8 md:p-12 text-center shadow-xl">
{/* Subtle gradient accent */}
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5 pointer-events-none" />
<div className="absolute -top-24 -right-24 w-48 h-48 bg-primary/20 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -bottom-24 -left-24 w-48 h-48 bg-accent/20 rounded-full blur-3xl pointer-events-none" />
<div className="relative">
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4">
<span className="block sm:inline">Start Protecting</span>
<span className="text-gradient from-primary via-accent to-accent-purple block sm:inline"> Your Code Today</span>
</h2>
<p className="text-base sm:text-lg text-muted-foreground mb-6 sm:mb-8 max-w-2xl mx-auto px-4">
Join developers who trust Gitea Mirror to keep their repositories safe and accessible.
Free, open source, and ready to deploy.
</p>
{/* Stats */}
<GitHubStats />
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4">
<Button size="lg" className="group w-full sm:w-auto min-h-[48px] bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 transition-all duration-300" asChild>
<a href="https://github.com/RayLabsHQ/gitea-mirror" target="_blank" rel="noopener noreferrer">
Get Started Now
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</a>
</Button>
<Button size="lg" variant="outline" className="w-full sm:w-auto min-h-[48px] bg-background/80 backdrop-blur-sm hover:bg-primary/10 hover:border-primary/30 hover:text-foreground transition-all duration-300" asChild>
<a href="https://github.com/RayLabsHQ/gitea-mirror/discussions" target="_blank" rel="noopener noreferrer">
Join Community
</a>
</Button>
</div>
</div>
</div>
{/* Open source note */}
<div className="mt-8 sm:mt-12 text-center">
<p className="text-xs sm:text-sm text-muted-foreground">
Gitea Mirror is licensed under GPL-3.0.
<a href="https://github.com/RayLabsHQ/gitea-mirror/blob/main/LICENSE"
className="ml-1 underline hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer">
View License
</a>
</p>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,138 @@
---
import { HelpCircle } from 'lucide-react';
const faqs = [
{
question: "What is Gitea Mirror and why do I need it?",
answer: "Gitea Mirror is a self-hosted tool that automatically backs up your GitHub repositories to your own Gitea server. You need it because GitHub outages, account issues, or policy changes can lock you out of your code. With Gitea Mirror, you own your backups completely—no monthly fees, no third-party storage, just full control over your repository history, issues, pull requests, and releases."
},
{
question: "How is this different from BackHub or Rewind?",
answer: "BackHub and Rewind are cloud services that cost $600-2400/year and store your code on their servers. Gitea Mirror is free, open source, and runs on your own infrastructure. You pay $0/month and have complete data ownership. The tradeoff: you manage the infrastructure yourself, while cloud services are fully managed."
},
{
question: "Does Gitea Mirror backup issues, pull requests, and releases?",
answer: "Yes! Enable 'Mirror metadata' in settings to backup issues with comments, labels, and assignees. Pull requests are mirrored as enriched issues with full metadata, commit history, and file changes (Gitea's API doesn't support creating PRs from external sources). Releases, including binary assets, and wiki pages are also backed up when enabled."
},
{
question: "How long does setup take?",
answer: "15-20 minutes with Docker. Run 'docker compose -f docker-compose.alt.yml up -d', visit localhost:4321, create an account, paste your GitHub and Gitea tokens, select repos to backup—done. The Proxmox LXC one-liner is even faster. No complex configuration files or manual scripting required."
},
{
question: "What happens if GitHub goes down or I lose access?",
answer: "You can immediately clone from your Gitea server instead. Your local backups include full commit history, branches, tags, issues, releases—everything. Recovery time is typically under 2 minutes (just point git to your Gitea URL). This is why it's called disaster recovery, not just mirroring."
},
{
question: "How often does it sync with GitHub?",
answer: "Configurable from every 15 minutes to once per day (or longer). Most users choose 1-8 hours based on how fresh they want backups. The scheduler auto-discovers new repos and respects per-repo intervals, unlike Gitea's built-in mirroring which defaults to 24 hours."
},
{
question: "Can I backup starred repositories?",
answer: "Yes! Gitea Mirror can automatically backup all your GitHub stars into a dedicated Gitea organization. Perfect for preserving important open source projects before they disappear (projects get deleted, renamed, or removed all the time)."
},
{
question: "What are the system requirements?",
answer: "Minimal: 2 vCPU, 2GB RAM, 5-10GB storage (grows with repo count). Runs on Docker, Kubernetes, Proxmox LXC, or bare metal. Works on AMD64 and ARM64 (Raspberry Pi compatible). A small homelab server or cheap VPS is plenty for personal use."
},
{
question: "Is my GitHub token stored securely?",
answer: "Yes. All GitHub and Gitea tokens are encrypted at rest using AES-256-GCM. Even if someone gains access to the SQLite database, they can't read your tokens without the encryption key. Use the ENCRYPTION_SECRET environment variable for additional security."
},
{
question: "Can I backup private repositories?",
answer: "Absolutely. Just use a GitHub personal access token (classic) with 'repo' scope enabled. Gitea Mirror will backup all repositories you have access to—public, private, and internal."
},
{
question: "What if I have multiple GitHub organizations?",
answer: "Gitea Mirror supports multiple organizations with flexible destination strategies: preserve GitHub structure in Gitea, consolidate into a single org, or use mixed mode (personal repos in one place, org repos preserve structure). Edit individual organization destinations via the dashboard."
},
{
question: "Does it support Git LFS (large files)?",
answer: "Yes! Enable 'Mirror LFS' in settings. Make sure your Gitea server has LFS enabled (LFS_START_SERVER = true) and Git v2.1.2+. Large assets like videos, datasets, and binaries are backed up alongside your code."
},
{
question: "How do I restore from backup?",
answer: "Simple: 'git clone https://your-gitea-server/owner/repo.git'. Your full history, branches, and tags are there. For issues/PRs/releases, they're already in Gitea's web interface. For complete disaster recovery, restore the data volume to a fresh Gitea Mirror instance—everything (config, sync history) is preserved."
},
{
question: "Can I run this alongside a cloud backup service?",
answer: "Yes! Many users run Gitea Mirror for local/warm backups while using cloud services for offsite redundancy. Best of both worlds: instant local recovery and geographic disaster protection. Totally compatible."
},
{
question: "Is this enterprise-ready with SLA guarantees?",
answer: "No. Gitea Mirror is a community open source project—no 24/7 support, no compliance certifications, no guaranteed uptime. It's perfect for homelabs, indie developers, and small teams comfortable with self-hosting. If you need enterprise SLAs, consider commercial solutions like BackHub or GitHub Enterprise Backup."
}
];
// Generate FAQ schema for SEO
const faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faqs.map(faq => ({
"@type": "Question",
"name": faq.question,
"acceptedAnswer": {
"@type": "Answer",
"text": faq.answer
}
}))
};
---
<section id="faq" class="py-16 sm:py-24 px-4 sm:px-6 lg:px-8 bg-muted/30">
<div class="max-w-4xl mx-auto">
<div class="text-center mb-12 sm:mb-16">
<span class="inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-4">
<HelpCircle className="w-4 h-4" />
FAQ
</span>
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight">
Common Questions About GitHub Backups
</h2>
<p class="mt-4 text-base sm:text-lg text-muted-foreground">
Everything you need to know about self-hosted repository backups with Gitea Mirror.
</p>
</div>
<div class="space-y-4">
{faqs.map((faq, index) => (
<details class="group rounded-xl border bg-background/80 p-6 shadow-sm transition-all hover:shadow-md open:ring-1 open:ring-primary/20">
<summary class="flex cursor-pointer items-start justify-between gap-4 font-semibold text-foreground list-none">
<span class="text-left">{faq.question}</span>
<svg
class="h-5 w-5 flex-shrink-0 text-muted-foreground transition-transform group-open:rotate-180"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<p class="mt-4 text-sm sm:text-base text-muted-foreground leading-relaxed">
{faq.answer}
</p>
</details>
))}
</div>
<div class="mt-12 text-center">
<p class="text-sm text-muted-foreground">
Still have questions?
<a href="https://github.com/RayLabsHQ/gitea-mirror/discussions" class="text-primary hover:text-primary/80 font-medium transition-colors ml-1">
Ask in our GitHub Discussions
</a>
</p>
</div>
</div>
</section>
<!-- FAQ Schema for SEO -->
<script type="application/ld+json" set:html={JSON.stringify(faqSchema)} />
<style>
details summary::-webkit-details-marker {
display: none;
}
</style>

View File

@@ -0,0 +1,90 @@
---
import {
RefreshCw,
Building2,
FolderTree,
Activity,
Lock,
Heart,
} from 'lucide-react';
const features = [
{
title: "Automated Mirroring",
description: "Set it and forget it. Automatically sync your GitHub repositories to Gitea on a schedule.",
icon: RefreshCw,
gradient: "from-primary/10 to-accent/10",
iconColor: "text-primary"
},
{
title: "Bulk Operations",
description: "Mirror entire organizations or user accounts with a single configuration.",
icon: Building2,
gradient: "from-accent/10 to-accent-teal/10",
iconColor: "text-accent"
},
{
title: "Preserve Structure",
description: "Maintain your GitHub organization structure or customize how repos are organized.",
icon: FolderTree,
gradient: "from-accent-teal/10 to-primary/10",
iconColor: "text-accent-teal"
},
{
title: "Real-time Status",
description: "Monitor mirror progress with live updates and detailed activity logs.",
icon: Activity,
gradient: "from-accent-coral/10 to-primary/10",
iconColor: "text-accent-coral"
},
{
title: "Secure & Private",
description: "Self-hosted solution keeps your code on your infrastructure with full control.",
icon: Lock,
gradient: "from-accent-purple/10 to-primary/10",
iconColor: "text-accent-purple"
},
{
title: "Open Source",
description: "Free, transparent, and community-driven development. Contribute and customize.",
icon: Heart,
gradient: "from-primary/10 to-accent-purple/10",
iconColor: "text-primary"
}
];
---
<section id="features" class="py-16 sm:py-24 px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-12 sm:mb-16">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight px-4">
Everything You Need for
<span class="text-gradient from-primary to-accent block sm:inline"> Reliable Backups</span>
</h2>
<p class="mt-4 text-base sm:text-lg text-muted-foreground max-w-2xl mx-auto px-4">
Powerful features designed to keep your code safe and accessible, no matter what happens.
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
{features.map((feature) => {
const Icon = feature.icon;
return (
<div
class={`group relative p-6 sm:p-8 rounded-xl sm:rounded-2xl border bg-gradient-to-br ${feature.gradient} backdrop-blur-sm hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 hover:-translate-y-1 hover:border-primary/30 overflow-hidden`}
>
<div class="absolute inset-0 bg-gradient-to-br from-transparent to-background/50 opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div class="relative">
<div class={`inline-flex p-2.5 sm:p-3 rounded-lg bg-background/80 backdrop-blur-sm mb-3 sm:mb-4 ${feature.iconColor} shadow-sm`}>
<Icon className="w-5 h-5 sm:w-6 sm:h-6" />
</div>
<h3 class="text-lg sm:text-xl font-semibold mb-2">{feature.title}</h3>
<p class="text-sm sm:text-base text-muted-foreground">{feature.description}</p>
</div>
</div>
);
})}
</div>
</div>
</section>

View File

@@ -0,0 +1,83 @@
---
import { Github, Book, MessageSquare, Bug } from 'lucide-react';
const links = [
{
title: "Source Code",
href: "https://github.com/RayLabsHQ/gitea-mirror",
icon: Github
},
{
title: "Documentation",
href: "https://github.com/RayLabsHQ/gitea-mirror/tree/main/docs",
icon: Book
},
{
title: "Discussions",
href: "https://github.com/RayLabsHQ/gitea-mirror/discussions",
icon: MessageSquare
},
{
title: "Report Issue",
href: "https://github.com/RayLabsHQ/gitea-mirror/issues",
icon: Bug
}
];
const currentYear = new Date().getFullYear();
---
<footer class="border-t py-8 sm:py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-7xl mx-auto">
<div class="flex flex-col items-center gap-6 sm:gap-8">
<!-- Logo and tagline -->
<div class="text-center">
<div class="flex items-center justify-center gap-2 mb-2">
<img
src="/assets/logo.png"
alt="Gitea Mirror"
class="w-7 h-6 md:w-10 md:h-8"
/>
<span class="font-semibold text-base sm:text-lg">Gitea Mirror</span>
</div>
<p class="text-xs sm:text-sm text-muted-foreground">
Keep your GitHub code safe and synced
</p>
</div>
<!-- Links -->
<nav class="grid grid-cols-2 sm:flex items-center justify-center gap-4 sm:gap-6 text-center">
{links.map((link) => {
const Icon = link.icon;
return (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors py-2 sm:py-0"
>
<Icon className="w-3 h-3 sm:w-4 sm:h-4" />
<span>{link.title}</span>
</a>
);
})}
</nav>
<!-- Copyright -->
<div class="text-center text-xs sm:text-sm text-muted-foreground px-4">
<p>© {currentYear} Gitea Mirror. Open source under GPL-3.0 License.</p>
<p class="mt-1">
Made with dedication by the{' '}
<a
href="https://github.com/RayLabsHQ"
class="underline hover:text-foreground transition-colors"
target="_blank"
rel="noopener noreferrer"
>
RayLabs team
</a>
</p>
</div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,60 @@
import React, { useEffect, useState } from 'react';
import { Github, Star } from 'lucide-react';
import { Button } from './ui/button';
export function GitHubButton() {
const [stars, setStars] = useState<number | null>(null);
useEffect(() => {
const fetchStars = async () => {
try {
const response = await fetch('https://api.github.com/repos/RayLabsHQ/gitea-mirror');
if (response.ok) {
const data = await response.json();
setStars(data.stargazers_count);
}
} catch (error) {
console.error('Failed to fetch GitHub stars:', error);
}
};
fetchStars();
}, []);
return (
<>
{/* Mobile version - compact with text */}
<Button
variant="outline"
size="sm"
className="md:hidden hover:bg-primary/10 hover:border-primary/30 hover:text-foreground transition-all duration-300 px-3"
asChild
>
<a href="https://github.com/RayLabsHQ/gitea-mirror" target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5">
<Star className="w-4 h-4" />
<span className="font-semibold">{stars || '—'}</span>
</a>
</Button>
{/* Desktop version - full button */}
<Button
variant="outline"
size="sm"
className="hidden md:flex hover:bg-primary/10 hover:border-primary/30 hover:text-foreground transition-all duration-300"
asChild
>
<a href="https://github.com/RayLabsHQ/gitea-mirror" target="_blank" rel="noopener noreferrer" className="flex items-center">
<Github className="w-4 h-4 mr-2" />
<span>Star on GitHub</span>
{stars !== null && (
<>
<span className="mx-2 text-muted-foreground"></span>
<Star className="w-3 h-3 mr-1" />
<span>{stars}</span>
</>
)}
</a>
</Button>
</>
);
}

View File

@@ -0,0 +1,59 @@
import React, { useEffect, useState } from 'react';
import { Star, GitFork, Users } from 'lucide-react';
interface GitHubRepo {
stargazers_count: number;
forks_count: number;
open_issues_count: number;
}
export function GitHubStats() {
const [stats, setStats] = useState<GitHubRepo | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchStats = async () => {
try {
const response = await fetch('https://api.github.com/repos/RayLabsHQ/gitea-mirror');
if (response.ok) {
const data = await response.json();
setStats(data);
}
} catch (error) {
console.error('Failed to fetch GitHub stats:', error);
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
if (loading) {
return (
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 md:gap-8 mb-6 sm:mb-8 text-white/80 text-sm sm:text-base">
<div className="flex items-center gap-2">
<Star className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="font-semibold">Loading...</span>
</div>
</div>
);
}
return (
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 md:gap-8 mb-6 sm:mb-8 text-foreground text-sm sm:text-base">
<div className="flex items-center gap-2">
<Star className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="font-semibold">{stats?.stargazers_count || 0} Stars</span>
</div>
<div className="flex items-center gap-2">
<GitFork className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="font-semibold">{stats?.forks_count || 0} Forks</span>
</div>
<div className="flex items-center gap-2">
<Users className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="font-semibold">Active Community</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import React, { useEffect, useState } from 'react';
import { ThemeToggle } from './ThemeToggle';
import { GitHubButton } from './GitHubButton';
export function Header() {
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const navLinks = [
{ href: '/#features', label: 'Features' },
{ href: '/#use-cases', label: 'Use Cases' },
{ href: '/#screenshots', label: 'Screenshots' },
{ href: '/#installation', label: 'Installation' }
];
return (
<header
className={`fixed left-0 right-0 z-50 transition-all duration-300 ${
isScrolled ? 'backdrop-blur-lg bg-background/80 border-b shadow-sm' : 'bg-background/50'
}`}
style={{ top: 'var(--promo-banner-height, 0px)' }}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<a href="/" className="flex items-center gap-2 group">
<img
src="/assets/logo.png"
alt="Gitea Mirror Logo"
className="w-7 h-6 md:w-10 md:h-8"
/>
<span className="text-lg sm:text-xl font-bold">Gitea Mirror</span>
</a>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-8">
{navLinks.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
{/* Desktop Actions */}
<div className="hidden md:flex items-center gap-4">
<ThemeToggle />
<GitHubButton />
</div>
{/* Mobile Actions */}
<div className="flex md:hidden items-center gap-3">
<GitHubButton />
<ThemeToggle />
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,98 @@
import { Button } from "./ui/button";
import { ArrowRight, Shield, RefreshCw, HardDrive } from "lucide-react";
import { GitHubLogoIcon } from "@radix-ui/react-icons";
import React, { Suspense } from 'react';
const Spline = React.lazy(() => import('@splinetool/react-spline'));
export function Hero() {
return (
<section className="relative min-h-[100vh] pt-20 pb-10 flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8 overflow-hidden">
{/* spline object */}
<div className="spline-object absolute inset-0 max-lg:-z-10 max-h-[40rem] -translate-y-16 md:max-h-[50rem] lg:max-h-[60%] xl:max-h-[70%] 2xl:max-h-[80%] md:-translate-y-24 lg:-translate-y-28 flex items-center justify-center">
<div className="block md:hidden w-[80%]">
<img
src="/assets/hero_logo.webp"
alt="Gitea Mirror hero image"
className="w-full h-full object-contain"
/>
</div>
<div className="absolute right-2 bottom-4 h-20 w-40 bg-background hidden md:block"/>
<Suspense fallback={
<div className="w-full h-full md:flex items-center justify-center hidden">
<img
src="/assets/hero_logo.webp"
alt="Gitea Mirror hero logo"
className="w-[200px] h-[160px] md:w-[280px] md:h-[240px] lg:w-[360px] lg:h-[320px] xl:w-[420px] xl:h-[380px] 2xl:w-[480px] 2xl:h-[420px] object-contain"
/>
</div>
}>
<Spline
scene="https://prod.spline.design/jl0aKWbdH9vHQnYV/scene.splinecode"
className="hidden md:block"
/>
</Suspense>
</div>
{/* div to avoid clipping in lower screen heights */}
<div className="clip-avoid w-full h-[16rem] md:h-[20rem] lg:h-[12rem] 2xl:h-[16rem]" aria-hidden="true"></div>
<div className="max-w-7xl mx-auto pb-20 lg:pb-60 xl:pb-24 text-center w-full">
<h1 className="pt-10 2xl:pt-20 text-3xl xs:text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold tracking-tight leading-tight">
<span className="text-foreground">Backup Your GitHub</span>
<br />
<span className="text-gradient from-primary via-accent to-accent-purple">
To Self-Hosted Gitea
</span>
</h1>
<p className="mt-4 sm:mt-6 text-base sm:text-lg md:text-xl text-muted-foreground max-w-3xl mx-auto px-4 z-20">
Automatic, private, and free. Own your code history forever.
Preserve issues, PRs, releases, and wiki in your own Gitea server.
</p>
<div className="mt-6 sm:mt-8 flex flex-wrap items-center justify-center gap-3 text-xs sm:text-sm text-muted-foreground px-4 z-20">
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary">
<HardDrive className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="font-medium">Self-Hosted Backup</span>
</div>
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-accent/10 text-accent">
<RefreshCw className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="font-medium">Automated Syncing</span>
</div>
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-accent-purple/10 text-accent-purple">
<Shield className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="font-medium">$0/month</span>
</div>
</div>
{/* Product Hunt Badge */}
<div className="mt-6 sm:mt-8 flex items-center justify-center px-4 z-20">
<a
href="https://www.producthunt.com/products/gitea-mirror?embed=true&utm_source=badge-featured&utm_medium=badge&utm_source=badge-gitea-mirror"
target="_blank"
rel="noopener noreferrer"
className="inline-block transition-transform hover:scale-105"
>
<img
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1013721&theme=light&t=1757620787136"
alt="Gitea Mirror - Automated github to gitea repository mirroring & backup | Product Hunt"
style={{ width: '250px', height: '54px' }}
width="250"
height="54"
className="dark:hidden"
/>
<img
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1013721&theme=dark&t=1757620890723"
alt="Gitea Mirror - Automated github to gitea repository mirroring & backup | Product Hunt"
style={{ width: '250px', height: '54px' }}
width="250"
height="54"
className="hidden dark:block"
/>
</a>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,169 @@
import React, { useState } from 'react';
import { Button } from './ui/button';
import { Copy, Check, Terminal, Container, Cloud } from 'lucide-react';
type InstallMethod = 'docker' | 'manual' | 'proxmox';
export function Installation() {
const [activeMethod, setActiveMethod] = useState<InstallMethod>('docker');
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
const copyToClipboard = async (text: string, commandId: string) => {
await navigator.clipboard.writeText(text);
setCopiedCommand(commandId);
setTimeout(() => setCopiedCommand(null), 2000);
};
const installMethods = {
docker: {
icon: Container,
title: "Docker",
description: "Recommended for most users",
steps: [
{
title: "Clone the repository",
command: "git clone https://github.com/RayLabsHQ/gitea-mirror.git && cd gitea-mirror",
id: "docker-clone"
},
{
title: "Start with Docker Compose",
command: "docker compose -f docker-compose.alt.yml up -d",
id: "docker-start"
},
{
title: "Access the application",
command: "# Open http://localhost:4321 in your browser",
id: "docker-access"
}
]
},
manual: {
icon: Terminal,
title: "Manual",
description: "For development or custom setups",
steps: [
{
title: "Install Bun runtime",
command: "curl -fsSL https://bun.sh/install | bash",
id: "manual-bun"
},
{
title: "Clone and setup",
command: "git clone https://github.com/RayLabsHQ/gitea-mirror.git\ncd gitea-mirror\nbun run setup",
id: "manual-setup"
},
{
title: "Start the application",
command: "bun run dev",
id: "manual-start"
}
]
},
proxmox: {
icon: Cloud,
title: "Proxmox LXC",
description: "One-click install for Proxmox VE",
steps: [
{
title: "Run the installation script",
command: 'bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/gitea-mirror.sh)"',
id: "proxmox-install"
},
{
title: "Follow the prompts",
command: "# The script will guide you through the setup",
id: "proxmox-follow"
}
]
}
};
return (
<section id="installation" className="py-16 sm:py-24 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-8 sm:mb-16">
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight">
Get Started in Minutes
</h2>
<p className="mt-4 text-base sm:text-lg text-muted-foreground">
Choose your preferred installation method
</p>
</div>
{/* Installation method tabs */}
<div className="flex flex-col sm:flex-row flex-wrap justify-center gap-3 sm:gap-4 mb-8 sm:mb-12">
{(Object.entries(installMethods) as [InstallMethod, typeof installMethods[InstallMethod]][]).map(([method, config]) => {
const Icon = config.icon;
return (
<button
key={method}
onClick={() => setActiveMethod(method)}
className={`flex items-center gap-3 px-4 sm:px-6 py-3 rounded-lg border transition-all min-h-[60px] ${
activeMethod === method
? 'bg-gradient-to-r from-primary to-accent text-primary-foreground border-transparent shadow-lg shadow-primary/25'
: 'bg-card hover:bg-muted border-border hover:border-primary/30'
}`}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<div className="text-left">
<p className="font-semibold text-sm sm:text-base">{config.title}</p>
<p className={`text-xs ${activeMethod === method ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>
{config.description}
</p>
</div>
</button>
);
})}
</div>
{/* Installation steps */}
<div className="space-y-4 sm:space-y-6">
{installMethods[activeMethod].steps.map((step, index) => (
<div key={step.id} className="relative">
<div className="flex items-start gap-3 sm:gap-4">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gradient-to-br from-primary to-accent flex items-center justify-center shadow-md shadow-primary/20">
<span className="text-sm font-semibold text-primary-foreground">{index + 1}</span>
</div>
<div className="flex-grow min-w-0">
<h3 className="font-semibold mb-2 text-sm sm:text-base">{step.title}</h3>
<div className="relative group">
<div className="relative overflow-hidden rounded-lg">
<pre className="bg-muted/50 p-3 sm:p-4 pr-10 sm:pr-12 overflow-x-auto text-[11px] sm:text-sm font-mono scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent">
<code className="block whitespace-nowrap">{step.command}</code>
</pre>
{/* Scroll indicator gradient for mobile */}
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-muted/50 to-transparent pointer-events-none sm:hidden" />
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 sm:top-2 sm:right-2 w-7 h-7 sm:w-9 sm:h-9 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity z-10"
onClick={() => copyToClipboard(step.command, step.id)}
>
{copiedCommand === step.id ? (
<Check className="h-3 w-3 sm:h-4 sm:w-4 text-green-600" />
) : (
<Copy className="h-3 w-3 sm:h-4 sm:w-4" />
)}
</Button>
</div>
</div>
</div>
</div>
{index < installMethods[activeMethod].steps.length - 1 && (
<div className="absolute left-4 top-10 bottom-0 w-[1px] bg-border -z-10" />
)}
</div>
))}
</div>
{/* Additional info */}
<div className="mt-8 sm:mt-12 p-4 sm:p-6 rounded-lg bg-muted/30 border">
<p className="text-xs sm:text-sm text-muted-foreground">
<strong className="text-foreground">First user becomes admin.</strong> After installation,
create your account and configure GitHub and Gitea connections through the web interface.
</p>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,51 @@
import React, { useEffect, useRef } from 'react';
import { Calendar, Sparkles } from 'lucide-react';
export function PromoBanner() {
const bannerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Update CSS custom property for header offset
const updateOffset = () => {
if (bannerRef.current) {
const height = bannerRef.current.offsetHeight;
document.documentElement.style.setProperty('--promo-banner-height', `${height}px`);
}
};
updateOffset();
window.addEventListener('resize', updateOffset);
return () => window.removeEventListener('resize', updateOffset);
}, []);
return (
<div
ref={bannerRef}
className="fixed top-0 left-0 right-0 z-[60] bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-600 text-white"
>
<a
href="https://lumical.app"
target="_blank"
rel="noopener noreferrer"
className="block max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5 hover:bg-white/5 transition-colors"
>
<div className="flex items-center justify-center gap-x-3 text-sm">
<span className="flex items-center gap-1.5">
<Sparkles className="w-4 h-4" />
<span className="font-medium">New from RayLabs:</span>
</span>
<span className="inline-flex items-center gap-1.5 font-semibold">
<Calendar className="w-4 h-4" />
Lumical
</span>
<span className="hidden sm:inline text-white/90">
Scan meeting invites to your calendar with AI
</span>
<span className="ml-1 inline-flex items-center gap-1 rounded-full bg-white/20 px-3 py-0.5 text-xs font-medium">
Try it free
</span>
</div>
</a>
</div>
);
}

View File

@@ -0,0 +1,245 @@
---
import { Image } from 'astro:assets';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from './ui/button';
// Import all images
import dashboardDesktop from '../../public/assets/dashboard.png';
import dashboardMobile from '../../public/assets/dashboard_mobile.png';
import organisationDesktop from '../../public/assets/organisation.png';
import organisationMobile from '../../public/assets/organisation_mobile.png';
import repositoriesDesktop from '../../public/assets/repositories.png';
import repositoriesMobile from '../../public/assets/repositories_mobile.png';
import configurationDesktop from '../../public/assets/configuration.png';
import configurationMobile from '../../public/assets/configuration_mobile.png';
import activityDesktop from '../../public/assets/activity.png';
import activityMobile from '../../public/assets/activity_mobile.png';
const screenshots = [
{
title: "Dashboard Overview",
description: "Monitor all your mirrored repositories in one place",
desktop: dashboardDesktop,
mobile: dashboardMobile
},
{
title: "Organization Management",
description: "Easily manage and sync entire GitHub organizations",
desktop: organisationDesktop,
mobile: organisationMobile
},
{
title: "Repository Control",
description: "Fine-grained control over individual repository mirrors",
desktop: repositoriesDesktop,
mobile: repositoriesMobile
},
{
title: "Configuration",
description: "Simple and intuitive configuration interface",
desktop: configurationDesktop,
mobile: configurationMobile
},
{
title: "Activity Monitoring",
description: "Track sync progress and view detailed logs",
desktop: activityDesktop,
mobile: activityMobile
}
];
---
<section id="screenshots" class="py-16 sm:py-24 px-4 sm:px-6 lg:px-8 bg-gradient-to-b from-muted/30 via-primary/5 to-muted/30">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-8 sm:mb-16">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight">
See It In Action
</h2>
<p class="mt-4 text-base sm:text-lg text-muted-foreground max-w-2xl mx-auto px-4">
A clean, intuitive interface designed for efficiency and ease of use
</p>
</div>
<div class="relative max-w-5xl mx-auto">
<!-- Screenshot viewer -->
<div id="screenshot-container" class="relative group">
<div class="aspect-[9/16] sm:aspect-[16/10] overflow-hidden rounded-lg sm:rounded-2xl bg-card border shadow-lg">
{screenshots.map((screenshot, index) => (
<picture data-index={index} class={index === 0 ? 'block' : 'hidden'}>
<source media="(max-width: 640px)" srcset={screenshot.mobile.src} />
<Image
src={screenshot.desktop}
alt={screenshot.title}
class="w-full h-full object-cover object-top"
draggable={false}
loading={index === 0 ? 'eager' : 'lazy'}
/>
</picture>
))}
</div>
<!-- Navigation buttons -->
<Button
variant="outline"
size="icon"
className="screenshot-nav-prev absolute left-2 sm:left-4 top-1/2 -translate-y-1/2 opacity-0 sm:opacity-100 group-hover:opacity-100 transition-opacity hidden sm:flex"
aria-label="Previous screenshot"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="screenshot-nav-next absolute right-2 sm:right-4 top-1/2 -translate-y-1/2 opacity-0 sm:opacity-100 group-hover:opacity-100 transition-opacity hidden sm:flex"
aria-label="Next screenshot"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<!-- Screenshot info -->
<div class="mt-6 sm:mt-8 text-center">
<h3 id="screenshot-title" class="text-lg sm:text-xl font-semibold">{screenshots[0].title}</h3>
<p id="screenshot-description" class="mt-2 text-sm sm:text-base text-muted-foreground">{screenshots[0].description}</p>
</div>
<!-- Dots indicator -->
<div class="mt-6 sm:mt-8 flex justify-center gap-2">
{screenshots.map((_, index) => (
<button
data-index={index}
class={`screenshot-dot transition-all duration-300 ${
index === 0
? 'w-8 h-2 bg-primary rounded-full'
: 'w-2 h-2 bg-muted-foreground/30 hover:bg-muted-foreground/50 rounded-full'
}`}
aria-label={`Go to screenshot ${index + 1}`}
/>
))}
</div>
<!-- Mobile swipe hint -->
<p class="mt-4 text-xs text-muted-foreground text-center sm:hidden">
Swipe left or right to navigate
</p>
</div>
<!-- Thumbnail grid -->
<div class="hidden lg:grid grid-cols-5 gap-4 mt-12 px-8">
{screenshots.map((screenshot, index) => (
<button
data-index={index}
class={`screenshot-thumb relative overflow-hidden rounded-lg transition-all duration-300 ${
index === 0
? 'ring-2 ring-primary shadow-lg scale-105'
: 'opacity-60 hover:opacity-100'
}`}
>
<Image
src={screenshot.desktop}
alt={screenshot.title}
class="w-full h-full object-cover"
loading="lazy"
/>
</button>
))}
</div>
</div>
</section>
<script define:vars={{ screenshots }}>
let currentIndex = 0;
let touchStart = 0;
let touchEnd = 0;
const minSwipeDistance = 50;
const container = document.getElementById('screenshot-container');
const pictures = container.querySelectorAll('picture');
const dots = document.querySelectorAll('.screenshot-dot');
const thumbs = document.querySelectorAll('.screenshot-thumb');
const titleEl = document.getElementById('screenshot-title');
const descriptionEl = document.getElementById('screenshot-description');
const prevBtn = container.querySelector('.screenshot-nav-prev');
const nextBtn = container.querySelector('.screenshot-nav-next');
function updateView(newIndex) {
// Hide current, show new
pictures[currentIndex].classList.add('hidden');
pictures[newIndex].classList.remove('hidden');
// Update dots
dots[currentIndex].classList.remove('w-8', 'h-2', 'bg-primary');
dots[currentIndex].classList.add('w-2', 'h-2', 'bg-muted-foreground/30');
dots[newIndex].classList.remove('w-2', 'h-2', 'bg-muted-foreground/30');
dots[newIndex].classList.add('w-8', 'h-2', 'bg-primary');
// Update thumbnails
if (thumbs.length > 0) {
thumbs[currentIndex].classList.remove('ring-2', 'ring-primary', 'shadow-lg', 'scale-105');
thumbs[currentIndex].classList.add('opacity-60');
thumbs[newIndex].classList.remove('opacity-60');
thumbs[newIndex].classList.add('ring-2', 'ring-primary', 'shadow-lg', 'scale-105');
}
// Update text
titleEl.textContent = screenshots[newIndex].title;
descriptionEl.textContent = screenshots[newIndex].description;
currentIndex = newIndex;
}
function goToPrevious() {
const newIndex = currentIndex === 0 ? screenshots.length - 1 : currentIndex - 1;
updateView(newIndex);
}
function goToNext() {
const newIndex = (currentIndex + 1) % screenshots.length;
updateView(newIndex);
}
// Touch handling
container.addEventListener('touchstart', (e) => {
touchEnd = 0;
touchStart = e.targetTouches[0].clientX;
});
container.addEventListener('touchmove', (e) => {
touchEnd = e.targetTouches[0].clientX;
});
container.addEventListener('touchend', () => {
if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > minSwipeDistance;
const isRightSwipe = distance < -minSwipeDistance;
if (isLeftSwipe && currentIndex < screenshots.length - 1) {
goToNext();
}
if (isRightSwipe && currentIndex > 0) {
goToPrevious();
}
});
// Button navigation
prevBtn?.addEventListener('click', goToPrevious);
nextBtn?.addEventListener('click', goToNext);
// Dot navigation
dots.forEach((dot, index) => {
dot.addEventListener('click', () => updateView(index));
});
// Thumbnail navigation
thumbs.forEach((thumb, index) => {
thumb.addEventListener('click', () => updateView(index));
});
// Keyboard navigation
window.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') goToPrevious();
if (e.key === 'ArrowRight') goToNext();
});
</script>

View File

@@ -0,0 +1,140 @@
---
---
<canvas id="shader-canvas" class="hidden lg:block absolute inset-0 w-full h-full dark:opacity-90 z-10 pointer-events-none"></canvas>
<script>
const canvas = document.getElementById('shader-canvas') as HTMLCanvasElement | null;
if (!canvas) {
console.error('Canvas element not found');
} else {
const gl = canvas.getContext('webgl', { alpha: true });
if (!gl) {
console.error('WebGL not supported!');
} else {
const vertexShaderSource = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
const fragmentShaderSource = `
precision mediump float;
uniform vec2 iResolution;
uniform float iTime;
uniform vec3 u_color1;
uniform vec3 u_color2;
#define iterations 2
void main() {
vec2 uv = gl_FragCoord.xy / iResolution.xy;
vec2 mirrored_uv = uv;
if (mirrored_uv.x > 0.5) {
mirrored_uv.x = 1.0 - mirrored_uv.x;
}
float res = 1.0;
for (int i = 0; i < iterations; i++) {
res += cos(mirrored_uv.y * 12.345 - iTime * 1.0 + cos(res * 12.234) * 0.2 + cos(mirrored_uv.x * 32.2345 + cos(mirrored_uv.y * 17.234))) + cos(mirrored_uv.x * 12.345);
}
vec3 c = mix(u_color1, u_color2, cos(res + cos(mirrored_uv.y * 24.3214) * 0.1 + cos(mirrored_uv.x * 6.324 + iTime * 1.0) + iTime) * 0.5 + 0.5);
float vignette = clamp((length(mirrored_uv - 0.1 + cos(iTime * 0.9 + mirrored_uv.yx * 4.34 + mirrored_uv.xy * res) * 0.2) * 5.0 - 0.4), 0.0, 1.0);
vec3 final_color = mix(c, vec3(0.0), vignette);
gl_FragColor = vec4(final_color, length(final_color));
}
`;
function createShader(context: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
const shader = context.createShader(type);
if (!shader) return null;
context.shaderSource(shader, source);
context.compileShader(shader);
if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) {
console.error('Shader compilation error:', context.getShaderInfoLog(shader));
context.deleteShader(shader);
return null;
}
return shader;
}
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
if (vertexShader && fragmentShader) {
const program = gl.createProgram();
if (program) {
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program linking error:', gl.getProgramInfoLog(program));
} else {
gl.useProgram(program);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = [-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const positionAttributeLocation = gl.getAttribLocation(program, "a_position");
gl.enableVertexAttribArray(positionAttributeLocation);
gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);
const resolutionLocation = gl.getUniformLocation(program, "iResolution");
const timeLocation = gl.getUniformLocation(program, "iTime");
const color1Location = gl.getUniformLocation(program, "u_color1");
const color2Location = gl.getUniformLocation(program, "u_color2");
const color1 = [0.122, 0.502, 0.122]; // #1f801f
const color2 = [0.059, 0.251, 0.059]; // #0f400f
function render(time: number): void {
if (!gl || !program || !canvas) return;
time *= 0.001;
const displayWidth = canvas.clientWidth;
const displayHeight = canvas.clientHeight;
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
canvas.width = displayWidth;
canvas.height = displayHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
}
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
if (resolutionLocation !== null) {
gl.uniform2f(resolutionLocation, canvas.width, canvas.height);
}
if (timeLocation !== null) {
gl.uniform1f(timeLocation, time);
}
if (color1Location !== null) {
gl.uniform3fv(color1Location, color1);
}
if (color2Location !== null) {
gl.uniform3fv(color2Location, color2);
}
gl.drawArrays(gl.TRIANGLES, 0, 6);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
}
}
}
}
}
</script>

View File

@@ -0,0 +1,40 @@
import React, { useEffect, useState } from 'react';
import { Moon, Sun } from 'lucide-react';
import { Button } from './ui/button';
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
// Check for saved theme preference or default to light
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
setTheme(initialTheme);
document.documentElement.classList.toggle('dark', initialTheme === 'dark');
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
document.documentElement.classList.toggle('dark', newTheme === 'dark');
};
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="w-10 h-10 rounded-full"
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? (
<Moon className="h-5 w-5 transition-all" />
) : (
<Sun className="h-5 w-5 transition-all" />
)}
</Button>
);
}

View File

@@ -0,0 +1,74 @@
---
import { ArrowRight } from 'lucide-react';
import { useCases } from '@/lib/use-cases';
---
<section id="use-cases" class="py-16 sm:py-24 px-4 sm:px-6 lg:px-8 bg-muted/30 border-y">
<div class="max-w-7xl mx-auto">
<div class="text-center max-w-3xl mx-auto mb-12 sm:mb-16">
<span class="inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-4">
Use Cases
</span>
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight">
Proven Ways Teams Depend on
<span class="text-gradient from-primary to-accent block sm:inline"> Gitea Mirror</span>
</h2>
<p class="mt-4 text-base sm:text-lg text-muted-foreground">
Explore real-world workflows where automated mirroring removes risk, accelerates migrations, and keeps engineering teams shipping.
</p>
</div>
<div class="grid gap-4 sm:gap-6 lg:gap-8 lg:grid-cols-3">
{useCases.slice(0, 3).map((useCase) => (
<article class="group relative flex flex-col rounded-2xl border bg-background/80 p-6 sm:p-8 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-lg">
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-primary/5 via-accent/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div class="relative flex flex-col h-full">
<h3 class="text-xl font-semibold mb-3">
{useCase.title}
</h3>
<p class="text-sm sm:text-base text-muted-foreground mb-4">
{useCase.summary}
</p>
<dl class="grid gap-3 text-sm sm:text-base text-muted-foreground">
<div>
<dt class="font-semibold text-foreground">Pain Point</dt>
<dd>{useCase.painPoint}</dd>
</div>
<div>
<dt class="font-semibold text-foreground">Outcome</dt>
<dd>{useCase.outcome}</dd>
</div>
</dl>
<div class="mt-6 flex flex-wrap gap-2">
{useCase.tags.map((tag) => (
<span class="inline-flex items-center rounded-full border border-muted px-3 py-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{tag}
</span>
))}
</div>
<a
href={`/use-cases/${useCase.slug}/`}
class="mt-auto inline-flex items-center gap-2 pt-6 text-sm font-medium text-primary transition-colors hover:text-primary/80"
>
Read the playbook
<ArrowRight class="h-4 w-4 transition-transform group-hover:translate-x-1" />
</a>
</div>
</article>
))}
</div>
<div class="mt-10 text-center">
<a
href="/use-cases/"
class="inline-flex items-center gap-2 rounded-full border border-primary/40 bg-primary/10 px-6 py-2 text-sm font-semibold text-primary transition-colors hover:bg-primary/15"
>
View more use cases
<ArrowRight class="h-4 w-4" />
</a>
</div>
</div>
</section>

View File

@@ -0,0 +1,79 @@
import { useCases } from '@/lib/use-cases';
import { ArrowRight } from 'lucide-react';
const featured = useCases.slice(0, 3);
const more = useCases.slice(3);
export function FeaturedUseCases() {
return (
<div className="mt-8 grid gap-6 lg:grid-cols-3">
{featured.map((item) => (
<article key={item.slug} className="group relative flex flex-col rounded-3xl border border-primary/50 bg-primary/5 p-6 sm:p-7 shadow-lg shadow-primary/10 transition-all duration-300 hover:-translate-y-1">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{item.tags.map((tag) => (
<span key={tag} className="inline-flex items-center rounded-full border border-muted px-2.5 py-1">{tag}</span>
))}
</div>
<h2 className="mt-4 text-xl font-semibold sm:text-2xl text-foreground">{item.title}</h2>
<p className="mt-3 text-sm sm:text-base text-muted-foreground">{item.summary}</p>
<div className="mt-5 grid gap-3 text-sm text-muted-foreground">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/70">Pain point</h3>
<p>{item.painPoint}</p>
</div>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/70">Outcome</h3>
<p>{item.outcome}</p>
</div>
</div>
<a
href={`/use-cases/${item.slug}/`}
className="mt-6 inline-flex w-max items-center gap-2 rounded-full border border-primary/50 px-4 py-2 text-sm font-semibold text-primary transition-colors hover:bg-primary/10"
>
View playbook
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</a>
</article>
))}
</div>
);
}
export function MoreUseCases() {
return (
<div className="mt-8 grid gap-6 md:grid-cols-2">
{more.map((item) => (
<article key={item.slug} className="group relative flex flex-col rounded-3xl border border-muted bg-background/70 p-6 sm:p-7 transition-all duration-300 hover:-translate-y-1 hover:border-primary/40 hover:shadow-lg">
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{item.tags.map((tag) => (
<span key={tag} className="inline-flex items-center rounded-full border border-muted px-2.5 py-1">{tag}</span>
))}
</div>
<h2 className="mt-4 text-xl font-semibold sm:text-2xl text-foreground/90">{item.title}</h2>
<p className="mt-3 text-sm sm:text-base text-muted-foreground">{item.summary}</p>
<div className="mt-5 grid gap-3 text-sm text-muted-foreground">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/70">Pain point</h3>
<p>{item.painPoint}</p>
</div>
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground/70">Outcome</h3>
<p>{item.outcome}</p>
</div>
</div>
<a
href={`/use-cases/${item.slug}/`}
className="mt-6 inline-flex w-max items-center gap-2 rounded-full border border-primary/50 px-4 py-2 text-sm font-semibold text-primary transition-colors hover:bg-primary/10"
>
View playbook
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</a>
</article>
))}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}
>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
})
Button.displayName = "Button"
export { Button, buttonVariants }