initial commit
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
/* ============================================================================
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user