diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f150f24 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +src/components/DarkModeScript.astro diff --git a/package-lock.json b/package-lock.json index 2eb806b..cfbeb1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,11 @@ "@astrojs/tailwind": "^5.1.0", "@astropub/md": "^0.4.0", "@tailwindcss/typography": "^0.5.10", - "@types/he": "^1.2.3", "astro": "^4.5.4", - "date-fns": "^3.5.0", "github-slugger": "^2.0.0", - "he": "^1.2.0", "marked": "^12.0.1", "tailwindcss": "^3.4.1", + "tiny-decode": "^0.1.3", "typescript": "^5.4.2" }, "devDependencies": { @@ -1336,11 +1334,6 @@ "@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": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", @@ -2357,15 +2350,6 @@ "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": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -3149,14 +3133,6 @@ "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -6629,6 +6605,14 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", diff --git a/package.json b/package.json index 214e963..d765125 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,11 @@ "@astrojs/tailwind": "^5.1.0", "@astropub/md": "^0.4.0", "@tailwindcss/typography": "^0.5.10", - "@types/he": "^1.2.3", "astro": "^4.5.4", - "date-fns": "^3.5.0", "github-slugger": "^2.0.0", - "he": "^1.2.0", "marked": "^12.0.1", "tailwindcss": "^3.4.1", + "tiny-decode": "^0.1.3", "typescript": "^5.4.2" }, "devDependencies": { diff --git a/src/components/AgeRestrictedModal.astro b/src/components/AgeRestrictedModal.astro index d491e18..764f2c1 100644 --- a/src/components/AgeRestrictedModal.astro +++ b/src/components/AgeRestrictedModal.astro @@ -1,10 +1,14 @@ -<Fragment> +--- + +--- + +<template id="template-modal-age-restricted"> <div 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" 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="h-14 w-14 text-bm-500 sm:h-16 sm:w-16 dark:text-bm-400"> @@ -44,32 +48,33 @@ </div> </div> </div> - <script is:inline data-astro-rerun> - (function () { - if (localStorage.getItem("ageVerified") !== "true") { - const modal = document.querySelector("#modal-age-restricted"); - const rejectButton = modal.querySelector("button[data-modal-reject]"); - const acceptButton = modal.querySelector("button[data-modal-accept]"); - function onRejectButtonClick(e) { - e.preventDefault(); - location.href = "about:blank"; - } - function onAcceptButtonClick(e) { - e.preventDefault(); - rejectButton.removeEventListener("click", onRejectButtonClick); - acceptButton.removeEventListener("click", onAcceptButtonClick); - 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(); +</template> + +<script> + (function () { + if (localStorage.getItem("ageVerified") !== "true") { + document.body.appendChild( + (document.getElementById("template-modal-age-restricted") as HTMLTemplateElement).content.cloneNode(true), + ); + const modal = document.querySelector<HTMLDivElement>("body > #modal-age-restricted")!; + const rejectButton = modal.querySelector<HTMLButtonElement>("button[data-modal-reject]")!; + const acceptButton = modal.querySelector<HTMLButtonElement>("button[data-modal-accept]")!; + function onRejectButtonClick(e: MouseEvent) { + e.preventDefault(); + location.href = "about:blank"; } - })(); - </script> -</Fragment> + function onAcceptButtonClick(e: MouseEvent) { + e.preventDefault(); + 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> diff --git a/src/components/DarkModeScript.astro b/src/components/DarkModeScript.astro index cce2332..3abd902 100644 --- a/src/components/DarkModeScript.astro +++ b/src/components/DarkModeScript.astro @@ -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 () { var colorScheme = localStorage.getItem("colorScheme"); if (colorScheme == null || colorScheme === "auto") { - localStorage.setItem("colorScheme", "auto"); 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) { button.addEventListener("click", function (e) { e.preventDefault(); if (colorScheme === "dark") { colorScheme = "light"; - bodyClassList.remove("dark"); + document.body.classList.remove("dark"); } else { colorScheme = "dark"; - bodyClassList.add("dark"); + document.body.classList.add("dark"); } localStorage.setItem("colorScheme", colorScheme); }); diff --git a/src/components/MastodonComments.astro b/src/components/MastodonComments.astro new file mode 100644 index 0000000..653e11b --- /dev/null +++ b/src/components/MastodonComments.astro @@ -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> diff --git a/src/content/config.ts b/src/content/config.ts index 0ac24f4..4829f65 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -20,6 +20,11 @@ const localUrlRegex = /^((\/?\.\.(?=\/))+|(\.(?=\/)))?\/?[a-z0-9_-]+(\/[a-z0-9_- const lang = z.enum(["eng", "tok"]).default("eng"); 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 Website = z.infer<typeof website>; @@ -53,6 +58,7 @@ const storiesCollection = defineCollection({ next: reference("stories").nullish(), relatedStories: z.array(reference("stories")).default([]), relatedGames: z.array(reference("games")).default([]), + mastodonPost: mastodonPost.optional(), }), }); @@ -78,6 +84,7 @@ const gamesCollection = defineCollection({ lang, relatedStories: z.array(reference("stories")).default([]), relatedGames: z.array(reference("games")).default([]), + mastodonPost: mastodonPost.optional(), }), }); diff --git a/src/content/games/crossing-over.md b/src/content/games/crossing-over.md index 2df033d..2d169b3 100644 --- a/src/content/games/crossing-over.md +++ b/src/content/games/crossing-over.md @@ -26,6 +26,10 @@ descriptionPlaintext: > An original soundtrack with 9 exclusive songs; A challenging physics-based fishing minigame with scaling difficulty; And a special cutscene... +mastodonPost: + instance: meow.social + user: BadManners + postId: "112009918919441027" tags: - oral vore - anthro predator diff --git a/src/content/stories/tiny-accident.md b/src/content/stories/tiny-accident.md index 6333b8c..403f94f 100644 --- a/src/content/stories/tiny-accident.md +++ b/src/content/stories/tiny-accident.md @@ -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... 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: - anthro predator - anthro prey diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 0684bff..7dd5d41 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -3,6 +3,11 @@ import "../styles/base.css"; import "../styles/fonts.css"; import DarkModeScript from "../components/DarkModeScript.astro"; import AgeRestrictedModal from "../components/AgeRestrictedModal.astro"; + +type Props = { + pageTitle?: string; +}; + const { pageTitle } = Astro.props; --- diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro index 842870c..96b5b2e 100644 --- a/src/layouts/GameLayout.astro +++ b/src/layouts/GameLayout.astro @@ -2,13 +2,12 @@ import { getImage } from "astro:assets"; import { type CollectionEntry, getEntry, getEntries } from "astro:content"; 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 BaseLayout from "./BaseLayout.astro"; import Authors from "../components/Authors.astro"; import CopyrightedCharacters from "../components/CopyrightedCharacters.astro"; import Prose from "../components/Prose.astro"; +import MastodonComments from "../components/MastodonComments.astro"; type Props = CollectionEntry<"games">["data"]; @@ -118,7 +117,7 @@ const thumbnail = ) : 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"> + <article id="game" class="pr-1 font-serif"> <Prose> <slot /> </Prose> @@ -137,9 +136,15 @@ const thumbnail = 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={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> ) } @@ -175,10 +180,21 @@ const thumbnail = } </ul> </section> + <MastodonComments + instance={props.mastodonPost?.instance} + user={props.mastodonPost?.user} + postId={props.mastodonPost?.postId} + /> </main> <div class="pt-6 text-center text-xs text-black dark:text-white"> - <span>© {formatDate(props.pubDate, "yyyy")} | </span> - <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank">Licenses</a> + <span + >© { + 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> </BaseLayout> diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro index 9d28139..a3067c7 100644 --- a/src/layouts/StoryLayout.astro +++ b/src/layouts/StoryLayout.astro @@ -2,14 +2,13 @@ import { getImage } from "astro:assets"; import { type CollectionEntry, getEntry, getEntries, getCollection } from "astro:content"; 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 BaseLayout from "./BaseLayout.astro"; import Authors from "../components/Authors.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"; type Props = CollectionEntry<"stories">["data"]; @@ -226,11 +225,15 @@ const thumbnail = 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={formatDate(props.pubDate, "MMMM do, yyyy", { locale: enUSLocale })} + aria-description={props.pubDate.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + })} > {props.lang === "tok" - ? `tenpo suno ${formatDate(props.pubDate, "yyyy-MM-dd")}` - : formatDate(props.pubDate, "yyyy-MM-dd")} + ? `tenpo suno ${props.pubDate.toISOString().slice(undefined, 10)}` + : props.pubDate.toISOString().slice(undefined, 10)} </p> ) } @@ -322,13 +325,16 @@ const thumbnail = } </ul> </section> + <MastodonComments + instance={props.mastodonPost?.instance} + user={props.mastodonPost?.user} + postId={props.mastodonPost?.postId} + /> </main> <div class="pt-6 text-center text-xs text-black dark:text-white"> <span >© { - props.lang === "tok" - ? `tenpo pi sike suno ${formatDate(props.pubDate, "yyyy")}` - : formatDate(props.pubDate, "yyyy") + 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" diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts index e0df54a..8956900 100644 --- a/src/pages/feed.xml.ts +++ b/src/pages/feed.xml.ts @@ -1,7 +1,6 @@ import rss, { type RSSFeedItem } from "@astrojs/rss"; import type { APIRoute } from "astro"; import { getCollection } from "astro:content"; -import { getUnixTime, addMinutes } from "date-fns"; type FeedItem = RSSFeedItem & { pubDate: Date; @@ -9,9 +8,19 @@ type FeedItem = RSSFeedItem & { const MAX_ITEMS = 10; +function toNoonUTCDate(date: Date) { + const adjustedDate = new Date(date); + adjustedDate.setUTCHours(12); + return adjustedDate; +} + 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 games = (await getCollection("games", (game) => !game.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)) + .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({ title: "Gallery | Bad Manners", description: "Stories, games, and (possibly) more by Bad Manners", @@ -19,7 +28,7 @@ export const GET: APIRoute = async ({ site }) => { items: [ stories.map<FeedItem>((story) => ({ 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}`, 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) => ({ 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}`, description: `${game.data.contentWarning} ${game.data.descriptionPlaintext || game.data.description}` .replaceAll(/[\n ]+/g, " ") @@ -38,7 +47,7 @@ export const GET: APIRoute = async ({ site }) => { })), ] .flat() - .sort((a, b) => getUnixTime(b.pubDate) - getUnixTime(a.pubDate)) + .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()) .slice(0, MAX_ITEMS), }); }; diff --git a/src/pages/games.astro b/src/pages/games.astro index 9e13955..faf0e5e 100644 --- a/src/pages/games.astro +++ b/src/pages/games.astro @@ -1,12 +1,10 @@ --- import { Image } from "astro:assets"; 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"; 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" /> <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> - <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) => ( <li> <a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}> {game.data.thumbnail ? ( - <div class="max-w-[288px] aspect-[630/500] flex justify-center"> - <Image - class="m-auto" - src={game.data.thumbnail} - alt={`Thumbnail for ${game.data.title}`} - width={288} - /> + <div class="flex aspect-[630/500] max-w-[288px] justify-center"> + <Image class="m-auto" src={game.data.thumbnail} alt={`Thumbnail for ${game.data.title}`} width={288} /> </div> ) : null} <div class="max-w-[288px] text-sm"> <> <span>{game.data.title}</span> <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> </a> diff --git a/src/pages/index.astro b/src/pages/index.astro index 8012ce0..192b12c 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,8 +1,6 @@ --- import { type CollectionEntry, getCollection } from "astro:content"; 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"; const MAX_ITEMS = 5; @@ -15,25 +13,32 @@ interface LatestItemsEntry { 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 games = (await getCollection("games", (game) => !game.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)) + .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[] = [ - stories.map<LatestItemsEntry>(story => ({ + stories.map<LatestItemsEntry>((story) => ({ type: "Story", thumbnail: story.data.thumbnail, href: `/stories/${story.slug}`, title: story.data.title, pubDate: story.data.pubDate, })), - games.map<LatestItemsEntry>(game => ({ + games.map<LatestItemsEntry>((game) => ({ type: "Game", thumbnail: game.data.thumbnail, href: `/games/${game.slug}`, title: game.data.title, 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"> @@ -54,25 +59,23 @@ const latestItems: LatestItemsEntry[] = [ </p> <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> - <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) => ( <li class="break-inside-avoid"> <a class="text-link hover:underline focus:underline" href={entry.href}> {entry.thumbnail ? ( - <div class="max-w-[192px] aspect-square flex justify-center"> - <Image - class="m-auto" - src={entry.thumbnail} - alt={`Thumbnail for ${entry.title}`} - width={192} - /> + <div class="flex aspect-square max-w-[192px] justify-center"> + <Image class="m-auto" src={entry.thumbnail} alt={`Thumbnail for ${entry.title}`} width={192} /> </div> ) : null} <div class="max-w-[192px] text-sm"> <span>{entry.title}</span> <br /> - <span class="italic">{entry.type} – {formatDate(entry.pubDate, "MMM d, yyyy", { locale: enUSLocale })}</span> + <span class="italic"> + {entry.type} –{" "} + {entry.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} + </span> </div> </a> </li> diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro index cee229e..4cebc20 100644 --- a/src/pages/stories/[page].astro +++ b/src/pages/stories/[page].astro @@ -2,14 +2,12 @@ import type { GetStaticPaths, Page } from "astro"; import { Image } from "astro:assets"; 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 type { CollectionEntry } from "astro:content"; export const getStaticPaths: GetStaticPaths = async ({ paginate }) => { 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 }); }; @@ -62,13 +60,13 @@ const totalPages = Math.ceil(page.total / page.size); ) } </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) => ( <li class="break-inside-avoid"> <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}> {story.data.thumbnail ? ( - <div class="max-w-[192px] aspect-square flex justify-center"> + <div class="flex aspect-square max-w-[192px] justify-center"> <Image class="m-auto" src={story.data.thumbnail} @@ -80,7 +78,9 @@ const totalPages = Math.ceil(page.total / page.size); <div class="max-w-[192px] text-sm"> <span>{story.data.title}</span> <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> </a> </li> diff --git a/src/pages/stories/export/description/[website]/[...slug].ts b/src/pages/stories/export/description/[website]/[...slug].ts index fd9c52f..f60d5a7 100644 --- a/src/pages/stories/export/description/[website]/[...slug].ts +++ b/src/pages/stories/export/description/[website]/[...slug].ts @@ -1,7 +1,7 @@ import { type APIRoute, type GetStaticPaths } from "astro"; import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content"; import { marked, type RendererApi } from "marked"; -import he from "he"; +import { decode as tinyDecode } from "tiny-decode"; import { type Website } from "../../../../../content/config"; 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; // BBCode exports 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"; // Markdown exports (no-op) } else if (!markdownExport.includes(website)) { diff --git a/src/pages/stories/the-lost-of-the-marshes.astro b/src/pages/stories/the-lost-of-the-marshes.astro index c60c34b..174b037 100644 --- a/src/pages/stories/the-lost-of-the-marshes.astro +++ b/src/pages/stories/the-lost-of-the-marshes.astro @@ -1,7 +1,6 @@ --- import { getCollection, getEntry } from "astro:content"; import { Image } from "astro:assets"; -import { getUnixTime } from "date-fns"; import GalleryLayout from "../../layouts/GalleryLayout.astro"; 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 mainChapters = stories .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 .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); --- diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro index 950fe0e..e89e8af 100644 --- a/src/pages/tags/[slug].astro +++ b/src/pages/tags/[slug].astro @@ -3,7 +3,6 @@ import type { GetStaticPaths } from "astro"; import { Image } from "astro:assets"; import { type CollectionEntry, getCollection } from "astro:content"; import { slug } from "github-slugger"; -import { getUnixTime } from "date-fns"; import GalleryLayout from "../../layouts/GalleryLayout.astro"; export const getStaticPaths: GetStaticPaths = async () => { @@ -27,10 +26,10 @@ export const getStaticPaths: GetStaticPaths = async () => { tag, stories: stories .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 .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"> Stories </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) => ( <li class="break-inside-avoid"> <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}> {story.data.thumbnail ? ( - <div class="max-w-[192px] aspect-square flex justify-center"> + <div class="flex aspect-square max-w-[192px] justify-center"> <Image class="m-auto" 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"> Games </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) => ( <li class="break-inside-avoid"> <a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}> {game.data.thumbnail ? ( - <div class="max-w-[192px] aspect-[630/500] flex justify-center"> - <Image - class="m-auto" - src={game.data.thumbnail} - alt={`Thumbnail for ${game.data.title}`} - width={192} - /> - </div> + <div class="flex aspect-[630/500] max-w-[192px] justify-center"> + <Image + class="m-auto" + src={game.data.thumbnail} + alt={`Thumbnail for ${game.data.title}`} + width={192} + /> + </div> ) : null} <div class="max-w-48 text-sm">{game.data.title}</div> </a>