404 lines
16 KiB
Text
404 lines
16 KiB
Text
---
|
|
import type { ImageMetadata } from "astro";
|
|
import { getImage } from "astro:assets";
|
|
import { type CollectionEntry, getEntry, getCollection } from "astro:content";
|
|
import { Markdown } from "@astropub/md";
|
|
import { slug } from "github-slugger";
|
|
import { DEFAULT_LANG, t, type Lang } from "../i18n";
|
|
import BaseLayout from "./BaseLayout.astro";
|
|
import CopyrightedCharacters from "../components/CopyrightedCharacters.astro";
|
|
import Prose from "../components/Prose.astro";
|
|
import MastodonComments from "../components/MastodonComments.astro";
|
|
import type { CopyrightedCharacters as CopyrightedCharactersType, Posts } from "../content/config";
|
|
import { qualifyLocalURLsInMarkdown } from "../utils/qualify_local_urls_in_markdown";
|
|
import {
|
|
IconSun,
|
|
IconMoon,
|
|
IconCircleInfo,
|
|
IconArrowBack,
|
|
IconChevronLeft,
|
|
IconChevronRight,
|
|
IconArrowUp,
|
|
} from "../components/icons";
|
|
|
|
interface RelatedContent {
|
|
link: string;
|
|
title: string;
|
|
}
|
|
|
|
type Props = {
|
|
/* Content attributes */
|
|
title: string;
|
|
lang: Lang;
|
|
isDraft: boolean;
|
|
pubDate?: Date;
|
|
description: string;
|
|
summary?: string;
|
|
tags: string[];
|
|
thumbnail?: ImageMetadata;
|
|
thumbnailWidth?: number;
|
|
thumbnailHeight?: number;
|
|
copyrightedCharacters?: CopyrightedCharactersType;
|
|
series?: CollectionEntry<"series">;
|
|
prev?: RelatedContent;
|
|
next?: RelatedContent;
|
|
relatedStories?: CollectionEntry<"stories">[];
|
|
relatedGames?: CollectionEntry<"games">[];
|
|
posts: Posts;
|
|
|
|
/* Layout attributes */
|
|
publishedContentType: "story" | "game";
|
|
labelReturnTo: RelatedContent;
|
|
labelPreviousContent: string;
|
|
labelNextContent: string;
|
|
labelTitleSection: string;
|
|
labelInformationSection: string;
|
|
labelArticleSection: string;
|
|
};
|
|
|
|
const { props } = Astro;
|
|
const series = props.series && (await getEntry(props.series));
|
|
const categorizedTags = Object.fromEntries(
|
|
(await getCollection("tag-categories")).flatMap((category) =>
|
|
category.data.tags.map<[string, string | null]>(({ name }) =>
|
|
typeof name === "string" ? [name, name] : [name[DEFAULT_LANG], name[props.lang] ?? null],
|
|
),
|
|
),
|
|
);
|
|
const description = await qualifyLocalURLsInMarkdown(props.description, props.lang);
|
|
const summary = props.summary && (await qualifyLocalURLsInMarkdown(props.summary, props.lang));
|
|
const tags = props.tags.map<{ id: string; name: string }>((tag) => {
|
|
const tagSlug = slug(tag);
|
|
if (!(tag in categorizedTags)) {
|
|
console.warn(`WARNING: Tag "${tag}" doesn't have a category in the "tag-categories" collection`);
|
|
return { id: tagSlug, name: tag };
|
|
}
|
|
if (categorizedTags[tag] == null) {
|
|
console.warn(`WARNING: No "${props.lang}" translation for tag "${tag}"`);
|
|
return { id: tagSlug, name: tag };
|
|
}
|
|
return { id: tagSlug, name: categorizedTags[tag] };
|
|
});
|
|
const thumbnail =
|
|
props.thumbnail &&
|
|
(await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight }));
|
|
---
|
|
|
|
<BaseLayout pageTitle={props.title} lang={props.lang}>
|
|
<Fragment slot="head">
|
|
{props.isDraft ? <meta name="robots" content="noindex" /> : null}
|
|
<meta property="og:title" content={props.title} data-pagefind-meta="title[content]" />
|
|
<meta property="og:url" content={Astro.url} data-pagefind-meta="url[content]" />
|
|
{
|
|
thumbnail ? (
|
|
<Fragment>
|
|
<meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" />
|
|
<meta
|
|
property="og:image:alt"
|
|
content={t(props.lang, "published_content/cover_art_alt", props.title)}
|
|
data-pagefind-meta="image_alt[content]"
|
|
/>
|
|
</Fragment>
|
|
) : null
|
|
}
|
|
<slot name="head" />
|
|
</Fragment>
|
|
<div
|
|
id="top"
|
|
class="min-w-screen relative min-h-screen bg-radial from-bm-300 to-bm-600 px-1 pb-24 pt-28 sm:px-4 md:px-6 lg:pt-32 dark:from-green-700 dark:to-green-950 print:bg-none print:pb-0 print:pt-0"
|
|
>
|
|
<div
|
|
id="toolbox-buttons"
|
|
aria-label="Toolbox"
|
|
class="pointer-events-none absolute top-0 h-[80vh] py-2 pl-2 lg:inset-y-0 lg:h-full"
|
|
>
|
|
<div
|
|
class="pointer-events-auto sticky top-6 flex rounded-full bg-white px-1 py-1 shadow-md dark:bg-black print:hidden"
|
|
>
|
|
<a
|
|
href={series ? series.data.link : props.labelReturnTo.link}
|
|
class="text-link my-1 p-2"
|
|
aria-labelledby="label-return-to"
|
|
>
|
|
<IconArrowBack width="1.25rem" height="1.25rem" />
|
|
<span class="sr-only select-none" id="label-return-to"
|
|
>{
|
|
series ? t(props.lang, "published_content/return_to_series", series.data.name) : props.labelReturnTo.title
|
|
}</span
|
|
>
|
|
</a>
|
|
<a
|
|
href="#description"
|
|
class="text-link my-1 border-l border-stone-300 p-2 dark:border-stone-700"
|
|
aria-labelledby="label-go-to-description"
|
|
>
|
|
<IconCircleInfo width="1.25rem" height="1.25rem" />
|
|
<span class="sr-only select-none" id="label-go-to-description"
|
|
>{t(props.lang, "published_content/go_to_description")}</span
|
|
>
|
|
</a>
|
|
<button
|
|
data-dark-mode
|
|
style={{ display: "none" }}
|
|
class="text-link my-1 border-l border-stone-300 p-2 dark:border-stone-700"
|
|
aria-labelledby="label-toggle-dark-mode"
|
|
>
|
|
<IconSun width="1.25rem" height="1.25rem" class="hidden dark:block" />
|
|
<IconMoon width="1.25rem" height="1.25rem" class="block dark:hidden" />
|
|
<span class="sr-only select-none" id="label-toggle-dark-mode"
|
|
>{t(props.lang, "published_content/toggle_dark_mode")}</span
|
|
>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<main
|
|
class="h-entry mx-auto max-w-6xl rounded-lg bg-stone-50 px-2 pb-10 shadow-sm sm:px-6 md:px-32 lg:px-64 dark:bg-stone-900 print:max-w-full print:bg-none print:shadow-none"
|
|
data-pagefind-body={props.isDraft ? undefined : ""}
|
|
data-pagefind-meta={`type:${props.publishedContentType}`}
|
|
>
|
|
{
|
|
props.prev || props.next ? (
|
|
<div class="print:hidden">
|
|
<div id="nav-top" class="my-4 grid grid-cols-2 justify-items-stretch gap-2 pt-4 text-lg font-light">
|
|
{props.prev ? (
|
|
<a
|
|
href={props.prev.link}
|
|
rel="prev"
|
|
class="text-link flex items-center justify-center border-r border-stone-400 px-1 py-3 underline dark:border-stone-600"
|
|
aria-label={props.labelPreviousContent}
|
|
>
|
|
<IconChevronLeft width="1.25rem" height="1.25rem" />
|
|
<span class="ml-1">{props.prev.title}</span>
|
|
</a>
|
|
) : (
|
|
<div class="h-full border-r border-stone-400 dark:border-stone-600" aria-hidden="true" />
|
|
)}
|
|
{props.next ? (
|
|
<a
|
|
href={props.next.link}
|
|
rel="next"
|
|
class="text-link flex items-center justify-center px-1 py-3 underline"
|
|
aria-label={props.labelNextContent}
|
|
>
|
|
<span class="mr-1">{props.next.title}</span>
|
|
<IconChevronRight width="1.25rem" height="1.25rem" />
|
|
</a>
|
|
) : (
|
|
<div aria-hidden="true" />
|
|
)}
|
|
</div>
|
|
<hr class="mx-auto mb-5 w-full max-w-2xl border-stone-400 dark:border-stone-600" />
|
|
</div>
|
|
) : (
|
|
<div class="pt-5 sm:pt-7" aria-hidden="true" />
|
|
)
|
|
}
|
|
<h1
|
|
id="section-title"
|
|
class="p-name px-1 pt-4 font-serif text-4xl font-semibold text-stone-800 dark:text-stone-100"
|
|
aria-label={props.labelTitleSection}
|
|
>
|
|
{props.title}
|
|
</h1>
|
|
<hr class="mb-3 ml-[2px] mt-2 h-[4px] w-1/2 rounded-sm bg-stone-800 dark:bg-stone-100" />
|
|
<section
|
|
id="section-information"
|
|
class="p-summary mt-1 space-y-2 px-2 font-serif font-light italic text-stone-600 dark:text-stone-200"
|
|
aria-label={props.labelInformationSection}
|
|
>
|
|
<slot name="section-information" />
|
|
{
|
|
props.isDraft ? (
|
|
<p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
|
|
{t(props.lang, "published_content/draft_warning")}
|
|
</p>
|
|
) : null
|
|
}
|
|
<slot name="section-content-warning" />
|
|
</section>
|
|
{
|
|
thumbnail ? (
|
|
<Fragment>
|
|
<hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" />
|
|
<img
|
|
loading="eager"
|
|
src={thumbnail.src}
|
|
alt={t(props.lang, "published_content/cover_art_alt", props.title)}
|
|
width={props.thumbnailWidth}
|
|
height={props.thumbnailHeight}
|
|
class="u-photo mx-auto my-10 shadow-lg sm:my-16"
|
|
/>
|
|
</Fragment>
|
|
) : null
|
|
}
|
|
<hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" />
|
|
<article id="content" class="e-content pr-1 font-serif" aria-label={props.labelArticleSection}>
|
|
<slot name="section-article" />
|
|
</article>
|
|
<hr class="mx-auto mb-6 mt-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" />
|
|
{
|
|
props.isDraft ? (
|
|
<p
|
|
id="draft-warning-bottom"
|
|
class="py-2 text-center font-serif text-2xl font-semibold not-italic text-red-600"
|
|
>
|
|
{t(props.lang, "published_content/draft_warning")}
|
|
</p>
|
|
) : props.pubDate ? (
|
|
<time
|
|
id="publish-date"
|
|
datetime={props.pubDate.toISOString().slice(0, 10)}
|
|
class="dt-published mt-2 block px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
|
|
aria-label={t(props.lang, "published_content/publish_date_aria_label")}
|
|
aria-description={
|
|
t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined
|
|
}
|
|
data-pagefind-index-attrs="aria-description"
|
|
data-pagefind-meta="date[datetime]"
|
|
>
|
|
{t(props.lang, "published_content/publish_date", props.pubDate)}
|
|
</time>
|
|
) : null
|
|
}
|
|
<section id="description" class="px-2 font-serif" aria-describedby="title-description">
|
|
<h2 id="title-description" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
|
|
{t(props.lang, "published_content/description")}
|
|
</h2>
|
|
<Prose>
|
|
<Markdown of={description} />
|
|
<CopyrightedCharacters copyrightedCharacters={props.copyrightedCharacters} lang={props.lang} />
|
|
</Prose>
|
|
</section>
|
|
{
|
|
summary ? (
|
|
<section id="summary" class="px-2 font-serif" aria-describedby="title-summary">
|
|
<h2 id="title-summary" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
|
|
{t(props.lang, "published_content/summary")}
|
|
</h2>
|
|
<details class="mb-6 mt-1 rounded-lg border border-stone-400 bg-stone-50 text-stone-800 dark:border-stone-500 dark:bg-stone-900 dark:text-stone-100">
|
|
<summary class="rounded-lg bg-stone-200 px-2 py-1 dark:bg-stone-800">
|
|
{t(props.lang, "published_content/reveal_summary")}
|
|
</summary>
|
|
<div class="px-2 py-1">
|
|
<Prose>
|
|
<Markdown of={summary} />
|
|
</Prose>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
) : null
|
|
}
|
|
<div class="pr-3 text-right print:hidden">
|
|
<a href="#top" class="text-link inline-flex items-center underline" aria-labelledby="label-to-top">
|
|
<IconArrowUp width="1.25rem" height="1.25rem" />
|
|
<span id="label-to-top">{t(props.lang, "published_content/to_top")}</span>
|
|
</a>
|
|
</div>
|
|
{
|
|
props.prev || props.next ? (
|
|
<Fragment>
|
|
<hr class="mx-auto mt-5 w-full max-w-2xl border-stone-400 dark:border-stone-600" />
|
|
<div id="nav-bottom" class="my-4 grid grid-cols-2 justify-items-stretch gap-2 text-lg font-light">
|
|
{props.prev ? (
|
|
<a
|
|
href={props.prev.link}
|
|
rel="prev"
|
|
class="text-link flex items-center justify-center border-r border-stone-400 px-1 py-3 underline dark:border-stone-600"
|
|
aria-label={props.labelPreviousContent}
|
|
>
|
|
<IconChevronLeft width="1.25rem" height="1.25rem" />
|
|
<span class="ml-1">{props.prev.title}</span>
|
|
</a>
|
|
) : (
|
|
<div class="h-full border-r border-stone-400 dark:border-stone-600" aria-hidden="true" />
|
|
)}
|
|
{props.next ? (
|
|
<a
|
|
href={props.next.link}
|
|
rel="next"
|
|
class="text-link flex items-center justify-center px-1 py-3 underline"
|
|
aria-label={props.labelNextContent}
|
|
>
|
|
<span class="mr-1">{props.next.title}</span>
|
|
<IconChevronRight width="1.25rem" height="1.25rem" />
|
|
</a>
|
|
) : (
|
|
<div aria-hidden="true" />
|
|
)}
|
|
</div>
|
|
<hr class="mx-auto mb-5 w-full max-w-2xl border-stone-400 dark:border-stone-600" />
|
|
</Fragment>
|
|
) : null
|
|
}
|
|
{
|
|
props.relatedStories?.length ? (
|
|
<section id="related" aria-describedby="title-related" class="my-5">
|
|
<h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
|
|
{t(props.lang, "published_content/related_stories")}
|
|
</h2>
|
|
<Prose>
|
|
<ul>
|
|
{props.relatedStories.map((story) => (
|
|
<li>
|
|
<a href={`/stories/${story.slug}`}>{story.data.title}</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Prose>
|
|
</section>
|
|
) : null
|
|
}
|
|
{
|
|
props.relatedGames?.length ? (
|
|
<section id="related" aria-describedby="title-related" class="my-5">
|
|
<h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
|
|
{t(props.lang, "published_content/related_games")}
|
|
</h2>
|
|
<Prose>
|
|
<ul>
|
|
{props.relatedGames.map((game) => (
|
|
<li>
|
|
<a href={`/games/${game.slug}`}>{game.data.title}</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Prose>
|
|
</section>
|
|
) : null
|
|
}
|
|
{
|
|
tags.length ? (
|
|
<section id="tags" aria-describedby="title-tags" class="my-5">
|
|
<h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
|
|
{t(props.lang, "published_content/tags")}
|
|
</h2>
|
|
<ul class="p-category flex flex-wrap gap-x-2 gap-y-3 px-3">
|
|
{tags.map(({ id, name }) => (
|
|
<li>
|
|
<a
|
|
class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm hover:underline focus:underline dark:bg-bm-600 dark:text-white print:bg-none"
|
|
href={`/tags/${id}`}
|
|
>
|
|
{name}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
) : null
|
|
}
|
|
{props.posts.mastodon ? <MastodonComments lang={props.lang} {...props.posts.mastodon} /> : null}
|
|
</main>
|
|
<div
|
|
class="pt-14 text-center text-xs text-black dark:text-white"
|
|
aria-label={t(props.lang, "published_content/copyright_aria_label")}
|
|
>
|
|
<span
|
|
set:html={t(props.lang, "published_content/copyright_year", (props.pubDate || new Date()).getFullYear())}
|
|
/><span> |</span>
|
|
<a class="hover:underline focus:underline" href="/licenses.toml" rel="license"
|
|
>{t(props.lang, "published_content/licenses")}</a
|
|
>
|
|
</div>
|
|
</div>
|
|
</BaseLayout>
|