Add Mastodon comments and remove date-fns dependency

This commit is contained in:
Bad Manners 2024-03-27 11:54:18 -03:00
parent 00fa1fb164
commit 3e8bcbcf43
19 changed files with 393 additions and 142 deletions

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
src/components/DarkModeScript.astro

34
package-lock.json generated
View file

@ -13,13 +13,11 @@
"@astrojs/tailwind": "^5.1.0", "@astrojs/tailwind": "^5.1.0",
"@astropub/md": "^0.4.0", "@astropub/md": "^0.4.0",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@types/he": "^1.2.3",
"astro": "^4.5.4", "astro": "^4.5.4",
"date-fns": "^3.5.0",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"he": "^1.2.0",
"marked": "^12.0.1", "marked": "^12.0.1",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tiny-decode": "^0.1.3",
"typescript": "^5.4.2" "typescript": "^5.4.2"
}, },
"devDependencies": { "devDependencies": {
@ -1336,11 +1334,6 @@
"@types/unist": "*" "@types/unist": "*"
} }
}, },
"node_modules/@types/he": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz",
"integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA=="
},
"node_modules/@types/mdast": { "node_modules/@types/mdast": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz",
@ -2357,15 +2350,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/date-fns": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.5.0.tgz",
"integrity": "sha512-a+DwyXn7NOfdJireCzAA0B9p7jIXEu/Q9JKCyMYvH6+0vPUNbQceA0neXrdfJ/xzl3mhOh5vibQQ3936Tssm6A==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@ -3149,14 +3133,6 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"bin": {
"he": "bin/he"
}
},
"node_modules/html-escaper": { "node_modules/html-escaper": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@ -6629,6 +6605,14 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tiny-decode": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/tiny-decode/-/tiny-decode-0.1.3.tgz",
"integrity": "sha512-1z+tXaZpPUyREOfjKDQj5lR6HfD6Pa4NF7pb/9ep7sP4+X5WF76bGdJktWCY1Rm+aMR46vJ75VAL/oAptpD1AA==",
"dependencies": {
"entities": "^4.4.0"
}
},
"node_modules/to-fast-properties": { "node_modules/to-fast-properties": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",

View file

@ -17,13 +17,11 @@
"@astrojs/tailwind": "^5.1.0", "@astrojs/tailwind": "^5.1.0",
"@astropub/md": "^0.4.0", "@astropub/md": "^0.4.0",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@types/he": "^1.2.3",
"astro": "^4.5.4", "astro": "^4.5.4",
"date-fns": "^3.5.0",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"he": "^1.2.0",
"marked": "^12.0.1", "marked": "^12.0.1",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tiny-decode": "^0.1.3",
"typescript": "^5.4.2" "typescript": "^5.4.2"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,10 +1,14 @@
<Fragment> ---
---
<template id="template-modal-age-restricted">
<div <div
id="modal-age-restricted" id="modal-age-restricted"
class="fixed inset-0 hidden bg-stone-100 dark:bg-stone-900" class="fixed inset-0 bg-stone-100 dark:bg-stone-900"
tabindex="-1" tabindex="-1"
role="dialog" role="dialog"
aria-hidden="true" aria-hidden="false"
> >
<div class="mx-auto flex min-h-screen max-w-3xl flex-col items-center justify-center text-center tracking-tight"> <div class="mx-auto flex min-h-screen max-w-3xl flex-col items-center justify-center text-center tracking-tight">
<div class="h-14 w-14 text-bm-500 sm:h-16 sm:w-16 dark:text-bm-400"> <div class="h-14 w-14 text-bm-500 sm:h-16 sm:w-16 dark:text-bm-400">
@ -44,32 +48,33 @@
</div> </div>
</div> </div>
</div> </div>
<script is:inline data-astro-rerun> </template>
(function () {
if (localStorage.getItem("ageVerified") !== "true") { <script>
const modal = document.querySelector("#modal-age-restricted"); (function () {
const rejectButton = modal.querySelector("button[data-modal-reject]"); if (localStorage.getItem("ageVerified") !== "true") {
const acceptButton = modal.querySelector("button[data-modal-accept]"); document.body.appendChild(
function onRejectButtonClick(e) { (document.getElementById("template-modal-age-restricted") as HTMLTemplateElement).content.cloneNode(true),
e.preventDefault(); );
location.href = "about:blank"; const modal = document.querySelector<HTMLDivElement>("body > #modal-age-restricted")!;
} const rejectButton = modal.querySelector<HTMLButtonElement>("button[data-modal-reject]")!;
function onAcceptButtonClick(e) { const acceptButton = modal.querySelector<HTMLButtonElement>("button[data-modal-accept]")!;
e.preventDefault(); function onRejectButtonClick(e: MouseEvent) {
rejectButton.removeEventListener("click", onRejectButtonClick); e.preventDefault();
acceptButton.removeEventListener("click", onAcceptButtonClick); location.href = "about:blank";
localStorage.setItem("ageVerified", "true");
document.body.style = "overflow:auto;";
modal.classList.add("hidden");
modal.setAttribute("aria-hidden", "true");
}
rejectButton.addEventListener("click", onRejectButtonClick);
acceptButton.addEventListener("click", onAcceptButtonClick);
document.body.style = "overflow:hidden;";
modal.setAttribute("aria-hidden", "false");
modal.classList.remove("hidden");
rejectButton.focus();
} }
})(); function onAcceptButtonClick(e: MouseEvent) {
</script> e.preventDefault();
</Fragment> rejectButton.removeEventListener("click", onRejectButtonClick);
acceptButton.removeEventListener("click", onAcceptButtonClick);
localStorage.setItem("ageVerified", "true");
document.body.style.overflow = "auto";
modal.remove();
}
rejectButton.addEventListener("click", onRejectButtonClick);
acceptButton.addEventListener("click", onAcceptButtonClick);
document.body.style.overflow = "hidden";
rejectButton.focus();
}
})();
</script>

View file

@ -2,27 +2,23 @@
--- ---
<script is:inline data-astro-rerun> <script is:inline>!function(){var a="dark",b="auto",c="colorScheme",d=document.body.classList,e=localStorage,f=e.getItem(c);f&&f!==b?f===a&&d.add(a):(e.setItem(c,b),matchMedia("(prefers-color-scheme: dark)").matches&&d.add(a))}();</script>
<script>
(function () { (function () {
var colorScheme = localStorage.getItem("colorScheme"); var colorScheme = localStorage.getItem("colorScheme");
if (colorScheme == null || colorScheme === "auto") { if (colorScheme == null || colorScheme === "auto") {
localStorage.setItem("colorScheme", "auto");
colorScheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; colorScheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} }
const bodyClassList = document.body.classList;
if (colorScheme === "dark") {
bodyClassList.add("dark");
}
document.querySelectorAll("button[data-dark-mode]").forEach(function (button) { document.querySelectorAll("button[data-dark-mode]").forEach(function (button) {
button.addEventListener("click", function (e) { button.addEventListener("click", function (e) {
e.preventDefault(); e.preventDefault();
if (colorScheme === "dark") { if (colorScheme === "dark") {
colorScheme = "light"; colorScheme = "light";
bodyClassList.remove("dark"); document.body.classList.remove("dark");
} else { } else {
colorScheme = "dark"; colorScheme = "dark";
bodyClassList.add("dark"); document.body.classList.add("dark");
} }
localStorage.setItem("colorScheme", colorScheme); localStorage.setItem("colorScheme", colorScheme);
}); });

View file

@ -0,0 +1,220 @@
---
type Props = {
instance?: string;
user?: string;
postId?: string;
};
const { instance, user, postId } = Astro.props;
---
<section
id="comment-section"
class="hidden px-2 font-serif"
aria-describedby="title-comment-section"
data-instance={instance || ""}
data-user={user || ""}
data-post-id={postId || ""}
>
<h2 id="title-comment-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
Comments
</h2>
<div class="text-stone-800 dark:text-stone-100" id="comments">
<button
class="mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 underline disabled:bg-bm-400 disabled:no-underline dark:bg-green-800 dark:disabled:bg-green-900"
id="load-comments-button"
data-load-comments
>
<span>Click to load comments</span>
</button>
</div>
</section>
<template id="template-comments-loading">
<svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-100"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>Loading...</span>
</template>
<template id="template-comment-box">
<div class="my-2 rounded-md border-2 border-stone-400 bg-stone-200 p-2 dark:border-stone-600 dark:bg-stone-800">
<div class="ml-1">
<a data-author class="text-link flex items-center text-lg hover:underline focus:underline">
<img data-avatar class="mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" />
<span data-display-name></span>
</a>
<a data-post-link class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline">
<span class="mr-1" data-publish-date></span>
</a>
</div>
<div data-content class="prose-a:text-link prose prose-story my-1 dark:prose-invert prose-img:my-0"></div>
<div class="ml-1 flex flex-row pb-2 pt-1">
<div class="flex" aria-label="Favorites">
<span data-favorites></span>
<svg class="ml-2 w-5 fill-current" viewBox="0 0 576 512" aria-hidden>
<path
d="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z"
></path>
</svg>
</div>
<div class="ml-4 flex" aria-label="Reblogs">
<span data-reblogs></span>
<svg class="ml-2 w-5 fill-current" viewBox="0 0 512 512" aria-hidden>
<path
d="M0 224c0 17.7 14.3 32 32 32s32-14.3 32-32c0-53 43-96 96-96H320v32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S320 19.1 320 32V64H160C71.6 64 0 135.6 0 224zm512 64c0-17.7-14.3-32-32-32s-32 14.3-32 32c0 53-43 96-96 96H192V352c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6V448H352c88.4 0 160-71.6 160-160z"
></path>
</svg>
</div>
</div>
<div data-comment-thread class="-mb-2"></div>
</div>
</template>
<script>
interface Emoji {
shortcode: string;
url: string;
static_url: string;
}
interface Comment {
id: string;
in_reply_to_id: string;
url: string;
favourites_count: number;
reblogs_count: number;
created_at: string;
edited_at: string | null;
account: {
username: string;
acct: string;
display_name: string;
url: string;
avatar: string;
avatar_static: string;
emojis: Emoji[];
};
content: string;
emojis: Emoji[];
}
(function () {
const replaceEmojis = (text: string, emojis: Emoji[], imgClass: string) =>
emojis.reduce(
(acc, emoji) =>
acc.replaceAll(
`:${emoji.shortcode}:`,
`<img class="${imgClass}" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`,
),
text,
);
const commentSection = document.querySelector<Element>("#comment-section")!;
const instance = commentSection.getAttribute("data-instance");
const user = commentSection.getAttribute("data-user");
const postId = commentSection.getAttribute("data-post-id");
if (instance && user && postId) {
commentSection.classList.remove("hidden");
commentSection.querySelector<HTMLButtonElement>("button[data-load-comments]")!.addEventListener("click", (e) => {
e.preventDefault();
const loadCommentsButton = e.target as HTMLButtonElement;
loadCommentsButton.setAttribute("disabled", "true");
loadCommentsButton.replaceChildren(
(document.getElementById("template-comments-loading") as HTMLTemplateElement).content.cloneNode(true),
);
const renderComments = async () => {
try {
if (!instance || !user || !postId) {
throw new Error(
`Cannot fetch comments without all fields (instance=${instance}, user=${user}, post-id=${postId})`,
);
}
const response = await fetch(`https://${instance}/api/v1/statuses/${postId}/context`);
if (!response.ok) {
throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
}
const data: { ancestors: Comment[]; descendants: Comment[] } = await response.json();
// console.log(data);
const commentsList: HTMLElement[] = [];
const commentMap: Record<string, number> = {};
const commentTemplate = document.getElementById("template-comment-box") as HTMLTemplateElement;
data.descendants.forEach((comment) => {
const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement;
const commentBoxAuthor = commentBox.querySelector<HTMLAnchorElement>("[data-author]")!;
commentBoxAuthor.href = comment.account.url;
commentBoxAuthor.target = "_blank";
const avatar = commentBoxAuthor.querySelector<HTMLImageElement>("[data-avatar]")!;
avatar.src = comment.account.avatar;
avatar.alt = `Profile picture of ${comment.account.username}`;
const displayName = commentBoxAuthor.querySelector<HTMLSpanElement>("[data-display-name]")!;
displayName.innerHTML = replaceEmojis(
comment.account.display_name,
comment.account.emojis,
"inline mx-[1px] w-5",
);
const commentBoxPostLink = commentBox.querySelector<HTMLAnchorElement>("[data-post-link]")!;
commentBoxPostLink.href = comment.url;
commentBoxPostLink.target = "_blank";
const publishDate = commentBoxPostLink.querySelector<HTMLSpanElement>("[data-publish-date]")!;
// TO-DO Pretty format date
publishDate.innerText = new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
if (comment.edited_at) {
const edited = document.createElement("span");
edited.className = "italic";
edited.innerText = "(edited)";
commentBoxPostLink.appendChild(edited);
}
const commentBoxContent = commentBox.querySelector<HTMLDivElement>("[data-content]")!;
commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis, "inline mx-[1px] w-5");
const commentBoxFavorites = commentBox.querySelector<HTMLSpanElement>("[data-favorites]")!;
commentBoxFavorites.innerText = comment.favourites_count.toString();
const commentBoxReblogs = commentBox.querySelector<HTMLSpanElement>("[data-reblogs]")!;
commentBoxReblogs.innerText = comment.reblogs_count.toString();
if (comment.in_reply_to_id === postId || !(comment.in_reply_to_id in commentMap)) {
commentMap[comment.id] = commentsList.length;
commentsList.push(commentBox);
} else {
const commentsIndex = commentMap[comment.in_reply_to_id];
commentMap[comment.id] = commentsIndex;
commentsList[commentsIndex]
.querySelector<HTMLDivElement>("[data-comment-thread]")!
.appendChild(commentBox);
}
});
const commentsDiv = commentSection.querySelector<HTMLDivElement>("#comments")!;
if (commentsList.length === 0) {
commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">Be the first to join the conversation on Mastodon</a>.</p>`;
} else {
commentsDiv.innerHTML = `<p class="my-1">Join the conversation <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">by replying on Mastodon</a>.</p>`;
commentsDiv.append(...commentsList);
}
} catch (e) {
loadCommentsButton.innerHTML = `<span>Unable to load comments.</span>`;
console.error("Fetch Mastodon comments error", e);
}
};
renderComments();
});
}
})();
</script>

View file

@ -20,6 +20,11 @@ const localUrlRegex = /^((\/?\.\.(?=\/))+|(\.(?=\/)))?\/?[a-z0-9_-]+(\/[a-z0-9_-
const lang = z.enum(["eng", "tok"]).default("eng"); const lang = z.enum(["eng", "tok"]).default("eng");
const website = z.enum(WEBSITE_LIST); const website = z.enum(WEBSITE_LIST);
const mastodonPost = z.object({
instance: z.string(),
user: z.string(),
postId: z.string(),
});
export type Lang = z.output<typeof lang>; export type Lang = z.output<typeof lang>;
export type Website = z.infer<typeof website>; export type Website = z.infer<typeof website>;
@ -53,6 +58,7 @@ const storiesCollection = defineCollection({
next: reference("stories").nullish(), next: reference("stories").nullish(),
relatedStories: z.array(reference("stories")).default([]), relatedStories: z.array(reference("stories")).default([]),
relatedGames: z.array(reference("games")).default([]), relatedGames: z.array(reference("games")).default([]),
mastodonPost: mastodonPost.optional(),
}), }),
}); });
@ -78,6 +84,7 @@ const gamesCollection = defineCollection({
lang, lang,
relatedStories: z.array(reference("stories")).default([]), relatedStories: z.array(reference("stories")).default([]),
relatedGames: z.array(reference("games")).default([]), relatedGames: z.array(reference("games")).default([]),
mastodonPost: mastodonPost.optional(),
}), }),
}); });

