Unified mod sources display

Co-authored-by: felix-fx-top <253056634+felix-fx-top@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-03-31 10:41:42 +00:00 committed by lovable
parent b9e8a45cd6
commit dd34789ca7
5 changed files with 113 additions and 104 deletions

View File

@ -1,5 +1,5 @@
import { Link } from "react-router-dom";
import { Download, Heart, Eye } from "lucide-react";
import { Download, Heart } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@ -13,7 +13,7 @@ interface ModCardProps {
followers: number;
categories: string[];
projectType: string;
viewMode?: "grid" | "list";
viewMode?: "grid" | "list" | "compact";
source?: "modrinth" | "curseforge";
}
@ -34,10 +34,36 @@ const typeLabels: Record<string, string> = {
const ModCard = ({ id, slug, title, description, iconUrl, downloads, followers, categories, projectType, viewMode = "grid", source = "modrinth" }: ModCardProps) => {
const linkPath = source === "curseforge" ? `/curseforge/${id}` : `/mod/${slug || id}`;
// Compact view — minimal row
if (viewMode === "compact") {
return (
<Link to={linkPath}>
<div className="group flex items-center gap-3 rounded-lg border border-border/60 bg-card/50 px-3 py-2.5 transition-all duration-200 hover:border-primary/40 hover:bg-card">
<div className="h-9 w-9 shrink-0 overflow-hidden rounded-md 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-sm">🧊</div>
)}
</div>
<h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
{title}
</h3>
<span className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Download className="h-3 w-3" />
{formatNumber(downloads)}
</span>
</div>
</Link>
);
}
// List view — horizontal card
if (viewMode === "list") {
return (
<Link to={linkPath}>
<Card className="group overflow-hidden border-border bg-card transition-all duration-300 hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5">
<Card className="group overflow-hidden border-border/60 bg-card transition-all duration-200 hover:border-primary/40 hover:bg-card/80 hover:shadow-md hover:shadow-primary/5">
<CardContent className="flex items-center gap-3 p-3 sm:gap-4 sm:p-4">
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-secondary sm:h-14 sm:w-14">
{iconUrl ? (
@ -51,7 +77,7 @@ const ModCard = ({ id, slug, title, description, iconUrl, downloads, followers,
<h3 className="truncate text-sm font-bold text-foreground group-hover:text-primary transition-colors sm:text-base">
{title}
</h3>
<Badge variant="secondary" className="hidden shrink-0 text-xs sm:inline-flex">
<Badge variant="secondary" className="hidden shrink-0 text-[10px] sm:inline-flex">
{typeLabels[projectType] || projectType}
</Badge>
</div>
@ -73,9 +99,10 @@ const ModCard = ({ id, slug, title, description, iconUrl, downloads, followers,
);
}
// Grid view — card
return (
<Link to={linkPath}>
<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">
<Card className="group h-full overflow-hidden border-border/60 bg-card transition-all duration-200 hover:border-primary/40 hover:bg-card/80 hover:shadow-md hover:shadow-primary/5">
<CardContent className="flex gap-3 p-3 sm:gap-4 sm:p-4">
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-secondary sm:h-16 sm:w-16">
{iconUrl ? (
@ -89,7 +116,7 @@ const ModCard = ({ id, slug, title, description, iconUrl, downloads, followers,
<h3 className="truncate text-sm font-bold text-foreground group-hover:text-primary transition-colors sm:text-base">
{title}
</h3>
<Badge variant="secondary" className="hidden shrink-0 text-xs sm:inline-flex">
<Badge variant="secondary" className="hidden shrink-0 text-[10px] sm:inline-flex">
{typeLabels[projectType] || projectType}
</Badge>
</div>

View File

@ -1,8 +1,7 @@
import ModCard from "./ModCard";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { LayoutGrid, List, Monitor, Smartphone } from "lucide-react";
import { LayoutGrid, List, AlignJustify, Monitor, Smartphone } from "lucide-react";
import { useState } from "react";
interface Mod {
@ -37,7 +36,14 @@ const ModGrid = ({
onPlatformChange,
selectedPlatform = "all",
}: ModGridProps) => {
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [viewMode, setViewMode] = useState<"grid" | "list" | "compact">("grid");
const gridClass =
viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: viewMode === "list"
? "flex flex-col gap-2"
: "grid gap-1.5 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3";
return (
<section className="py-6 sm:py-8">
@ -70,25 +76,25 @@ const ModGrid = ({
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(val) => val && setViewMode(val as "grid" | "list")}
onValueChange={(val) => val && setViewMode(val as "grid" | "list" | "compact")}
className="rounded-lg border border-border bg-secondary/50 p-0.5"
>
<ToggleGroupItem value="grid" className="h-8 w-8 p-0 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
<ToggleGroupItem value="grid" className="h-8 w-8 p-0 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground" aria-label="شبكة">
<LayoutGrid className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="list" className="h-8 w-8 p-0 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
<ToggleGroupItem value="list" className="h-8 w-8 p-0 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground" aria-label="قائمة">
<List className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="compact" className="h-8 w-8 p-0 data-[state=on]:bg-primary data-[state=on]:text-primary-foreground" aria-label="مختصر">
<AlignJustify className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
)}
</div>
</div>
{isLoading ? (
<div className={viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-2"
}>
<div className={gridClass}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex gap-3 rounded-lg border border-border bg-card p-3 sm:gap-4 sm:p-4">
<Skeleton className="h-12 w-12 shrink-0 rounded-lg sm:h-16 sm:w-16" />
@ -105,13 +111,10 @@ const ModGrid = ({
لا توجد إضافات حالياً
</div>
) : (
<div className={viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-2"
}>
<div className={gridClass}>
{mods.map((mod) => (
<ModCard
key={mod.id}
key={`${mod.source || "m"}-${mod.id}`}
id={mod.id}
slug={mod.slug}
title={mod.title}

View File

@ -64,14 +64,11 @@ const CurseForgeDetails = () => {
{project.logo?.url ? (
<img src={project.logo.url} alt={project.name} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-4xl">🟠</div>
<div className="flex h-full w-full items-center justify-center text-4xl">🧊</div>
)}
</div>
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
<h1 className="text-3xl font-black">{project.name}</h1>
<Badge variant="outline" className="text-orange-400 border-orange-400">CurseForge</Badge>
</div>
<h1 className="mb-2 text-3xl font-black">{project.name}</h1>
<p className="mb-4 text-muted-foreground">{project.summary}</p>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
@ -95,7 +92,6 @@ const CurseForgeDetails = () => {
</div>
</div>
{/* Files / Versions */}
<Card>
<CardHeader>
<CardTitle>الملفات</CardTitle>

View File

@ -17,63 +17,67 @@ const Index = () => {
},
});
const { data: discoverData, isLoading: loadingDiscover } = useQuery({
queryKey: ["discover", randomKeyword],
queryFn: () => searchMods(randomKeyword, 0, 12),
const { data: discoverMods, isLoading: loadingDiscover } = useQuery({
queryKey: ["discover-all", randomKeyword],
queryFn: async () => {
const [modrinthData, cfData] = await Promise.all([
searchMods(randomKeyword, 0, 8),
searchCurseForge(randomKeyword, 0, 8),
]);
const modrinthMods = modrinthData?.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,
source: "modrinth" as const,
})) || [];
const cfMods = cfData?.data?.map((mod: any) => ({
id: String(mod.id),
slug: String(mod.id),
title: mod.name,
description: mod.summary,
icon_url: mod.logo?.url,
downloads: mod.downloadCount,
followers: mod.thumbsUpCount || 0,
categories: mod.categories?.map((c: any) => c.name) || [],
project_type: "mod",
source: "curseforge" as const,
})) || [];
// Interleave results for a mixed feel
const merged = [];
const maxLen = Math.max(modrinthMods.length, cfMods.length);
for (let i = 0; i < maxLen; i++) {
if (i < modrinthMods.length) merged.push(modrinthMods[i]);
if (i < cfMods.length) merged.push(cfMods[i]);
}
return merged;
},
});
const { data: cfData, isLoading: loadingCF } = useQuery({
queryKey: ["curseforge-discover", randomKeyword],
queryFn: () => searchCurseForge(randomKeyword, 0, 12),
});
const discoverMods = discoverData?.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,
source: "modrinth" as const,
})) || [];
const curseForgeMods = cfData?.data?.map((mod: any) => ({
id: String(mod.id),
slug: String(mod.id),
title: mod.name,
description: mod.summary,
icon_url: mod.logo?.url,
downloads: mod.downloadCount,
followers: mod.thumbsUpCount || 0,
categories: mod.categories?.map((c: any) => c.name) || [],
project_type: "mod",
source: "curseforge" as const,
})) || [];
return (
<div className="flex min-h-screen flex-col">
<Navbar />
<main className="flex-1">
<HeroSection />
<div className="container mx-auto px-4">
<div className="container mx-auto px-4 pb-8">
<ModGrid
title="إضافاتنا"
title="إضافاتنا ⚡"
mods={userMods || []}
isLoading={loadingUser}
/>
<ModGrid
title="اكتشف إضافات جديدة 🔥"
mods={discoverMods}
mods={discoverMods || []}
isLoading={loadingDiscover}
/>
<ModGrid
title="إضافات CurseForge 🟠"
mods={curseForgeMods}
isLoading={loadingCF}
/>
</div>
</main>
<Footer />

View File

@ -8,7 +8,6 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Search, Languages, Loader2 } from "lucide-react";
import { searchMods, searchCurseForge, translateQuery } from "@/lib/api";
import { toast } from "sonner";
@ -22,7 +21,6 @@ const SearchPage = () => {
const [isTranslating, setIsTranslating] = useState(false);
const [platform, setPlatform] = useState("all");
const [lastSubmit, setLastSubmit] = useState(0);
const [source, setSource] = useState<"all" | "modrinth" | "curseforge">("all");
const facets = platform === "java"
? '[["project_type:mod"],["categories:fabric","categories:forge","categories:neoforge","categories:quilt"]]'
@ -31,19 +29,14 @@ const SearchPage = () => {
: "";
const { data, isLoading } = useQuery({
queryKey: ["search", initialQuery, translatedText, platform, source],
queryKey: ["search", initialQuery, translatedText, platform],
queryFn: async () => {
const searchTerm = translatedText || initialQuery;
const modrinthPromise = source !== "curseforge"
? searchMods(searchTerm, 0, 20, facets)
: Promise.resolve({ hits: [], total_hits: 0 });
const cfPromise = source !== "modrinth"
? searchCurseForge(searchTerm, 0, 20)
: Promise.resolve({ data: [], pagination: { totalCount: 0 } });
const [modrinthResult, cfResult] = await Promise.all([modrinthPromise, cfPromise]);
const [modrinthResult, cfResult] = await Promise.all([
searchMods(searchTerm, 0, 20, facets),
searchCurseForge(searchTerm, 0, 20),
]);
const totalHits = (modrinthResult.total_hits || 0) + (cfResult.pagination?.totalCount || 0);
@ -54,8 +47,8 @@ const SearchPage = () => {
if (translated !== initialQuery) {
setTranslatedText(translated);
const [mr, cr] = await Promise.all([
source !== "curseforge" ? searchMods(translated, 0, 20, facets) : Promise.resolve({ hits: [], total_hits: 0 }),
source !== "modrinth" ? searchCurseForge(translated, 0, 20) : Promise.resolve({ data: [], pagination: { totalCount: 0 } }),
searchMods(translated, 0, 20, facets),
searchCurseForge(translated, 0, 20),
]);
setIsTranslating(false);
return { modrinth: mr, curseforge: cr };
@ -129,7 +122,13 @@ const SearchPage = () => {
source: "curseforge" as const,
})) || [];
const mods = [...modrinthMods, ...cfMods];
// Interleave results
const mods: typeof modrinthMods = [];
const maxLen = Math.max(modrinthMods.length, cfMods.length);
for (let i = 0; i < maxLen; i++) {
if (i < modrinthMods.length) mods.push(modrinthMods[i]);
if (i < cfMods.length) mods.push(cfMods[i]);
}
const totalHits = (data?.modrinth?.total_hits || 0) + (data?.curseforge?.pagination?.totalCount || 0);
return (
@ -185,26 +184,6 @@ const SearchPage = () => {
)}
</div>
{/* Source filter */}
<div className="mb-4 flex flex-wrap gap-2 sm:mb-6">
<ToggleGroup
type="single"
value={source}
onValueChange={(val) => val && setSource(val as "all" | "modrinth" | "curseforge")}
className="rounded-lg border border-border bg-secondary/50 p-0.5"
>
<ToggleGroupItem value="all" className="h-8 px-3 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
الكل
</ToggleGroupItem>
<ToggleGroupItem value="modrinth" className="h-8 px-3 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
Modrinth
</ToggleGroupItem>
<ToggleGroupItem value="curseforge" className="h-8 px-3 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
CurseForge
</ToggleGroupItem>
</ToggleGroup>
</div>
{initialQuery ? (
<>
<p className="mb-3 text-xs text-muted-foreground sm:mb-4 sm:text-sm">