Files
sharper.ly/apps/web/app/routes/knowledge-hub.tsx
T
2026-09-11 02:46:56 +02:00

310 lines
9.9 KiB
TypeScript

/* ============================================================================
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<KnowledgeEntry>(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 (
<PageShell>
<PageHeader heading={copy.heading} intro={copy.intro} />
{/* The map of the hub. Hidden once a filter is on, where it would just
repeat the control the reader has already used. */}
{!isFiltered ? (
<nav aria-label={t("nav.sections.knowledgeHub")}>
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{browse.map((item) => (
<Card.Content
key={item.value}
as="li"
titleAs="h2"
href={`${pathname}${withParam(new URLSearchParams(), "type", item.value)}`}
title={item.label}
excerpt={item.blurb}
actionLabel={t("actions.browseHub")}
/>
))}
</ul>
</nav>
) : null}
<section className="flex flex-col gap-lg" aria-label={t("filters.heading")}>
<FilterBar
legend={t("filters.heading")}
action={pathname}
applyLabel={t("filters.apply")}
clearLabel={t("filters.clear")}
clearHref={pathname}
showClear={isFiltered}
>
<FilterSearch
label={t("search.label")}
placeholder={t("search.placeholder")}
defaultValue={filters.q}
/>
<FilterSelect
name="type"
label={t("filters.category")}
value={filters.type}
options={facets.types}
allLabel={t("filters.all")}
/>
<FilterSelect
name="topic"
label={t("filters.topic")}
value={filters.topic}
options={facets.topics}
allLabel={t("filters.all")}
/>
</FilterBar>
{filters.q ? (
<p className="body-md text-text-secondary">
{t("search.resultsFor", { query: filters.q })}
</p>
) : null}
<ResultSummary pagination={pagination} t={t} />
{entries.length === 0 ? (
<EmptyState
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
>
{isFiltered ? (
<Button as={Link} to={pathname} variant="secondary" size="sm">
{t("filters.clear")}
</Button>
) : null}
</EmptyState>
) : (
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{entries.map((entry) => (
<Card.Content
key={entry.id}
as="li"
href={`${sectionPath("knowledgeHub", locale)}/${entry.slug}`}
title={entry.title}
actionLabel={t("actions.readMore")}
{...(entry.typeLabel ? { eyebrow: entry.typeLabel } : {})}
{...(entry.summary ? { excerpt: entry.summary } : {})}
{...(entry.dateLabel ? { badge: entry.dateLabel } : {})}
{...(entry.image ? { image: entry.image } : {})}
/>
))}
</ul>
)}
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
</section>
</PageShell>
);
}