View file

@ -26,6 +26,10 @@ descriptionPlaintext: >
An original soundtrack with 9 exclusive songs; An original soundtrack with 9 exclusive songs;
A challenging physics-based fishing minigame with scaling difficulty; A challenging physics-based fishing minigame with scaling difficulty;
And a special cutscene... And a special cutscene...
mastodonPost:
instance: meow.social
user: BadManners
postId: "112009918919441027"
tags: tags:
- oral vore - oral vore
- anthro predator - anthro predator

View file

@ -14,6 +14,10 @@ descriptionPlaintext: >
Kolo's day at the airship is nearly over, but a tiny stalker will unwittingly make his evening quite eventful... Kolo's day at the airship is nearly over, but a tiny stalker will unwittingly make his evening quite eventful...
Finally got around to finishing a story ever since I worked on Crossing Over! I wanna get back into writing more stuff again, and this short story has finally broken my writer's block. My goal is to go back to working on commissions, but I feel I'm not quite in the headspace to tackle them just yet... Nevertheless, I hope you enjoy this! Finally got around to finishing a story ever since I worked on Crossing Over! I wanna get back into writing more stuff again, and this short story has finally broken my writer's block. My goal is to go back to working on commissions, but I feel I'm not quite in the headspace to tackle them just yet... Nevertheless, I hope you enjoy this!
mastodonPost:
instance: meow.social
user: BadManners
postId: "112157812554023271"
tags: tags:
- anthro predator - anthro predator
- anthro prey - anthro prey

