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:
gpt-engineer-app[bot] 2026-03-30 13:57:05 +00:00
commit 1f93ef040b
4 changed files with 156 additions and 40 deletions

View File

@ -1,5 +1,5 @@
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 { Badge } from "@/components/ui/badge";
@ -13,6 +13,7 @@ interface ModCardProps {
followers: number;
categories: string[];
projectType: string;
viewMode?: "grid" | "list";
}
const formatNumber = (num: number) => {
@ -30,29 +31,68 @@ const typeLabels: Record<string, string> = {
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 (
<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">
<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 ? (
<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 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">
<h3 className="truncate text-sm font-bold text-foreground group-hover:text-primary transition-colors sm:text-base">
{title}
</h3>
<Badge variant="secondary" className="shrink-0 text-xs">
<Badge variant="secondary" className="hidden shrink-0 text-xs sm:inline-flex">
{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">
<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-3 text-xs text-muted-foreground sm:gap-4">
<span className="flex items-center gap-1">
<Download className="h-3 w-3" />
{formatNumber(downloads)}

View File

@ -1,5 +1,9 @@
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 { useState } from "react";
interface Mod {
id: string;
@ -17,22 +21,79 @@ interface ModGridProps {
mods: Mod[];
isLoading?: boolean;
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 (
<section className="py-8">
{title && (
<h2 className="mb-6 text-2xl font-bold">{title}</h2>
)}
<section className="py-6 sm:py-8">
<div className="mb-4 flex flex-col gap-3 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
{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 ? (
<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) => (
<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 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" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4 sm:h-5" />
<Skeleton className="h-3 w-full sm:h-4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
@ -43,7 +104,10 @@ const ModGrid = ({ mods, isLoading, title }: ModGridProps) => {
لا توجد إضافات حالياً
</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) => (
<ModCard
key={mod.id}
@ -56,6 +120,7 @@ const ModGrid = ({ mods, isLoading, title }: ModGridProps) => {
followers={mod.followers}
categories={mod.categories || []}
projectType={mod.project_type}
viewMode={viewMode}
/>
))}
</div>

View File

@ -36,11 +36,12 @@ export async function getUserProjects(username = "fxfelixzero") {
return callFetchMods({ action: "user_projects", username });
}
export async function searchMods(query: string, offset = 0, limit = 20) {
// Sanitize client-side too
export async function searchMods(query: string, offset = 0, limit = 20, facets = "") {
const cleanQuery = query.slice(0, 200).replace(/[<>{}]/g, "").trim();
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) {

View File

@ -19,24 +19,28 @@ const SearchPage = () => {
const [autoTranslate, setAutoTranslate] = useState(true);
const [translatedText, setTranslatedText] = useState<string | null>(null);
const [isTranslating, setIsTranslating] = useState(false);
// Spam prevention: track rapid submissions
const [platform, setPlatform] = useState("all");
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({
queryKey: ["search", initialQuery, translatedText],
queryKey: ["search", initialQuery, translatedText, platform],
queryFn: async () => {
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) {
setIsTranslating(true);
try {
const { translated } = await translateQuery(initialQuery);
if (translated !== initialQuery) {
setTranslatedText(translated);
const translatedResult = await searchMods(translated);
const translatedResult = await searchMods(translated, 0, 20, facets);
setIsTranslating(false);
return translatedResult;
}
@ -56,7 +60,6 @@ const SearchPage = () => {
const trimmed = query.trim().slice(0, 200);
if (!trimmed) return;
// Anti-spam: minimum 1s between searches
const now = Date.now();
if (now - lastSubmit < 1000) {
toast.error("انتظر قليلاً قبل البحث مرة أخرى");
@ -100,34 +103,34 @@ const SearchPage = () => {
<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>
<div className="container mx-auto px-3 py-6 sm:px-4 sm:py-8">
<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">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="ابحث عن مود، حزمة موارد، خريطة..."
className="bg-secondary pr-10"
className="bg-secondary pr-10 text-sm sm:text-base"
maxLength={200}
/>
<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 type="submit" className="font-bold text-sm sm:text-base">
بحث
</Button>
</form>
{/* 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">
<Switch
id="auto-translate"
checked={autoTranslate}
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>
</div>
@ -137,7 +140,7 @@ const SearchPage = () => {
size="sm"
onClick={handleManualTranslate}
disabled={isTranslating}
className="gap-1"
className="gap-1 text-xs sm:text-sm"
>
{isTranslating ? (
<Loader2 className="h-3 w-3 animate-spin" />
@ -151,7 +154,7 @@ const SearchPage = () => {
{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>
{translatedText && (
<span className="mr-2 text-primary">
@ -166,7 +169,14 @@ const SearchPage = () => {
جاري الترجمة والبحث...
</div>
) : (
<ModGrid mods={mods} isLoading={isLoading} />
<ModGrid
mods={mods}
isLoading={isLoading}
showViewToggle
showPlatformFilter
selectedPlatform={platform}
onPlatformChange={setPlatform}
/>
)}
</>
) : (