/* ============================================================================ KNOWLEDGE HUB - listing ---------------------------------------------------------------------------- /{locale}/knowledge-hub ยง7 of docs/SHARPER_Website_Draft_Section_EN_AR.docx: training modules, toolkits, Civil Society Law Session guides, case studies and FAQs. Browsing is by type, by tag and by free-text search, all three held in the query string so a browse is a shareable, crawlable URL: ?type=case-study&topic=access-to-justice&q=municipality The ?type= values are the `knowledge-item.itemType` enumeration in the CMS, verbatim, so the URL and the Strapi filter cannot drift apart. Tags are the shared `topics` taxonomy - the same vocabulary the resources listing and the news archive filter on - so a tag means the same thing everywhere on the site instead of drifting per section. While the hub is empty the four type cards still describe what will land in it, using the draft's own one-line descriptions; nothing is invented and no count is shown. ========================================================================= */ import { Link, useLocation } from "react-router"; import { Button } from "~/components/ui/Button"; import { Card } from "~/components/ui/Card"; import { formatDate, sectionPath, useTranslation, type Locale, } from "~/i18n"; import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo"; import type { Route } from "./+types/knowledge-hub"; import { CMS, KNOWLEDGE_TYPES, fetchCollection, fetchFilterOptions, toImage, type CmsImage, type KnowledgeEntry, } from "./_shared/cms"; import { PAGE_SIZE, buildPagination, compactFilters, parseOptionalEnum, parsePage, parseQuery, parseSlug, readRootSeo, requireLocale, withParam, } from "./_shared/listing"; import { EmptyState, FilterBar, FilterSearch, FilterSelect, PageHeader, PageShell, Pagination, ResultSummary, } from "./_shared/listing-ui"; import { KNOWLEDGE_COPY, KNOWLEDGE_TYPE_BLURBS, KNOWLEDGE_TYPE_LABELS, } from "./_shared/page-copy"; const DATE_FIELD = "publicationDate"; /** The label for one `itemType`, or the raw value if a new one is added. */ function typeLabel(value: string, locale: Locale): string { return KNOWLEDGE_TYPE_LABELS[locale][value] ?? value; } interface KnowledgeListItem { readonly id: string; readonly slug: string; readonly title: string; readonly summary?: string; readonly typeLabel?: string; readonly dateLabel?: string; readonly image?: CmsImage; } /* --------------------------------------------------------------------------- LOADER ------------------------------------------------------------------------ */ export async function loader({ params, request }: Route.LoaderArgs) { const locale = requireLocale(params.lang); const search = new URL(request.url).searchParams; const page = parsePage(search); const type = parseOptionalEnum(search, "type", KNOWLEDGE_TYPES); const topic = parseSlug(search, "topic"); const query = parseQuery(search); const filters = compactFilters({ itemType: type ? { $eq: type } : undefined, topics: topic ? { slug: { $eq: topic } } : undefined, // Free text hits the title and the summary; the body is deliberately left // out so a passing mention does not outrank a real match. $or: query ? [{ title: { $containsi: query } }, { summary: { $containsi: query } }] : undefined, }); const [listing, topicOptions] = await Promise.all([ fetchCollection(CMS.knowledgeItems, { locale, filters, sort: [`${DATE_FIELD}:desc`, "title:asc"], populate: { thumbnail: { fields: ["url", "alternativeText", "width", "height", "formats"], }, topics: { fields: ["name", "slug"] }, }, pagination: { page, pageSize: PAGE_SIZE, withCount: true }, }), fetchFilterOptions(CMS.topics, locale), ]); const entries: KnowledgeListItem[] = listing.entries.map((entry) => toListItem(entry, locale), ); return { entries, pagination: buildPagination(page, PAGE_SIZE, listing.pagination.total), available: listing.available, filters: { type: type ?? "", topic: topic ?? "", q: query ?? "", }, facets: { types: KNOWLEDGE_TYPES.map((value) => ({ value, label: typeLabel(value, locale), })), topics: topicOptions, }, /** The five type cards, with the draft's descriptions. */ browse: KNOWLEDGE_TYPES.map((value) => ({ value, label: typeLabel(value, locale), blurb: KNOWLEDGE_TYPE_BLURBS[locale][value] ?? "", })), }; } function toListItem(entry: KnowledgeEntry, locale: Locale): KnowledgeListItem { const image = toImage(entry.thumbnail, "small"); const date = entry.publicationDate ?? entry.publishedAt ?? undefined; return { id: entry.documentId, slug: entry.slug, title: entry.title, ...(entry.summary ? { summary: entry.summary } : {}), ...(entry.itemType ? { typeLabel: typeLabel(entry.itemType, locale) } : {}), ...(date ? { dateLabel: formatDate(date, locale) } : {}), ...(image ? { image } : {}), }; } /* --------------------------------------------------------------------------- META Canonical + hreflang are emitted by the shell for every route, so they are not repeated here - see root.tsx `Layout`. ------------------------------------------------------------------------ */ export function meta({ matches, loaderData }: Route.MetaArgs) { const root = readRootSeo(matches); const copy = KNOWLEDGE_COPY[root.locale]; return buildMeta({ locale: root.locale, pathname: root.pathname, siteUrl: root.siteUrl, title: copy.heading, description: copy.metaDescription, // If the CMS could not be reached the page is an empty shell, not an empty // section - keep it out of the index rather than teaching a crawler that // SHARP-ER has published nothing. noIndex: loaderData?.available === false, jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [ { name: "SHARP-ER", pathname: sectionPath("home", root.locale) }, { name: copy.heading, pathname: sectionPath("knowledgeHub", root.locale) }, ]), }); } /* --------------------------------------------------------------------------- PAGE ------------------------------------------------------------------------ */ export default function KnowledgeHub({ loaderData }: Route.ComponentProps) { const { entries, pagination, filters, facets, browse } = loaderData; const { t, locale } = useTranslation(); const { pathname, search } = useLocation(); const params = new URLSearchParams(search); const copy = KNOWLEDGE_COPY[locale]; const isFiltered = Boolean(filters.type || filters.topic || filters.q); return ( {/* The map of the hub. Hidden once a filter is on, where it would just repeat the control the reader has already used. */} {!isFiltered ? ( ) : null}
{filters.q ? (

{t("search.resultsFor", { query: filters.q })}

) : null} {entries.length === 0 ? ( {isFiltered ? ( ) : null} ) : (
    {entries.map((entry) => ( ))}
)}
); }