View file

@ -3,6 +3,11 @@ import "../styles/base.css";
import "../styles/fonts.css"; import "../styles/fonts.css";
import DarkModeScript from "../components/DarkModeScript.astro"; import DarkModeScript from "../components/DarkModeScript.astro";
import AgeRestrictedModal from "../components/AgeRestrictedModal.astro"; import AgeRestrictedModal from "../components/AgeRestrictedModal.astro";
type Props = {
pageTitle?: string;
};
const { pageTitle } = Astro.props; const { pageTitle } = Astro.props;
--- ---

View file

@ -2,13 +2,12 @@
import { getImage } from "astro:assets"; import { getImage } from "astro:assets";
import { type CollectionEntry, getEntry, getEntries } from "astro:content"; import { type CollectionEntry, getEntry, getEntries } from "astro:content";
import { Markdown } from "@astropub/md"; import { Markdown } from "@astropub/md";
import { format as formatDate } from "date-fns";
import { enUS as enUSLocale } from "date-fns/locale/en-US";
import { slug } from "github-slugger"; import { slug } from "github-slugger";
import BaseLayout from "./BaseLayout.astro"; import BaseLayout from "./BaseLayout.astro";
import Authors from "../components/Authors.astro"; import Authors from "../components/Authors.astro";
import CopyrightedCharacters from "../components/CopyrightedCharacters.astro"; import CopyrightedCharacters from "../components/CopyrightedCharacters.astro";
import Prose from "../components/Prose.astro"; import Prose from "../components/Prose.astro";
import MastodonComments from "../components/MastodonComments.astro";
type Props = CollectionEntry<"games">["data"]; type Props = CollectionEntry<"games">["data"];
@ -118,7 +117,7 @@ const thumbnail =
) : null ) : null
} }
<hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" /> <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"> <article id="game" class="pr-1 font-serif">
<Prose> <Prose>
<slot /> <slot />
</Prose> </Prose>
@ -137,9 +136,15 @@ const thumbnail =
id="publish-date" id="publish-date"
class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200" class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
aria-label="Publish date" aria-label="Publish date"
aria-description={formatDate(props.pubDate, "MMMM do, yyyy", { locale: enUSLocale })} aria-description={props.pubDate.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
> >
{formatDate(props.pubDate, "yyyy-MM-dd")} {props.lang === "tok"
? `tenpo suno ${props.pubDate.toISOString().slice(undefined, 10)}`
: props.pubDate.toISOString().slice(undefined, 10)}
</p> </p>
) )
} }
@ -175,10 +180,21 @@ const thumbnail =
} }
</ul> </ul>
</section> </section>
<MastodonComments
instance={props.mastodonPost?.instance}
user={props.mastodonPost?.user}
postId={props.mastodonPost?.postId}
/>
</main> </main>
<div class="pt-6 text-center text-xs text-black dark:text-white"> <div class="pt-6 text-center text-xs text-black dark:text-white">
<span>&copy; {formatDate(props.pubDate, "yyyy")} | </span> <span
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank">Licenses</a> >&copy; {
props.lang === "tok" ? `tenpo pi sike suno ${props.pubDate.getFullYear()}` : props.pubDate.getFullYear()
} |
</span>
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
>{props.lang === "eng" ? "Licenses" : props.lang === "tok" ? "lipu lawa" : null}</a
>
</div> </div>
</div> </div>
</BaseLayout> </BaseLayout>

