Added search translation edge fn

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:53:20 +00:00
parent bb9422d1f0
commit a03c38c1ed
3 changed files with 183 additions and 16 deletions

View File

@ -2,15 +2,29 @@ import { supabase } from "@/integrations/supabase/client";
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
// Anti-spam: debounce tracking
let lastSearchTime = 0;
const SEARCH_COOLDOWN = 500; // ms between searches
async function callFetchMods(params: Record<string, string>) {
const now = Date.now();
if (now - lastSearchTime < SEARCH_COOLDOWN) {
await new Promise((r) => setTimeout(r, SEARCH_COOLDOWN - (now - lastSearchTime)));
}
lastSearchTime = Date.now();
const searchParams = new URLSearchParams(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}`,
Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY}`,
},
});
if (response.status === 429) {
throw new Error("تم تجاوز الحد المسموح، حاول مرة أخرى بعد قليل");
}
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
@ -23,13 +37,41 @@ export async function getUserProjects(username = "fxfelixzero") {
}
export async function searchMods(query: string, offset = 0, limit = 20) {
return callFetchMods({ action: "search", query, offset: String(offset), limit: String(limit) });
// Sanitize client-side too
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) });
}
export async function getProject(id: string) {
if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error("Invalid project id");
return callFetchMods({ action: "project", id });
}
export async function getProjectVersions(id: string) {
if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error("Invalid project id");
return callFetchMods({ action: "versions", id });
}
export async function translateQuery(query: string): Promise<{ translated: string; original: string }> {
const cleanQuery = query.slice(0, 200).trim();
if (!cleanQuery) return { translated: query, original: query };
const { data, error } = await supabase.functions.invoke("translate-search", {
body: { query: cleanQuery },
});
if (error) {
console.error("Translation error:", error);
return { translated: query, original: query };
}
return data;
}
// Random search keywords for homepage
const RANDOM_KEYWORDS = ["world", "mod", "skin", "map", "adventure", "survival", "building", "tools", "armor", "biome"];
export function getRandomKeyword(): string {
return RANDOM_KEYWORDS[Math.floor(Math.random() * RANDOM_KEYWORDS.length)];
}

View File

@ -1,16 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
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";
import { getUserProjects, searchMods, getRandomKeyword } from "@/lib/api";
const Index = () => {
const { data: mods, isLoading } = useQuery({
const randomKeyword = useMemo(() => getRandomKeyword(), []);
const { data: userMods, isLoading: loadingUser } = useQuery({
queryKey: ["user-projects"],
queryFn: () => getUserProjects(),
});
const { data: discoverData, isLoading: loadingDiscover } = useQuery({
queryKey: ["discover", randomKeyword],
queryFn: () => searchMods(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,
})) || [];
return (
<div className="flex min-h-screen flex-col">
<Navbar />
@ -19,8 +39,13 @@ const Index = () => {
<div className="container mx-auto px-4">
<ModGrid
title="إضافاتنا"
mods={mods || []}
isLoading={isLoading}
mods={userMods || []}
isLoading={loadingUser}
/>
<ModGrid
title="اكتشف إضافات جديدة 🔥"
mods={discoverMods}
isLoading={loadingDiscover}
/>
</div>
</main>

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import Navbar from "@/components/Navbar";
@ -6,25 +6,82 @@ 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";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Search, Languages, Loader2 } from "lucide-react";
import { searchMods, translateQuery } from "@/lib/api";
import { toast } from "sonner";
const SearchPage = () => {
const [searchParams, setSearchParams] = useSearchParams();
const initialQuery = searchParams.get("q") || "";
const [query, setQuery] = useState(initialQuery);
const [autoTranslate, setAutoTranslate] = useState(true);
const [translatedText, setTranslatedText] = useState<string | null>(null);
const [isTranslating, setIsTranslating] = useState(false);
// Spam prevention: track rapid submissions
const [lastSubmit, setLastSubmit] = useState(0);
const { data, isLoading } = useQuery({
queryKey: ["search", initialQuery],
queryFn: () => searchMods(initialQuery),
queryKey: ["search", initialQuery, translatedText],
queryFn: async () => {
const searchTerm = translatedText || initialQuery;
const result = await searchMods(searchTerm);
// 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);
setIsTranslating(false);
return translatedResult;
}
} catch {
toast.error("فشل في ترجمة البحث");
}
setIsTranslating(false);
}
return result;
},
enabled: !!initialQuery,
});
const handleSearch = (e: React.FormEvent) => {
const handleSearch = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
setSearchParams({ q: query.trim() });
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("انتظر قليلاً قبل البحث مرة أخرى");
return;
}
setLastSubmit(now);
setTranslatedText(null);
setSearchParams({ q: trimmed });
}, [query, lastSubmit, setSearchParams]);
const handleManualTranslate = async () => {
if (!initialQuery) return;
setIsTranslating(true);
try {
const { translated } = await translateQuery(initialQuery);
if (translated !== initialQuery) {
setTranslatedText(translated);
toast.success(`تمت الترجمة: "${translated}"`);
} else {
toast.info("النص بالفعل باللغة الإنجليزية");
}
} catch {
toast.error("فشل في ترجمة البحث");
}
setIsTranslating(false);
};
const mods = data?.hits?.map((hit: any) => ({
@ -46,13 +103,14 @@ const SearchPage = () => {
<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">
<form onSubmit={handleSearch} className="mb-4 flex gap-3">
<div className="relative flex-1">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="ابحث عن مود، حزمة موارد، خريطة..."
className="bg-secondary pr-10"
maxLength={200}
/>
<Search className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
@ -61,13 +119,55 @@ const SearchPage = () => {
</Button>
</form>
{/* Translation controls */}
<div className="mb-6 flex flex-wrap items-center gap-4">
<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>
</div>
{initialQuery && (
<Button
variant="outline"
size="sm"
onClick={handleManualTranslate}
disabled={isTranslating}
className="gap-1"
>
{isTranslating ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Languages className="h-3 w-3" />
)}
ترجم للإنجليزية
</Button>
)}
</div>
{initialQuery ? (
<>
<p className="mb-4 text-muted-foreground">
نتائج البحث عن: <span className="font-bold text-foreground">"{initialQuery}"</span>
{translatedText && (
<span className="mr-2 text-primary">
تمت الترجمة إلى: "{translatedText}"
</span>
)}
{data && ` (${data.total_hits || 0} نتيجة)`}
</p>
<ModGrid mods={mods} isLoading={isLoading} />
{isTranslating ? (
<div className="flex items-center justify-center gap-2 py-20 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
جاري الترجمة والبحث...
</div>
) : (
<ModGrid mods={mods} isLoading={isLoading} />
)}
</>
) : (
<div className="py-20 text-center text-muted-foreground">