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

293 lines
9.2 KiB
TypeScript

/* ============================================================================
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>
);
}