View file

@ -2,14 +2,13 @@
import { getImage } from "astro:assets"; import { getImage } from "astro:assets";
import { type CollectionEntry, getEntry, getEntries, getCollection } from "astro:content"; import { type CollectionEntry, getEntry, getEntries, getCollection } from "astro:content";
import { Markdown } from "@astropub/md"; import { Markdown } from "@astropub/md";
import { format as formatDate } from "date-fns";
import { enUS as enUSLocale } from "date-fns/locale/en-US";
import { slug } from "github-slugger"; import { slug } from "github-slugger";
import BaseLayout from "./BaseLayout.astro"; import BaseLayout from "./BaseLayout.astro";
import Authors from "../components/Authors.astro"; import Authors from "../components/Authors.astro";
import UserComponent from "../components/UserComponent.astro"; import UserComponent from "../components/UserComponent.astro";
import CopyrightedCharacters from "../components/CopyrightedCharacters.astro"; import CopyrightedCharacters from "../components/CopyrightedCharacters.astro";
import Prose from "../components/Prose.astro"; import Prose from "../components/Prose.astro";
import MastodonComments from "../components/MastodonComments.astro";
type Props = CollectionEntry<"stories">["data"]; type Props = CollectionEntry<"stories">["data"];
@ -226,11 +225,15 @@ const thumbnail =
id="publish-date" id="publish-date"
class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200" class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
aria-label="Publish date" aria-label="Publish date"
aria-description={formatDate(props.pubDate, "MMMM do, yyyy", { locale: enUSLocale })} aria-description={props.pubDate.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
> >
{props.lang === "tok" {props.lang === "tok"
? `tenpo suno ${formatDate(props.pubDate, "yyyy-MM-dd")}` ? `tenpo suno ${props.pubDate.toISOString().slice(undefined, 10)}`
: formatDate(props.pubDate, "yyyy-MM-dd")} : props.pubDate.toISOString().slice(undefined, 10)}
</p> </p>
) )
} }
@ -322,13 +325,16 @@ const thumbnail =
} }
</ul> </ul>
</section> </section>
<MastodonComments
instance={props.mastodonPost?.instance}
user={props.mastodonPost?.user}
postId={props.mastodonPost?.postId}
/>
</main> </main>
<div class="pt-6 text-center text-xs text-black dark:text-white"> <div class="pt-6 text-center text-xs text-black dark:text-white">
<span <span
>&copy; { >&copy; {
props.lang === "tok" props.lang === "tok" ? `tenpo pi sike suno ${props.pubDate.getFullYear()}` : props.pubDate.getFullYear()
? `tenpo pi sike suno ${formatDate(props.pubDate, "yyyy")}`
: formatDate(props.pubDate, "yyyy")
} | } |
</span> </span>
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank" <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"

