Unified mod sources display
Co-authored-by: felix-fx-top <253056634+felix-fx-top@users.noreply.github.com>
This commit is contained in:
parent
b9e8a45cd6
commit
dd34789ca7
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user