diff --git a/api/src/app/endpoints.ts b/api/src/app/endpoints.ts index f1aa3db0..133ff309 100644 --- a/api/src/app/endpoints.ts +++ b/api/src/app/endpoints.ts @@ -12,6 +12,7 @@ import { GetProjectsForSitemapResponse, GetProjectsResponse, } from "src/project/types"; +import { SearchResponse } from "src/search/types"; // ts-prune-ignore-next export interface Endpoints { @@ -50,4 +51,8 @@ export interface Endpoints { "api:MileStones/dzcode": { response: GetMilestonesResponse; }; + "api:Search": { + response: SearchResponse; + query: [["query", string], ["limit", number]]; + }; } diff --git a/api/src/search/service.ts b/api/src/search/service.ts index b72b9acb..b2156b32 100644 --- a/api/src/search/service.ts +++ b/api/src/search/service.ts @@ -34,7 +34,7 @@ export class SearchService { indexUid: "contribution", q, limit, - attributesToRetrieve: ["id", "title", "type", "activityCount"], + attributesToRetrieve: ["id", "title", "type", "activityCount", "url"], }, { indexUid: "contributor", q, limit, attributesToRetrieve: ["id", "name", "avatarUrl"] }, ], diff --git a/web/src/_entry/app.tsx b/web/src/_entry/app.tsx index faf09521..7b39681d 100644 --- a/web/src/_entry/app.tsx +++ b/web/src/_entry/app.tsx @@ -9,6 +9,7 @@ import { TopBar, TopBarProps } from "src/components/top-bar"; import { StoreProvider } from "src/redux/store"; import { getInitialLanguageCode } from "src/utils/website-language"; import React from "react"; +import { Search } from "src/components/search"; let routes: Array< RouteProps & { @@ -119,6 +120,7 @@ const App = () => { return ( <>
+ {routes.map((route) => { diff --git a/web/src/components/contribution-card.tsx b/web/src/components/contribution-card.tsx new file mode 100644 index 00000000..e678f740 --- /dev/null +++ b/web/src/components/contribution-card.tsx @@ -0,0 +1,72 @@ +import { GetContributionsResponse } from "@dzcode.io/api/dist/contribution/types"; +import React from "react"; +import { Link } from "src/components/link"; +import { useLocale } from "src/components/locale"; +import { Markdown } from "src/components/markdown"; +import { getElapsedTime } from "src/utils/elapsed-time"; + +export function ContributionCard({ + contribution, + compact = false, +}: { + contribution: GetContributionsResponse["contributions"][number]; + compact?: boolean; +}) { + const { localize } = useLocale(); + + return ( +
+
+
+

+ +

+ + {!compact && ( + <> + {contribution.repository.project.name} + + {contribution.repository.owner}/{contribution.repository.name} + + + )} +
+ {!compact && ( + + )} +
+ {contribution.activityCount > 0 && ( +
+ + + + {contribution.activityCount} +
+ )} + {!compact && ( +
+ {getElapsedTime(contribution.updatedAt, localize("elapsed-time-suffixes"))} +
+ )} + + {contribution.type === "ISSUE" + ? localize("contribute-read-issue") + : localize("contribute-review-changes")} + +
+
+
+
+ ); +} diff --git a/web/src/components/contributor-card.tsx b/web/src/components/contributor-card.tsx new file mode 100644 index 00000000..f7b11a16 --- /dev/null +++ b/web/src/components/contributor-card.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Link } from "src/components/link"; +import { getContributorURL } from "src/utils/contributor"; +import { GetContributorsResponse } from "@dzcode.io/api/dist/contributor/types"; + +export function ContributorCard({ + contributor, + compact = false, + onClick, +}: { + contributor: GetContributorsResponse["contributors"][number]; + compact?: boolean; + onClick?: () => void; +}) { + return ( + + {contributor.name} +
+
+ {contributor.name} + {contributor.name} +
+ {!compact && ( +
+
+ + + + {contributor.totalContributionScore} +
+
+
+ {contributor.totalRepositoryCount} + + + + +
+
+ )} +
+ + ); +} diff --git a/web/src/components/locale/dictionary.ts b/web/src/components/locale/dictionary.ts index a8e39272..fb6a93da 100644 --- a/web/src/components/locale/dictionary.ts +++ b/web/src/components/locale/dictionary.ts @@ -16,6 +16,7 @@ export const dictionary = { "navbar-section-projects": { en: "Projects", ar: "مشاريع" }, "navbar-section-articles": { en: "Articles", ar: "مقالات" }, "navbar-section-faq": { en: "FAQ", ar: "أسئلة / أجوبة" }, + "navbar-section-search": { en: "Search...", ar: "بحث..." }, "footer-category-title-helpful-links": { en: "Helpful Links", @@ -433,4 +434,24 @@ Besides the open tasks on [/Contribute](/Contribute) page, you can also contribu en: "Algeria Codes", ar: "الجزائر تبرمج", }, + "search-contributions": { + en: "Contributions", + ar: "مساهمات", + }, + "search-contributors": { + en: "Contributors", + ar: "مساهمين", + }, + "search-projects": { + en: "Projects", + ar: "مشاريع", + }, + "search-type-to-search": { + en: "Type to search...", + ar: "اكتب للبحث...", + }, + "search-no-results-found": { + en: "No results found", + ar: "لم يتم العثور على نتائج", + }, } as const; diff --git a/web/src/components/project-card.tsx b/web/src/components/project-card.tsx new file mode 100644 index 00000000..0a2bc67a --- /dev/null +++ b/web/src/components/project-card.tsx @@ -0,0 +1,85 @@ +import { GetProjectsResponse } from "@dzcode.io/api/dist/project/types"; +import React from "react"; +import { Link } from "src/components/link"; +import { getProjectURL } from "src/utils/project"; + +export function ProjectCard({ + project, + compact = false, + onClick, +}: { + project: GetProjectsResponse["projects"][number]; + compact?: boolean; + onClick?: () => void; +}) { + return ( + +

{project.name}

+ {!compact && ( + <> +
+
+
+ + + + {project.totalRepoStars} +
+
+
+ + + + {project.totalRepoScore} +
+
+
+ + + + {project.totalRepoContributorCount} +
+
+ + )} + + ); +} diff --git a/web/src/components/search.tsx b/web/src/components/search.tsx new file mode 100644 index 00000000..746ed78e --- /dev/null +++ b/web/src/components/search.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Locale, useLocale } from "./locale"; +import { useSearch } from "src/utils/search"; +import { useSearchModal } from "src/utils/search-modal"; +import { ContributionCard } from "./contribution-card"; +import { ContributorCard } from "./contributor-card"; +import { ProjectCard } from "./project-card"; +import { GetContributionsResponse } from "@dzcode.io/api/dist/contribution/types"; +import { GetContributorsResponse } from "@dzcode.io/api/dist/contributor/types"; +import { GetProjectsResponse } from "@dzcode.io/api/dist/project/types"; + +export function Search(): JSX.Element { + const { localize } = useLocale(); + const [query, setQuery] = useState(""); + + const { results, isFetching } = useSearch(query); + + const { hideModal, showModal } = useSearchModal(); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "/") { + event.preventDefault(); + showModal(); + } + }; + + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [showModal]); + + const onSearchInputChange = useCallback((e: React.ChangeEvent) => { + setQuery(e.target.value); + }, []); + + const projectsList = useMemo(() => { + return ( + results + .filter((result) => result.indexUid === "project") + // @TODO-OB: fix type casting + .flatMap((projects) => projects.hits) as unknown as Array< + GetProjectsResponse["projects"][number] + > + ); + }, [results]); + + const contributorsList = useMemo(() => { + return ( + results + .filter((result) => result.indexUid === "contributor") + // @TODO-OB: fix type casting + .flatMap((contributors) => contributors.hits) as unknown as Array< + GetContributorsResponse["contributors"][number] + > + ); + }, [results]); + + const contributionsList = useMemo(() => { + return ( + results + .filter((result) => result.indexUid === "contribution") + // @TODO-OB: fix type casting + .flatMap((contributions) => contributions.hits) as unknown as Array< + GetContributionsResponse["contributions"][number] + > + ); + }, [results]); + + const searchTextOutput = useMemo(() => { + if (isFetching) { + return ""; + } + if (query === "") { + return localize("search-type-to-search"); + } + if ( + projectsList.length === 0 && + contributorsList.length === 0 && + contributionsList.length === 0 + ) { + return localize("search-no-results-found"); + } + return ""; + }, [ + isFetching, + query, + projectsList.length, + contributorsList.length, + contributionsList.length, + localize, + ]); + + return ( + +
+ +
+ {searchTextOutput && ( +
+

{searchTextOutput}

+
+ )} + {contributionsList?.length > 0 && ( +
+

+ +

+
+ {contributionsList.map((contribution) => ( + + ))} +
+
+ )} + {contributorsList?.length > 0 && ( +
+

+ +

+
+ {contributorsList.map((contributor) => ( + + ))} +
+
+ )} + {projectsList?.length > 0 && ( +
+

+ +

+
+ {projectsList.map((project) => ( + + ))} +
+
+ )} +
+
+
+ +
+
+ ); +} diff --git a/web/src/components/top-bar.tsx b/web/src/components/top-bar.tsx index e27dad6b..4fcf6edd 100644 --- a/web/src/components/top-bar.tsx +++ b/web/src/components/top-bar.tsx @@ -2,16 +2,17 @@ import React from "react"; import { useMemo } from "react"; import { useLocation } from "react-router-dom"; import logoWide from "src/assets/svg/logo-wide.svg"; -import logoWideExtended from "src/assets/svg/logo-wide-extended.svg"; +import logoSquare from "src/assets/svg/logo-square.svg"; import { Image } from "src/components/image"; import { Link } from "src/components/link"; -import { Locale } from "src/components/locale"; +import { Locale, useLocale } from "src/components/locale"; import { DictionaryKeys } from "src/components/locale/dictionary"; import { changeLanguage } from "src/redux/actions/settings"; import { useAppSelector } from "src/redux/store"; import { stripLanguageCodeFromHRef } from "src/utils/website-language"; import { Language, Languages } from "./locale/languages"; +import { useSearchModal } from "src/utils/search-modal"; export interface TopBarProps { version: string; @@ -25,6 +26,8 @@ export function TopBar({ version, links }: TopBarProps): JSX.Element { return links.findIndex(({ href }) => languageLessPathname.startsWith(href)); }, [languageLessPathname, links]); + const { showModal } = useSearchModal(); + const selectedLanguageCode = useAppSelector((state) => state.settings.languageCode); const { selectedLanguage, languageOptions } = useMemo(() => { @@ -37,20 +40,45 @@ export function TopBar({ version, links }: TopBarProps): JSX.Element { return { selectedLanguage, languageOptions }; }, [selectedLanguageCode]); + const { localize } = useLocale(); + return (
-
+
- DzCode i/o SVG Logo wide + DzCode i/o SVG Logo wide {version}
+ +
{selectedLanguage.label} diff --git a/web/src/pages/contribute/index.tsx b/web/src/pages/contribute/index.tsx index ccd29a3c..06c17a2a 100644 --- a/web/src/pages/contribute/index.tsx +++ b/web/src/pages/contribute/index.tsx @@ -1,14 +1,12 @@ +import React from "react"; import { useEffect } from "react"; import { Helmet } from "react-helmet-async"; -import { Link } from "src/components/link"; +import { ContributionCard } from "src/components/contribution-card"; import { Loading } from "src/components/loading"; import { Locale, useLocale } from "src/components/locale"; -import { Markdown } from "src/components/markdown"; import { TryAgain } from "src/components/try-again"; import { fetchContributionsListAction } from "src/redux/actions/contributions"; import { useAppDispatch, useAppSelector } from "src/redux/store"; -import { getElapsedTime } from "src/utils/elapsed-time"; -import React from "react"; // ts-prune-ignore-next export default function Page(): JSX.Element { @@ -45,58 +43,7 @@ export default function Page(): JSX.Element { ) : (
{contributionsList.map((contribution, contributionIndex) => ( -
-
-
-

- -

- - {contribution.repository.project.name} - - {contribution.repository.owner}/{contribution.repository.name} - -
- -
- {contribution.activityCount > 0 && ( -
- - - - {contribution.activityCount} -
- )} -
- {getElapsedTime(contribution.updatedAt, localize("elapsed-time-suffixes"))} -
- - {contribution.type === "ISSUE" - ? localize("contribute-read-issue") - : localize("contribute-review-changes")} - -
-
-
-
+ ))}
)} diff --git a/web/src/pages/projects/index.tsx b/web/src/pages/projects/index.tsx index ade77194..bb2827d0 100644 --- a/web/src/pages/projects/index.tsx +++ b/web/src/pages/projects/index.tsx @@ -1,13 +1,12 @@ import React from "react"; import { useEffect } from "react"; import { Helmet } from "react-helmet-async"; -import { Link } from "src/components/link"; import { Loading } from "src/components/loading"; import { Locale, useLocale } from "src/components/locale"; +import { ProjectCard } from "src/components/project-card"; import { TryAgain } from "src/components/try-again"; import { fetchProjectsListAction } from "src/redux/actions/projects"; import { useAppDispatch, useAppSelector } from "src/redux/store"; -import { getProjectURL } from "src/utils/project"; // ts-prune-ignore-next export default function Page(): JSX.Element { @@ -43,70 +42,7 @@ export default function Page(): JSX.Element { ) : (
{projectsList.map((project, projectIndex) => ( - -

{project.name}

-
-
-
- - - - {project.totalRepoStars} -
-
-
- - - - {project.totalRepoScore} -
-
-
- - - - {project.totalRepoContributorCount} -
-
- + ))}
)} diff --git a/web/src/pages/team/index.tsx b/web/src/pages/team/index.tsx index 042c304a..eae494f4 100644 --- a/web/src/pages/team/index.tsx +++ b/web/src/pages/team/index.tsx @@ -1,13 +1,12 @@ import React from "react"; import { useEffect } from "react"; import { Helmet } from "react-helmet-async"; -import { Link } from "src/components/link"; +import { ContributorCard } from "src/components/contributor-card"; import { Loading } from "src/components/loading"; import { Locale, useLocale } from "src/components/locale"; import { TryAgain } from "src/components/try-again"; import { fetchContributorsListAction } from "src/redux/actions/contributors"; import { useAppDispatch, useAppSelector } from "src/redux/store"; -import { getContributorURL } from "src/utils/contributor"; // ts-prune-ignore-next export default function Page(): JSX.Element { @@ -44,71 +43,7 @@ export default function Page(): JSX.Element { ) : (
{contributorsList.map((contributor, contributorIndex) => ( - - {contributor.name} -
-
- {contributor.name} - {contributor.name} -
-
-
- - - - - {contributor.totalContributionScore} -
-
-
- {contributor.totalRepositoryCount} - - - - - -
-
-
- + ))}
)} diff --git a/web/src/utils/search-modal.ts b/web/src/utils/search-modal.ts new file mode 100644 index 00000000..ff24aa7f --- /dev/null +++ b/web/src/utils/search-modal.ts @@ -0,0 +1,16 @@ +import { useCallback } from "react"; + +export const useSearchModal = () => { + const showModal = useCallback(() => { + (document.getElementById("search-modal") as HTMLDialogElement)?.showModal(); + }, []); + + const hideModal = useCallback(() => { + (document.getElementById("search-modal") as HTMLDialogElement).hidePopover(); + }, []); + + return { + showModal, + hideModal, + }; +}; diff --git a/web/src/utils/search.ts b/web/src/utils/search.ts new file mode 100644 index 00000000..ed65ead7 --- /dev/null +++ b/web/src/utils/search.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchV2 } from "./fetch"; +import { SearchResponse } from "@dzcode.io/api/dist/search/types"; + +export const useSearch = (query: string, limit: number = 5) => { + const [results, setResults] = useState([]); + const [isFetching, setIsFetching] = useState(false); + const queryRef = useRef(""); + const timeoutRef = useRef(null); + + const search = useCallback(async () => { + setIsFetching(true); + const searchResults = await fetchV2("api:Search", { + query: [ + ["query", queryRef.current], + ["limit", limit], + ], + }); + + setResults(searchResults.searchResults.results); + setIsFetching(false); + }, [limit]); + + useEffect(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + setIsFetching(true); + timeoutRef.current = setTimeout(() => { + queryRef.current = query; + if (queryRef.current.length) search(); + else { + setResults([]); + setIsFetching(false); + } + }, 300); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [query, search]); + + return { results, isFetching }; +};