378 lines
17 KiB
Text
378 lines
17 KiB
Text
---
|
|
import { getImage } from "astro:assets";
|
|
import { type CollectionEntry, getEntry, getEntries, getCollection } from "astro:content";
|
|
import { Markdown } from "@astropub/md";
|
|
import { slug } from "github-slugger";
|
|
import { DEFAULT_LANG, t } from "../i18n";
|
|
import BaseLayout from "./BaseLayout.astro";
|
|
import Authors from "../components/Authors.astro";
|
|
import Commissioners from "../components/Commissioners.astro";
|
|
import Requesters from "../components/Requesters.astro";
|
|
import UserComponent from "../components/UserComponent.astro";
|
|
import CopyrightedCharacters from "../components/CopyrightedCharacters.astro";
|
|
import Prose from "../components/Prose.astro";
|
|
import MastodonComments from "../components/MastodonComments.astro";
|
|
import { formatCopyrightedCharacters } from "../utils/format_copyrighted_characters";
|
|
|
|
type Props = CollectionEntry<"stories">["data"];
|
|
|
|
const { props } = Astro;
|
|
let prev = props.prev && (await getEntry(props.prev));
|
|
if (prev && prev.data.isDraft) {
|
|
prev = undefined;
|
|
}
|
|
let next = props.next && (await getEntry(props.next));
|
|
if (next && next.data.isDraft) {
|
|
next = undefined;
|
|
}
|
|
const series = props.series && (await getEntry(props.series));
|
|
const authors = await getEntries([props.authors].flat());
|
|
const commissioner = props.commissioner && (await getEntry(props.commissioner));
|
|
const requester = props.requester && (await getEntry(props.requester));
|
|
const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters);
|
|
const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
|
|
// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
|
|
const categorizedTags = Object.fromEntries(
|
|
(await getCollection("tag-categories")).flatMap((category) =>
|
|
category.data.tags.map((tag) => {
|
|
if (typeof tag === "string") {
|
|
return [tag, tag];
|
|
}
|
|
return [t(DEFAULT_LANG, tag as any), t(props.lang, tag as any)];
|
|
}),
|
|
),
|
|
);
|
|
const tags = props.tags.map<[string, string]>((tag) => {
|
|
const tagSlug = slug(tag);
|
|
if (!(tag in categorizedTags)) {
|
|
console.log(`Tag "${tag}" doesn't have a category in the "tag-categories" collection!`);
|
|
return [tagSlug, tag];
|
|
}
|
|
return [tagSlug, categorizedTags[tag]!];
|
|
});
|
|
const thumbnail =
|
|
props.thumbnail &&
|
|
(await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight }));
|
|
const wordCount = props.wordCount ? `${props.wordCount}` : "???";
|
|
---
|
|
|
|
<BaseLayout pageTitle={props.title}>
|
|
<Fragment slot="head">
|
|
<meta property="og:title" content={props.title} data-pagefind-meta="title[content]" />
|
|
<meta property="og:description" content={t(props.lang, "story/warnings", wordCount, props.contentWarning.trim())} />
|
|
<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={`Cover art for ${props.shortTitle || props.title}`}
|
|
data-pagefind-meta="image_alt[content]"
|
|
/>
|
|
</Fragment>
|
|
) : null
|
|
}
|
|
<meta name="theme-color" content="#7DD05A" data-react-helmet="true" />
|
|
</Fragment>
|
|
<div
|
|
id="top"
|
|
class="min-w-screen relative min-h-screen bg-radial from-bm-300 to-bm-600 px-1 pb-16 pt-20 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.url : "/stories/1"}
|
|
class="text-link my-1 h-9 w-9 p-2"
|
|
aria-label={series
|
|
? t(props.lang, "story/return_to_series", series.data.name)
|
|
: t(props.lang, "story/return_to_stories")}
|
|
>
|
|
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
|
|
<path
|
|
d="M48.5 224H40c-13.3 0-24-10.7-24-24V72c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2L98.6 96.6c87.6-86.5 228.7-86.2 315.8 1c87.5 87.5 87.5 229.3 0 316.8s-229.3 87.5-316.8 0c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0c62.5 62.5 163.8 62.5 226.3 0s62.5-163.8 0-226.3c-62.2-62.2-162.7-62.5-225.3-1L185 183c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8H48.5z"
|
|
></path>
|
|
</svg>
|
|
</a>
|
|
<a
|
|
href="#description"
|
|
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
|
|
aria-label={t(props.lang, "story/go_to_description")}
|
|
>
|
|
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
|
|
<path
|
|
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"
|
|
></path>
|
|
</svg>
|
|
</a>
|
|
<button
|
|
data-dark-mode
|
|
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
|
|
aria-label={t(props.lang, "story/toggle_dark_mode")}
|
|
>
|
|
<svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true">
|
|
<path
|
|
d="M361.5 1.2c5 2.1 8.6 6.6 9.6 11.9L391 121l107.9 19.8c5.3 1 9.8 4.6 11.9 9.6s1.5 10.7-1.6 15.2L446.9 256l62.3 90.3c3.1 4.5 3.7 10.2 1.6 15.2s-6.6 8.6-11.9 9.6L391 391 371.1 498.9c-1 5.3-4.6 9.8-9.6 11.9s-10.7 1.5-15.2-1.6L256 446.9l-90.3 62.3c-4.5 3.1-10.2 3.7-15.2 1.6s-8.6-6.6-9.6-11.9L121 391 13.1 371.1c-5.3-1-9.8-4.6-11.9-9.6s-1.5-10.7 1.6-15.2L65.1 256 2.8 165.7c-3.1-4.5-3.7-10.2-1.6-15.2s6.6-8.6 11.9-9.6L121 121 140.9 13.1c1-5.3 4.6-9.8 9.6-11.9s10.7-1.5 15.2 1.6L256 65.1 346.3 2.8c4.5-3.1 10.2-3.7 15.2-1.6zM160 256a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zm224 0a128 128 0 1 0 -256 0 128 128 0 1 0 256 0z"
|
|
></path>
|
|
</svg>
|
|
<svg viewBox="0 0 512 512" class="block fill-current dark:hidden" aria-hidden="true">
|
|
<path
|
|
d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"
|
|
></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<main
|
|
class="mx-auto max-w-3xl rounded-lg bg-stone-50 px-2 pb-4 pt-1 shadow-sm dark:bg-stone-900 print:max-w-full print:bg-none print:shadow-none"
|
|
data-pagefind-body={props.isDraft ? undefined : ""}
|
|
data-pagefind-meta="type:story"
|
|
>
|
|
{
|
|
prev || next ? (
|
|
<div class="print:hidden">
|
|
<div id="story-nav-top" class="my-4 grid grid-cols-2 justify-items-stretch gap-2">
|
|
{prev ? (
|
|
<a
|
|
href={`/stories/${prev.slug}`}
|
|
class="text-link flex items-center justify-center border-r border-stone-400 px-1 py-3 font-light underline dark:border-stone-600"
|
|
>
|
|
<svg class="mr-1 h-5 w-5 fill-current" viewBox="0 0 320 512">
|
|
<path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z" />
|
|
</svg>
|
|
<span>Previous: {prev.data.shortTitle || prev.data.title}</span>
|
|
</a>
|
|
) : (
|
|
<div class="h-full border-r border-stone-400 dark:border-stone-600" />
|
|
)}
|
|
{next ? (
|
|
<a
|
|
href={`/stories/${next.slug}`}
|
|
class="text-link flex items-center justify-center px-1 py-3 font-light underline"
|
|
>
|
|
<span>Next: {next.data.shortTitle || next.data.title}</span>
|
|
<svg class="ml-1 h-5 w-5 fill-current" viewBox="0 0 320 512">
|
|
<path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" />
|
|
</svg>
|
|
</a>
|
|
) : (
|
|
<div />
|
|
)}
|
|
</div>
|
|
<hr class="mx-auto mb-5 w-full max-w-2xl border-stone-400 dark:border-stone-600" />
|
|
</div>
|
|
) : null
|
|
}
|
|
<h1 id="story-title" class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100">
|
|
{props.title}
|
|
</h1>
|
|
<section
|
|
id="story-information"
|
|
class="mt-1 space-y-2 px-2 font-serif font-light italic text-stone-600 dark:text-stone-200"
|
|
>
|
|
<Authors lang={props.lang}>
|
|
{authors.map((author) => <UserComponent lang={props.lang} user={author} />)}
|
|
</Authors>
|
|
{
|
|
props.isDraft ? (
|
|
<p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
|
|
{t(props.lang, "story/draft_warning")}
|
|
</p>
|
|
) : null
|
|
}
|
|
{
|
|
commissioner && (
|
|
<Commissioners lang={props.lang}>
|
|
<UserComponent user={commissioner} lang={props.lang} />
|
|
</Commissioners>
|
|
)
|
|
}
|
|
{
|
|
requester && (
|
|
<Requesters lang={props.lang}>
|
|
<UserComponent user={requester} lang={props.lang} />
|
|
</Requesters>
|
|
)
|
|
}
|
|
<div id="content-warning">
|
|
<p>
|
|
{t(props.lang, "story/warnings", wordCount, props.contentWarning.trim())}
|
|
</p>
|
|
</div>
|
|
</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={`Cover art for ${props.shortTitle || props.title}`}
|
|
width={props.thumbnailWidth}
|
|
height={props.thumbnailHeight}
|
|
class="mx-auto my-5 shadow-lg"
|
|
/>
|
|
</Fragment>
|
|
) : null
|
|
}
|
|
<hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" />
|
|
<article id="story" class="pr-1 font-serif">
|
|
<Prose>
|
|
<slot />
|
|
</Prose>
|
|
</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, "story/draft_warning")}
|
|
</p>
|
|
) : props.pubDate ? (
|
|
<p
|
|
id="publish-date"
|
|
class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
|
|
aria-label="Publish date"
|
|
aria-description={props.pubDate.toLocaleDateString("en-US", {
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
})}
|
|
data-pagefind-index-attrs="aria-description"
|
|
data-pagefind-meta={`date:${props.pubDate.toISOString().slice(undefined, 10)}`}
|
|
>
|
|
{t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(undefined, 10))}
|
|
</p>
|
|
) : 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, "story/description")}
|
|
</h2>
|
|
<Prose>
|
|
<Markdown of={props.description} />
|
|
<CopyrightedCharacters copyrightedCharacters={copyrightedCharacters} lang={props.lang} />
|
|
</Prose>
|
|
</section>
|
|
{
|
|
props.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, "story/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, "story/reveal_summary")}
|
|
</summary>
|
|
<div class="px-2 py-1">
|
|
<Prose>
|
|
<Markdown of={props.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"
|
|
><svg class="mr-1 h-6 w-6 fill-current" viewBox="0 0 384 512" aria-hidden="true"
|
|
><path
|
|
d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.2L329.4 246.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z"
|
|
></path></svg
|
|
><span>{t(props.lang, "story/to_top")}</span></a
|
|
>
|
|
</div>
|
|
{
|
|
prev || next ? (
|
|
<Fragment>
|
|
<hr class="mx-auto mt-5 w-full max-w-2xl border-stone-400 dark:border-stone-600" />
|
|
<div id="story-nav-top" class="my-4 grid grid-cols-2 justify-items-stretch gap-2">
|
|
{prev ? (
|
|
<a
|
|
href={`/stories/${prev.slug}`}
|
|
class="text-link flex items-center justify-center border-r border-stone-400 px-1 py-3 font-light underline dark:border-stone-600"
|
|
>
|
|
<svg class="mr-1 h-5 w-5 fill-current" viewBox="0 0 320 512">
|
|
<path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z" />
|
|
</svg>
|
|
<span>Previous: {prev.data.shortTitle || prev.data.title}</span>
|
|
</a>
|
|
) : (
|
|
<div class="h-full border-r border-stone-400 dark:border-stone-600" />
|
|
)}
|
|
{next ? (
|
|
<a
|
|
href={`/stories/${next.slug}`}
|
|
class="text-link flex items-center justify-center px-1 py-3 font-light underline"
|
|
>
|
|
<span>Next: {next.data.shortTitle || next.data.title}</span>
|
|
<svg class="ml-1 h-5 w-5 fill-current" viewBox="0 0 320 512">
|
|
<path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" />
|
|
</svg>
|
|
</a>
|
|
) : (
|
|
<div />
|
|
)}
|
|
</div>
|
|
<hr class="mx-auto mb-5 w-full max-w-2xl border-stone-400 dark:border-stone-600" />
|
|
</Fragment>
|
|
) : null
|
|
}
|
|
{
|
|
relatedStories.length > 0 ? (
|
|
<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">
|
|
Related stories
|
|
</h2>
|
|
<Prose>
|
|
<ul>
|
|
{relatedStories.map((stories) => (
|
|
<li>
|
|
<a href={`/stories/${stories.slug}`}>{stories.data.title}</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Prose>
|
|
</section>
|
|
) : null
|
|
}
|
|
{
|
|
tags.length > 0 ? (
|
|
<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, "story/tags")}
|
|
</h2>
|
|
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
|
|
{tags.map(([tagSlug, tagText]) => (
|
|
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
|
|
<a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}>
|
|
{tagText}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
) : null
|
|
}
|
|
<MastodonComments
|
|
instance={props.posts.mastodon?.instance}
|
|
user={props.posts.mastodon?.user}
|
|
postId={props.posts.mastodon?.postId}
|
|
/>
|
|
</main>
|
|
<div class="pt-6 text-center text-xs text-black dark:text-white">
|
|
<span>{t(props.lang, "story/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
|
|
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
|
|
>{t(props.lang, "story/licenses")}</a
|
|
>
|
|
</div>
|
|
</div>
|
|
</BaseLayout>
|