Add a visually engaging Linux distribution discovery app

Implement a Tinder-style card swiping interface for discovering Linux distributions, featuring landscape screenshots, physics-based animations, and dynamic backgrounds.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e522d728-b28a-437f-b576-83935afe7ea0
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 214eff6b-5420-4bc2-8d15-c6c2ebb822ab
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/55e426a8-5a2b-421b-8bd4-30481ae0063b/e522d728-b28a-437f-b576-83935afe7ea0/3ouALIb
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
yusufipk
2025-12-17 13:34:14 +00:00
parent 51eacc7a1c
commit b1ca5c29fe
22 changed files with 601 additions and 5 deletions
+4
View File
@@ -3,6 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<meta name="description" content="DistroMatch - Find your perfect Linux distribution with a Tinder-style swipe experience. Discover desktop environments that match your personality." />
<meta property="og:title" content="DistroMatch - Find Your Perfect Linux Distro" />
<meta property="og:description" content="Swipe through beautiful Linux desktops and find the one that matches your style." />
<title>DistroMatch - Find Your Perfect Linux Distro</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+2 -3
View File
@@ -4,13 +4,12 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/not-found";
import Home from "@/pages/home";
function Router() {
return (
<Switch>
{/* Add pages below */}
{/* <Route path="/" component={Home}/> */}
{/* Fallback to 404 */}
<Route path="/" component={Home} />
<Route component={NotFound} />
</Switch>
);
+56
View File
@@ -0,0 +1,56 @@
import { Heart, X, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface ActionButtonsProps {
onLike: () => void;
onDislike: () => void;
onReset?: () => void;
showReset?: boolean;
disabled?: boolean;
}
export default function ActionButtons({
onLike,
onDislike,
onReset,
showReset = false,
disabled = false,
}: ActionButtonsProps) {
return (
<div className="flex items-center justify-center gap-6">
<Button
size="icon"
variant="outline"
className="h-16 w-16 rounded-full border-2 border-red-500/50 bg-transparent transition-all duration-200 hover:scale-110 hover:border-red-500 hover:bg-red-500/10"
onClick={onDislike}
disabled={disabled}
data-testid="button-dislike"
>
<X className="h-8 w-8 text-red-500" />
</Button>
{showReset && onReset && (
<Button
size="icon"
variant="outline"
className="h-12 w-12 rounded-full border-2 border-yellow-500/50 bg-transparent transition-all duration-200 hover:scale-110 hover:border-yellow-500 hover:bg-yellow-500/10"
onClick={onReset}
data-testid="button-reset"
>
<RotateCcw className="h-5 w-5 text-yellow-500" />
</Button>
)}
<Button
size="icon"
variant="outline"
className="h-16 w-16 rounded-full border-2 border-green-500/50 bg-transparent transition-all duration-200 hover:scale-110 hover:border-green-500 hover:bg-green-500/10"
onClick={onLike}
disabled={disabled}
data-testid="button-like"
>
<Heart className="h-8 w-8 text-green-500" fill="currentColor" />
</Button>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useState, useCallback } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import SwipeCard from './SwipeCard';
import ActionButtons from './ActionButtons';
import EndOfDeck from './EndOfDeck';
import type { DistroCard } from '@/data/distros';
interface CardStackProps {
cards: DistroCard[];
onBackgroundChange?: (colors: string[]) => void;
}
export default function CardStack({ cards, onBackgroundChange }: CardStackProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [likedCards, setLikedCards] = useState<DistroCard[]>([]);
const [dislikedCards, setDislikedCards] = useState<DistroCard[]>([]);
const visibleCards = cards.slice(currentIndex, currentIndex + 3);
const isFinished = currentIndex >= cards.length;
const handleSwipe = useCallback(
(direction: 'left' | 'right') => {
const currentCard = cards[currentIndex];
if (!currentCard) return;
if (direction === 'right') {
setLikedCards((prev) => [...prev, currentCard]);
console.log('Liked:', currentCard.distroName);
} else {
setDislikedCards((prev) => [...prev, currentCard]);
console.log('Disliked:', currentCard.distroName);
}
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex);
const nextCard = cards[nextIndex];
if (nextCard && onBackgroundChange) {
onBackgroundChange(nextCard.colorPalette);
}
},
[currentIndex, cards, onBackgroundChange]
);
const handleReset = useCallback(() => {
setCurrentIndex(0);
setLikedCards([]);
setDislikedCards([]);
if (cards[0] && onBackgroundChange) {
onBackgroundChange(cards[0].colorPalette);
}
}, [cards, onBackgroundChange]);
if (isFinished) {
return <EndOfDeck likedCards={likedCards} onReset={handleReset} />;
}
return (
<div className="flex flex-col items-center gap-8">
<div className="relative flex h-[550px] w-[900px] max-w-[90vw] items-center justify-center">
<AnimatePresence>
{visibleCards.map((card, index) => {
const isTop = index === 0;
const scale = 1 - index * 0.05;
const offsetY = index * 12;
const zIndex = visibleCards.length - index;
return (
<SwipeCard
key={card.id}
card={card}
isTop={isTop}
onSwipe={handleSwipe}
scale={scale}
zIndex={zIndex}
offsetY={offsetY}
/>
);
})}
</AnimatePresence>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<ActionButtons
onLike={() => handleSwipe('right')}
onDislike={() => handleSwipe('left')}
onReset={handleReset}
showReset={currentIndex > 0}
disabled={isFinished}
/>
</motion.div>
<div className="text-center text-sm text-white/60">
{currentIndex + 1} / {cards.length}
</div>
</div>
);
}
@@ -0,0 +1,23 @@
import { motion } from 'framer-motion';
interface DynamicBackgroundProps {
colors: string[];
}
export default function DynamicBackground({ colors }: DynamicBackgroundProps) {
const gradientColors = colors.length >= 2
? colors
: ['#6B46C1', '#4F46E5', '#2563EB'];
return (
<motion.div
className="fixed inset-0 -z-10"
animate={{
background: `radial-gradient(ellipse at center, ${gradientColors[0]}30 0%, ${gradientColors[1]}20 50%, ${gradientColors[2] || gradientColors[0]}10 100%)`,
}}
transition={{ duration: 0.5, ease: 'easeInOut' }}
>
<div className="absolute inset-0 bg-background/80 backdrop-blur-3xl" />
</motion.div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { motion } from 'framer-motion';
import { PartyPopper, RotateCcw, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { DistroCard } from '@/data/distros';
interface EndOfDeckProps {
likedCards: DistroCard[];
onReset: () => void;
}
export default function EndOfDeck({ likedCards, onReset }: EndOfDeckProps) {
return (
<motion.div
className="flex flex-col items-center gap-8 text-center"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 20 }}
>
<motion.div
animate={{
rotate: [0, -10, 10, -10, 10, 0],
scale: [1, 1.1, 1],
}}
transition={{ duration: 0.6, delay: 0.2 }}
>
<PartyPopper className="h-24 w-24 text-yellow-400" />
</motion.div>
<div>
<h2 className="font-mono text-3xl font-bold text-white" data-testid="text-end-title">
Öneriler Hazır!
</h2>
<p className="mt-2 text-lg text-white/70">
{likedCards.length > 0
? `${likedCards.length} dağıtım beğendin`
: 'Hiçbir dağıtım beğenmedin'}
</p>
</div>
{likedCards.length > 0 && (
<div className="flex flex-wrap justify-center gap-4">
{likedCards.map((card) => (
<motion.div
key={card.id}
className="overflow-hidden rounded-lg bg-white/10 backdrop-blur-sm"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
data-testid={`result-card-${card.id}`}
>
<img
src={card.screenshot}
alt={card.distroName}
className="h-24 w-40 object-cover"
/>
<div className="p-3">
<p className="font-semibold text-white">{card.distroName}</p>
<p className="text-xs text-white/60">{card.desktopEnvironment}</p>
</div>
</motion.div>
))}
</div>
)}
<div className="flex gap-4">
<Button
variant="outline"
className="gap-2 border-white/20 bg-white/10 text-white hover:bg-white/20"
onClick={onReset}
data-testid="button-restart"
>
<RotateCcw className="h-4 w-4" />
Tekrar Başla
</Button>
{likedCards.length > 0 && (
<Button
className="gap-2 bg-primary text-primary-foreground"
data-testid="button-download"
>
<Download className="h-4 w-4" />
İndir
</Button>
)}
</div>
</motion.div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { motion } from 'framer-motion';
import { Terminal } from 'lucide-react';
export default function Header() {
return (
<motion.header
className="fixed left-0 right-0 top-0 z-50 flex items-center justify-center p-6"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="flex items-center gap-3">
<div className="rounded-lg bg-primary p-2">
<Terminal className="h-6 w-6 text-primary-foreground" />
</div>
<h1 className="font-mono text-2xl font-bold text-white" data-testid="text-logo">
DistroMatch
</h1>
</div>
</motion.header>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { motion, useMotionValue, useTransform, PanInfo } from 'framer-motion';
import { Heart, X } from 'lucide-react';
import type { DistroCard } from '@/data/distros';
interface SwipeCardProps {
card: DistroCard;
isTop: boolean;
onSwipe: (direction: 'left' | 'right') => void;
scale?: number;
zIndex: number;
offsetY?: number;
}
export default function SwipeCard({
card,
isTop,
onSwipe,
scale = 1,
zIndex,
offsetY = 0,
}: SwipeCardProps) {
const x = useMotionValue(0);
const rotate = useTransform(x, [-300, 0, 300], [-15, 0, 15]);
const likeOpacity = useTransform(x, [0, 150], [0, 1]);
const nopeOpacity = useTransform(x, [-150, 0], [1, 0]);
const handleDragEnd = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
const threshold = 150;
if (info.offset.x > threshold) {
onSwipe('right');
} else if (info.offset.x < -threshold) {
onSwipe('left');
}
};
return (
<motion.div
className="absolute cursor-grab active:cursor-grabbing"
style={{
x: isTop ? x : 0,
rotate: isTop ? rotate : 0,
scale,
zIndex,
y: offsetY,
}}
drag={isTop ? 'x' : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.9}
onDragEnd={isTop ? handleDragEnd : undefined}
animate={{
scale,
y: offsetY,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 20,
}}
exit={{
x: x.get() > 0 ? 500 : -500,
opacity: 0,
rotate: x.get() > 0 ? 20 : -20,
transition: { duration: 0.3 },
}}
>
<div
className="relative w-[900px] max-w-[90vw] overflow-hidden rounded-2xl bg-black/90 shadow-2xl"
data-testid={`card-distro-${card.id}`}
>
<div className="relative aspect-video">
<img
src={card.screenshot}
alt={`${card.distroName} desktop`}
className="h-full w-full object-cover"
draggable={false}
/>
{isTop && (
<>
<motion.div
className="absolute inset-0 flex items-center justify-center bg-green-500/30"
style={{ opacity: likeOpacity }}
>
<div className="rounded-full border-4 border-white bg-green-500 p-6">
<Heart className="h-16 w-16 text-white" fill="white" />
</div>
</motion.div>
<motion.div
className="absolute inset-0 flex items-center justify-center bg-red-500/30"
style={{ opacity: nopeOpacity }}
>
<div className="rounded-full border-4 border-white bg-red-500 p-6">
<X className="h-16 w-16 text-white" />
</div>
</motion.div>
</>
)}
</div>
<div className="bg-black/80 px-8 py-6 backdrop-blur-md">
<h2
className="font-mono text-2xl font-bold text-white"
data-testid={`text-title-${card.id}`}
>
{card.wittyTitle}
</h2>
<div className="mt-2 flex flex-wrap items-center gap-3">
<span className="text-lg font-semibold text-white/90">
{card.distroName}
</span>
<span className="text-sm text-white/60">
{card.desktopEnvironment}
</span>
<div className="flex gap-2">
{card.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded-full bg-white/10 px-3 py-1 text-xs text-white/70"
>
{tag}
</span>
))}
</div>
</div>
</div>
</div>
</motion.div>
);
}
@@ -0,0 +1,14 @@
import ActionButtons from '../ActionButtons';
export default function ActionButtonsExample() {
return (
<div className="flex min-h-[200px] items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800 p-8">
<ActionButtons
onLike={() => console.log('Liked!')}
onDislike={() => console.log('Disliked!')}
onReset={() => console.log('Reset!')}
showReset={true}
/>
</div>
);
}
@@ -0,0 +1,13 @@
import CardStack from '../CardStack';
import { distroCards } from '@/data/distros';
export default function CardStackExample() {
return (
<div className="flex min-h-[750px] items-center justify-center bg-gradient-to-br from-purple-900/50 to-blue-900/50 p-8">
<CardStack
cards={distroCards}
onBackgroundChange={(colors) => console.log('Colors:', colors)}
/>
</div>
);
}
@@ -0,0 +1,15 @@
import EndOfDeck from '../EndOfDeck';
import { distroCards } from '@/data/distros';
export default function EndOfDeckExample() {
const likedCards = distroCards.slice(0, 3);
return (
<div className="flex min-h-[500px] items-center justify-center bg-gradient-to-br from-purple-900/50 to-blue-900/50 p-8">
<EndOfDeck
likedCards={likedCards}
onReset={() => console.log('Reset!')}
/>
</div>
);
}
@@ -0,0 +1,9 @@
import Header from '../Header';
export default function HeaderExample() {
return (
<div className="relative min-h-[100px] bg-gradient-to-br from-purple-900/50 to-blue-900/50">
<Header />
</div>
);
}
@@ -0,0 +1,21 @@
import SwipeCard from '../SwipeCard';
import { distroCards } from '@/data/distros';
export default function SwipeCardExample() {
const card = distroCards[0];
return (
<div className="flex min-h-[600px] items-center justify-center bg-gradient-to-br from-purple-900/50 to-blue-900/50 p-8">
<div className="relative">
<SwipeCard
card={card}
isTop={true}
onSwipe={(dir) => console.log('Swiped:', dir)}
scale={1}
zIndex={10}
offsetY={0}
/>
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import ubuntuScreenshot from '@assets/generated_images/ubuntu_gnome_desktop_screenshot.png';
import archScreenshot from '@assets/generated_images/arch_linux_i3_terminal_screenshot.png';
import elementaryScreenshot from '@assets/generated_images/elementary_os_mac-like_screenshot.png';
import fedoraScreenshot from '@assets/generated_images/fedora_kde_plasma_screenshot.png';
import mintScreenshot from '@assets/generated_images/linux_mint_cinnamon_screenshot.png';
import popScreenshot from '@assets/generated_images/pop_os_developer_screenshot.png';
export interface DistroCard {
id: string;
distroName: string;
screenshot: string;
wittyTitle: string;
desktopEnvironment: string;
tags: string[];
colorPalette: string[];
}
export const distroCards: DistroCard[] = [
{
id: '1',
distroName: 'Ubuntu',
screenshot: ubuntuScreenshot,
wittyTitle: 'Sadece çalışsın yeter',
desktopEnvironment: 'GNOME',
tags: ['beginner-friendly', 'stable', 'popular'],
colorPalette: ['#E95420', '#77216F', '#5E2750'],
},
{
id: '2',
distroName: 'Arch Linux',
screenshot: archScreenshot,
wittyTitle: 'Terminalden çıkmam',
desktopEnvironment: 'i3wm',
tags: ['advanced', 'minimal', 'customizable'],
colorPalette: ['#1793D1', '#333333', '#0D47A1'],
},
{
id: '3',
distroName: 'elementary OS',
screenshot: elementaryScreenshot,
wittyTitle: 'Mac gibi olsun',
desktopEnvironment: 'Pantheon',
tags: ['beautiful', 'simple', 'curated'],
colorPalette: ['#64BAFF', '#3689E6', '#0D52BF'],
},
{
id: '4',
distroName: 'Fedora',
screenshot: fedoraScreenshot,
wittyTitle: 'Her zaman güncel',
desktopEnvironment: 'KDE Plasma',
tags: ['cutting-edge', 'developer', 'reliable'],
colorPalette: ['#51A2DA', '#294172', '#3C6EB4'],
},
{
id: '5',
distroName: 'Linux Mint',
screenshot: mintScreenshot,
wittyTitle: 'Windows gibi ama özgür',
desktopEnvironment: 'Cinnamon',
tags: ['familiar', 'stable', 'beginner-friendly'],
colorPalette: ['#87CF3E', '#5FAD56', '#3E8E41'],
},
{
id: '6',
distroName: 'Pop!_OS',
screenshot: popScreenshot,
wittyTitle: 'Özelleştirme canavarı',
desktopEnvironment: 'COSMIC',
tags: ['gaming', 'developer', 'modern'],
colorPalette: ['#FAA41A', '#48B9C7', '#574F4A'],
},
];
+2 -2
View File
@@ -43,9 +43,9 @@
--chart-3: 245 85% 55%;
--chart-4: 310 70% 48%;
--chart-5: 230 80% 52%;
--font-sans: Open Sans, sans-serif;
--font-sans: Inter, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--font-mono: 'Space Grotesk', monospace;
--radius: .5rem;
--shadow-2xs: 0px 2px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(0 0% 0% / 0.00);
+29
View File
@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import Header from '@/components/Header';
import CardStack from '@/components/CardStack';
import DynamicBackground from '@/components/DynamicBackground';
import { distroCards } from '@/data/distros';
export default function Home() {
const [backgroundColors, setBackgroundColors] = useState<string[]>(
distroCards[0]?.colorPalette || ['#6B46C1', '#4F46E5', '#2563EB']
);
useEffect(() => {
document.documentElement.classList.add('dark');
}, []);
return (
<div className="relative min-h-screen w-full overflow-hidden">
<DynamicBackground colors={backgroundColors} />
<Header />
<main className="flex min-h-screen items-center justify-center pt-20">
<CardStack
cards={distroCards}
onBackgroundChange={setBackgroundColors}
/>
</main>
</div>
);
}