SPB Git

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%
8.6 KB · 240 lines tsx
Raw Blame History
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"&nbsp;{q}&nbsp;»</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&nbsp;: 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)}&nbsp;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