spb/trouve-ka Public
Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com
Python 76.8%
TypeScript 15.7%
SQL 3.9%
Shell 1.4%
CSS 1.3%
Dockerfile 0.7%
1/**2 * Trouve-KA — contenu client de la page de résultats3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 */67"use client";89import Link from "next/link";10import { useRouter, useSearchParams } from "next/navigation";11import { useEffect, useState } from "react";12import { FilterPills, type SearchFilters } from "@/components/filter-pills";13import { GallerySkeleton, ImageGallery } from "@/components/image-gallery";14import { ResultItem } from "@/components/result-item";15import { SearchBar } from "@/components/search-bar";16import { Button } from "@/components/ui/button";17import { formatNumber } from "@/lib/format";18import type { SearchResponse } from "@/lib/types";1920const PAGE_SIZE = 10;21const GALLERY_PAGE_SIZE = 24;2223function buildSearchUrl(24 q: string,25 page: number,26 filters: SearchFilters,27): string {28 const params = new URLSearchParams({ q });29 if (page > 1) params.set("page", String(page));30 if (filters.category) params.set("category", filters.category);31 if (filters.language) params.set("language", filters.language);32 if (filters.quebecOnly) params.set("quebec_only", "true");33 if (filters.images) params.set("images", "true");34 return `/search?${params.toString()}`;35}3637function ResultsSkeleton() {38 return (39 <div aria-hidden="true" className="divide-y divide-line">40 {Array.from({ length: 5 }, (_, i) => (41 <div42 key={i}43 className="flex animate-pulse items-start gap-4 py-6 sm:gap-6"44 >45 <div className="min-w-0 flex-1 space-y-2.5">46 <div className="h-3.5 w-1/3 rounded bg-[rgba(20,24,20,0.07)]" />47 <div className="h-5 w-2/3 rounded bg-line" />48 <div className="h-3.5 w-full rounded bg-[rgba(20,24,20,0.07)]" />49 <div className="h-3.5 w-5/6 rounded bg-[rgba(20,24,20,0.07)]" />50 <div className="flex gap-2 pt-0.5">51 <div className="h-[22px] w-[72px] rounded-[5px] bg-[rgba(20,24,20,0.07)]" />52 <div className="h-[22px] w-24 rounded-[5px] bg-[rgba(20,24,20,0.07)]" />53 </div>54 </div>55 {i % 2 === 0 ? (56 <div className="h-[76px] w-[76px] shrink-0 rounded-card bg-[rgba(20,24,20,0.07)] sm:h-[110px] sm:w-[110px]" />57 ) : null}58 </div>59 ))}60 </div>61 );62}6364export function SearchContent() {65 const router = useRouter();66 const searchParams = useSearchParams();6768 const q = searchParams.get("q")?.trim() ?? "";69 const page = Math.max(1, Number.parseInt(searchParams.get("page") ?? "1", 10) || 1);70 const filters: SearchFilters = {71 category: searchParams.get("category") ?? undefined,72 language: searchParams.get("language") ?? undefined,73 quebecOnly: searchParams.get("quebec_only") === "true",74 images: searchParams.get("images") === "true",75 };76 const imagesMode = Boolean(filters.images);7778 const [data, setData] = useState<SearchResponse | null>(null);79 const [loading, setLoading] = useState(Boolean(q));80 const [error, setError] = useState(false);8182 useEffect(() => {83 if (!q) {84 setData(null);85 setLoading(false);86 return;87 }8889 const controller = new AbortController();90 setLoading(true);91 setError(false);9293 const gallery = searchParams.get("images") === "true";94 const params = new URLSearchParams({95 q,96 page: String(page),97 limit: String(gallery ? GALLERY_PAGE_SIZE : PAGE_SIZE),98 });99 const category = searchParams.get("category");100 const language = searchParams.get("language");101 if (category) params.set("category", category);102 if (language) params.set("language", language);103 if (searchParams.get("quebec_only") === "true") {104 params.set("quebec_only", "true");105 }106 if (gallery) params.set("images", "true");107108 fetch(`/api/search?${params.toString()}`, { signal: controller.signal })109 .then((res) => {110 if (!res.ok) throw new Error(`Erreur ${res.status}`);111 return res.json() as Promise<SearchResponse>;112 })113 .then((json) => {114 setData(json);115 setLoading(false);116 })117 .catch((err: unknown) => {118 if (err instanceof DOMException && err.name === "AbortError") return;119 setError(true);120 setLoading(false);121 });122123 return () => controller.abort();124 }, [q, page, searchParams]);125126 function navigate(nextPage: number, nextFilters: SearchFilters) {127 router.push(buildSearchUrl(q, nextPage, nextFilters));128 window.scrollTo({ top: 0 });129 }130131 const totalPages = data ? Math.max(1, Math.ceil(data.total / data.limit)) : 1;132 const hasPrevious = page > 1;133 const hasNext = data ? page < totalPages : false;134135 return (136 <div className="flex-1">137 {/* Barre persistante sous l'en-tête global (h-14) : recherche + filtres. */}138 <div className="sticky top-14 z-30 border-b border-line bg-paper md:bg-[rgba(245,243,238,0.92)] md:backdrop-blur">139 <div className="mx-auto max-w-3xl px-4 pt-3">140 <SearchBar key={q} initialQuery={q} className="max-w-xl" />141 <div className="overflow-x-auto py-3">142 <FilterPills143 filters={filters}144 onChange={(next) => navigate(1, next)}145 />146 </div>147 </div>148 </div>149150 <main id="contenu" className="mx-auto max-w-3xl px-4 py-6">151 {!q ? (152 <p className="py-12 text-center text-ink-2">153 Entrez une requête pour chercher le web québécois.154 </p>155 ) : loading ? (156 imagesMode ? <GallerySkeleton /> : <ResultsSkeleton />157 ) : error ? (158 <div className="py-12 text-center">159 <p className="font-display font-bold text-ink">160 Le moteur est momentanément injoignable.161 </p>162 <p className="mt-2 text-sm text-ink-2">163 Réessayez dans quelques instants ou consultez l'164 <Link165 href="/status"166 className="font-medium text-green underline-offset-2 hover:underline"167 >168 état du moteur169 </Link>170 .171 </p>172 </div>173 ) : data && data.results.length === 0 ? (174 <div className="rise py-14 text-center">175 <p className="klabel">0 résultat</p>176 <h2 className="mx-auto mt-4 max-w-xl text-3xl font-bold leading-tight tracking-[-0.03em] [overflow-wrap:anywhere] sm:text-4xl">177 Rien pour <span className="hl">« {q} »</span> — encore.178 </h2>179 <p className="mx-auto mt-5 max-w-md text-sm leading-relaxed text-ink-2">180 Vérifiez l'orthographe, essayez des termes plus généraux ou181 retirez un filtre. Le robot crawle le Québec en continu : ce182 qui manque aujourd'hui peut apparaître demain.183 </p>184 <Link href="/soumettre" className="btn btn-primary mt-8">185 Soumettre un site québécois186 </Link>187 </div>188 ) : data ? (189 <>190 <p aria-live="polite" className="klabel">191 <span className="mr-1 rounded-[4px] bg-lime px-1.5 py-0.5 text-[11px] text-ink">192 {formatNumber(data.total)}193 </span>{" "}194 {data.total === 1 ? "résultat" : "résultats"} (195 {formatNumber(data.took_ms)} ms)196 </p>197 {imagesMode ? (198 <div className="mt-4">199 <ImageGallery results={data.results} />200 </div>201 ) : (202 <div className="mt-2 divide-y divide-line">203 {data.results.map((result) => (204 <ResultItem key={result.url} result={result} />205 ))}206 </div>207 )}208 {hasPrevious || hasNext ? (209 <nav210 aria-label="Pagination des résultats"211 className="mt-8 flex items-center justify-between border-t border-line pt-5"212 >213 <Button214 variant="ghost"215 size="sm"216 disabled={!hasPrevious}217 onClick={() => navigate(page - 1, filters)}218 >219 ← Précédent220 </Button>221 <span className="gk-mono text-xs font-medium text-ink-2">222 Page {formatNumber(page)} de {formatNumber(totalPages)}223 </span>224 <Button225 variant="ghost"226 size="sm"227 disabled={!hasNext}228 onClick={() => navigate(page + 1, filters)}229 >230 Suivant →231 </Button>232 </nav>233 ) : null}234 </>235 ) : null}236 </main>237 </div>238 );239}240