Added platform filter and RTL
Implemented responsive ModGrid with grid/list toggle and added Java/Bedrock/Both platform filtering in the search page; updated API searchMods to accept facets for platform filtering. X-Lovable-Edit-ID: edt-303d9ae1-7736-4f74-80dd-e2250f2fed00 Co-authored-by: felix-fx-top <253056634+felix-fx-top@users.noreply.github.com>
This commit is contained in:
commit
1f93ef040b
@ -1,5 +1,5 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Download, Heart } from "lucide-react";
|
import { Download, Heart, Eye } from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
@ -13,6 +13,7 @@ interface ModCardProps {
|
|||||||
followers: number;
|
followers: number;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
projectType: string;
|
projectType: string;
|
||||||
|
viewMode?: "grid" | "list";
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatNumber = (num: number) => {
|
const formatNumber = (num: number) => {
|
||||||
@ -30,29 +31,68 @@ const typeLabels: Record<string, string> = {
|
|||||||
plugin: "إضافة سيرفر",
|
plugin: "إضافة سيرفر",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ModCard = ({ id, slug, title, description, iconUrl, downloads, followers, categories, projectType }: ModCardProps) => {
|
const ModCard = ({ id, slug, title, description, iconUrl, downloads, followers, categories, projectType, viewMode = "grid" }: ModCardProps) => {
|
||||||
|
if (viewMode === "list") {
|
||||||
|
return (
|
||||||
|
<Link to={`/mod/${slug || id}`}>
|
||||||
|
<Card className="group overflow-hidden border-border bg-card transition-all duration-300 hover:border-primary/50 hover:shadow-lg 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 ? (
|
||||||
|
<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-xl sm:text-2xl">🧊</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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">
|
||||||
|
{typeLabels[projectType] || projectType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-1 text-xs text-muted-foreground sm:text-sm">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
{formatNumber(downloads)}
|
||||||
|
</span>
|
||||||
|
<span className="hidden items-center gap-1 sm:flex">
|
||||||
|
<Heart className="h-3 w-3" />
|
||||||
|
{formatNumber(followers)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/mod/${slug || id}`}>
|
<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">
|
<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">
|
<CardContent className="flex gap-3 p-3 sm:gap-4 sm:p-4">
|
||||||
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-lg bg-secondary">
|
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-secondary sm:h-16 sm:w-16">
|
||||||
{iconUrl ? (
|
{iconUrl ? (
|
||||||
<img src={iconUrl} alt={title} className="h-full w-full object-cover" loading="lazy" />
|
<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 className="flex h-full w-full items-center justify-center text-xl sm:text-2xl">🧊</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="mb-1 flex items-start justify-between gap-2">
|
<div className="mb-1 flex items-start justify-between gap-2">
|
||||||
<h3 className="truncate font-bold text-foreground group-hover:text-primary transition-colors">
|
<h3 className="truncate text-sm font-bold text-foreground group-hover:text-primary transition-colors sm:text-base">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
<Badge variant="secondary" className="hidden shrink-0 text-xs sm:inline-flex">
|
||||||
{typeLabels[projectType] || projectType}
|
{typeLabels[projectType] || projectType}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-3 line-clamp-2 text-sm text-muted-foreground">{description}</p>
|
<p className="mb-2 line-clamp-2 text-xs text-muted-foreground sm:mb-3 sm:text-sm">{description}</p>
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground sm:gap-4">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Download className="h-3 w-3" />
|
<Download className="h-3 w-3" />
|
||||||
{formatNumber(downloads)}
|
{formatNumber(downloads)}
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import ModCard from "./ModCard";
|
import ModCard from "./ModCard";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 { useState } from "react";
|
||||||
|
|
||||||
interface Mod {
|
interface Mod {
|
||||||
id: string;
|
id: string;
|
||||||
@ -17,22 +21,79 @@ interface ModGridProps {
|
|||||||
mods: Mod[];
|
mods: Mod[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
showViewToggle?: boolean;
|
||||||
|
showPlatformFilter?: boolean;
|
||||||
|
onPlatformChange?: (platform: string) => void;
|
||||||
|
selectedPlatform?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModGrid = ({ mods, isLoading, title }: ModGridProps) => {
|
const ModGrid = ({
|
||||||
|
mods,
|
||||||
|
isLoading,
|
||||||
|
title,
|
||||||
|
showViewToggle = true,
|
||||||
|
showPlatformFilter = false,
|
||||||
|
onPlatformChange,
|
||||||
|
selectedPlatform = "all",
|
||||||
|
}: ModGridProps) => {
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-8">
|
<section className="py-6 sm:py-8">
|
||||||
{title && (
|
<div className="mb-4 flex flex-col gap-3 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="mb-6 text-2xl font-bold">{title}</h2>
|
{title && (
|
||||||
)}
|
<h2 className="text-xl font-bold sm:text-2xl">{title}</h2>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{showPlatformFilter && (
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={selectedPlatform}
|
||||||
|
onValueChange={(val) => val && onPlatformChange?.(val)}
|
||||||
|
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="java" className="h-8 px-3 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||||
|
<Monitor className="ml-1 h-3 w-3" />
|
||||||
|
جافا
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="bedrock" className="h-8 px-3 text-xs data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||||
|
<Smartphone className="ml-1 h-3 w-3" />
|
||||||
|
بيدروك
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
|
{showViewToggle && (
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={viewMode}
|
||||||
|
onValueChange={(val) => val && setViewMode(val as "grid" | "list")}
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<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"
|
||||||
|
}>
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div key={i} className="flex gap-4 rounded-lg border border-border bg-card p-4">
|
<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-16 w-16 shrink-0 rounded-lg" />
|
<Skeleton className="h-12 w-12 shrink-0 rounded-lg sm:h-16 sm:w-16" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-5 w-3/4" />
|
<Skeleton className="h-4 w-3/4 sm:h-5" />
|
||||||
<Skeleton className="h-4 w-full" />
|
<Skeleton className="h-3 w-full sm:h-4" />
|
||||||
<Skeleton className="h-3 w-1/2" />
|
<Skeleton className="h-3 w-1/2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -43,7 +104,10 @@ const ModGrid = ({ mods, isLoading, title }: ModGridProps) => {
|
|||||||
لا توجد إضافات حالياً
|
لا توجد إضافات حالياً
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<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"
|
||||||
|
}>
|
||||||
{mods.map((mod) => (
|
{mods.map((mod) => (
|
||||||
<ModCard
|
<ModCard
|
||||||
key={mod.id}
|
key={mod.id}
|
||||||
@ -56,6 +120,7 @@ const ModGrid = ({ mods, isLoading, title }: ModGridProps) => {
|
|||||||
followers={mod.followers}
|
followers={mod.followers}
|
||||||
categories={mod.categories || []}
|
categories={mod.categories || []}
|
||||||
projectType={mod.project_type}
|
projectType={mod.project_type}
|
||||||
|
viewMode={viewMode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -36,11 +36,12 @@ export async function getUserProjects(username = "fxfelixzero") {
|
|||||||
return callFetchMods({ action: "user_projects", username });
|
return callFetchMods({ action: "user_projects", username });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchMods(query: string, offset = 0, limit = 20) {
|
export async function searchMods(query: string, offset = 0, limit = 20, facets = "") {
|
||||||
// Sanitize client-side too
|
|
||||||
const cleanQuery = query.slice(0, 200).replace(/[<>{}]/g, "").trim();
|
const cleanQuery = query.slice(0, 200).replace(/[<>{}]/g, "").trim();
|
||||||
if (!cleanQuery) return { hits: [], total_hits: 0 };
|
if (!cleanQuery) return { hits: [], total_hits: 0 };
|
||||||
return callFetchMods({ action: "search", query: cleanQuery, offset: String(offset), limit: String(limit) });
|
const params: Record<string, string> = { action: "search", query: cleanQuery, offset: String(offset), limit: String(limit) };
|
||||||
|
if (facets) params.facets = facets;
|
||||||
|
return callFetchMods(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProject(id: string) {
|
export async function getProject(id: string) {
|
||||||
|
|||||||
@ -19,24 +19,28 @@ const SearchPage = () => {
|
|||||||
const [autoTranslate, setAutoTranslate] = useState(true);
|
const [autoTranslate, setAutoTranslate] = useState(true);
|
||||||
const [translatedText, setTranslatedText] = useState<string | null>(null);
|
const [translatedText, setTranslatedText] = useState<string | null>(null);
|
||||||
const [isTranslating, setIsTranslating] = useState(false);
|
const [isTranslating, setIsTranslating] = useState(false);
|
||||||
|
const [platform, setPlatform] = useState("all");
|
||||||
// Spam prevention: track rapid submissions
|
|
||||||
const [lastSubmit, setLastSubmit] = useState(0);
|
const [lastSubmit, setLastSubmit] = useState(0);
|
||||||
|
|
||||||
|
const facets = platform === "java"
|
||||||
|
? '[["project_type:mod"],["categories:fabric","categories:forge","categories:neoforge","categories:quilt"]]'
|
||||||
|
: platform === "bedrock"
|
||||||
|
? '[["project_type:mod"],["categories:bedrock"]]'
|
||||||
|
: "";
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["search", initialQuery, translatedText],
|
queryKey: ["search", initialQuery, translatedText, platform],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const searchTerm = translatedText || initialQuery;
|
const searchTerm = translatedText || initialQuery;
|
||||||
const result = await searchMods(searchTerm);
|
const result = await searchMods(searchTerm, 0, 20, facets);
|
||||||
|
|
||||||
// If no results and autoTranslate is on, try translating
|
|
||||||
if (autoTranslate && !translatedText && result.total_hits === 0 && initialQuery) {
|
if (autoTranslate && !translatedText && result.total_hits === 0 && initialQuery) {
|
||||||
setIsTranslating(true);
|
setIsTranslating(true);
|
||||||
try {
|
try {
|
||||||
const { translated } = await translateQuery(initialQuery);
|
const { translated } = await translateQuery(initialQuery);
|
||||||
if (translated !== initialQuery) {
|
if (translated !== initialQuery) {
|
||||||
setTranslatedText(translated);
|
setTranslatedText(translated);
|
||||||
const translatedResult = await searchMods(translated);
|
const translatedResult = await searchMods(translated, 0, 20, facets);
|
||||||
setIsTranslating(false);
|
setIsTranslating(false);
|
||||||
return translatedResult;
|
return translatedResult;
|
||||||
}
|
}
|
||||||
@ -56,7 +60,6 @@ const SearchPage = () => {
|
|||||||
const trimmed = query.trim().slice(0, 200);
|
const trimmed = query.trim().slice(0, 200);
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
// Anti-spam: minimum 1s between searches
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastSubmit < 1000) {
|
if (now - lastSubmit < 1000) {
|
||||||
toast.error("انتظر قليلاً قبل البحث مرة أخرى");
|
toast.error("انتظر قليلاً قبل البحث مرة أخرى");
|
||||||
@ -100,34 +103,34 @@ const SearchPage = () => {
|
|||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-3 py-6 sm:px-4 sm:py-8">
|
||||||
<h1 className="mb-6 text-3xl font-black">البحث عن إضافات</h1>
|
<h1 className="mb-4 text-2xl font-black sm:mb-6 sm:text-3xl">البحث عن إضافات</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="mb-4 flex gap-3">
|
<form onSubmit={handleSearch} className="mb-3 flex gap-2 sm:mb-4 sm:gap-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="ابحث عن مود، حزمة موارد، خريطة..."
|
placeholder="ابحث عن مود، حزمة موارد، خريطة..."
|
||||||
className="bg-secondary pr-10"
|
className="bg-secondary pr-10 text-sm sm:text-base"
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
/>
|
/>
|
||||||
<Search className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="font-bold">
|
<Button type="submit" className="font-bold text-sm sm:text-base">
|
||||||
بحث
|
بحث
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Translation controls */}
|
{/* Translation controls */}
|
||||||
<div className="mb-6 flex flex-wrap items-center gap-4">
|
<div className="mb-4 flex flex-col gap-3 sm:mb-6 sm:flex-row sm:items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="auto-translate"
|
id="auto-translate"
|
||||||
checked={autoTranslate}
|
checked={autoTranslate}
|
||||||
onCheckedChange={setAutoTranslate}
|
onCheckedChange={setAutoTranslate}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="auto-translate" className="text-sm text-muted-foreground">
|
<Label htmlFor="auto-translate" className="text-xs text-muted-foreground sm:text-sm">
|
||||||
ترجمة تلقائية عند عدم وجود نتائج
|
ترجمة تلقائية عند عدم وجود نتائج
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
@ -137,7 +140,7 @@ const SearchPage = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleManualTranslate}
|
onClick={handleManualTranslate}
|
||||||
disabled={isTranslating}
|
disabled={isTranslating}
|
||||||
className="gap-1"
|
className="gap-1 text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
{isTranslating ? (
|
{isTranslating ? (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
@ -151,7 +154,7 @@ const SearchPage = () => {
|
|||||||
|
|
||||||
{initialQuery ? (
|
{initialQuery ? (
|
||||||
<>
|
<>
|
||||||
<p className="mb-4 text-muted-foreground">
|
<p className="mb-3 text-xs text-muted-foreground sm:mb-4 sm:text-sm">
|
||||||
نتائج البحث عن: <span className="font-bold text-foreground">"{initialQuery}"</span>
|
نتائج البحث عن: <span className="font-bold text-foreground">"{initialQuery}"</span>
|
||||||
{translatedText && (
|
{translatedText && (
|
||||||
<span className="mr-2 text-primary">
|
<span className="mr-2 text-primary">
|
||||||
@ -166,7 +169,14 @@ const SearchPage = () => {
|
|||||||
جاري الترجمة والبحث...
|
جاري الترجمة والبحث...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ModGrid mods={mods} isLoading={isLoading} />
|
<ModGrid
|
||||||
|
mods={mods}
|
||||||
|
isLoading={isLoading}
|
||||||
|
showViewToggle
|
||||||
|
showPlatformFilter
|
||||||
|
selectedPlatform={platform}
|
||||||
|
onPlatformChange={setPlatform}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user