diff --git a/apps/ui/locales/en.json b/apps/ui/locales/en.json index 7c6c775..7f03039 100644 --- a/apps/ui/locales/en.json +++ b/apps/ui/locales/en.json @@ -105,7 +105,8 @@ "features": { "searchPlaceholder": "Search features...", "loadMore": "See more", - "viewFeature": "Learn more" + "viewFeature": "Learn more", + "filterTagsLabel": "Categories" }, "blog": { "title": "Blog", diff --git a/apps/ui/src/components/case-study/CaseStudiesGrid.tsx b/apps/ui/src/components/case-study/CaseStudiesGrid.tsx index 40ef6a1..f645837 100644 --- a/apps/ui/src/components/case-study/CaseStudiesGrid.tsx +++ b/apps/ui/src/components/case-study/CaseStudiesGrid.tsx @@ -1,14 +1,11 @@ "use client" -import { MagnifyingGlassIcon } from "@phosphor-icons/react/ssr" import type { Data } from "@repo/strapi-types" import { useTranslations } from "next-intl" import { useEffect, useMemo, useRef, useState, useTransition } from "react" +import { SearchFilterSidebar } from "@/components/elementary/SearchFilterSidebar" import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" import { cn } from "@/lib/styles" import type { @@ -148,47 +145,16 @@ export function CaseStudiesGrid({ return (
- +
diff --git a/apps/ui/src/components/elementary/GlobalSearchModal.tsx b/apps/ui/src/components/elementary/GlobalSearchModal.tsx index 3eaf863..ddd842b 100644 --- a/apps/ui/src/components/elementary/GlobalSearchModal.tsx +++ b/apps/ui/src/components/elementary/GlobalSearchModal.tsx @@ -6,6 +6,7 @@ import { FeatherIcon, BookOpenIcon, AddressBookIcon, + LightningIcon, } from "@phosphor-icons/react/ssr" import { Command } from "cmdk" import { useLocale } from "next-intl" @@ -36,6 +37,7 @@ const EMPTY_RESULT: GlobalSearchResult = { caseStudies: [], pages: [], blogPosts: [], + features: [], docs: [], } @@ -132,7 +134,8 @@ export function GlobalSearchModal({ results.pages.length > 0 || results.docs.length > 0 || results.blogPosts.length > 0 || - results.caseStudies.length > 0 + results.caseStudies.length > 0 || + results.features.length > 0 return ( @@ -273,6 +276,44 @@ export function GlobalSearchModal({ )} + {results.features.length > 0 && ( + + {results.features.map((item) => { + const href = item.url ?? "" + const isExternal = /^https?:\/\//.test(href) + + return ( + { + if (!href) return + if (isExternal) { + handleOpenChange(false) + window.open(href, "_blank", "noopener,noreferrer") + } else { + navigateAndClose(href) + } + }} + className={itemClass} + > + +
+ + {item.title} + + {item.feature_tag && ( + + {item.feature_tag} + + )} +
+
+ ) + })} +
+ )} + {results.caseStudies.length > 0 && ( {results.caseStudies.map((item) => { diff --git a/apps/ui/src/components/elementary/SearchFilterSidebar.tsx b/apps/ui/src/components/elementary/SearchFilterSidebar.tsx new file mode 100644 index 0000000..4a75cc7 --- /dev/null +++ b/apps/ui/src/components/elementary/SearchFilterSidebar.tsx @@ -0,0 +1,87 @@ +"use client" + +import { MagnifyingGlassIcon } from "@phosphor-icons/react/ssr" + +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { cn } from "@/lib/styles" + +export interface FilterOption { + readonly label: string + readonly value: string +} + +interface SearchFilterSidebarProps { + readonly query: string + readonly onQueryChange: (next: string) => void + readonly searchPlaceholder: string + readonly filterLabel?: string + readonly filterOptions?: readonly FilterOption[] + readonly selectedValues?: ReadonlySet + readonly onToggleValue?: (value: string) => void + readonly idPrefix: string + readonly className?: string +} + +export function SearchFilterSidebar({ + query, + onQueryChange, + searchPlaceholder, + filterLabel, + filterOptions, + selectedValues, + onToggleValue, + idPrefix, + className, +}: SearchFilterSidebarProps) { + const showFilters = + filterOptions != null && filterOptions.length > 0 && filterLabel != null + + return ( + + ) +} diff --git a/apps/ui/src/components/elementary/global-search-action.ts b/apps/ui/src/components/elementary/global-search-action.ts index e9b1679..a23731e 100644 --- a/apps/ui/src/components/elementary/global-search-action.ts +++ b/apps/ui/src/components/elementary/global-search-action.ts @@ -4,6 +4,7 @@ import { DOCS_INDEX_NAME, getBlogPostsIndexName, getCaseStudiesIndexName, + getFeaturesIndexName, getMeilisearchClient, getMeilisearchDocsClient, getPagesIndexName, @@ -13,6 +14,7 @@ import type { BlogPostGlobalHit, CaseStudyGlobalHit, DocsGlobalHit, + FeatureGlobalHit, GlobalSearchResult, PageGlobalHit, } from "./global-search-types" @@ -35,7 +37,7 @@ export async function globalSearch({ const trimmed = query.trim() if (trimmed.length === 0) { - return { caseStudies: [], pages: [], blogPosts: [], docs: [] } + return { caseStudies: [], pages: [], blogPosts: [], features: [], docs: [] } } const [siteRes, docsRes] = await Promise.all([ @@ -60,6 +62,12 @@ export async function globalSearch({ limit: PER_INDEX_LIMIT, attributesToRetrieve: ["slug", "title", "description"], }, + { + indexUid: getFeaturesIndexName(), + q: trimmed, + limit: PER_INDEX_LIMIT, + attributesToRetrieve: ["title", "description", "url", "feature_tag"], + }, ], }), getMeilisearchDocsClient() @@ -78,7 +86,7 @@ export async function globalSearch({ .catch(() => ({ hits: [] })), ]) - const [caseStudies, pages, blogPosts] = siteRes.results + const [caseStudies, pages, blogPosts, features] = siteRes.results return { caseStudies: (caseStudies?.hits ?? @@ -86,6 +94,7 @@ export async function globalSearch({ pages: (pages?.hits ?? []) as unknown as readonly PageGlobalHit[], blogPosts: (blogPosts?.hits ?? []) as unknown as readonly BlogPostGlobalHit[], + features: (features?.hits ?? []) as unknown as readonly FeatureGlobalHit[], docs: (docsRes.hits ?? []) as unknown as readonly DocsGlobalHit[], } } diff --git a/apps/ui/src/components/elementary/global-search-types.ts b/apps/ui/src/components/elementary/global-search-types.ts index da1d054..ca16699 100644 --- a/apps/ui/src/components/elementary/global-search-types.ts +++ b/apps/ui/src/components/elementary/global-search-types.ts @@ -17,6 +17,13 @@ export interface BlogPostGlobalHit { readonly description?: string | null } +export interface FeatureGlobalHit { + readonly title: string + readonly description?: string | null + readonly url?: string | null + readonly feature_tag?: string | null +} + export interface DocsGlobalHit { readonly url: string readonly hierarchy_lvl0?: string | null @@ -30,5 +37,6 @@ export interface GlobalSearchResult { readonly caseStudies: readonly CaseStudyGlobalHit[] readonly pages: readonly PageGlobalHit[] readonly blogPosts: readonly BlogPostGlobalHit[] + readonly features: readonly FeatureGlobalHit[] readonly docs: readonly DocsGlobalHit[] } diff --git a/apps/ui/src/components/feature-page/FeaturePagesGrid.tsx b/apps/ui/src/components/feature-page/FeaturePagesGrid.tsx index e29c023..6c351f3 100644 --- a/apps/ui/src/components/feature-page/FeaturePagesGrid.tsx +++ b/apps/ui/src/components/feature-page/FeaturePagesGrid.tsx @@ -1,12 +1,13 @@ "use client" -import { ArrowRightIcon, MagnifyingGlassIcon } from "@phosphor-icons/react/ssr" +import type { Data } from "@repo/strapi-types" import { useTranslations } from "next-intl" -import { useEffect, useRef, useState, useTransition } from "react" +import { useEffect, useMemo, useRef, useState, useTransition } from "react" +import { SearchFilterSidebar } from "@/components/elementary/SearchFilterSidebar" +import { StrapiBasicImage } from "@/components/page-builder/components/utilities/StrapiBasicImage" import { Button } from "@/components/ui/button" -import { Card, CardContent, CardFooter } from "@/components/ui/card" -import { Input } from "@/components/ui/input" +import { Card, CardContent } from "@/components/ui/card" import { Link } from "@/lib/navigation" import { cn } from "@/lib/styles" @@ -28,6 +29,17 @@ interface FeaturePagesGridProps { const DEFAULT_PAGE_SIZE = 12 +function collectTags( + into: Set, + items: readonly FeaturePageHit[] +): Set { + for (const item of items) { + if (item.feature_tag) into.add(item.feature_tag) + } + + return into +} + export function FeaturePagesGrid({ locale, initialHits, @@ -41,8 +53,24 @@ export function FeaturePagesGrid({ const [hits, setHits] = useState(initialHits) const [total, setTotal] = useState(initialTotal) const [query, setQuery] = useState("") + const [selectedTags, setSelectedTags] = useState>( + new Set() + ) + const [knownTags, setKnownTags] = useState>(() => + collectTags(new Set(), initialHits) + ) const [isPending, startTransition] = useTransition() + const tagOptions = useMemo(() => [...knownTags], [knownTags]) + + function mergeTags(items: readonly FeaturePageHit[]) { + setKnownTags((prev) => { + const next = collectTags(new Set(prev), items) + + return next.size === prev.size ? prev : next + }) + } + const isFirstRender = useRef(true) useEffect(() => { if (isFirstRender.current) { @@ -56,29 +84,47 @@ export function FeaturePagesGrid({ const res = await searchAction({ locale, query, + featureTagTitles: [...selectedTags], offset: 0, limit: pageSize, }) setHits(res.hits) setTotal(res.total) + mergeTags(res.hits) }) }, 200) return () => clearTimeout(handle) - }, [query, locale, pageSize, searchAction]) + }, [query, selectedTags, locale, pageSize, searchAction]) + + function toggleTag(tag: string) { + setSelectedTags((prev) => { + const next = new Set(prev) + + if (next.has(tag)) { + next.delete(tag) + } else { + next.add(tag) + } + + return next + }) + } function loadMore() { startTransition(async () => { const res = await searchAction({ locale, query, + featureTagTitles: [...selectedTags], offset: hits.length, limit: pageSize, }) setHits((prev) => [...prev, ...res.hits]) setTotal(res.total) + mergeTags(res.hits) }) } @@ -86,41 +132,68 @@ export function FeaturePagesGrid({ return (
- + ({ label: tag, value: tag }))} + selectedValues={selectedTags} + onToggleValue={toggleTag} + idPrefix="feature-tag" + />
- {hits.map((item) => ( - - - -

- {item.title} -

-
- - - - {t("viewFeature")} - + {hits.map((item) => { + const href = item.url ?? "" + const isExternal = /^https?:\/\//.test(href) + const iconComponent = item.icon + ? ({ + media: item.icon, + alt: item.icon.alternativeText ?? item.title, + } as unknown as Data.Component<"utilities.basic-image">) + : null + + return ( + + {item.feature_tag && ( + + {item.feature_tag} - - -
- ))} + )} + + + + {iconComponent && ( + + )} + +

+ {item.title} +

+ + {item.description && ( +

+ {item.description} +

+ )} +
+ + + ) + })}
{hasMore && ( diff --git a/apps/ui/src/components/feature-page/feature-pages-search-types.ts b/apps/ui/src/components/feature-page/feature-pages-search-types.ts index d0c80c3..2643bc9 100644 --- a/apps/ui/src/components/feature-page/feature-pages-search-types.ts +++ b/apps/ui/src/components/feature-page/feature-pages-search-types.ts @@ -1,11 +1,19 @@ +export interface FeatureIconMedia { + readonly url?: string + readonly alternativeText?: string | null + readonly width?: number + readonly height?: number +} + export interface FeaturePageHit { readonly id?: number readonly documentId?: string - readonly slug: string readonly title: string - readonly fullPath: string + readonly description?: string | null + readonly url?: string | null + readonly feature_tag?: string | null + readonly icon?: FeatureIconMedia | null readonly locale?: string - readonly pageType?: string readonly [key: string]: unknown } @@ -17,6 +25,7 @@ export interface FeaturePagesSearchResult { export interface SearchFeaturePagesArgs { readonly locale: string readonly query: string + readonly featureTagTitles: readonly string[] readonly offset: number readonly limit: number } diff --git a/apps/ui/src/components/feature-page/feature-pages-search.ts b/apps/ui/src/components/feature-page/feature-pages-search.ts index 7a30ec3..27267f3 100644 --- a/apps/ui/src/components/feature-page/feature-pages-search.ts +++ b/apps/ui/src/components/feature-page/feature-pages-search.ts @@ -1,6 +1,6 @@ "use server" -import { getMeilisearchClient, getPagesIndexName } from "@/lib/meilisearch" +import { getFeaturesIndexName, getMeilisearchClient } from "@/lib/meilisearch" import type { FeaturePageHit, @@ -14,18 +14,21 @@ function escape(value: string): string { } export async function searchFeaturePages({ - locale, query, + featureTagTitles, offset, limit, }: SearchFeaturePagesArgs): Promise { - const index = - getMeilisearchClient().index(getPagesIndexName()) + const index = getMeilisearchClient().index( + getFeaturesIndexName() + ) - const filter: string[] = [ - `pageType = "feature"`, - `locale = "${escape(locale)}"`, - ] + const filter: string[] = [] + + if (featureTagTitles.length > 0) { + const list = featureTagTitles.map((t) => `"${escape(t)}"`).join(", ") + filter.push(`feature_tag IN [${list}]`) + } const res = await index.search(query.trim(), { offset, diff --git a/apps/ui/src/components/page-builder/components/sections/strapi-dynamic-grid/StrapiDynamicFeaturesGrid.tsx b/apps/ui/src/components/page-builder/components/sections/strapi-dynamic-grid/StrapiDynamicFeaturesGrid.tsx index 5aae414..ef12b65 100644 --- a/apps/ui/src/components/page-builder/components/sections/strapi-dynamic-grid/StrapiDynamicFeaturesGrid.tsx +++ b/apps/ui/src/components/page-builder/components/sections/strapi-dynamic-grid/StrapiDynamicFeaturesGrid.tsx @@ -22,6 +22,7 @@ export function StrapiDynamicFeaturesGrid({ searchFeaturePages({ locale, query: "", + featureTagTitles: [], offset: 0, limit: INITIAL_PAGE_SIZE, }) diff --git a/apps/ui/src/lib/meilisearch.ts b/apps/ui/src/lib/meilisearch.ts index 991e4bb..e726691 100644 --- a/apps/ui/src/lib/meilisearch.ts +++ b/apps/ui/src/lib/meilisearch.ts @@ -53,3 +53,9 @@ export function getBlogPostsIndexName(): string { ? "blog-posts-production" : "blog-posts-testing" } + +export function getFeaturesIndexName(): string { + return process.env.MEILISEARCH_PRODUCTION === "true" + ? "features-production" + : "features-testing" +}