From 29377a3fa2a5a33c1c89dc5d1d5e63ffef1640b0 Mon Sep 17 00:00:00 2001 From: Sophie <47993817+sdankel@users.noreply.github.com> Date: Wed, 21 May 2025 12:59:31 -0400 Subject: [PATCH 1/8] feat: search bar --- .../features/toolbar/components/SearchBar.tsx | 90 ++++-- app/src/pages/SearchResults.tsx | 293 +++++++++++++----- app/src/utils/date.ts | 9 + app/src/utils/http.ts | 26 ++ src/db/package_version.rs | 67 ++++ src/main.rs | 15 +- 6 files changed, 391 insertions(+), 109 deletions(-) diff --git a/app/src/features/toolbar/components/SearchBar.tsx b/app/src/features/toolbar/components/SearchBar.tsx index d58754f..0a7bd17 100644 --- a/app/src/features/toolbar/components/SearchBar.tsx +++ b/app/src/features/toolbar/components/SearchBar.tsx @@ -1,7 +1,13 @@ "use client"; -import React, { Suspense, useCallback, useEffect } from "react"; -import { usePathname } from "next/navigation"; +import React, { + Suspense, + useCallback, + useState, + useRef, + useEffect, +} from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useIsMobile } from "../hooks/useIsMobile"; import InputAdornment from "@mui/material/InputAdornment"; import { styled, useTheme } from "@mui/material"; @@ -12,23 +18,12 @@ import "./SearchBar.css"; function SearchBarComponent() { const isMobile = useIsMobile(); - const pathname = usePathname(); + const searchParams = useSearchParams(); const theme = useTheme(); - - useEffect(() => { - const handlePopState = () => { - const params = new URLSearchParams(window.location.search); - const input = document.querySelector( - ".search-input input", - ); - if (input) { - input.value = params.get("q") || ""; - } - }; - - window.addEventListener("popstate", handlePopState); - return () => window.removeEventListener("popstate", handlePopState); - }, []); + const [searchValue, setSearchValue] = useState( + searchParams.get("query") || "", + ); + const inputRef = useRef(null); // Create a styled version of Input with placeholder color override const StyledInput = styled(Input)({ @@ -41,39 +36,68 @@ function SearchBarComponent() { }, }); + const updateSearchParams = useCallback((value: string) => { + const url = new URL(window.location.href); + + if (value === "") { + url.searchParams.delete("query"); + url.searchParams.delete("page"); + url.pathname = "/"; + } else { + url.searchParams.set("query", value); + url.searchParams.set("page", "1"); + url.pathname = "/search"; + } + + window.history.pushState({}, "", url); + }, []); + + // Sync URL changes back to input + useEffect(() => { + const query = searchParams.get("query") || ""; + if (query !== searchValue) { + setSearchValue(query); + } + }, [searchParams, searchValue]); + const handleChange = useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value; - const params = new URLSearchParams(window.location.search); + setSearchValue(newValue); + updateSearchParams(newValue); + }, + [updateSearchParams], + ); - if (newValue) { - params.set("q", newValue); - const newUrl = - pathname === "/search" - ? `${pathname}?${params.toString()}` - : `/search?${params.toString()}`; - window.history.pushState({ path: newUrl }, "", newUrl); - } else if (pathname === "/search") { - window.history.pushState({ path: "/" }, "", "/"); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setSearchValue(""); + updateSearchParams(""); } }, - [pathname], + [updateSearchParams], ); return ( -
+
Loading...
}> diff --git a/app/src/pages/SearchResults.tsx b/app/src/pages/SearchResults.tsx index 8e12fe9..927682a 100644 --- a/app/src/pages/SearchResults.tsx +++ b/app/src/pages/SearchResults.tsx @@ -1,93 +1,238 @@ -import React, { Suspense } from "react"; -import { useSearchParams } from "next/navigation"; - -interface SearchResult { - name: string; - version: string; - homepage: string; - documentation: string; - repository: string; - updated: string; - downloads: number; -} +"use client"; + +import React, { Suspense, useEffect, useState } from "react"; +import { + useRouter, + useSearchParams, + ReadonlyURLSearchParams, +} from "next/navigation"; +import { + Box, + Card, + CardContent, + Typography, + CircularProgress, + Pagination, +} from "@mui/material"; +import HTTP, { PackagePreview } from "../utils/http"; +import { formatDate } from "../utils/date"; +import NextLink from "next/link"; -const dummySearchResults: SearchResult[] = [ - // { - // name: 'std', - // version: '0.1.0', - // homepage: 'www.google.com', - // documentation: 'www.google.com', - // repository: 'www.google.com', - // updated: '2024-02-02', - // downloads: 100, - // }, - // { - // name: 'core', - // version: '0.1.0', - // homepage: 'www.google.com', - // documentation: 'www.google.com', - // repository: 'www.google.com', - // updated: '2024-01-01', - // downloads: 200, - // }, -]; - -interface SearchResultsProps { - searchParams: ReturnType; +export interface SearchResultsProps { + searchParams: URLSearchParams | ReadonlyURLSearchParams; } +const PER_PAGE = 10; + function SearchResults({ searchParams }: SearchResultsProps) { - const matchingResults = dummySearchResults.filter((result) => { - return result.name - .toLowerCase() - .includes(searchParams.get("q")?.toLowerCase() || ""); - }); + const router = useRouter(); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + + useEffect(() => { + const query = searchParams.get("query"); + const page = parseInt(searchParams.get("page") || "1", 10); + setCurrentPage(page); + + if (!query) { + setResults([]); + return; + } + + setLoading(true); + setError(null); + + HTTP.get("/search", { + params: { + query, + page: page.toString(), + per_page: PER_PAGE.toString(), + }, + }) + .then((response) => { + setResults(response.data.data); + setTotalPages(response.data.totalPages); + setTotalCount(response.data.totalCount); + }) + .catch((err) => { + setError("Failed to fetch search results"); + console.error("Search error:", err); + }) + .finally(() => { + setLoading(false); + }); + }, [searchParams]); + + const containerStyles = { + maxWidth: "1200px", + mx: "auto", + px: 3, + width: "100%", + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (results.length === 0) { + return ( + + No results found + + ); + } return ( -
-
-

{"Search Results"}

-
-
{"Under construction"}
- - {matchingResults.map((result) => ( -
+ + + Search Results + + + Found {totalCount} package{totalCount === 1 ? "" : "s"} + + + + + {results.map((result) => ( + + + + + + + {result.name} + + + v{result.version} + + + + + {result.description || ""} + + + + Updated {formatDate(result.updatedAt)} + + + + + + ))} + + + {totalPages > 1 && ( + -
{result.name}
-
{result.version}
-
{result.homepage}
-
{result.documentation}
-
{result.repository}
-
{result.updated}
-
{result.downloads}
-
- ))} -
+ { + const newParams = new URLSearchParams(searchParams); + newParams.set("page", page.toString()); + router.replace(`/search?${newParams.toString()}`); + }} + color="primary" + showFirstButton + showLastButton + /> + + )} + ); } -function SearchResultsWithParams() { +export default function SearchResultsWrapper() { const searchParams = useSearchParams(); - return ; -} -function SearchResultsWrapper() { return ( Loading search results...
}> - + ); } - -export default SearchResultsWrapper; diff --git a/app/src/utils/date.ts b/app/src/utils/date.ts index 7e4c8ba..cfe7e28 100644 --- a/app/src/utils/date.ts +++ b/app/src/utils/date.ts @@ -1,3 +1,12 @@ +export function formatDate(date: string | Date): string { + const d = new Date(date); + return d.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + export function formatTimeAgo(date: string | Date): string { const now = new Date(); const past = new Date(date); diff --git a/app/src/utils/http.ts b/app/src/utils/http.ts index 64a9b5d..c2905a4 100644 --- a/app/src/utils/http.ts +++ b/app/src/utils/http.ts @@ -4,6 +4,22 @@ import { SERVER_URI } from "../constants"; import { RecentPackagesResponse } from "../features/dashboard/hooks/useFetchRecentPackages"; import { FullPackage } from "../features/detail/hooks/usePackageDetail"; +export interface SearchResponse { + data: PackagePreview[]; + totalCount: number; + totalPages: number; + currentPage: number; + perPage: number; +} + +export interface PackagePreview { + name: string; + version: string; + description: string | null; + createdAt: string; + updatedAt: string; +} + export interface AuthenticatedUser { fullName: string; email?: string; @@ -102,6 +118,16 @@ type Routes = [ method: "GET"; jsonResponse: RecentPackagesResponse; }, + { + route: "/search"; + method: "GET"; + queryParams: { + query: string; + page?: string; + per_page?: string; + }; + jsonResponse: SearchResponse; + }, { route: "/package"; method: "GET"; diff --git a/src/db/package_version.rs b/src/db/package_version.rs index 0a01f19..a1e72d6 100644 --- a/src/db/package_version.rs +++ b/src/db/package_version.rs @@ -367,4 +367,71 @@ impl DbConn<'_> { ) .collect()) } + + /// Search for packages by name or description. + pub fn search_packages( + &mut self, + query: String, + pagination: Pagination, + ) -> Result, DatabaseError> { + let query = format!("%{}%", query.to_lowercase()); + + let packages = diesel::sql_query( + r#"WITH ranked_versions AS ( + SELECT + p.id AS package_id, + p.package_name AS name, + pv.num AS version, + pv.package_description AS description, + p.created_at AS created_at, + pv.created_at AS updated_at, + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pv.created_at DESC) AS rank + FROM package_versions pv + JOIN packages p ON pv.package_id = p.id + WHERE p.package_name ILIKE $1 OR pv.package_description ILIKE $1 + ) + SELECT + name, + version, + description, + created_at, + updated_at + FROM ranked_versions + WHERE rank = 1 + ORDER BY created_at DESC + OFFSET $2 + LIMIT $3; + "#, + ) + .bind::(query.clone()) + .bind::(pagination.offset()) + .bind::(pagination.limit()) + .load::(self.inner()) + .map_err(|err: diesel::result::Error| { + DatabaseError::QueryFailed("search packages".to_string(), err) + })?; + + // Count total matches + let total = diesel::sql_query( + r#"SELECT COUNT(*) FROM ( + SELECT DISTINCT p.id + FROM packages p + JOIN package_versions pv ON pv.package_id = p.id + WHERE p.package_name ILIKE $1 OR pv.package_description ILIKE $1 + ) AS matches; + "#, + ) + .bind::(query) + .get_result::(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("search count".to_string(), err))? + .count; + + Ok(PaginatedResponse { + data: packages, + total_count: total, + total_pages: ((total as f64) / (pagination.limit() as f64)).ceil() as i64, + current_page: pagination.page(), + per_page: pagination.limit(), + }) + } } diff --git a/src/main.rs b/src/main.rs index 85961fa..7244e3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ use forc_pub::handlers::upload::{handle_project_upload, install_forc_at_path, Up use forc_pub::middleware::cors::Cors; use forc_pub::middleware::session_auth::{SessionAuth, SESSION_COOKIE_NAME}; use forc_pub::middleware::token_auth::TokenAuth; -use forc_pub::models::PackageVersionInfo; +use forc_pub::models::{PackagePreview, PackageVersionInfo}; use forc_pub::util::{load_env, validate_or_format_semver}; use rocket::http::Status; use rocket::tokio::task; @@ -340,6 +340,16 @@ fn default_catcher(status: Status, _req: &Request<'_>) -> response::status::Cust } // Indicates the service is running +#[get("/search?&")] +fn search( + db: &State, + query: String, + pagination: Pagination, +) -> ApiResult> { + let result = db.transaction(|conn| conn.search_packages(query, pagination))?; + Ok(Json(result)) +} + #[get("/health")] fn health() -> String { "true".to_string() @@ -369,13 +379,14 @@ async fn rocket() -> _ { user, new_token, delete_token, + tokens, publish, upload_project, - tokens, packages, package, package_versions, recent_packages, + search, all_options, health ], From a32cb6c81aec8eb8f1b34f8f82fbbf3dd8c60fe6 Mon Sep 17 00:00:00 2001 From: z Date: Wed, 4 Jun 2025 10:37:01 +1200 Subject: [PATCH 2/8] readme upd --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b64a33d..10dfc69 100644 --- a/README.md +++ b/README.md @@ -42,15 +42,17 @@ To connect to the database for development, you may optionally install the [Dies Diesel is the Rust ORM used to create and run database migrations. It requires a separate C library called `libpq` to be installed as well. ```sh -# Mac only +# Mac brew install libpq - -# Ubuntu only +# or Ubuntu/Debian apt-get install libpq5 # Install diesel CLI cargo install diesel_cli --no-default-features --features postgres +# Install cargo-binstall +cargo install cargo-binstall + # On macOS-arm64, you may need additional rust flags: RUSTFLAGS='-L /opt/homebrew/opt/libpq/lib' cargo install diesel_cli --no-default-features --features postgres ``` From 7d74b7a0b60754ed8f3f7354eb56907683712fb4 Mon Sep 17 00:00:00 2001 From: z Date: Wed, 4 Jun 2025 10:37:25 +1200 Subject: [PATCH 3/8] mobile responsivenes for nav bar --- app/src/App.tsx | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 060a2c1..a0f7053 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -3,7 +3,6 @@ import React, { ReactNode, Suspense } from "react"; import { AppBar, Toolbar, Box, useTheme } from "@mui/material"; import UserButton from "./features/toolbar/components/UserButton"; -import { useIsMobile } from "./features/toolbar/hooks/useIsMobile"; import SearchBar from "./features/toolbar/components/SearchBar"; interface AppProps { @@ -11,7 +10,6 @@ interface AppProps { } function App({ children }: AppProps) { - const isMobile = useIsMobile(); const theme = useTheme(); return ( @@ -32,19 +30,23 @@ function App({ children }: AppProps) { sx={{ backgroundColor: "#181818", boxShadow: "0 2px 8px rgba(0, 0, 0, 0.3)", + padding: { xs: "8px 16px", sm: "6px 24px" }, + gap: { xs: 1, sm: 2 }, + flexWrap: { xs: "wrap", sm: "nowrap" }, + alignItems: "center", }} > (window.location.href = "/")} sx={{ - flexGrow: 1, - display: "block", color: theme.palette.primary.main, fontSize: "24px", fontFamily: "monospace", cursor: "pointer", fontWeight: "bold", transition: "color 0.2s ease-in-out", + flexShrink: 0, + order: { xs: 1, sm: 1 }, "&:hover": { color: theme.palette.primary.light, }, @@ -52,12 +54,29 @@ function App({ children }: AppProps) { > forc.pub - {!isMobile && } - Loading...}> - - + + }> + + + + + Loading...}> + + + - {isMobile && } Date: Wed, 4 Jun 2025 10:37:51 +1200 Subject: [PATCH 4/8] removed redundant page --- app/src/app/page.tsx | 26 +++++++++++++++++++++++--- app/src/app/search/page.tsx | 15 --------------- 2 files changed, 23 insertions(+), 18 deletions(-) delete mode 100644 app/src/app/search/page.tsx diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 4c17ca6..7c0b717 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -1,15 +1,35 @@ "use client"; +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; import App from "../App"; import PackageDashboard from "../features/dashboard/components/PackageDashboard"; +import SearchResultsWrapper from "../pages/SearchResults"; + +function HomePage() { + const searchParams = useSearchParams(); + const query = searchParams.get("query")?.trim(); -export default function HomePage() { return (
-

{"The Sway community's package registry"}

- + {query ? ( + + ) : ( + <> +

{"The Sway community's package registry"}

+ + + )}
); } + +export default function HomePageWrapper() { + return ( + Loading...}> + + + ); +} diff --git a/app/src/app/search/page.tsx b/app/src/app/search/page.tsx deleted file mode 100644 index 0ef9120..0000000 --- a/app/src/app/search/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import { Suspense } from "react"; -import App from "../../App"; -import SearchResults from "../../pages/SearchResults"; - -export default function SearchPage() { - return ( - - Loading...}> - - - - ); -} From b5645e357b53bb9b9ee6f2b6b8e4e5e6a4bfa8f8 Mon Sep 17 00:00:00 2001 From: z Date: Wed, 4 Jun 2025 10:38:30 +1200 Subject: [PATCH 5/8] refactor search bar and search results to make responsive searches - with debounce --- .../features/toolbar/components/SearchBar.tsx | 158 +++++++++--------- app/src/pages/SearchResults.tsx | 153 +++++++++-------- 2 files changed, 168 insertions(+), 143 deletions(-) diff --git a/app/src/features/toolbar/components/SearchBar.tsx b/app/src/features/toolbar/components/SearchBar.tsx index 0a7bd17..f65a22d 100644 --- a/app/src/features/toolbar/components/SearchBar.tsx +++ b/app/src/features/toolbar/components/SearchBar.tsx @@ -1,117 +1,121 @@ "use client"; -import React, { - Suspense, - useCallback, - useState, - useRef, - useEffect, -} from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useIsMobile } from "../hooks/useIsMobile"; +import React, { useCallback, useState, useRef, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import InputAdornment from "@mui/material/InputAdornment"; -import { styled, useTheme } from "@mui/material"; +import { styled, useTheme, useMediaQuery } from "@mui/material"; import Input from "@mui/material/Input"; import SearchIcon from "@mui/icons-material/Search"; -import dynamic from "next/dynamic"; import "./SearchBar.css"; -function SearchBarComponent() { - const isMobile = useIsMobile(); +const StyledInput = styled(Input)(({ theme }) => ({ + "& input::placeholder": { + color: theme.palette.text.secondary, + opacity: 1, + }, + "& input": { + color: theme.palette.text.primary, + }, +})); + +function SearchBar() { + const router = useRouter(); const searchParams = useSearchParams(); const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const [searchValue, setSearchValue] = useState( - searchParams.get("query") || "", + () => searchParams.get("query") || "", ); - const inputRef = useRef(null); + const debounceTimeoutRef = useRef(); - // Create a styled version of Input with placeholder color override - const StyledInput = styled(Input)({ - "& input::placeholder": { - color: theme.palette.text.secondary, - opacity: 1, - }, - "& input": { - color: theme.palette.text.primary, + const updateURL = useCallback( + (value: string) => { + const trimmed = value.trim(); + const url = trimmed + ? `/?query=${encodeURIComponent(trimmed)}&page=1` + : "/"; + router.replace(url, { scroll: false }); }, - }); - - const updateSearchParams = useCallback((value: string) => { - const url = new URL(window.location.href); - - if (value === "") { - url.searchParams.delete("query"); - url.searchParams.delete("page"); - url.pathname = "/"; - } else { - url.searchParams.set("query", value); - url.searchParams.set("page", "1"); - url.pathname = "/search"; - } + [router], + ); - window.history.pushState({}, "", url); - }, []); + const debouncedUpdateURL = useCallback( + (value: string) => { + clearTimeout(debounceTimeoutRef.current); + debounceTimeoutRef.current = setTimeout(() => updateURL(value), 400); + }, + [updateURL], + ); - // Sync URL changes back to input useEffect(() => { const query = searchParams.get("query") || ""; - if (query !== searchValue) { - setSearchValue(query); - } - }, [searchParams, searchValue]); + setSearchValue(query); + }, [searchParams]); + + useEffect(() => { + return () => clearTimeout(debounceTimeoutRef.current); + }, []); const handleChange = useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value; setSearchValue(newValue); - updateSearchParams(newValue); + debouncedUpdateURL(newValue); }, - [updateSearchParams], + [debouncedUpdateURL], ); const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { + (e: React.KeyboardEvent) => { if (e.key === "Escape") { setSearchValue(""); - updateSearchParams(""); + clearTimeout(debounceTimeoutRef.current); + updateURL(""); + } else if (e.key === "Enter") { + clearTimeout(debounceTimeoutRef.current); + updateURL(searchValue); } }, - [updateSearchParams], + [searchValue, updateURL], ); return (
- Loading...
}> - - - - } - /> - + + + + } + /> ); } -const SearchBar = dynamic(() => Promise.resolve(SearchBarComponent), { - ssr: false, -}); - export default SearchBar; diff --git a/app/src/pages/SearchResults.tsx b/app/src/pages/SearchResults.tsx index 927682a..1234a5b 100644 --- a/app/src/pages/SearchResults.tsx +++ b/app/src/pages/SearchResults.tsx @@ -1,11 +1,7 @@ "use client"; -import React, { Suspense, useEffect, useState } from "react"; -import { - useRouter, - useSearchParams, - ReadonlyURLSearchParams, -} from "next/navigation"; +import React, { Suspense, useEffect, useState, useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { Box, Card, @@ -18,54 +14,81 @@ import HTTP, { PackagePreview } from "../utils/http"; import { formatDate } from "../utils/date"; import NextLink from "next/link"; -export interface SearchResultsProps { - searchParams: URLSearchParams | ReadonlyURLSearchParams; -} - const PER_PAGE = 10; -function SearchResults({ searchParams }: SearchResultsProps) { +function SearchResults() { const router = useRouter(); + const searchParams = useSearchParams(); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [totalPages, setTotalPages] = useState(1); const [totalCount, setTotalCount] = useState(0); - const [currentPage, setCurrentPage] = useState(1); + const abortControllerRef = useRef(); + const searchTimeoutRef = useRef(); + + const query = searchParams.get("query")?.trim() || ""; + const currentPage = parseInt(searchParams.get("page") || "1", 10); useEffect(() => { - const query = searchParams.get("query"); - const page = parseInt(searchParams.get("page") || "1", 10); - setCurrentPage(page); + // Clear any pending search timeout + clearTimeout(searchTimeoutRef.current); if (!query) { setResults([]); + setTotalPages(1); + setTotalCount(0); + setError(null); + setLoading(false); + abortControllerRef.current?.abort(); return; } + // Cancel previous request + abortControllerRef.current?.abort(); + abortControllerRef.current = new AbortController(); + setLoading(true); setError(null); - HTTP.get("/search", { - params: { - query, - page: page.toString(), - per_page: PER_PAGE.toString(), - }, - }) - .then((response) => { - setResults(response.data.data); - setTotalPages(response.data.totalPages); - setTotalCount(response.data.totalCount); + // Add small delay to reduce cancelled requests when typing fast + searchTimeoutRef.current = setTimeout(() => { + HTTP.get("/search", { + params: { + query, + page: currentPage.toString(), + per_page: PER_PAGE.toString(), + }, + signal: abortControllerRef.current?.signal, }) - .catch((err) => { - setError("Failed to fetch search results"); - console.error("Search error:", err); - }) - .finally(() => { - setLoading(false); - }); - }, [searchParams]); + .then((response) => { + setResults(response.data.data); + setTotalPages(response.data.totalPages); + setTotalCount(response.data.totalCount); + }) + .catch((err) => { + if (err.name !== "AbortError") { + setError("Failed to fetch search results"); + console.error("Search error:", err); + } + }) + .finally(() => { + setLoading(false); + }); + }, 100); // 100ms delay to reduce cancelled requests + + return () => { + clearTimeout(searchTimeoutRef.current); + abortControllerRef.current?.abort(); + }; + }, [query, currentPage]); + + const handlePageChange = (_: React.ChangeEvent, page: number) => { + const newParams = new URLSearchParams(searchParams); + newParams.set("page", page.toString()); + router.replace(`/?${newParams.toString()}`); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; const containerStyles = { maxWidth: "1200px", @@ -77,7 +100,12 @@ function SearchResults({ searchParams }: SearchResultsProps) { if (loading) { return ( - + + + + Searching for "{query}"... + + ); } @@ -85,15 +113,27 @@ function SearchResults({ searchParams }: SearchResultsProps) { if (error) { return ( + + Search Error + {error} + + Please try again or contact support if the problem persists. + ); } if (results.length === 0) { return ( - - No results found + + + No packages found + + + No results found for "{query}". Try different keywords or + check your spelling. + ); } @@ -105,14 +145,15 @@ function SearchResults({ searchParams }: SearchResultsProps) { Search Results - Found {totalCount} package{totalCount === 1 ? "" : "s"} + Found {totalCount} package{totalCount === 1 ? "" : "s"} for " + {query}" {results.map((result) => ( @@ -131,12 +172,7 @@ function SearchResults({ searchParams }: SearchResultsProps) { }} > - {result.description || ""} + {result.description || "No description available"} {totalPages > 1 && ( - + { - const newParams = new URLSearchParams(searchParams); - newParams.set("page", page.toString()); - router.replace(`/search?${newParams.toString()}`); - }} + onChange={handlePageChange} color="primary" showFirstButton showLastButton + size="large" /> )} @@ -228,11 +251,9 @@ function SearchResults({ searchParams }: SearchResultsProps) { } export default function SearchResultsWrapper() { - const searchParams = useSearchParams(); - return ( Loading search results...}> - + ); } From 1eecabedc31050da4fec477aaec2ac86e0f66743 Mon Sep 17 00:00:00 2001 From: z Date: Wed, 4 Jun 2025 15:04:53 +1200 Subject: [PATCH 6/8] validate query parameter in search function to ensure it is not empty and does not exceed 100 characters --- src/main.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main.rs b/src/main.rs index 7244e3a..09f2af5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -346,6 +346,13 @@ fn search( query: String, pagination: Pagination, ) -> ApiResult> { + if query.trim().is_empty() || query.len() > 100 { + return Err(ApiError::Generic( + "Invalid query parameter".into(), + Status::BadRequest, + )); + } + let result = db.transaction(|conn| conn.search_packages(query, pagination))?; Ok(Json(result)) } From 48f0f6b885690cc177ed1faf441641013fd977a4 Mon Sep 17 00:00:00 2001 From: z Date: Wed, 4 Jun 2025 15:05:14 +1200 Subject: [PATCH 7/8] enhance package search functionality with fuzzy matching and relevance scoring --- src/db/package_version.rs | 41 ++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/db/package_version.rs b/src/db/package_version.rs index a1e72d6..4eaa6a6 100644 --- a/src/db/package_version.rs +++ b/src/db/package_version.rs @@ -368,13 +368,13 @@ impl DbConn<'_> { .collect()) } - /// Search for packages by name or description. + /// Search for packages by name or description using fuzzy search. pub fn search_packages( &mut self, query: String, pagination: Pagination, ) -> Result, DatabaseError> { - let query = format!("%{}%", query.to_lowercase()); + let query_lower = query.to_lowercase(); let packages = diesel::sql_query( r#"WITH ranked_versions AS ( @@ -385,10 +385,22 @@ impl DbConn<'_> { pv.package_description AS description, p.created_at AS created_at, pv.created_at AS updated_at, - ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pv.created_at DESC) AS rank + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pv.created_at DESC) AS rank, + -- Combined relevance scoring + GREATEST( + similarity($1, LOWER(p.package_name)), + CASE + WHEN LOWER(p.package_name) ILIKE '%' || $1 || '%' THEN 0.7 + ELSE 0.0 + END, + similarity($1, LOWER(COALESCE(pv.package_description, ''))) * 0.3 + ) AS relevance_score FROM package_versions pv JOIN packages p ON pv.package_id = p.id - WHERE p.package_name ILIKE $1 OR pv.package_description ILIKE $1 + WHERE + LOWER(p.package_name) ILIKE '%' || $1 || '%' OR + similarity($1, LOWER(p.package_name)) > 0.2 OR + similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1 ) SELECT name, @@ -397,13 +409,13 @@ impl DbConn<'_> { created_at, updated_at FROM ranked_versions - WHERE rank = 1 - ORDER BY created_at DESC + WHERE rank = 1 AND relevance_score > 0.1 + ORDER BY relevance_score DESC, created_at DESC OFFSET $2 LIMIT $3; "#, ) - .bind::(query.clone()) + .bind::(query_lower.clone()) .bind::(pagination.offset()) .bind::(pagination.limit()) .load::(self.inner()) @@ -413,15 +425,16 @@ impl DbConn<'_> { // Count total matches let total = diesel::sql_query( - r#"SELECT COUNT(*) FROM ( - SELECT DISTINCT p.id - FROM packages p - JOIN package_versions pv ON pv.package_id = p.id - WHERE p.package_name ILIKE $1 OR pv.package_description ILIKE $1 - ) AS matches; + r#"SELECT COUNT(DISTINCT p.id) AS count + FROM packages p + JOIN package_versions pv ON pv.package_id = p.id + WHERE + LOWER(p.package_name) ILIKE '%' || $1 || '%' OR + similarity($1, LOWER(p.package_name)) > 0.2 OR + similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1 "#, ) - .bind::(query) + .bind::(query_lower) .get_result::(self.inner()) .map_err(|err| DatabaseError::QueryFailed("search count".to_string(), err))? .count; From 8e03c95ea61bf0b1f639c105d41bd6c9c85806d6 Mon Sep 17 00:00:00 2001 From: z Date: Wed, 4 Jun 2025 15:05:23 +1200 Subject: [PATCH 8/8] add search indexes migration for fuzzy search functionality --- .../down.sql | 8 +++++++ .../up.sql | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 migrations/2025-04-02-120000_add_search_indexes/down.sql create mode 100644 migrations/2025-04-02-120000_add_search_indexes/up.sql diff --git a/migrations/2025-04-02-120000_add_search_indexes/down.sql b/migrations/2025-04-02-120000_add_search_indexes/down.sql new file mode 100644 index 0000000..a566691 --- /dev/null +++ b/migrations/2025-04-02-120000_add_search_indexes/down.sql @@ -0,0 +1,8 @@ +-- Drop all search-related indexes +DROP INDEX IF EXISTS idx_packages_created_at; +DROP INDEX IF EXISTS idx_package_versions_package_id_created_at; +DROP INDEX IF EXISTS idx_package_versions_desc_trgm; +DROP INDEX IF EXISTS idx_packages_name_trgm; +DROP INDEX IF EXISTS idx_packages_name_lower; + +-- Note: We don't drop the pg_trgm extension as other parts of the system might be using it \ No newline at end of file diff --git a/migrations/2025-04-02-120000_add_search_indexes/up.sql b/migrations/2025-04-02-120000_add_search_indexes/up.sql new file mode 100644 index 0000000..2fd0fd1 --- /dev/null +++ b/migrations/2025-04-02-120000_add_search_indexes/up.sql @@ -0,0 +1,21 @@ +-- Enable the pg_trgm extension for fuzzy search +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Functional index for fast case-insensitive package name searches +CREATE INDEX idx_packages_name_lower + ON packages USING btree (LOWER(package_name)); + +-- Trigram indexes for fuzzy search +CREATE INDEX idx_packages_name_trgm + ON packages USING gin (LOWER(package_name) gin_trgm_ops); + +CREATE INDEX idx_package_versions_desc_trgm + ON package_versions USING gin (LOWER(package_description) gin_trgm_ops); + +-- Composite index for the join and filtering +CREATE INDEX idx_package_versions_package_id_created_at + ON package_versions (package_id, created_at DESC); + +-- Index for pagination +CREATE INDEX idx_packages_created_at + ON packages (created_at DESC); \ No newline at end of file