View file

@ -1,7 +1,6 @@
import rss, { type RSSFeedItem } from "@astrojs/rss"; import rss, { type RSSFeedItem } from "@astrojs/rss";
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import { getUnixTime, addMinutes } from "date-fns";
type FeedItem = RSSFeedItem & { type FeedItem = RSSFeedItem & {
pubDate: Date; pubDate: Date;
@ -9,9 +8,19 @@ type FeedItem = RSSFeedItem & {
const MAX_ITEMS = 10; const MAX_ITEMS = 10;
function toNoonUTCDate(date: Date) {
const adjustedDate = new Date(date);
adjustedDate.setUTCHours(12);
return adjustedDate;
}
export const GET: APIRoute = async ({ site }) => { export const GET: APIRoute = async ({ site }) => {
const stories = (await getCollection("stories", (story) => !story.data.isDraft)).sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)).slice(0, MAX_ITEMS); const stories = (await getCollection("stories", (story) => !story.data.isDraft))
const games = (await getCollection("games", (game) => !game.data.isDraft)).sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)).slice(0, MAX_ITEMS); .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
.slice(0, MAX_ITEMS);
const games = (await getCollection("games", (game) => !game.data.isDraft))
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
.slice(0, MAX_ITEMS);
return rss({ return rss({
title: "Gallery | Bad Manners", title: "Gallery | Bad Manners",
description: "Stories, games, and (possibly) more by Bad Manners", description: "Stories, games, and (possibly) more by Bad Manners",
@ -19,7 +28,7 @@ export const GET: APIRoute = async ({ site }) => {
items: [ items: [
stories.map<FeedItem>((story) => ({ stories.map<FeedItem>((story) => ({
title: `New story! "${story.data.title}"`, title: `New story! "${story.data.title}"`,
pubDate: addMinutes(story.data.pubDate, 12 * 60 - story.data.pubDate.getTimezoneOffset()), pubDate: toNoonUTCDate(story.data.pubDate),
link: `/stories/${story.slug}`, link: `/stories/${story.slug}`,
description: description:
`Word count: ${story.data.wordCount}. ${story.data.contentWarning} ${story.data.descriptionPlaintext || story.data.description}` `Word count: ${story.data.wordCount}. ${story.data.contentWarning} ${story.data.descriptionPlaintext || story.data.description}`
@ -29,7 +38,7 @@ export const GET: APIRoute = async ({ site }) => {
})), })),
games.map<FeedItem>((game) => ({ games.map<FeedItem>((game) => ({
title: `New game! "${game.data.title}"`, title: `New game! "${game.data.title}"`,
pubDate: addMinutes(game.data.pubDate, 12 * 60 - game.data.pubDate.getTimezoneOffset()), pubDate: toNoonUTCDate(game.data.pubDate),
link: `/games/${game.slug}`, link: `/games/${game.slug}`,
description: `${game.data.contentWarning} ${game.data.descriptionPlaintext || game.data.description}` description: `${game.data.contentWarning} ${game.data.descriptionPlaintext || game.data.description}`
.replaceAll(/[\n ]+/g, " ") .replaceAll(/[\n ]+/g, " ")
@ -38,7 +47,7 @@ export const GET: APIRoute = async ({ site }) => {
})), })),
] ]
.flat() .flat()
.sort((a, b) => getUnixTime(b.pubDate) - getUnixTime(a.pubDate)) .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
.slice(0, MAX_ITEMS), .slice(0, MAX_ITEMS),
}); });
}; };

View file

@ -1,12 +1,10 @@
--- ---
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import { getUnixTime, format as formatDate } from "date-fns";
import { enUS as enUSLocale } from "date-fns/locale/en-US";
import GalleryLayout from "../layouts/GalleryLayout.astro"; import GalleryLayout from "../layouts/GalleryLayout.astro";
const games = (await getCollection("games", (game) => !game.data.isDraft)).sort( const games = (await getCollection("games", (game) => !game.data.isDraft)).sort(
(a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate), (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
); );
--- ---
@ -14,26 +12,23 @@ const games = (await getCollection("games", (game) => !game.data.isDraft)).sort(
<meta slot="head-description" content="Bad Manners || A game that I've gone and done." property="og:description" /> <meta slot="head-description" content="Bad Manners || A game that I've gone and done." property="og:description" />
<h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Games</h1> <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Games</h1>
<p class="my-4">A game that I've gone and done.</p> <p class="my-4">A game that I've gone and done.</p>
<ul class="my-6 flex flex-wrap justify-center gap-4 text-center md:justify-normal items-start"> <ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
{ {
games.map((game) => ( games.map((game) => (
<li> <li>
<a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}> <a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
{game.data.thumbnail ? ( {game.data.thumbnail ? (
<div class="max-w-[288px] aspect-[630/500] flex justify-center"> <div class="flex aspect-[630/500] max-w-[288px] justify-center">
<Image <Image class="m-auto" src={game.data.thumbnail} alt={`Thumbnail for ${game.data.title}`} width={288} />
class="m-auto"
src={game.data.thumbnail}
alt={`Thumbnail for ${game.data.title}`}
width={288}
/>
</div> </div>
) : null} ) : null}
<div class="max-w-[288px] text-sm"> <div class="max-w-[288px] text-sm">
<> <>
<span>{game.data.title}</span> <span>{game.data.title}</span>
<br /> <br />
<span class="italic">{formatDate(game.data.pubDate, "MMM d, yyyy", { locale: enUSLocale })}</span> <span class="italic">
{game.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
</> </>
</div> </div>
</a> </a>

View file

@ -1,8 +1,6 @@
--- ---
import { type CollectionEntry, getCollection } from "astro:content"; import { type CollectionEntry, getCollection } from "astro:content";
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import { getUnixTime, format as formatDate } from "date-fns";
import { enUS as enUSLocale } from "date-fns/locale/en-US";
import GalleryLayout from "../layouts/GalleryLayout.astro"; import GalleryLayout from "../layouts/GalleryLayout.astro";
const MAX_ITEMS = 5; const MAX_ITEMS = 5;
@ -15,25 +13,32 @@ interface LatestItemsEntry {
pubDate: Date; pubDate: Date;
} }
const stories = (await getCollection("stories", (story) => !story.data.isDraft)).sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)).slice(0, MAX_ITEMS); const stories = (await getCollection("stories", (story) => !story.data.isDraft))
const games = (await getCollection("games", (game) => !game.data.isDraft)).sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)).slice(0, MAX_ITEMS); .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
.slice(0, MAX_ITEMS);
const games = (await getCollection("games", (game) => !game.data.isDraft))
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
.slice(0, MAX_ITEMS);
const latestItems: LatestItemsEntry[] = [ const latestItems: LatestItemsEntry[] = [
stories.map<LatestItemsEntry>(story => ({ stories.map<LatestItemsEntry>((story) => ({
type: "Story", type: "Story",
thumbnail: story.data.thumbnail, thumbnail: story.data.thumbnail,
href: `/stories/${story.slug}`, href: `/stories/${story.slug}`,
title: story.data.title, title: story.data.title,
pubDate: story.data.pubDate, pubDate: story.data.pubDate,
})), })),
games.map<LatestItemsEntry>(game => ({ games.map<LatestItemsEntry>((game) => ({
type: "Game", type: "Game",
thumbnail: game.data.thumbnail, thumbnail: game.data.thumbnail,
href: `/games/${game.slug}`, href: `/games/${game.slug}`,
title: game.data.title, title: game.data.title,
pubDate: game.data.pubDate, pubDate: game.data.pubDate,
})), })),
].flat().sort((a, b) => getUnixTime(b.pubDate) - getUnixTime(a.pubDate)).slice(0, MAX_ITEMS); ]
.flat()
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
.slice(0, MAX_ITEMS);
--- ---
<GalleryLayout pageTitle="Gallery"> <GalleryLayout pageTitle="Gallery">
@ -54,25 +59,23 @@ const latestItems: LatestItemsEntry[] = [
</p> </p>
<section class="my-2" aria-labelledby="latest-uploads"> <section class="my-2" aria-labelledby="latest-uploads">
<h2 id="latest-uploads" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">Latest uploads</h2> <h2 id="latest-uploads" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">Latest uploads</h2>
<ul class="flex flex-wrap justify-center gap-4 text-center md:justify-normal items-start"> <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
{ {
latestItems.map((entry) => ( latestItems.map((entry) => (
<li class="break-inside-avoid"> <li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={entry.href}> <a class="text-link hover:underline focus:underline" href={entry.href}>
{entry.thumbnail ? ( {entry.thumbnail ? (
<div class="max-w-[192px] aspect-square flex justify-center"> <div class="flex aspect-square max-w-[192px] justify-center">
<Image <Image class="m-auto" src={entry.thumbnail} alt={`Thumbnail for ${entry.title}`} width={192} />
class="m-auto"
src={entry.thumbnail}
alt={`Thumbnail for ${entry.title}`}
width={192}
/>
</div> </div>
) : null} ) : null}
<div class="max-w-[192px] text-sm"> <div class="max-w-[192px] text-sm">
<span>{entry.title}</span> <span>{entry.title}</span>
<br /> <br />
<span class="italic">{entry.type} &ndash; {formatDate(entry.pubDate, "MMM d, yyyy", { locale: enUSLocale })}</span> <span class="italic">
{entry.type} &ndash;{" "}
{entry.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
</div> </div>
</a> </a>
</li> </li>

View file

@ -2,14 +2,12 @@
import type { GetStaticPaths, Page } from "astro"; import type { GetStaticPaths, Page } from "astro";
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import { getUnixTime, format as formatDate } from "date-fns";
import { enUS as enUSLocale } from "date-fns/locale/en-US";
import GalleryLayout from "../../layouts/GalleryLayout.astro"; import GalleryLayout from "../../layouts/GalleryLayout.astro";
import type { CollectionEntry } from "astro:content"; import type { CollectionEntry } from "astro:content";
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => { export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
const stories = (await getCollection("stories", (story) => !story.data.isDraft)).sort( const stories = (await getCollection("stories", (story) => !story.data.isDraft)).sort(
(a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate), (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
); );
return paginate(stories, { pageSize: 30 }); return paginate(stories, { pageSize: 30 });
}; };
@ -62,13 +60,13 @@ const totalPages = Math.ceil(page.total / page.size);
) )
} }
</div> </div>
<ul class="flex flex-wrap justify-center gap-4 text-center md:justify-normal items-start"> <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
{ {
page.data.map((story) => ( page.data.map((story) => (
<li class="break-inside-avoid"> <li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}> <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
{story.data.thumbnail ? ( {story.data.thumbnail ? (
<div class="max-w-[192px] aspect-square flex justify-center"> <div class="flex aspect-square max-w-[192px] justify-center">
<Image <Image
class="m-auto" class="m-auto"
src={story.data.thumbnail} src={story.data.thumbnail}
@ -80,7 +78,9 @@ const totalPages = Math.ceil(page.total / page.size);
<div class="max-w-[192px] text-sm"> <div class="max-w-[192px] text-sm">
<span>{story.data.title}</span> <span>{story.data.title}</span>
<br /> <br />
<span class="italic">{formatDate(story.data.pubDate, "MMM d, yyyy", { locale: enUSLocale })}</span> <span class="italic">
{story.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
</div> </div>
</a> </a>
</li> </li>

View file

@ -1,7 +1,7 @@
import { type APIRoute, type GetStaticPaths } from "astro"; import { type APIRoute, type GetStaticPaths } from "astro";
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content"; import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
import { marked, type RendererApi } from "marked"; import { marked, type RendererApi } from "marked";
import he from "he"; import { decode as tinyDecode } from "tiny-decode";
import { type Website } from "../../../../../content/config"; import { type Website } from "../../../../../content/config";
const WEBSITE_LIST = ["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"] as const satisfies Website[]; const WEBSITE_LIST = ["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"] as const satisfies Website[];
@ -269,7 +269,7 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, params: {
const markdownExport: ReadonlyArray<ExportWebsite> = ["weasyl"] as const; const markdownExport: ReadonlyArray<ExportWebsite> = ["weasyl"] as const;
// BBCode exports // BBCode exports
if (bbcodeExports.includes(website)) { if (bbcodeExports.includes(website)) {
storyDescription = he.decode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription)); storyDescription = tinyDecode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription));
headers["Content-Type"] = "text/plain; charset=utf-8"; headers["Content-Type"] = "text/plain; charset=utf-8";
// Markdown exports (no-op) // Markdown exports (no-op)
} else if (!markdownExport.includes(website)) { } else if (!markdownExport.includes(website)) {

View file

@ -1,7 +1,6 @@
--- ---
import { getCollection, getEntry } from "astro:content"; import { getCollection, getEntry } from "astro:content";
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import { getUnixTime } from "date-fns";
import GalleryLayout from "../../layouts/GalleryLayout.astro"; import GalleryLayout from "../../layouts/GalleryLayout.astro";
import mapImage from "../../assets/images/tlotm_map.jpg"; import mapImage from "../../assets/images/tlotm_map.jpg";
@ -9,10 +8,10 @@ const series = await getEntry("series", "the-lost-of-the-marshes");
const stories = await getCollection("stories", (story) => !story.data.isDraft && story.data.series?.id === series.id); const stories = await getCollection("stories", (story) => !story.data.isDraft && story.data.series?.id === series.id);
const mainChapters = stories const mainChapters = stories
.filter((story) => story.slug.startsWith("the-lost-of-the-marshes/chapter-")) .filter((story) => story.slug.startsWith("the-lost-of-the-marshes/chapter-"))
.sort((a, b) => getUnixTime(a.data.pubDate) - getUnixTime(b.data.pubDate)); .sort((a, b) => a.data.pubDate.getTime() - b.data.pubDate.getTime());
const bonusChapters = stories const bonusChapters = stories
.filter((story) => story.slug.startsWith("the-lost-of-the-marshes/bonus-")) .filter((story) => story.slug.startsWith("the-lost-of-the-marshes/bonus-"))
.sort((a, b) => getUnixTime(a.data.pubDate) - getUnixTime(b.data.pubDate)); .sort((a, b) => a.data.pubDate.getTime() - b.data.pubDate.getTime());
const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summary); const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summary);
--- ---

View file

@ -3,7 +3,6 @@ import type { GetStaticPaths } from "astro";
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import { type CollectionEntry, getCollection } from "astro:content"; import { type CollectionEntry, getCollection } from "astro:content";
import { slug } from "github-slugger"; import { slug } from "github-slugger";
import { getUnixTime } from "date-fns";
import GalleryLayout from "../../layouts/GalleryLayout.astro"; import GalleryLayout from "../../layouts/GalleryLayout.astro";
export const getStaticPaths: GetStaticPaths = async () => { export const getStaticPaths: GetStaticPaths = async () => {
@ -27,10 +26,10 @@ export const getStaticPaths: GetStaticPaths = async () => {
tag, tag,
stories: stories stories: stories
.filter((story) => !story.data.isDraft && story.data.tags.includes(tag)) .filter((story) => !story.data.isDraft && story.data.tags.includes(tag))
.sort((a, b) => getUnixTime(b.data.pubDate!) - getUnixTime(a.data.pubDate!)), .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()),
games: games games: games
.filter((game) => !game.data.isDraft && game.data.tags.includes(tag)) .filter((game) => !game.data.isDraft && game.data.tags.includes(tag))
.sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)), .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()),
}, },
})); }));
}; };
@ -69,12 +68,12 @@ if (count == 1) {
<h2 id="content-stories" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100"> <h2 id="content-stories" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">
Stories Stories
</h2> </h2>
<ul class="flex flex-wrap justify-center gap-4 text-center md:justify-normal items-start"> <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
{stories.map((story) => ( {stories.map((story) => (
<li class="break-inside-avoid"> <li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}> <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
{story.data.thumbnail ? ( {story.data.thumbnail ? (
<div class="max-w-[192px] aspect-square flex justify-center"> <div class="flex aspect-square max-w-[192px] justify-center">
<Image <Image
class="m-auto" class="m-auto"
src={story.data.thumbnail} src={story.data.thumbnail}
@ -97,19 +96,19 @@ if (count == 1) {
<h2 id="content-games" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100"> <h2 id="content-games" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">
Games Games
</h2> </h2>
<ul class="flex flex-wrap justify-center gap-4 text-center md:justify-normal items-start"> <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
{games.map((game) => ( {games.map((game) => (
<li class="break-inside-avoid"> <li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}> <a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
{game.data.thumbnail ? ( {game.data.thumbnail ? (
<div class="max-w-[192px] aspect-[630/500] flex justify-center"> <div class="flex aspect-[630/500] max-w-[192px] justify-center">
<Image <Image
class="m-auto" class="m-auto"
src={game.data.thumbnail} src={game.data.thumbnail}
alt={`Thumbnail for ${game.data.title}`} alt={`Thumbnail for ${game.data.title}`}
width={192} width={192}
/> />
</div> </div>
) : null} ) : null}
<div class="max-w-48 text-sm">{game.data.title}</div> <div class="max-w-48 text-sm">{game.data.title}</div>
</a> </a>