initial commit
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
/* ============================================================================
|
||||
KNOWLEDGE HUB - one item
|
||||
----------------------------------------------------------------------------
|
||||
/{locale}/knowledge-hub/{slug}
|
||||
|
||||
Registered in app/routes.ts as "knowledgeHubDetail", a SIBLING of the
|
||||
listing route under the ":lang" layout, and typed against its generated
|
||||
`./+types/` module.
|
||||
|
||||
A missing slug is a 404. A CMS outage is not: `fetchBySlug` only converts
|
||||
Strapi's own 404 into null and rethrows everything else.
|
||||
========================================================================= */
|
||||
|
||||
import { useLoaderData } from "react-router";
|
||||
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import {
|
||||
formatDate,
|
||||
formatFileSize,
|
||||
sectionPath,
|
||||
toIsoDate,
|
||||
useTranslation,
|
||||
type Locale,
|
||||
} from "~/i18n";
|
||||
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
import { requireEntity } from "~/lib/strapi";
|
||||
|
||||
import type { Route } from "./+types/knowledge-hub.$slug";
|
||||
import {
|
||||
collectLocalizedSlugs,
|
||||
detailAlternateDescriptors,
|
||||
LOCALIZATION_POPULATE,
|
||||
SELF_ALTERNATES_HANDLE,
|
||||
} from "./_shared/alternates";
|
||||
import {
|
||||
CMS,
|
||||
fetchBySlug,
|
||||
toFile,
|
||||
toImage,
|
||||
type CmsFile,
|
||||
type KnowledgeEntry,
|
||||
} from "./_shared/cms";
|
||||
import { readRootSeo, requireLocale, withParam } from "./_shared/listing";
|
||||
import {
|
||||
BackLink,
|
||||
MetaLine,
|
||||
MetaList,
|
||||
PageHeader,
|
||||
PageShell,
|
||||
TopicChips,
|
||||
type MetaItem,
|
||||
} from "./_shared/listing-ui";
|
||||
import { RichText } from "./_shared/RichText";
|
||||
import {
|
||||
DETAIL_COPY,
|
||||
KNOWLEDGE_COPY,
|
||||
KNOWLEDGE_DETAIL_COPY,
|
||||
KNOWLEDGE_TYPE_BLURBS,
|
||||
KNOWLEDGE_TYPE_LABELS,
|
||||
} from "./_shared/page-copy";
|
||||
|
||||
function typeLabel(value: string, locale: Locale): string {
|
||||
return KNOWLEDGE_TYPE_LABELS[locale][value] ?? value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strapi 5 gives the Arabic translation its own slug, so this route emits its
|
||||
* own canonical + hreflang - see ./_shared/alternates.ts.
|
||||
*/
|
||||
export const handle = SELF_ALTERNATES_HANDLE;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LOADER
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const locale = requireLocale(params.lang);
|
||||
const slug = params.slug ?? "";
|
||||
|
||||
const entry = requireEntity(
|
||||
await fetchBySlug<KnowledgeEntry>(CMS.knowledgeItems, slug, {
|
||||
locale,
|
||||
populate: {
|
||||
thumbnail: {
|
||||
fields: ["url", "alternativeText", "width", "height", "formats"],
|
||||
},
|
||||
// Multiple media, and the only download this type has: there is no
|
||||
// `externalUrl` field on a knowledge item.
|
||||
attachments: { fields: ["url", "name", "ext", "mime", "size"] },
|
||||
topics: { fields: ["name", "slug"] },
|
||||
localizations: LOCALIZATION_POPULATE,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const attachments = (entry.attachments ?? [])
|
||||
.map((media) => toFile(media))
|
||||
.filter((file): file is CmsFile => file !== undefined)
|
||||
.map((file) => ({
|
||||
...file,
|
||||
label: `${file.kind} · ${formatFileSize(file.sizeBytes, locale)}`,
|
||||
}));
|
||||
const date = entry.publicationDate ?? entry.publishedAt ?? undefined;
|
||||
|
||||
return {
|
||||
slug: entry.slug,
|
||||
alternates: collectLocalizedSlugs(locale, entry.slug, entry.localizations),
|
||||
title: entry.title,
|
||||
summary: entry.summary ?? undefined,
|
||||
body: entry.body ?? undefined,
|
||||
typeLabel: entry.itemType ? typeLabel(entry.itemType, locale) : undefined,
|
||||
typeBlurb: entry.itemType
|
||||
? KNOWLEDGE_TYPE_BLURBS[locale][entry.itemType]
|
||||
: undefined,
|
||||
date,
|
||||
dateIso: toIsoDate(date),
|
||||
dateLabel: date ? formatDate(date, locale) : undefined,
|
||||
modifiedIso: toIsoDate(entry.updatedAt),
|
||||
topics: (entry.topics ?? [])
|
||||
.filter((topic) => Boolean(topic?.slug && topic?.name))
|
||||
.map((topic) => ({ label: topic.name, slug: topic.slug })),
|
||||
cover: toImage(entry.thumbnail, "large"),
|
||||
attachments,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
META
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export function meta({ matches, loaderData }: Route.MetaArgs) {
|
||||
const root = readRootSeo(matches);
|
||||
const entry = loaderData as Awaited<ReturnType<typeof loader>> | undefined;
|
||||
const section = KNOWLEDGE_COPY[root.locale];
|
||||
|
||||
if (!entry) {
|
||||
return buildMeta({
|
||||
locale: root.locale,
|
||||
pathname: root.pathname,
|
||||
siteUrl: root.siteUrl,
|
||||
title: section.heading,
|
||||
description: section.metaDescription,
|
||||
});
|
||||
}
|
||||
|
||||
const descriptors = buildMeta({
|
||||
locale: root.locale,
|
||||
pathname: root.pathname,
|
||||
siteUrl: root.siteUrl,
|
||||
title: entry.title,
|
||||
description: entry.summary ?? entry.typeBlurb ?? section.metaDescription,
|
||||
type: "article",
|
||||
...(entry.cover ? { image: { url: entry.cover.src, alt: entry.cover.alt } } : {}),
|
||||
...(entry.dateIso
|
||||
? {
|
||||
article: {
|
||||
publishedTime: entry.dateIso,
|
||||
...(entry.modifiedIso ? { modifiedTime: entry.modifiedIso } : {}),
|
||||
...(entry.typeLabel ? { section: entry.typeLabel } : {}),
|
||||
tags: entry.topics.map((topic) => topic.label),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
jsonLd: [
|
||||
buildBreadcrumbJsonLd(root.siteUrl, [
|
||||
{ name: "SHARP-ER", pathname: sectionPath("home", root.locale) },
|
||||
{ name: section.heading, pathname: sectionPath("knowledgeHub", root.locale) },
|
||||
{ name: entry.title, pathname: root.pathname },
|
||||
]),
|
||||
...(entry.dateIso
|
||||
? [
|
||||
buildArticleJsonLd({
|
||||
siteUrl: root.siteUrl,
|
||||
locale: root.locale,
|
||||
headline: entry.title,
|
||||
pathname: root.pathname,
|
||||
datePublished: entry.dateIso,
|
||||
...(entry.modifiedIso ? { dateModified: entry.modifiedIso } : {}),
|
||||
...(entry.summary ? { description: entry.summary } : {}),
|
||||
...(entry.cover ? { image: entry.cover.src } : {}),
|
||||
...(entry.typeLabel ? { section: entry.typeLabel } : {}),
|
||||
keywords: entry.topics.map((topic) => topic.label),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
return [
|
||||
...descriptors,
|
||||
...detailAlternateDescriptors({
|
||||
siteUrl: root.siteUrl,
|
||||
section: "knowledgeHub",
|
||||
locale: root.locale,
|
||||
slugs: entry.alternates,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
PAGE
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export default function KnowledgeItemDetail() {
|
||||
const entry = useLoaderData<typeof loader>();
|
||||
const { t, locale } = useTranslation();
|
||||
|
||||
const listingPath = sectionPath("knowledgeHub", locale);
|
||||
const detail = KNOWLEDGE_DETAIL_COPY[locale];
|
||||
const shared = DETAIL_COPY[locale];
|
||||
|
||||
const metaItems: MetaItem[] = [
|
||||
...(entry.dateLabel
|
||||
? [
|
||||
{
|
||||
label: shared.published,
|
||||
value: <time dateTime={entry.dateIso ?? entry.date}>{entry.dateLabel}</time>,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(entry.typeLabel
|
||||
? [{ label: t("filters.category"), value: entry.typeLabel }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<BackLink to={listingPath} label={detail.backToListing} />
|
||||
|
||||
<article className="flex flex-col gap-2xl">
|
||||
<PageHeader
|
||||
eyebrow={entry.typeLabel}
|
||||
heading={entry.title}
|
||||
intro={entry.summary}
|
||||
>
|
||||
<MetaLine items={[entry.dateLabel ?? ""]} />
|
||||
</PageHeader>
|
||||
|
||||
{entry.cover ? (
|
||||
<img
|
||||
src={entry.cover.src}
|
||||
alt={entry.cover.alt}
|
||||
width={entry.cover.width}
|
||||
height={entry.cover.height}
|
||||
decoding="async"
|
||||
className="w-full rounded-card border border-border-subtle object-cover"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2xl lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start">
|
||||
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-lg">
|
||||
<RichText value={entry.body} />
|
||||
|
||||
{entry.attachments.length > 0 ? (
|
||||
<ul className="flex list-none flex-col gap-md">
|
||||
{entry.attachments.map((file) => (
|
||||
<li
|
||||
key={file.url}
|
||||
className="flex flex-wrap items-center gap-md"
|
||||
>
|
||||
<Button
|
||||
as="a"
|
||||
href={file.url}
|
||||
variant="primary"
|
||||
download={file.name}
|
||||
>
|
||||
{t("actions.download")}
|
||||
</Button>
|
||||
<span className="body-sm text-text-tertiary">
|
||||
{file.name} · {file.label}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside className="flex flex-col gap-lg">
|
||||
<MetaList heading={detail.aboutThis} items={metaItems} />
|
||||
<TopicChips
|
||||
heading={shared.topics}
|
||||
topics={entry.topics.map((topic) => ({
|
||||
label: topic.label,
|
||||
href: `${listingPath}${withParam(new URLSearchParams(), "topic", topic.slug)}`,
|
||||
}))}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user