Built FXCraft pages & UI
Added RTL Arabic landing UI with Hero, Navbar, Mod cards/grid, and Footer, plus ModDetails and Search pages. Implemented Modrinth proxy Edge function (fetch-mods) in Supabase and connected frontend API helpers to fetch user projects, project details, versions, and search results. X-Lovable-Edit-ID: edt-fabb3f35-6dfb-481a-907b-26ddf8ee71fd Co-authored-by: felix-fx-top <253056634+felix-fx-top@users.noreply.github.com>
This commit is contained in:
commit
5db5a734ae
24
index.html
24
index.html
@ -1,24 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ar" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- TODO: Set the document title to the name of your application -->
|
||||
<title>Lovable App</title>
|
||||
<meta name="description" content="Lovable Generated Project" />
|
||||
<meta name="author" content="Lovable" />
|
||||
|
||||
<!-- TODO: Update og:title to match your application name -->
|
||||
<meta property="og:title" content="Lovable App" />
|
||||
<meta property="og:description" content="Lovable Generated Project" />
|
||||
<title>FXCraft - إضافات ماين كرافت</title>
|
||||
<meta name="description" content="FXCraft - أفضل إضافات ماين كرافت العربية. تصفح وحمّل المودات وحزم الموارد والخرائط." />
|
||||
<meta name="author" content="FXCraft" />
|
||||
<meta property="og:title" content="FXCraft - إضافات ماين كرافت" />
|
||||
<meta property="og:description" content="أفضل إضافات ماين كرافت العربية. تصفح وحمّل المودات وحزم الموارد والخرائط." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@Lovable" />
|
||||
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;600;700;800;900&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
43
src/App.css
43
src/App.css
@ -1,42 +1 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
/* FXCraft global styles */
|
||||
|
||||
@ -3,8 +3,10 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import Index from "./pages/Index.tsx";
|
||||
import NotFound from "./pages/NotFound.tsx";
|
||||
import Index from "./pages/Index";
|
||||
import ModDetails from "./pages/ModDetails";
|
||||
import SearchPage from "./pages/SearchPage";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@ -16,7 +18,8 @@ const App = () => (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="/mod/:id" element={<ModDetails />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
17
src/components/Footer.tsx
Normal file
17
src/components/Footer.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="border-t border-border bg-card/50 py-8">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<div className="mb-2">
|
||||
<span className="text-xl font-bold text-primary">FX</span>
|
||||
<span className="text-xl font-bold text-foreground">Craft</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} FXCraft. جميع الحقوق محفوظة.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
41
src/components/HeroSection.tsx
Normal file
41
src/components/HeroSection.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pickaxe, Download } from "lucide-react";
|
||||
|
||||
const HeroSection = () => {
|
||||
return (
|
||||
<section className="relative overflow-hidden py-20 md:py-32">
|
||||
{/* Glow effect */}
|
||||
<div className="absolute left-1/2 top-0 -translate-x-1/2 h-[500px] w-[800px] rounded-full bg-primary/10 blur-[120px]" />
|
||||
|
||||
<div className="container relative mx-auto px-4 text-center">
|
||||
<div className="mx-auto max-w-3xl animate-fade-in">
|
||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-primary/30 bg-primary/10 px-4 py-2 text-sm text-primary">
|
||||
<Pickaxe className="h-4 w-4" />
|
||||
<span>أفضل إضافات ماين كرافت</span>
|
||||
</div>
|
||||
|
||||
<h1 className="mb-6 text-4xl font-black leading-tight md:text-6xl">
|
||||
اكتشف عالماً جديداً من{" "}
|
||||
<span className="text-primary">إضافات ماين كرافت</span>
|
||||
</h1>
|
||||
|
||||
<p className="mb-8 text-lg text-muted-foreground md:text-xl">
|
||||
تصفّح وحمّل أفضل المودات وحزم الموارد والإضافات لتحسين تجربة لعبك
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<Button asChild size="lg" className="gap-2 text-base font-bold">
|
||||
<Link to="/search">
|
||||
<Download className="h-5 w-5" />
|
||||
تصفح الإضافات
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
||||
72
src/components/ModCard.tsx
Normal file
72
src/components/ModCard.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Download, Heart } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface ModCardProps {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
iconUrl?: string;
|
||||
downloads: number;
|
||||
followers: number;
|
||||
categories: string[];
|
||||
projectType: string;
|
||||
}
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + "M";
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + "K";
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
mod: "مود",
|
||||
resourcepack: "حزمة موارد",
|
||||
datapack: "حزمة بيانات",
|
||||
shader: "شيدر",
|
||||
modpack: "حزمة مودات",
|
||||
plugin: "إضافة سيرفر",
|
||||
};
|
||||
|
||||
const ModCard = ({ id, slug, title, description, iconUrl, downloads, followers, categories, projectType }: ModCardProps) => {
|
||||
return (
|
||||
<Link to={`/mod/${slug || id}`}>
|
||||
<Card className="group h-full overflow-hidden border-border bg-card transition-all duration-300 hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5">
|
||||
<CardContent className="flex gap-4 p-4">
|
||||
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-lg bg-secondary">
|
||||
{iconUrl ? (
|
||||
<img src={iconUrl} alt={title} className="h-full w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-2xl">🧊</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-start justify-between gap-2">
|
||||
<h3 className="truncate font-bold text-foreground group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{typeLabels[projectType] || projectType}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mb-3 line-clamp-2 text-sm text-muted-foreground">{description}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="h-3 w-3" />
|
||||
{formatNumber(downloads)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-3 w-3" />
|
||||
{formatNumber(followers)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModCard;
|
||||
67
src/components/ModGrid.tsx
Normal file
67
src/components/ModGrid.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import ModCard from "./ModCard";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface Mod {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon_url?: string;
|
||||
downloads: number;
|
||||
followers: number;
|
||||
categories: string[];
|
||||
project_type: string;
|
||||
}
|
||||
|
||||
interface ModGridProps {
|
||||
mods: Mod[];
|
||||
isLoading?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const ModGrid = ({ mods, isLoading, title }: ModGridProps) => {
|
||||
return (
|
||||
<section className="py-8">
|
||||
{title && (
|
||||
<h2 className="mb-6 text-2xl font-bold">{title}</h2>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4 rounded-lg border border-border bg-card p-4">
|
||||
<Skeleton className="h-16 w-16 shrink-0 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : mods.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
لا توجد إضافات حالياً
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{mods.map((mod) => (
|
||||
<ModCard
|
||||
key={mod.id}
|
||||
id={mod.id}
|
||||
slug={mod.slug}
|
||||
title={mod.title}
|
||||
description={mod.description}
|
||||
iconUrl={mod.icon_url}
|
||||
downloads={mod.downloads}
|
||||
followers={mod.followers}
|
||||
categories={mod.categories || []}
|
||||
projectType={mod.project_type}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModGrid;
|
||||
94
src/components/Navbar.tsx
Normal file
94
src/components/Navbar.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Search, Menu, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const Navbar = () => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
setSearchQuery("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-md">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-3">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-primary">FX</span>
|
||||
<span className="text-2xl font-bold text-foreground">Craft</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<Link to="/" className="text-muted-foreground transition-colors hover:text-foreground">
|
||||
الرئيسية
|
||||
</Link>
|
||||
<Link to="/search" className="text-muted-foreground transition-colors hover:text-foreground">
|
||||
تصفح
|
||||
</Link>
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="ابحث عن إضافة..."
|
||||
className="w-64 bg-secondary pr-10"
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Mobile toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X /> : <Menu />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="border-t border-border px-4 pb-4 md:hidden">
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
الرئيسية
|
||||
</Link>
|
||||
<Link
|
||||
to="/search"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
تصفح
|
||||
</Link>
|
||||
<form onSubmit={handleSearch}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="ابحث عن إضافة..."
|
||||
className="bg-secondary pr-10"
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
105
src/index.css
105
src/index.css
@ -2,95 +2,46 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
|
||||
All colors MUST be HSL.
|
||||
*/
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--background: 240 30% 11%;
|
||||
--foreground: 210 40% 96%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card: 240 25% 14%;
|
||||
--card-foreground: 210 40% 96%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--popover: 240 25% 14%;
|
||||
--popover-foreground: 210 40% 96%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--primary: 122 39% 49%;
|
||||
--primary-foreground: 240 30% 6%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 240 20% 20%;
|
||||
--secondary-foreground: 210 40% 96%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--muted: 240 15% 18%;
|
||||
--muted-foreground: 215 15% 55%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--accent: 122 39% 49%;
|
||||
--accent-foreground: 240 30% 6%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--border: 240 15% 22%;
|
||||
--input: 240 15% 22%;
|
||||
--ring: 122 39% 49%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
--radius: 0.75rem;
|
||||
|
||||
--sidebar-background: 0 0% 98%;
|
||||
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
|
||||
--sidebar-border: 220 13% 91%;
|
||||
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-background: 240 25% 10%;
|
||||
--sidebar-foreground: 210 40% 96%;
|
||||
--sidebar-primary: 122 39% 49%;
|
||||
--sidebar-primary-foreground: 240 30% 6%;
|
||||
--sidebar-accent: 240 15% 18%;
|
||||
--sidebar-accent-foreground: 210 40% 96%;
|
||||
--sidebar-border: 240 15% 22%;
|
||||
--sidebar-ring: 122 39% 49%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -100,6 +51,6 @@ All colors MUST be HSL.
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground font-cairo;
|
||||
}
|
||||
}
|
||||
|
||||
42
src/lib/api.ts
Normal file
42
src/lib/api.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
|
||||
|
||||
async function callFetchMods(params: Record<string, string>) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
const { data, error } = await supabase.functions.invoke("fetch-mods", {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: null,
|
||||
});
|
||||
|
||||
// Use direct fetch for GET with query params
|
||||
const url = `${SUPABASE_URL}/functions/v1/fetch-mods?${searchParams.toString()}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getUserProjects(username = "fxfelixzero") {
|
||||
return callFetchMods({ action: "user_projects", username });
|
||||
}
|
||||
|
||||
export async function searchMods(query: string, offset = 0, limit = 20) {
|
||||
return callFetchMods({ action: "search", query, offset: String(offset), limit: String(limit) });
|
||||
}
|
||||
|
||||
export async function getProject(id: string) {
|
||||
return callFetchMods({ action: "project", id });
|
||||
}
|
||||
|
||||
export async function getProjectVersions(id: string) {
|
||||
return callFetchMods({ action: "versions", id });
|
||||
}
|
||||
@ -1,16 +1,32 @@
|
||||
// Update this page (the content is just a fallback if you fail to update the page)
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import HeroSection from "@/components/HeroSection";
|
||||
import ModGrid from "@/components/ModGrid";
|
||||
import Footer from "@/components/Footer";
|
||||
import { getUserProjects } from "@/lib/api";
|
||||
|
||||
const Index = () => {
|
||||
const { data: mods, isLoading } = useQuery({
|
||||
queryKey: ["user-projects"],
|
||||
queryFn: () => getUserProjects(),
|
||||
});
|
||||
|
||||
// IMPORTANT: Fully REPLACE this with your own code
|
||||
const PlaceholderIndex = () => {
|
||||
// PLACEHOLDER: Replace this entire return statement with the user's app.
|
||||
// The inline background color is intentionally not part of the design system.
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center" style={{ backgroundColor: '#fcfbf8' }}>
|
||||
<img data-lovable-blank-page-placeholder="REMOVE_THIS" src="/placeholder.svg" alt="Your app will live here!" />
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1">
|
||||
<HeroSection />
|
||||
<div className="container mx-auto px-4">
|
||||
<ModGrid
|
||||
title="إضافاتنا"
|
||||
mods={mods || []}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Index = PlaceholderIndex;
|
||||
|
||||
export default Index;
|
||||
|
||||
172
src/pages/ModDetails.tsx
Normal file
172
src/pages/ModDetails.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Footer from "@/components/Footer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Download, Heart, Calendar, ExternalLink } from "lucide-react";
|
||||
import { getProject, getProjectVersions } from "@/lib/api";
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + "M";
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + "K";
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const ModDetails = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const { data: project, isLoading: loadingProject } = useQuery({
|
||||
queryKey: ["project", id],
|
||||
queryFn: () => getProject(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const { data: versions, isLoading: loadingVersions } = useQuery({
|
||||
queryKey: ["versions", id],
|
||||
queryFn: () => getProjectVersions(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{loadingProject ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-6">
|
||||
<Skeleton className="h-24 w-24 rounded-xl" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : project ? (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col gap-6 md:flex-row">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-xl bg-secondary">
|
||||
{project.icon_url ? (
|
||||
<img src={project.icon_url} alt={project.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-4xl">🧊</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="mb-2 text-3xl font-black">{project.title}</h1>
|
||||
<p className="mb-4 text-muted-foreground">{project.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
{formatNumber(project.downloads)} تحميل
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-4 w-4" />
|
||||
{formatNumber(project.followers)} متابع
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{formatDate(project.published)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{project.categories?.map((cat: string) => (
|
||||
<Badge key={cat} variant="secondary">{cat}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{project.body && (
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle>الوصف</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: project.body }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Versions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>الإصدارات</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingVersions ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : versions && versions.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{versions.slice(0, 10).map((version: any) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="flex flex-col gap-3 rounded-lg border border-border bg-secondary/50 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="font-bold">{version.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{version.version_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
{version.game_versions?.slice(0, 5).map((gv: string) => (
|
||||
<span key={gv}>{gv}</span>
|
||||
))}
|
||||
{version.loaders?.map((l: string) => (
|
||||
<Badge key={l} variant="secondary" className="text-xs">{l}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{version.files?.[0] && (
|
||||
<Button asChild size="sm" className="gap-1 shrink-0">
|
||||
<a href={version.files[0].url} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="h-4 w-4" />
|
||||
تحميل
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">لا توجد إصدارات متاحة</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-20 text-center text-muted-foreground">
|
||||
الإضافة غير موجودة
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModDetails;
|
||||
84
src/pages/SearchPage.tsx
Normal file
84
src/pages/SearchPage.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import ModGrid from "@/components/ModGrid";
|
||||
import Footer from "@/components/Footer";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search } from "lucide-react";
|
||||
import { searchMods } from "@/lib/api";
|
||||
|
||||
const SearchPage = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialQuery = searchParams.get("q") || "";
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["search", initialQuery],
|
||||
queryFn: () => searchMods(initialQuery),
|
||||
enabled: !!initialQuery,
|
||||
});
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
setSearchParams({ q: query.trim() });
|
||||
}
|
||||
};
|
||||
|
||||
const mods = data?.hits?.map((hit: any) => ({
|
||||
id: hit.project_id,
|
||||
slug: hit.slug,
|
||||
title: hit.title,
|
||||
description: hit.description,
|
||||
icon_url: hit.icon_url,
|
||||
downloads: hit.downloads,
|
||||
followers: hit.follows,
|
||||
categories: hit.categories || [],
|
||||
project_type: hit.project_type,
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="mb-6 text-3xl font-black">البحث عن إضافات</h1>
|
||||
|
||||
<form onSubmit={handleSearch} className="mb-8 flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="ابحث عن مود، حزمة موارد، خريطة..."
|
||||
className="bg-secondary pr-10"
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
<Button type="submit" className="font-bold">
|
||||
بحث
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{initialQuery ? (
|
||||
<>
|
||||
<p className="mb-4 text-muted-foreground">
|
||||
نتائج البحث عن: <span className="font-bold text-foreground">"{initialQuery}"</span>
|
||||
{data && ` (${data.total_hits || 0} نتيجة)`}
|
||||
</p>
|
||||
<ModGrid mods={mods} isLoading={isLoading} />
|
||||
</>
|
||||
) : (
|
||||
<div className="py-20 text-center text-muted-foreground">
|
||||
ابدأ بالبحث عن إضافتك المفضلة
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPage;
|
||||
88
supabase/functions/fetch-mods/index.ts
Normal file
88
supabase/functions/fetch-mods/index.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
const MODRINTH_BASE = "https://api.modrinth.com/v2";
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = Deno.env.get("MODRINTH_API_KEY");
|
||||
const url = new URL(req.url);
|
||||
const action = url.searchParams.get("action");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": "FXCraft/1.0",
|
||||
};
|
||||
if (apiKey) {
|
||||
headers["Authorization"] = apiKey;
|
||||
}
|
||||
|
||||
let modrinthUrl = "";
|
||||
|
||||
switch (action) {
|
||||
case "user_projects": {
|
||||
const username = url.searchParams.get("username") || "fxfelixzero";
|
||||
modrinthUrl = `${MODRINTH_BASE}/user/${username}/projects`;
|
||||
break;
|
||||
}
|
||||
case "search": {
|
||||
const query = url.searchParams.get("query") || "";
|
||||
const facets = url.searchParams.get("facets") || "";
|
||||
const offset = url.searchParams.get("offset") || "0";
|
||||
const limit = url.searchParams.get("limit") || "20";
|
||||
modrinthUrl = `${MODRINTH_BASE}/search?query=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}`;
|
||||
if (facets) {
|
||||
modrinthUrl += `&facets=${encodeURIComponent(facets)}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "project": {
|
||||
const id = url.searchParams.get("id");
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: "Missing project id" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
modrinthUrl = `${MODRINTH_BASE}/project/${id}`;
|
||||
break;
|
||||
}
|
||||
case "versions": {
|
||||
const id = url.searchParams.get("id");
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: "Missing project id" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
modrinthUrl = `${MODRINTH_BASE}/project/${id}/version`;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return new Response(JSON.stringify({ error: "Invalid action" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(modrinthUrl, { headers });
|
||||
const data = await response.json();
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: response.status,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -13,6 +13,9 @@ export default {
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
cairo: ["Cairo", "sans-serif"],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
@ -65,25 +68,22 @@ export default {
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: {
|
||||
height: "0",
|
||||
},
|
||||
to: {
|
||||
height: "var(--radix-accordion-content-height)",
|
||||
},
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: {
|
||||
height: "var(--radix-accordion-content-height)",
|
||||
},
|
||||
to: {
|
||||
height: "0",
|
||||
},
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
"fade-in": {
|
||||
from: { opacity: "0", transform: "translateY(10px)" },
|
||||
to: { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"fade-in": "fade-in 0.5s ease-out forwards",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user