Several minor improvements to typing and misc.
- Improved schema validation - Move username parsing and other validators to schema types - Fix astro check command - Add JSON/YAML schema validation for data collections - Update licenses - Remove deployment script in favor of rsync - Prevent unsanitized input in export-story script - Change "eng" language to "en", per BCP47 - Clean up i18n keys and add aria attributes - Improve MastodonComments behavior on no-JS browsers
This commit is contained in:
parent
fe908a4989
commit
7bb8a952ef
54 changed files with 1005 additions and 962 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
|
|
@ -54,11 +54,13 @@
|
|||
(function () {
|
||||
if (localStorage.getItem("ageVerified") !== "true") {
|
||||
document.body.appendChild(
|
||||
(document.getElementById("template-modal-age-restricted") as HTMLTemplateElement).content.cloneNode(true),
|
||||
document
|
||||
.querySelector<HTMLElementTagNameMap["template"]>("template#template-modal-age-restricted")!
|
||||
.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]")!;
|
||||
const modal = document.querySelector<HTMLElementTagNameMap["div"]>("body > div#modal-age-restricted")!;
|
||||
const rejectButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!;
|
||||
const acceptButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!;
|
||||
function onRejectButtonClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
location.href = "about:blank";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import { type Lang } from "../content/config";
|
||||
import type { Lang } from "../content/config";
|
||||
import { t } from "../i18n";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -12,4 +12,12 @@ const authors = Astro.slots.has("default")
|
|||
: [];
|
||||
---
|
||||
|
||||
{authors.length ? <p id="authors" set:html={t(lang, "story/authors", authors)} /> : null}
|
||||
{
|
||||
authors.length ? (
|
||||
<p
|
||||
id="authors"
|
||||
aria-label={t(lang, "story/authors_aria_label", authors)}
|
||||
set:html={t(lang, "story/authors", authors)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import { type Lang } from "../content/config";
|
||||
import type { Lang } from "../content/config";
|
||||
import { t } from "../i18n";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -12,4 +12,12 @@ const commissioners = Astro.slots.has("default")
|
|||
: [];
|
||||
---
|
||||
|
||||
{commissioners.length ? <p id="commissioners" set:html={t(lang, "story/commissioned_by", commissioners)} /> : null}
|
||||
{
|
||||
commissioners.length ? (
|
||||
<p
|
||||
id="commissioners"
|
||||
aria-label={t(lang, "story/commissioners_aria_label", commissioners)}
|
||||
set:html={t(lang, "story/commissioned_by", commissioners)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import { type CollectionEntry } from "astro:content";
|
||||
import { type Lang } from "../content/config";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import type { Lang } from "../content/config";
|
||||
import { t } from "../i18n";
|
||||
import UserComponent from "./UserComponent.astro";
|
||||
import CopyrightedCharactersItem from "./CopyrightedCharactersItem.astro";
|
||||
|
|
@ -15,7 +15,7 @@ const { copyrightedCharacters, lang } = Astro.props;
|
|||
|
||||
{
|
||||
copyrightedCharacters ? (
|
||||
<section id="copyrighted-characters">
|
||||
<section id="copyrighted-characters" aria-label={t(lang, "characters/copyrighted_characters_aria_label")}>
|
||||
<ul>
|
||||
{copyrightedCharacters.map(([owner, characterList]) => (
|
||||
<CopyrightedCharactersItem
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@
|
|||
|
||||
<script>
|
||||
(function () {
|
||||
var colorScheme = localStorage.getItem("colorScheme");
|
||||
let colorScheme = localStorage.getItem("colorScheme");
|
||||
if (colorScheme == null || colorScheme === "auto") {
|
||||
colorScheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
document.querySelectorAll("button[data-dark-mode]").forEach(function (button) {
|
||||
button.addEventListener("click", function (e) {
|
||||
document.querySelectorAll<HTMLElementTagNameMap["button"]>("button[data-dark-mode]").forEach((button) => {
|
||||
button.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (colorScheme === "dark") {
|
||||
colorScheme = "light";
|
||||
|
|
|
|||
|
|
@ -1,37 +1,47 @@
|
|||
---
|
||||
import type { Lang } from "../content/config";
|
||||
|
||||
type Props = {
|
||||
instance?: string;
|
||||
user?: string;
|
||||
postId?: string;
|
||||
lang: Lang;
|
||||
link: string;
|
||||
instance: string;
|
||||
user: string;
|
||||
postId: string;
|
||||
};
|
||||
|
||||
const { instance, user, postId } = Astro.props;
|
||||
const { link, instance, user, postId } = Astro.props;
|
||||
---
|
||||
|
||||
<section
|
||||
id="comment-section"
|
||||
class="hidden px-2 font-serif"
|
||||
class="px-2 font-serif"
|
||||
aria-describedby="title-comment-section"
|
||||
data-instance={instance || ""}
|
||||
data-user={user || ""}
|
||||
data-post-id={postId || ""}
|
||||
data-link={link}
|
||||
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>
|
||||
<p class="my-1">
|
||||
<a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a>
|
||||
</p>
|
||||
</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">
|
||||
<template id="template-button">
|
||||
<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"
|
||||
>
|
||||
<span>Click to load comments</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template id="template-button-loading">
|
||||
<svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24" aria-hidden>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path
|
||||
class="opacity-100"
|
||||
|
|
@ -45,12 +55,16 @@ const { instance, user, postId } = Astro.props;
|
|||
<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">
|
||||
<a data-author class="text-link flex items-center text-lg hover:underline focus:underline" target="_blank">
|
||||
<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
|
||||
data-post-link
|
||||
class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="mr-1" data-publish-date aria-label="Publish date"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div data-content class="prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div>
|
||||
|
|
@ -72,11 +86,18 @@ const { instance, user, postId } = Astro.props;
|
|||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div data-comment-thread class="-mb-2"></div>
|
||||
<div data-comment-thread class="-mb-2" aria-hidden></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
interface Post {
|
||||
link: string;
|
||||
instance: string;
|
||||
user: string;
|
||||
postId: string;
|
||||
}
|
||||
|
||||
interface Emoji {
|
||||
shortcode: string;
|
||||
url: string;
|
||||
|
|
@ -104,115 +125,131 @@ const { instance, user, postId } = Astro.props;
|
|||
emojis: Emoji[];
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
ancestors: Comment[];
|
||||
descendants: Comment[];
|
||||
}
|
||||
|
||||
(function () {
|
||||
const replaceEmojis = (text: string, emojis: Emoji[], imgClass: string) =>
|
||||
emojis.reduce(
|
||||
function replaceEmojis(text: string, emojis: Emoji[]) {
|
||||
return emojis.reduce(
|
||||
(acc, emoji) =>
|
||||
acc.replaceAll(
|
||||
`:${emoji.shortcode}:`,
|
||||
`<img class="${imgClass}" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`,
|
||||
`<img class="inline mx-[1px] w-5" 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) => {
|
||||
async function renderComments(section: Element, post: Post) {
|
||||
const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
|
||||
try {
|
||||
const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
|
||||
}
|
||||
const data: ApiResponse = await response.json();
|
||||
|
||||
const commentsList: HTMLElement[] = [];
|
||||
const commentMap: Record<string, number> = {};
|
||||
const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
|
||||
"template#template-comment-box",
|
||||
)!;
|
||||
data.descendants.forEach((comment) => {
|
||||
const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement;
|
||||
|
||||
const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!;
|
||||
commentBoxAuthor.href = comment.account.url;
|
||||
const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!;
|
||||
avatar.src = comment.account.avatar;
|
||||
avatar.alt = `Profile picture of ${comment.account.username}`;
|
||||
const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!;
|
||||
displayName.innerHTML = replaceEmojis(comment.account.display_name, comment.account.emojis);
|
||||
|
||||
const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!;
|
||||
commentBoxPostLink.href = comment.url;
|
||||
const publishDate =
|
||||
commentBoxPostLink.querySelector<HTMLElementTagNameMap["span"]>("span[data-publish-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<HTMLElementTagNameMap["div"]>("div[data-content]")!;
|
||||
commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis);
|
||||
|
||||
const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!;
|
||||
commentBoxFavorites.innerText = comment.favourites_count.toString();
|
||||
|
||||
const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!;
|
||||
commentBoxReblogs.innerText = comment.reblogs_count.toString();
|
||||
|
||||
if (comment.in_reply_to_id === post.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;
|
||||
const parentThreadDiv =
|
||||
commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
|
||||
parentThreadDiv.removeAttribute("aria-hidden");
|
||||
parentThreadDiv.setAttribute("aria-label", "Replies");
|
||||
parentThreadDiv.appendChild(commentBox);
|
||||
}
|
||||
});
|
||||
if (commentsList.length === 0) {
|
||||
commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="${post.link}" target="_blank">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="${post.link}" target="_blank">by replying on Mastodon</a>.</p>`;
|
||||
commentsDiv.append(...commentsList);
|
||||
}
|
||||
} catch (e) {
|
||||
commentsDiv.innerHTML = `<p class="my-1">Unable to load comments. Please try again later.</p>`;
|
||||
console.error("Fetch Mastodon comments error", e);
|
||||
}
|
||||
}
|
||||
|
||||
function initCommentSection() {
|
||||
const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comment-section");
|
||||
if (!commentSection) {
|
||||
return;
|
||||
}
|
||||
const post = {
|
||||
link: commentSection.dataset.link,
|
||||
instance: commentSection.dataset.instance,
|
||||
user: commentSection.dataset.user,
|
||||
postId: commentSection.dataset.postId,
|
||||
};
|
||||
if (!post.link || !post.instance || !post.user || !post.postId) {
|
||||
return;
|
||||
}
|
||||
const loadCommentsButton = document
|
||||
.querySelector<HTMLElementTagNameMap["template"]>("template#template-button")!
|
||||
.content.cloneNode(true) as HTMLButtonElement;
|
||||
commentSection.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!.replaceChildren(loadCommentsButton);
|
||||
loadCommentsButton.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),
|
||||
document
|
||||
.querySelector<HTMLElementTagNameMap["template"]>("template#template-button-loading")!
|
||||
.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();
|
||||
|
||||
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]")!;
|
||||
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();
|
||||
renderComments(commentSection, post as Post);
|
||||
});
|
||||
}
|
||||
|
||||
initCommentSection();
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import { type Lang } from "../content/config";
|
||||
import type { Lang } from "../content/config";
|
||||
import { t } from "../i18n";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -12,4 +12,12 @@ const requesters = Astro.slots.has("default")
|
|||
: [];
|
||||
---
|
||||
|
||||
{requesters.length ? <p id="requesters" set:html={t(lang, "story/requested_by", requesters)} /> : null}
|
||||
{
|
||||
requesters.length ? (
|
||||
<p
|
||||
id="requesters"
|
||||
aria-label={t(lang, "story/requesters_aria_label", requesters)}
|
||||
set:html={t(lang, "story/requested_by", requesters)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import { type CollectionEntry } from "astro:content";
|
||||
import { type Lang } from "../content/config";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import type { Lang } from "../content/config";
|
||||
import { getUsernameForLang } from "../utils/get_username_for_lang";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -12,12 +12,7 @@ let { user, lang } = Astro.props;
|
|||
const username = getUsernameForLang(user, lang);
|
||||
let link: string | null = null;
|
||||
if (user.data.preferredLink) {
|
||||
const preferredLink = user.data.links[user.data.preferredLink]!;
|
||||
if (typeof preferredLink === "string") {
|
||||
link = preferredLink;
|
||||
} else {
|
||||
link = preferredLink[0];
|
||||
}
|
||||
link = user.data.links[user.data.preferredLink]!.link;
|
||||
}
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
../assets/LICENSE
|
||||
|
|
@ -2,89 +2,214 @@ import { defineCollection, reference, z } from "astro:content";
|
|||
|
||||
// Constants
|
||||
|
||||
export const WEBSITE_LIST = [
|
||||
"website",
|
||||
"eka",
|
||||
"furaffinity",
|
||||
"weasyl",
|
||||
"inkbunny",
|
||||
"sofurry",
|
||||
"mastodon",
|
||||
"twitter",
|
||||
"bluesky",
|
||||
"itaku",
|
||||
] as const;
|
||||
export const GAME_PLATFORMS = ["web", "windows", "linux", "macos", "android", "ios"] as const;
|
||||
export const DEFAULT_LANG = "eng";
|
||||
export const DEFAULT_AUTHOR_ID = "bad-manners";
|
||||
export const DEFAULT_LANG = "en";
|
||||
export const ANONYMOUS_USER_ID = "anonymous";
|
||||
|
||||
// Validators
|
||||
|
||||
const ekaPostUrlRegex = /^(?:https:\/\/)(?:www\.)?aryion\.com\/g4\/view\/([1-9]\d*)\/?$/;
|
||||
const furaffinityPostUrlRegex = /^(?:https:\/\/)(?:www\.)?furaffinity\.net\/view\/([1-9]\d*)\/?$/;
|
||||
const weasylPostUrlRegex =
|
||||
/^(?:https:\/\/)(?:www\.)?weasyl\.com\/~([a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/([1-9]\d*(?:\/[a-zA-Z0-9_-]+)?)\/?$/;
|
||||
const inkbunnyPostUrlRegex = /^(?:https:\/\/)(?:www\.)?inkbunny\.net\/s\/([1-9]\d*)\/?$/;
|
||||
const sofurryPostUrlRegex = /^(?:https:\/\/)www\.sofurry\.com\/view\/([1-9]\d*)\/?$/;
|
||||
const mastodonPostUrlRegex = /^(?:https:\/\/)((?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@([a-zA-Z][a-zA-Z0-9_-]+)\/([1-9]\d*)\/?$/;
|
||||
|
||||
const refineAuthors = [
|
||||
(value: { id: any } | any[]) => "id" in value || value.length > 0,
|
||||
`"authors" cannot be empty`,
|
||||
] as const;
|
||||
const refineCopyrightedCharacters = [
|
||||
(value: Record<string, any>) => !("" in value) || Object.keys(value).length == 1,
|
||||
`"copyrightedCharacters" cannot mix empty catch-all key with other keys`,
|
||||
] as const;
|
||||
|
||||
// Transformers
|
||||
|
||||
const trimText = (text: string) => text.trim();
|
||||
const adjustDateForUTCOffset = (date: Date) =>
|
||||
new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0);
|
||||
const parseMastodonPostUrl = (url: string, ctx: z.RefinementCtx) => {
|
||||
const match = mastodonPostUrlRegex.exec(url);
|
||||
if (!match) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `"mastodon" post contains an invalid URL`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return {
|
||||
instance: match[1]!,
|
||||
user: match[2]!,
|
||||
postId: match[3]!,
|
||||
function parseRegex<R extends { [key: string]: string }>(regex: RegExp) {
|
||||
return (url: string, ctx: z.RefinementCtx) => {
|
||||
const match = regex.exec(url);
|
||||
if (!match?.groups) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `"${ctx.path}" did not match regex`,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return match.groups as R;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Schema definitions
|
||||
|
||||
/** Record of website links for a user.
|
||||
*
|
||||
* For each entry, you can enter a URL for the value or - for any key apart
|
||||
* from `website` - a pre-parsed object containing the link and username.
|
||||
*/
|
||||
const websiteLinks = z.object({
|
||||
website: z
|
||||
.string()
|
||||
.url()
|
||||
.transform((link) => {
|
||||
link;
|
||||
}),
|
||||
eka: z.object({ link: z.string().url(), username: z.string() }).or(
|
||||
z.string().transform((link, ctx) => {
|
||||
const { username } = parseRegex<{ username: string }>(
|
||||
/^(?:https?:\/\/)?(?:www\.)?aryion\.com\/g4\/user\/(?<username>[^\/]+)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, username };
|
||||
}),
|
||||
),
|
||||
furaffinity: z.object({ link: z.string().url(), username: z.string() }).or(
|
||||
z.string().transform((link, ctx) => {
|
||||
const { username } = parseRegex<{ username: string }>(
|
||||
/^(?:https?:\/\/)?(?:www\.)?furaffinity\.net\/user\/(?<username>[^\/]+)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, username };
|
||||
}),
|
||||
),
|
||||
weasyl: z.object({ link: z.string().url(), username: z.string() }).or(
|
||||
z.string().transform((link, ctx) => {
|
||||
const { username } = parseRegex<{ username: string }>(
|
||||
/^(?:https?:\/\/)?(?:www\.)?weasyl\.com\/\~(?<username>[^\/]+)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, username };
|
||||
}),
|
||||
),
|
||||
inkbunny: z.object({ link: z.string().url(), username: z.string() }).or(
|
||||
z.string().transform((link, ctx) => {
|
||||
const { username } = parseRegex<{ username: string }>(
|
||||
/^(?:https?:\/\/)?(?:www\.)?inkbunny\.net\/(?<username>[^\/]+)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, username };
|
||||
}),
|
||||
),
|
||||
sofurry: z.object({ link: z.string().url(), username: z.string() }).or(
|
||||
z.string().transform((link, ctx) => {
|
||||
const { username } = parseRegex<{ username: string }>(/^(?:https?:\/\/)?(?<username>[^\.]+).sofurry.com\/?$/)(
|
||||
link,
|
||||
ctx,
|
||||
);
|
||||
return { link, username };
|
||||
}),
|
||||
),
|
||||
mastodon: z
|
||||
.object({ link: z.string().url(), username: z.string().regex(/^[^@]+@[^@]+$/) })
|
||||
.or(
|
||||
z.string().transform((link, ctx) => {
|
||||
const { username, instance } = parseRegex<{ username: string; instance: string }>(
|
||||
/^(?:https?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/(?:@|users\/)(?<username>[a-zA-Z][a-zA-Z0-9_-]+)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, username: `${username}@${instance}` };
|
||||
}),
|
||||
)
|
||||
.transform(({ link, username }) => {
|
||||
const i = username.indexOf("@");
|
||||
return { link, username, handle: username.slice(0, i), instance: username.slice(i + 1) };
|
||||
}),
|
||||
twitter: z.object({ link: z.string().url(), username: z.string() }).or(
|
||||
z.string().transform((link, ctx) => {
|
||||
const { username } = parseRegex<{ username: string }>(
|
||||
/^(?:https?:\/\/)?(?:www\.)?(?:twitter\.com|x\.com)\/@?(?<username>[^\/]+)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, username };
|
||||
}),
|
||||
),
|
||||
bluesky: z.object({ link: z.string().url(), username: z.string() }).or(
|
||||
z.string().transform((link, ctx) => {
|
||||
const { username } = parseRegex<{ username: string }>(
|
||||
/^(?:https?:\/\/)?bsky\.app\/profile\/(?<username>[^\/]+)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, username };
|
||||
}),
|
||||
),
|
||||
itaku: z.object({ link: z.string().url(), username: z.string() }).or(
|
||||
z.string().transform((link, ctx) => {
|
||||
const { username } = parseRegex<{ username: string }>(
|
||||
/^(?:https?:\/\/)?(?:www\.)?itaku\.ee\/profile\/(?<username>[^\/]+)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, username };
|
||||
}),
|
||||
),
|
||||
});
|
||||
/** Available languages. See https://r12a.github.io/app-subtags/ */
|
||||
const lang = z.enum(["en", "tok"]).default(DEFAULT_LANG);
|
||||
/** Platforms for a game. */
|
||||
const platform = z.enum(["web", "windows", "linux", "macos", "android", "ios"]);
|
||||
const userList = z
|
||||
.array(reference("users"))
|
||||
.refine((value) => value.length > 0, `user list cannot be empty`)
|
||||
.or(reference("users").transform((user) => [user]));
|
||||
/** A record of the format `{"Character name": "user-id"}`.
|
||||
*
|
||||
* An empty character name `""` indicates that all characters are copyrighted
|
||||
* by a certain user.
|
||||
*/
|
||||
const copyrightedCharacters = z
|
||||
.record(z.string(), reference("users"))
|
||||
.refine(
|
||||
(value) => !("" in value) || Object.keys(value).length == 1,
|
||||
`"copyrightedCharacters" cannot mix empty catch-all key with other keys`,
|
||||
)
|
||||
.default({});
|
||||
/** A record of the format `{ en: string, tok?: string, ... }`. */
|
||||
const langRecord = z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string()));
|
||||
/** Common attributes for published content (stories + games). */
|
||||
const publishedContent = z.object({
|
||||
// Required parameters
|
||||
title: z.string(),
|
||||
authors: userList,
|
||||
contentWarning: z.string().trim(),
|
||||
// Required parameters, but optional for drafts (isDraft == true)
|
||||
pubDate: z
|
||||
.date()
|
||||
.transform((date: Date) => new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0))
|
||||
.optional(),
|
||||
description: z.string().trim(),
|
||||
tags: z.array(z.string()),
|
||||
// Optional parameters
|
||||
isDraft: z.boolean().default(false),
|
||||
relatedStories: z.array(reference("stories")).default([]),
|
||||
relatedGames: z.array(reference("games")).default([]),
|
||||
lang: lang,
|
||||
copyrightedCharacters: copyrightedCharacters,
|
||||
series: reference("series").optional(),
|
||||
posts: z
|
||||
.object({
|
||||
eka: z.string().transform((link, ctx) => {
|
||||
const { postId } = parseRegex<{ postId: string }>(
|
||||
/^(?:https?:\/\/)(?:www\.)?aryion\.com\/g4\/view\/(?<postId>[1-9]\d*)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, postId };
|
||||
}),
|
||||
furaffinity: z.string().transform((link, ctx) => {
|
||||
const { postId } = parseRegex<{ postId: string }>(
|
||||
/^(?:https?:\/\/)(?:www\.)?furaffinity\.net\/view\/(?<postId>[1-9]\d*)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, postId };
|
||||
}),
|
||||
weasyl: z.string().transform((link, ctx) => {
|
||||
const { user, postId } = parseRegex<{ user: string; postId: string }>(
|
||||
/^(?:https?:\/\/)(?:www\.)?weasyl\.com\/~(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/(?<postId>[1-9]\d*(?:\/[a-zA-Z0-9_-]+)?)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, user, postId };
|
||||
}),
|
||||
inkbunny: z.string().transform((link, ctx) => {
|
||||
const { postId } = parseRegex<{ postId: string }>(
|
||||
/^(?:https?:\/\/)(?:www\.)?inkbunny\.net\/s\/(?<postId>[1-9]\d*)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, postId };
|
||||
}),
|
||||
sofurry: z.string().transform((link, ctx) => {
|
||||
const { postId } = parseRegex<{ postId: string }>(
|
||||
/^(?:https?:\/\/)www\.sofurry\.com\/view\/(?<postId>[1-9]\d*)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, postId };
|
||||
}),
|
||||
bluesky: z.string().transform((link, ctx) => {
|
||||
const { user, postId } = parseRegex<{ user: string; postId: string }>(
|
||||
/^(?:https?:\/\/)bsky\.app\/profile\/(?<user>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/post\/(?<postId>[a-z0-9]+)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, user, postId };
|
||||
}),
|
||||
mastodon: z.string().transform((link, ctx) => {
|
||||
const { instance, user, postId } = parseRegex<{ instance: string; user: string; postId: string }>(
|
||||
/^(?:https?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/(?<postId>[1-9]\d*)\/?$/,
|
||||
)(link, ctx);
|
||||
return { link, instance, user, postId };
|
||||
}),
|
||||
})
|
||||
.partial()
|
||||
.default({}),
|
||||
});
|
||||
|
||||
// Types
|
||||
|
||||
const lang = z.enum(["eng", "tok"]).default(DEFAULT_LANG);
|
||||
const website = z.enum(WEBSITE_LIST);
|
||||
const platform = z.enum(GAME_PLATFORMS);
|
||||
const mastodonPost = z
|
||||
.object({
|
||||
instance: z.string(),
|
||||
user: z.string(),
|
||||
postId: z.string(),
|
||||
})
|
||||
.or(z.string().transform(parseMastodonPostUrl));
|
||||
const userList = z
|
||||
.array(reference("users"))
|
||||
.or(reference("users").transform((user) => [user]))
|
||||
.refine((value) => value.length > 0, `user list cannot be empty`);
|
||||
const authors = userList.default([DEFAULT_AUTHOR_ID]);
|
||||
const copyrightedCharacters = z
|
||||
.record(z.string(), reference("users"))
|
||||
.default({})
|
||||
.refine(...refineCopyrightedCharacters);
|
||||
// { eng: string, tok?: string, ... }
|
||||
const langRecord = z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string()));
|
||||
|
||||
export type Lang = z.output<typeof lang>;
|
||||
export type Website = z.infer<typeof website>;
|
||||
export type Website = keyof z.input<typeof websiteLinks>;
|
||||
export type GamePlatform = z.infer<typeof platform>;
|
||||
export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
|
||||
|
||||
|
|
@ -93,79 +218,52 @@ export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
|
|||
const storiesCollection = defineCollection({
|
||||
type: "content",
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
// Required
|
||||
title: z.string(),
|
||||
wordCount: z.number().int().optional(),
|
||||
contentWarning: z.string().transform(trimText),
|
||||
description: z.string().transform(trimText),
|
||||
tags: z.array(z.string()),
|
||||
// Optional
|
||||
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
|
||||
isDraft: z.boolean().default(false),
|
||||
shortTitle: z.string().optional(),
|
||||
authors,
|
||||
summary: z.string().transform(trimText).optional(),
|
||||
thumbnail: image().optional(),
|
||||
thumbnailWidth: z.number().int().optional(),
|
||||
thumbnailHeight: z.number().int().optional(),
|
||||
series: reference("series").optional(),
|
||||
commissioner: userList.optional(),
|
||||
requester: userList.optional(),
|
||||
copyrightedCharacters: copyrightedCharacters,
|
||||
lang,
|
||||
prev: reference("stories").nullish(),
|
||||
next: reference("stories").nullish(),
|
||||
relatedStories: z.array(reference("stories")).default([]),
|
||||
relatedGames: z.array(reference("games")).default([]),
|
||||
posts: z
|
||||
.object({
|
||||
eka: z.string().regex(ekaPostUrlRegex),
|
||||
furaffinity: z.string().regex(furaffinityPostUrlRegex),
|
||||
weasyl: z.string().regex(weasylPostUrlRegex),
|
||||
inkbunny: z.string().regex(inkbunnyPostUrlRegex),
|
||||
sofurry: z.string().regex(sofurryPostUrlRegex),
|
||||
mastodon: mastodonPost,
|
||||
})
|
||||
.partial()
|
||||
.default({}),
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
// Required parameters, but optional for drafts (isDraft == true)
|
||||
wordCount: z.number().int().optional(),
|
||||
thumbnail: image().optional(),
|
||||
// Optional parameters
|
||||
shortTitle: z.string().optional(),
|
||||
commissioner: userList.optional(),
|
||||
requester: userList.optional(),
|
||||
summary: z.string().trim().optional(),
|
||||
thumbnailWidth: z.number().int().optional(),
|
||||
thumbnailHeight: z.number().int().optional(),
|
||||
prev: reference("stories").nullish(),
|
||||
next: reference("stories").nullish(),
|
||||
})
|
||||
.and(publishedContent)
|
||||
.refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published story`)
|
||||
.refine(
|
||||
({ isDraft, contentWarning }) => isDraft || contentWarning,
|
||||
`Missing "contentWarning" for published story`,
|
||||
)
|
||||
.refine(({ isDraft, wordCount }) => isDraft || wordCount, `Missing "wordCount" for published story`)
|
||||
.refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published story`)
|
||||
.refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published story`)
|
||||
.refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published story`),
|
||||
});
|
||||
|
||||
const gamesCollection = defineCollection({
|
||||
type: "content",
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
// Required
|
||||
title: z.string(),
|
||||
contentWarning: z.string().transform(trimText),
|
||||
description: z.string().transform(trimText),
|
||||
platforms: z.array(platform).refine((platforms) => platforms.length > 0, `"platforms" cannot be empty`),
|
||||
tags: z.array(z.string()),
|
||||
// Optional
|
||||
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
|
||||
isDraft: z.boolean().default(false),
|
||||
authors,
|
||||
thumbnail: image().optional(),
|
||||
thumbnailWidth: z.number().int().optional(),
|
||||
thumbnailHeight: z.number().int().optional(),
|
||||
series: reference("series").optional(),
|
||||
copyrightedCharacters: copyrightedCharacters,
|
||||
lang,
|
||||
relatedStories: z.array(reference("stories")).default([]),
|
||||
relatedGames: z.array(reference("games")).default([]),
|
||||
posts: z
|
||||
.object({
|
||||
eka: z.string().regex(ekaPostUrlRegex),
|
||||
furaffinity: z.string().regex(furaffinityPostUrlRegex),
|
||||
weasyl: z.string().regex(weasylPostUrlRegex),
|
||||
inkbunny: z.string().regex(inkbunnyPostUrlRegex),
|
||||
sofurry: z.string().regex(sofurryPostUrlRegex),
|
||||
mastodon: mastodonPost,
|
||||
})
|
||||
.partial()
|
||||
.default({}),
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
// Required parameters, but optional for drafts (isDraft == true)
|
||||
platforms: z.array(platform).default([]),
|
||||
thumbnail: image().optional(),
|
||||
// Optional parameters
|
||||
thumbnailWidth: z.number().int().optional(),
|
||||
thumbnailHeight: z.number().int().optional(),
|
||||
})
|
||||
.and(publishedContent)
|
||||
.refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published game`)
|
||||
.refine(({ isDraft, contentWarning }) => isDraft || contentWarning, `Missing "contentWarning" for published game`)
|
||||
.refine(({ isDraft, platforms }) => isDraft || platforms.length, `Missing "platforms" for published game`)
|
||||
.refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published game`)
|
||||
.refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published game`)
|
||||
.refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published game`),
|
||||
});
|
||||
|
||||
// Data collections
|
||||
|
|
@ -175,12 +273,11 @@ const usersCollection = defineCollection({
|
|||
schema: ({ image }) =>
|
||||
z
|
||||
.object({
|
||||
// Required
|
||||
name: z.string(),
|
||||
links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])),
|
||||
// Optional
|
||||
preferredLink: website.nullish(),
|
||||
lang: langRecord.optional(),
|
||||
// Required parameters
|
||||
name: langRecord.or(z.string()),
|
||||
links: websiteLinks.partial(),
|
||||
// Optional parameters
|
||||
preferredLink: websiteLinks.keyof().nullish(),
|
||||
avatar: image().optional(),
|
||||
})
|
||||
.refine(
|
||||
|
|
@ -195,7 +292,7 @@ const usersCollection = defineCollection({
|
|||
const seriesCollection = defineCollection({
|
||||
type: "data",
|
||||
schema: z.object({
|
||||
// Required
|
||||
// Required parameters
|
||||
name: z.string(),
|
||||
url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`),
|
||||
}),
|
||||
|
|
@ -204,16 +301,18 @@ const seriesCollection = defineCollection({
|
|||
const tagCategoriesCollection = defineCollection({
|
||||
type: "data",
|
||||
schema: z.object({
|
||||
// Required
|
||||
// Required parameters
|
||||
name: z.string(),
|
||||
index: z.number().int(),
|
||||
tags: z.array(
|
||||
z.object({
|
||||
name: z.union([z.string(), langRecord]),
|
||||
description: z.string().optional(),
|
||||
related: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
tags: z
|
||||
.array(
|
||||
z.object({
|
||||
name: langRecord.or(z.string()),
|
||||
description: z.string().trim().optional(),
|
||||
related: z.array(z.string()).default([]),
|
||||
}),
|
||||
)
|
||||
.refine((tags) => tags.length, `"tags" cannot be empty`),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ posts:
|
|||
inkbunny: https://inkbunny.net/s/3262911
|
||||
sofurry: https://www.sofurry.com/view/2109688
|
||||
weasyl: https://www.weasyl.com/~badmanners/submissions/2356092/crossing-over-vore-game
|
||||
bluesky: https://bsky.app/profile/badmanners.xyz/post/3kmigrf5q2x24
|
||||
mastodon: https://meow.social/@BadManners/112009918919441027
|
||||
tags:
|
||||
- oral vore
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
slug: playing-it-safe
|
||||
title: Playing It Safe
|
||||
pubDate: 2024-08-08
|
||||
isDraft: true
|
||||
authors: bad-manners
|
||||
wordCount: 9900
|
||||
contentWarning: >
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ posts:
|
|||
inkbunny: https://inkbunny.net/s/3283508
|
||||
sofurry: https://www.sofurry.com/view/2118138
|
||||
weasyl: https://www.weasyl.com/~badmanners/submissions/2363560/tiny-accident
|
||||
bluesky: https://bsky.app/profile/badmanners.xyz/post/3kok52wijz32c
|
||||
mastodon: https://meow.social/@BadManners/112157812554023271
|
||||
tags:
|
||||
- anthro predator
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: Types of vore
|
||||
index: 1
|
||||
tags:
|
||||
- name: { eng: oral vore, tok: moku musi kepeken uta }
|
||||
- name: { en: oral vore, tok: moku musi kepeken uta }
|
||||
description: Scenarios where prey are consumed by the predator through their mouth.
|
||||
- name: anal vore
|
||||
description: Scenarios where prey are consumed by the predator through their butt/anus.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ tags:
|
|||
description: Scenarios where at least one of the predators is an animal based on a real or mythological creature.
|
||||
- name: taur predator
|
||||
description: Scenarios where at least one of the predators is a multi-legged centaur-like creature, with an animal lower body and anthropomorphic upper body.
|
||||
- name: { eng: ambiguous predator, tok: sijelo pi jan pi wawa mute li ale }
|
||||
- name: { en: ambiguous predator, tok: sijelo pi jan pi wawa mute li ale }
|
||||
description: Scenarios where the body type of at least one of the predators is left ambiguous.
|
||||
- name: human prey
|
||||
description: Scenarios where at least one of the prey is a human person.
|
||||
|
|
@ -15,5 +15,5 @@ tags:
|
|||
description: Scenarios where at least one of the prey is an anthropomorphic animal, i.e. generally regarded as a "furry".
|
||||
- name: feral prey
|
||||
description: Scenarios where at least one of the predators is an animal based on a real or mythological creature.
|
||||
- name: { eng: ambiguous prey, tok: sijelo pi jan pi wawa lili li ale }
|
||||
- name: { en: ambiguous prey, tok: sijelo pi jan pi wawa lili li ale }
|
||||
description: Scenarios where the body type of at least one of the predators is left ambiguous.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ tags:
|
|||
description: Scenarios where at least one of the predators is a woman and/or female-presenting.
|
||||
- name: non-binary predator
|
||||
description: Scenarios where at least one of the predators has a non-binary gender expression, be they genderless/agender, intersex, androgynous, gender-fluid, non-binary and/or non-binary-presenting, et cetera, regardless of undergoing or having undergone gender transition or not.
|
||||
- name: { eng: ambiguous gender predator, tok: jan pi wawa mute li meli anu mije }
|
||||
- name: { en: ambiguous gender predator, tok: jan pi wawa mute li meli anu mije }
|
||||
description: Scenarios where the gender at least one of the predators is left ambiguous.
|
||||
- name: male prey
|
||||
description: Scenarios where at least one of the prey is a man and/or male-presenting.
|
||||
|
|
@ -27,5 +27,5 @@ tags:
|
|||
- female prey
|
||||
- name: non-binary prey
|
||||
description: Scenarios where at least one of the predators has a non-binary gender expression, be they genderless/agender, intersex, androgynous, gender-fluid, non-binary and/or non-binary-presenting, et cetera, regardless of undergoing or having undergone gender transition or not.
|
||||
- name: { eng: ambiguous gender prey, tok: jan pi wawa lili li meli anu mije }
|
||||
- name: { en: ambiguous gender prey, tok: jan pi wawa lili li meli anu mije }
|
||||
description: Scenarios where the gender at least one of the predators is left ambiguous.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: Willingness
|
||||
index: 5
|
||||
tags:
|
||||
- name: { eng: willing predator, tok: jan pi wawa mute li wile e moku musi }
|
||||
- name: { en: willing predator, tok: jan pi wawa mute li wile e moku musi }
|
||||
description: Scenarios where at least one of the predators participates in vore willingly.
|
||||
- name: semi-willing predator
|
||||
description: Scenarios where the willingness of at least one of the predators might be partial, oscillating between willing and unwilling, somewhere in-between, or ambiguous.
|
||||
|
|
@ -13,7 +13,7 @@ tags:
|
|||
description: Scenarios where at least one of the prey participates in vore willingly.
|
||||
- name: semi-willing prey
|
||||
description: Scenarios where the willingness of at least one of the prey might be partial, oscillating between willing and unwilling, somewhere in-between, or ambiguous.
|
||||
- name: { eng: unwilling prey, tok: jan pi wawa lili li wile ala e moku musi }
|
||||
- name: { en: unwilling prey, tok: jan pi wawa lili li wile ala e moku musi }
|
||||
description: Scenarios where at least one of the prey participates in vore unwillingly.
|
||||
- name: asleep prey
|
||||
description: Scenarios where at least one of the predators participates in vore while asleep.
|
||||
|
|
|
|||
|
|
@ -66,4 +66,4 @@ tags:
|
|||
- name: soul vore
|
||||
description: Scenarios where predators consume a soul instead of their prey's body.
|
||||
- name: Vore Day
|
||||
description: Stories created in commemoration of Vore Day, which is celebrated on August 8ᵗʰ, and/or are set in said day.
|
||||
description: Stories created in commemoration of Vore Day, which is celebrated on August 8th, and/or are set in said day.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ tags:
|
|||
description: Stories made by someone else's request, as a gift.
|
||||
- name: commission
|
||||
description: Stories made as part of a commission to someone else.
|
||||
- name: { eng: flash fiction, tok: lipu lili }
|
||||
- name: { en: flash fiction, tok: lipu lili }
|
||||
description: Short-format stories of no more than 2,500 words.
|
||||
- name: toki pona
|
||||
description: Stories written in toki pona, the language of good.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
name: Anonymous
|
||||
lang:
|
||||
eng: anonymous
|
||||
name:
|
||||
en: anonymous
|
||||
tok: jan pi nimi ala
|
||||
links: {}
|
||||
preferredLink: ~
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: Asof Yeun
|
||||
links:
|
||||
eka: https://aryion.com/g4/user/asofyeun
|
||||
furaffinity: https://www.furaffinity.net/user/asofyeun
|
||||
furaffinity: https://www.furaffinity.net/user/AsofYeun
|
||||
inkbunny: https://inkbunny.net/asofyeun
|
||||
sofurry: https://asofyeun.sofurry.com/
|
||||
weasyl: https://www.weasyl.com/~asofyeun
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
name: Bad Manners
|
||||
lang:
|
||||
eng: Bad Manners
|
||||
name:
|
||||
en: Bad Manners
|
||||
tok: nasin ike Pemene
|
||||
avatar: /src/assets/images/logo_bm.png
|
||||
links:
|
||||
|
|
@ -9,8 +8,8 @@ links:
|
|||
furaffinity: https://www.furaffinity.net/user/BadManners
|
||||
inkbunny: https://inkbunny.net/BadManners
|
||||
sofurry:
|
||||
- https://bad-manners.sofurry.com/
|
||||
- Bad Manners
|
||||
link: https://bad-manners.sofurry.com/
|
||||
username: Bad Manners
|
||||
weasyl: https://www.weasyl.com/~BadManners
|
||||
twitter: https://twitter.com/BadManners__
|
||||
mastodon: https://meow.social/@BadManners
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name: Dr. Hans Woofington
|
||||
links:
|
||||
furaffinity:
|
||||
- https://www.furaffinity.net/user/hanslewdington/
|
||||
- Hans_Lewdington
|
||||
link: https://www.furaffinity.net/user/hanslewdington/
|
||||
username: Hans_Lewdington
|
||||
preferredLink: furaffinity
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ name: YolkMonkey
|
|||
links:
|
||||
furaffinity: https://furaffinity.net/user/Vampire101
|
||||
sofurry:
|
||||
- https://vampire101.sofurry.com/
|
||||
- Vampire101
|
||||
link: https://vampire101.sofurry.com/
|
||||
username: Vampire101
|
||||
preferredLink: furaffinity
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { type GamePlatform, type Lang } from "../content/config";
|
||||
import type { GamePlatform, Lang } from "../content/config";
|
||||
import { DEFAULT_LANG } from "../content/config";
|
||||
export { DEFAULT_LANG } from "../content/config";
|
||||
|
||||
const UI_STRINGS = {
|
||||
// Utility functions
|
||||
"util/join_names": {
|
||||
eng: (names: string[]) =>
|
||||
en: (names: string[]) =>
|
||||
names.length <= 1
|
||||
? names.join("")
|
||||
: names.length == 2
|
||||
|
|
@ -13,10 +14,10 @@ const UI_STRINGS = {
|
|||
tok: (names: string[]) => names.join(" en "),
|
||||
},
|
||||
"util/capitalize": {
|
||||
eng: (text: string) => (text.length > 0 ? `${text[0].toUpperCase()}${text.slice(1)}` : ""),
|
||||
en: (text: string) => (text.length > 0 ? `${text[0].toUpperCase()}${text.slice(1)}` : ""),
|
||||
},
|
||||
"util/enumerate": {
|
||||
eng: (count: number, nounSingular: string, nounPlural: string | undefined) => {
|
||||
en: (count: number, nounSingular: string, nounPlural?: string) => {
|
||||
if (count == 0) {
|
||||
return `no ${nounPlural ?? nounSingular}`;
|
||||
}
|
||||
|
|
@ -25,146 +26,205 @@ const UI_STRINGS = {
|
|||
}
|
||||
return `${count} ${nounPlural ?? nounSingular}`;
|
||||
},
|
||||
tok: (count: number, nounSingular: string, nounPlural: string | undefined) =>
|
||||
tok: (count: number, nounSingular: string, nounPlural?: string) =>
|
||||
`${(count > 1 && nounPlural) || nounSingular} ${["ala", "wan", "tu"][count] || "mute"}`,
|
||||
},
|
||||
"export_story/writing": {
|
||||
eng: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`,
|
||||
// export-story API functions
|
||||
"export_story/authors": {
|
||||
en: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`,
|
||||
tok: (authorsList: string[]) => `lipu ni li tan jan ni: ${authorsList.join(" en ")}`,
|
||||
},
|
||||
"export_story/request_for": {
|
||||
eng: (requesterList: string[]) => `Request for: ${requesterList.join(" ")}`,
|
||||
en: (requesterList: string[]) => `Request for: ${requesterList.join(" ")}`,
|
||||
},
|
||||
"export_story/commissioned_by": {
|
||||
eng: (commissionerList: string[]) => `Commissioned by: ${commissionerList.join(" ")}`,
|
||||
en: (commissionerList: string[]) => `Commissioned by: ${commissionerList.join(" ")}`,
|
||||
},
|
||||
"story/return_to_stories": {
|
||||
eng: "Return to stories",
|
||||
tok: "o tawa e lipu ale",
|
||||
// Shared strings for published content (stories + games)
|
||||
"published_content/return_to_series": {
|
||||
en: (seriesName: string) => `Return to ${seriesName}`,
|
||||
},
|
||||
"story/return_to_series": {
|
||||
eng: (seriesName: string) => `Return to ${seriesName}`,
|
||||
},
|
||||
"story/go_to_description": {
|
||||
eng: "Go to description",
|
||||
"published_content/go_to_description": {
|
||||
en: "Go to description",
|
||||
tok: "o tawa e toki lipu",
|
||||
},
|
||||
"story/toggle_dark_mode": {
|
||||
eng: "Toggle dark mode",
|
||||
"published_content/toggle_dark_mode": {
|
||||
en: "Toggle dark mode",
|
||||
tok: "o ante e kule lipu",
|
||||
},
|
||||
"published_content/cover_art_alt": {
|
||||
en: (title: string) => `Cover art for ${title}`,
|
||||
tok: (_title: string) => `sitelen tawa lipu ni`,
|
||||
},
|
||||
"published_content/publish_date": {
|
||||
en: (date: Date) => date.toISOString().slice(0, 10),
|
||||
tok: (date: Date) => `tenpo suno ${date.toISOString().slice(0, 10)}`,
|
||||
},
|
||||
"published_content/publish_date_aria_label": {
|
||||
en: "Publish date",
|
||||
tok: "tenpo pi pana lipu",
|
||||
},
|
||||
"published_content/publish_date_aria_description": {
|
||||
en: (date: Date) =>
|
||||
date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}),
|
||||
tok: (_date: Date) => "",
|
||||
},
|
||||
"published_content/description": {
|
||||
en: "Description",
|
||||
tok: "toki lipu",
|
||||
},
|
||||
"published_content/to_top": {
|
||||
en: "To top",
|
||||
tok: "tawa sewi",
|
||||
},
|
||||
"published_content/tags": {
|
||||
en: "Tags",
|
||||
tok: "nimi kulupu",
|
||||
},
|
||||
"published_content/copyright_year": {
|
||||
en: (year: string | number) => `© ${year}`,
|
||||
tok: (year: string | number) => `© tenpo pi sike suno ${year}`,
|
||||
},
|
||||
"published_content/licenses": {
|
||||
en: "Licenses",
|
||||
tok: "lipu lawa",
|
||||
},
|
||||
"published_content/draft_warning": {
|
||||
en: "DRAFT VERSION – DO NOT REDISTRIBUTE",
|
||||
},
|
||||
"published_content/related_stories": {
|
||||
en: "Related stories",
|
||||
},
|
||||
"published_content/related_games": {
|
||||
en: "Related games",
|
||||
},
|
||||
// Story page-specific strings
|
||||
"story/return_to_stories": {
|
||||
en: "Return to stories",
|
||||
tok: "o tawa e lipu ale",
|
||||
},
|
||||
"story/title_aria_label": {
|
||||
en: "Story title",
|
||||
tok: "nimi lipu",
|
||||
},
|
||||
"story/authors_aria_label": {
|
||||
en: (authors: any[]) => (authors.length == 1 ? "Author" : "Authors"),
|
||||
tok: (_authors: any[]) => "jan pi pali lipu",
|
||||
},
|
||||
"story/requesters_aria_label": {
|
||||
en: (requesters: any[]) => (requesters.length == 1 ? "Requester" : "Requesters"),
|
||||
},
|
||||
"story/commissioners_aria_label": {
|
||||
en: (commissioners: any[]) => (commissioners.length == 1 ? "Commissioner" : "Commissioners"),
|
||||
},
|
||||
"story/warnings": {
|
||||
eng: (wordCount: number | string | undefined, contentWarning: string) =>
|
||||
en: (wordCount: number | string | undefined, contentWarning: string) =>
|
||||
wordCount ? `Word count: ${wordCount}. ${contentWarning}` : contentWarning,
|
||||
tok: (_wordCount: number | string | undefined, contentWarning: string) => contentWarning,
|
||||
},
|
||||
"story/publish_date": {
|
||||
eng: (date: string) => date,
|
||||
tok: (date: string) => `tenpo suno ${date}`,
|
||||
},
|
||||
"story/description": {
|
||||
eng: "Description",
|
||||
tok: "toki lipu",
|
||||
"story/article_aria_label": {
|
||||
en: "Story",
|
||||
tok: "lipu",
|
||||
},
|
||||
"story/summary": {
|
||||
eng: "Summary",
|
||||
en: "Summary",
|
||||
tok: "lipu tawa tenpo lili",
|
||||
},
|
||||
"story/reveal_summary": {
|
||||
eng: "Click to reveal",
|
||||
en: "Click to reveal",
|
||||
tok: "Click to reveal summary in English",
|
||||
},
|
||||
"story/to_top": {
|
||||
eng: "To top",
|
||||
tok: "tawa sewi",
|
||||
},
|
||||
"story/tags": {
|
||||
eng: "Tags",
|
||||
tok: "nimi kulupu",
|
||||
},
|
||||
"story/copyright_year": {
|
||||
eng: (year: string | number) => `© ${year}`,
|
||||
tok: (year: string | number) => `© tenpo pi sike suno ${year}`,
|
||||
},
|
||||
"story/licenses": {
|
||||
eng: "Licenses",
|
||||
tok: "lipu lawa",
|
||||
},
|
||||
"story/authors": {
|
||||
eng: (authorsList: string[]) => `by ${UI_STRINGS["util/join_names"].eng(authorsList)}`,
|
||||
en: (authorsList: string[]) => `by ${UI_STRINGS["util/join_names"].en(authorsList)}`,
|
||||
tok: (authorsList: string[]) =>
|
||||
authorsList.length > 1
|
||||
? `lipu ni li tan jan ni: ${UI_STRINGS["util/join_names"].tok(authorsList)}`
|
||||
: `lipu ni li tan ${authorsList[0]}`,
|
||||
},
|
||||
"story/commissioned_by": {
|
||||
eng: (commissionersList: string[]) => `Commissioned by ${UI_STRINGS["util/join_names"].eng(commissionersList)}`,
|
||||
en: (commissionersList: string[]) => `Commissioned by ${UI_STRINGS["util/join_names"].en(commissionersList)}`,
|
||||
},
|
||||
"story/requested_by": {
|
||||
eng: (requestersList: string[]) => `Requested by ${UI_STRINGS["util/join_names"].eng(requestersList)}`,
|
||||
en: (requestersList: string[]) => `Requested by ${UI_STRINGS["util/join_names"].en(requestersList)}`,
|
||||
},
|
||||
"story/draft_warning": {
|
||||
eng: "DRAFT VERSION – DO NOT REDISTRIBUTE",
|
||||
// Game page-specific strings
|
||||
"game/return_to_games": {
|
||||
en: "Return to games",
|
||||
},
|
||||
"characters/characters_are_copyrighted_by": {
|
||||
eng: (owner: string, charactersList: string[]) =>
|
||||
charactersList.length == 1
|
||||
? `${charactersList[0]} is © ${owner}`
|
||||
: `${UI_STRINGS["util/join_names"].eng(charactersList)} are © ${owner}`,
|
||||
},
|
||||
"characters/all_characters_are_copyrighted_by": {
|
||||
eng: (owner: string) => `All characters are © ${owner}`,
|
||||
"game/title_aria_label": {
|
||||
en: "Game title",
|
||||
},
|
||||
"game/platforms": {
|
||||
eng: (platforms: GamePlatform[]) => {
|
||||
en: (platforms: GamePlatform[]) => {
|
||||
if (platforms.length == 0) {
|
||||
return "";
|
||||
}
|
||||
const translatedPlatforms = platforms.map((platform) => {
|
||||
const platformLang = UI_STRINGS[`game/platform_${platform}`].eng;
|
||||
const platformLang = UI_STRINGS[`game/platform_${platform}`].en;
|
||||
if (!platformLang) {
|
||||
throw new Error(`Invalid platform "${platform}"`);
|
||||
}
|
||||
return platformLang;
|
||||
});
|
||||
return `A game for ${UI_STRINGS["util/join_names"].eng(translatedPlatforms)}.`;
|
||||
return `A game for ${UI_STRINGS["util/join_names"].en(translatedPlatforms)}.`;
|
||||
},
|
||||
},
|
||||
"game/platform_web": {
|
||||
eng: "web browsers",
|
||||
en: "web browsers",
|
||||
},
|
||||
"game/platform_windows": {
|
||||
eng: "Windows",
|
||||
en: "Windows",
|
||||
},
|
||||
"game/platform_linux": {
|
||||
eng: "Linux",
|
||||
en: "Linux",
|
||||
},
|
||||
"game/platform_macos": {
|
||||
eng: "macOS",
|
||||
en: "macOS",
|
||||
},
|
||||
"game/platform_android": {
|
||||
eng: "Android",
|
||||
en: "Android",
|
||||
},
|
||||
"game/platform_ios": {
|
||||
eng: "iOS",
|
||||
en: "iOS",
|
||||
},
|
||||
"game/warnings": {
|
||||
eng: (platforms: GamePlatform[], contentWarning: string) =>
|
||||
platforms.length > 0 ? `${UI_STRINGS["game/platforms"].eng(platforms)} ${contentWarning}` : contentWarning,
|
||||
en: (platforms: GamePlatform[], contentWarning: string) =>
|
||||
platforms.length > 0 ? `${UI_STRINGS["game/platforms"].en(platforms)} ${contentWarning}` : contentWarning,
|
||||
},
|
||||
"game/article_aria_label": {
|
||||
en: "Game",
|
||||
},
|
||||
// Copyrighted character-related strings
|
||||
"characters/copyrighted_characters_aria_label": {
|
||||
en: "Copyrighted characters",
|
||||
},
|
||||
"characters/characters_are_copyrighted_by": {
|
||||
en: (owner: string, charactersList: string[]) =>
|
||||
charactersList.length == 1
|
||||
? `${charactersList[0]} is © ${owner}`
|
||||
: `${UI_STRINGS["util/join_names"].en(charactersList)} are © ${owner}`,
|
||||
},
|
||||
"characters/all_characters_are_copyrighted_by": {
|
||||
en: (owner: string) => `All characters are © ${owner}`,
|
||||
},
|
||||
// Tag-related strings
|
||||
"tag/total_works_with_tag": {
|
||||
eng: (tag: string, storiesCount: number, gamesCount: number) => {
|
||||
en: (tag: string, storiesCount: number, gamesCount: number) => {
|
||||
const content = [];
|
||||
if (storiesCount > 0) {
|
||||
content.push(UI_STRINGS["util/enumerate"].eng(storiesCount, "story", "stories"));
|
||||
content.push(UI_STRINGS["util/enumerate"].en(storiesCount, "story", "stories"));
|
||||
}
|
||||
if (gamesCount > 0) {
|
||||
content.push(UI_STRINGS["util/enumerate"].eng(gamesCount, "game", "games"));
|
||||
content.push(UI_STRINGS["util/enumerate"].en(gamesCount, "game", "games"));
|
||||
}
|
||||
if (content.length == 0) {
|
||||
return `No works tagged with "${tag}".`;
|
||||
}
|
||||
return UI_STRINGS["util/capitalize"].eng(`${UI_STRINGS["util/join_names"].eng(content)} tagged with "${tag}".`);
|
||||
return UI_STRINGS["util/capitalize"].en(`${UI_STRINGS["util/join_names"].en(content)} tagged with "${tag}".`);
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -176,6 +236,9 @@ type TranslationEntry<T> = { [DEFAULT_LANG]: T } & {
|
|||
type TranslationArgs<K extends TranslationKey> =
|
||||
(typeof UI_STRINGS)[K] extends TranslationEntry<infer T> ? (T extends (...args: infer A) => string ? A : []) : never;
|
||||
|
||||
/** Translates some text according to the provided language, a translation key,
|
||||
* and optionally any required arguments.
|
||||
*/
|
||||
export function t<K extends TranslationKey>(lang: Lang, key: K, ...args: TranslationArgs<K>): string {
|
||||
if (key in UI_STRINGS) {
|
||||
const translation: string | ((...args: TranslationArgs<K>) => string) =
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ import AgeRestrictedModal from "../components/AgeRestrictedModal.astro";
|
|||
|
||||
type Props = {
|
||||
pageTitle?: string;
|
||||
lang?: string;
|
||||
};
|
||||
|
||||
const { pageTitle } = Astro.props;
|
||||
const { pageTitle = "Gallery", lang = "en" } = Astro.props;
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
|
@ -23,7 +24,7 @@ const { pageTitle } = Astro.props;
|
|||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{pageTitle || "Gallery"} | Bad Manners</title>
|
||||
<title>{pageTitle} | Bad Manners</title>
|
||||
<link rel="me" href="https://meow.social/@BadManners" />
|
||||
<link
|
||||
rel="alternate"
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ const { props } = Astro;
|
|||
const series = props.series && (await getEntry(props.series));
|
||||
const authorsList = await getEntries(props.authors);
|
||||
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 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<[string, string | null]>(({ name }) =>
|
||||
|
|
@ -44,10 +44,10 @@ const thumbnail =
|
|||
(await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight }));
|
||||
---
|
||||
|
||||
<BaseLayout pageTitle={props.title}>
|
||||
<BaseLayout pageTitle={props.title} lang={props.lang}>
|
||||
<Fragment slot="head">
|
||||
<meta property="og:title" content={props.title} data-pagefind-meta="title[content]" />
|
||||
<meta property="og:description" content={props.contentWarning} />
|
||||
<meta property="og:description" content={t(props.lang, "game/warnings", props.platforms, props.contentWarning)} />
|
||||
<meta property="og:url" content={Astro.url} data-pagefind-meta="url[content]" />
|
||||
{
|
||||
thumbnail ? (
|
||||
|
|
@ -55,7 +55,7 @@ const thumbnail =
|
|||
<meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" />
|
||||
<meta
|
||||
property="og:image:alt"
|
||||
content={`Cover art for ${props.title}`}
|
||||
content={t(props.lang, "published_content/cover_art_alt", props.title)}
|
||||
data-pagefind-meta="image_alt[content]"
|
||||
/>
|
||||
</Fragment>
|
||||
|
|
@ -78,7 +78,9 @@ const thumbnail =
|
|||
<a
|
||||
href={series ? series.data.url : "/games"}
|
||||
class="text-link my-1 h-9 w-9 p-2"
|
||||
aria-label={`Return to ${series ? series.data.name : "games"}`}
|
||||
aria-label={series
|
||||
? t(props.lang, "published_content/return_to_series", series.data.name)
|
||||
: t(props.lang, "game/return_to_games")}
|
||||
>
|
||||
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
|
||||
<path
|
||||
|
|
@ -89,7 +91,7 @@ const thumbnail =
|
|||
<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="Go to description"
|
||||
aria-label={t(props.lang, "published_content/go_to_description")}
|
||||
>
|
||||
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
|
||||
<path
|
||||
|
|
@ -100,7 +102,7 @@ const thumbnail =
|
|||
<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="Toggle dark mode"
|
||||
aria-label={t(props.lang, "published_content/toggle_dark_mode")}
|
||||
>
|
||||
<svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true">
|
||||
<path
|
||||
|
|
@ -120,7 +122,11 @@ const thumbnail =
|
|||
data-pagefind-body={props.isDraft ? undefined : ""}
|
||||
data-pagefind-meta="type:game"
|
||||
>
|
||||
<h1 id="game-title" class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100">
|
||||
<h1
|
||||
id="game-title"
|
||||
class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100"
|
||||
aria-label={t(props.lang, "game/title_aria_label")}
|
||||
>
|
||||
{props.title}
|
||||
</h1>
|
||||
<section
|
||||
|
|
@ -136,7 +142,7 @@ const thumbnail =
|
|||
{
|
||||
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")}
|
||||
{t(props.lang, "published_content/draft_warning")}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
|
|
@ -155,7 +161,7 @@ const thumbnail =
|
|||
<img
|
||||
loading="eager"
|
||||
src={thumbnail.src}
|
||||
alt={`Cover art for ${props.title}`}
|
||||
alt={t(props.lang, "published_content/cover_art_alt", props.title)}
|
||||
width={props.thumbnailWidth}
|
||||
height={props.thumbnailHeight}
|
||||
class="mx-auto my-5 shadow-lg"
|
||||
|
|
@ -165,7 +171,7 @@ const thumbnail =
|
|||
) : null
|
||||
}
|
||||
<hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" />
|
||||
<article id="game" class="pr-1 font-serif">
|
||||
<article id="game" class="pr-1 font-serif" aria-label={t(props.lang, "game/article_aria_label")}>
|
||||
<Prose>
|
||||
<slot />
|
||||
</Prose>
|
||||
|
|
@ -177,28 +183,26 @@ const thumbnail =
|
|||
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")}
|
||||
{t(props.lang, "published_content/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",
|
||||
})}
|
||||
aria-label={t(props.lang, "published_content/publish_date_aria_label")}
|
||||
aria-description={
|
||||
t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined
|
||||
}
|
||||
data-pagefind-index-attrs="aria-description"
|
||||
data-pagefind-meta={`date:${props.pubDate.toISOString().slice(0, 10)}`}
|
||||
>
|
||||
{t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(0, 10))}
|
||||
{t(props.lang, "published_content/publish_date", props.pubDate)}
|
||||
</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")}
|
||||
{t(props.lang, "published_content/description")}
|
||||
</h2>
|
||||
<Prose>
|
||||
<Markdown of={props.description} />
|
||||
|
|
@ -211,14 +215,50 @@ const thumbnail =
|
|||
><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
|
||||
><span>{t(props.lang, "published_content/to_top")}</span></a
|
||||
>
|
||||
</div>
|
||||
{
|
||||
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">
|
||||
{t(props.lang, "published_content/related_stories")}
|
||||
</h2>
|
||||
<Prose>
|
||||
<ul>
|
||||
{relatedStories.map((story) => (
|
||||
<li>
|
||||
<a href={`/stories/${story.slug}`}>{story.data.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Prose>
|
||||
</section>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
relatedGames.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">
|
||||
{t(props.lang, "published_content/related_games")}
|
||||
</h2>
|
||||
<Prose>
|
||||
<ul>
|
||||
{relatedGames.map((game) => (
|
||||
<li>
|
||||
<a href={`/games/${game.slug}`}>{game.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">
|
||||
Tags
|
||||
{t(props.lang, "published_content/tags")}
|
||||
</h2>
|
||||
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
|
||||
{tags.map(({ id, name }) => (
|
||||
|
|
@ -232,16 +272,12 @@ const thumbnail =
|
|||
</section>
|
||||
) : null
|
||||
}
|
||||
<MastodonComments
|
||||
instance={props.posts.mastodon?.instance}
|
||||
user={props.posts.mastodon?.user}
|
||||
postId={props.posts.mastodon?.postId}
|
||||
/>
|
||||
{props.posts.mastodon ? <MastodonComments lang={props.lang} {...props.posts.mastodon} /> : null}
|
||||
</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>
|
||||
<span>{t(props.lang, "published_content/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
|
||||
>{t(props.lang, "published_content/licenses")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,21 +17,15 @@ import { formatCopyrightedCharacters } from "../utils/format_copyrighted_charact
|
|||
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 prev = props.prev && (await getEntry(props.prev));
|
||||
const next = props.next && (await getEntry(props.next));
|
||||
const series = props.series && (await getEntry(props.series));
|
||||
const authorsList = await getEntries(props.authors);
|
||||
const commissionersList = props.commissioner && (await getEntries(props.commissioner));
|
||||
const requestersList = props.requester && (await getEntries(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 relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
|
||||
const categorizedTags = Object.fromEntries(
|
||||
(await getCollection("tag-categories")).flatMap((category) =>
|
||||
category.data.tags.map<[string, string | null]>(({ name }) =>
|
||||
|
|
@ -57,7 +51,7 @@ const thumbnail =
|
|||
const wordCount = props.wordCount?.toString();
|
||||
---
|
||||
|
||||
<BaseLayout pageTitle={props.title}>
|
||||
<BaseLayout pageTitle={props.title} lang={props.lang}>
|
||||
<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)} />
|
||||
|
|
@ -68,7 +62,7 @@ const wordCount = props.wordCount?.toString();
|
|||
<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}`}
|
||||
content={t(props.lang, "published_content/cover_art_alt", props.shortTitle || props.title)}
|
||||
data-pagefind-meta="image_alt[content]"
|
||||
/>
|
||||
</Fragment>
|
||||
|
|
@ -92,7 +86,7 @@ const wordCount = props.wordCount?.toString();
|
|||
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, "published_content/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">
|
||||
|
|
@ -104,7 +98,7 @@ const wordCount = props.wordCount?.toString();
|
|||
<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")}
|
||||
aria-label={t(props.lang, "published_content/go_to_description")}
|
||||
>
|
||||
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
|
||||
<path
|
||||
|
|
@ -115,7 +109,7 @@ const wordCount = props.wordCount?.toString();
|
|||
<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")}
|
||||
aria-label={t(props.lang, "published_content/toggle_dark_mode")}
|
||||
>
|
||||
<svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true">
|
||||
<path
|
||||
|
|
@ -136,7 +130,7 @@ const wordCount = props.wordCount?.toString();
|
|||
data-pagefind-meta="type:story"
|
||||
>
|
||||
{
|
||||
prev || next ? (
|
||||
(prev && !prev.data.isDraft) || (next && !next.data.isDraft) ? (
|
||||
<div class="print:hidden">
|
||||
<div id="story-nav-top" class="my-4 grid grid-cols-2 justify-items-stretch gap-2">
|
||||
{prev ? (
|
||||
|
|
@ -170,7 +164,11 @@ const wordCount = props.wordCount?.toString();
|
|||
</div>
|
||||
) : null
|
||||
}
|
||||
<h1 id="story-title" class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100">
|
||||
<h1
|
||||
id="story-title"
|
||||
class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100"
|
||||
aria-label={t(props.lang, "story/title_aria_label")}
|
||||
>
|
||||
{props.title}
|
||||
</h1>
|
||||
<section
|
||||
|
|
@ -180,13 +178,6 @@ const wordCount = props.wordCount?.toString();
|
|||
<Authors lang={props.lang}>
|
||||
{authorsList.map((author) => <UserComponent user={author} lang={props.lang} />)}
|
||||
</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
|
||||
}
|
||||
{
|
||||
requestersList && (
|
||||
<Requesters lang={props.lang}>
|
||||
|
|
@ -205,6 +196,13 @@ const wordCount = props.wordCount?.toString();
|
|||
</Commissioners>
|
||||
)
|
||||
}
|
||||
{
|
||||
props.isDraft ? (
|
||||
<p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
|
||||
{t(props.lang, "published_content/draft_warning")}
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
<div id="content-warning">
|
||||
<p>
|
||||
{t(props.lang, "story/warnings", wordCount, props.contentWarning)}
|
||||
|
|
@ -218,7 +216,7 @@ const wordCount = props.wordCount?.toString();
|
|||
<img
|
||||
loading="eager"
|
||||
src={thumbnail.src}
|
||||
alt={`Cover art for ${props.shortTitle || props.title}`}
|
||||
alt={t(props.lang, "published_content/cover_art_alt", props.shortTitle || props.title)}
|
||||
width={props.thumbnailWidth}
|
||||
height={props.thumbnailHeight}
|
||||
class="mx-auto my-5 shadow-lg"
|
||||
|
|
@ -227,7 +225,7 @@ const wordCount = props.wordCount?.toString();
|
|||
) : 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="story" class="pr-1 font-serif" aria-label={t(props.lang, "story/article_aria_label")}>
|
||||
<Prose>
|
||||
<slot />
|
||||
</Prose>
|
||||
|
|
@ -239,28 +237,26 @@ const wordCount = props.wordCount?.toString();
|
|||
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")}
|
||||
{t(props.lang, "published_content/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",
|
||||
})}
|
||||
aria-label={t(props.lang, "published_content/publish_date_aria_label")}
|
||||
aria-description={
|
||||
t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined
|
||||
}
|
||||
data-pagefind-index-attrs="aria-description"
|
||||
data-pagefind-meta={`date:${props.pubDate.toISOString().slice(0, 10)}`}
|
||||
>
|
||||
{t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(0, 10))}
|
||||
{t(props.lang, "published_content/publish_date", props.pubDate)}
|
||||
</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")}
|
||||
{t(props.lang, "published_content/description")}
|
||||
</h2>
|
||||
<Prose>
|
||||
<Markdown of={props.description} />
|
||||
|
|
@ -292,7 +288,7 @@ const wordCount = props.wordCount?.toString();
|
|||
><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
|
||||
><span>{t(props.lang, "published_content/to_top")}</span></a
|
||||
>
|
||||
</div>
|
||||
{
|
||||
|
|
@ -335,13 +331,31 @@ const wordCount = props.wordCount?.toString();
|
|||
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
|
||||
{t(props.lang, "published_content/related_stories")}
|
||||
</h2>
|
||||
<Prose>
|
||||
<ul>
|
||||
{relatedStories.map((stories) => (
|
||||
{relatedStories.map((story) => (
|
||||
<li>
|
||||
<a href={`/stories/${stories.slug}`}>{stories.data.title}</a>
|
||||
<a href={`/stories/${story.slug}`}>{story.data.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Prose>
|
||||
</section>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
relatedGames.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">
|
||||
{t(props.lang, "published_content/related_games")}
|
||||
</h2>
|
||||
<Prose>
|
||||
<ul>
|
||||
{relatedGames.map((game) => (
|
||||
<li>
|
||||
<a href={`/games/${game.slug}`}>{game.data.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -353,7 +367,7 @@ const wordCount = props.wordCount?.toString();
|
|||
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")}
|
||||
{t(props.lang, "published_content/tags")}
|
||||
</h2>
|
||||
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
|
||||
{tags.map(({ id, name }) => (
|
||||
|
|
@ -367,16 +381,12 @@ const wordCount = props.wordCount?.toString();
|
|||
</section>
|
||||
) : null
|
||||
}
|
||||
<MastodonComments
|
||||
instance={props.posts.mastodon?.instance}
|
||||
user={props.posts.mastodon?.user}
|
||||
postId={props.posts.mastodon?.postId}
|
||||
/>
|
||||
{props.posts.mastodon ? <MastodonComments lang={props.lang} {...props.posts.mastodon} /> : null}
|
||||
</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>
|
||||
<span>{t(props.lang, "published_content/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
|
||||
>{t(props.lang, "published_content/licenses")}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { APIRoute, GetStaticPaths } from "astro";
|
||||
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
|
||||
import type { Website } from "../../../content/config";
|
||||
import { getCollection, type CollectionEntry, getEntries } from "astro:content";
|
||||
import type { Lang, Website } from "../../../content/config";
|
||||
import { t } from "../../../i18n";
|
||||
import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
|
||||
import { markdownToBbcode } from "../../../utils/markdown_to_bbcode";
|
||||
|
|
@ -24,72 +24,8 @@ type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: in
|
|||
|
||||
function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string {
|
||||
const link = user.data.links[website];
|
||||
if (link) {
|
||||
if (typeof link === "string") {
|
||||
switch (website) {
|
||||
case "website":
|
||||
break;
|
||||
case "eka":
|
||||
const ekaMatch = link.match(/^.*\baryion\.com\/g4\/user\/([^\/]+)\/?$/);
|
||||
if (ekaMatch && ekaMatch[1]) {
|
||||
return ekaMatch[1];
|
||||
}
|
||||
break;
|
||||
case "furaffinity":
|
||||
const faMatch = link.match(/^.*\bfuraffinity\.net\/user\/([^\/]+)\/?$/);
|
||||
if (faMatch && faMatch[1]) {
|
||||
return faMatch[1];
|
||||
}
|
||||
break;
|
||||
case "inkbunny":
|
||||
const ibMatch = link.match(/^.*\binkbunny\.net\/([^\/]+)\/?$/);
|
||||
if (ibMatch && ibMatch[1]) {
|
||||
return ibMatch[1];
|
||||
}
|
||||
break;
|
||||
case "sofurry":
|
||||
const sfMatch = link.match(/^(?:https?:\/\/)?([^\.]+).sofurry.com\b.*$/);
|
||||
if (sfMatch && sfMatch[1]) {
|
||||
return sfMatch[1].replaceAll("-", " ");
|
||||
}
|
||||
break;
|
||||
case "weasyl":
|
||||
const weasylMatch = link.match(/^.*\bweasyl\.com\/\~([^\/]+)\/?$/);
|
||||
if (weasylMatch && weasylMatch[1]) {
|
||||
return weasylMatch[1];
|
||||
}
|
||||
break;
|
||||
case "twitter":
|
||||
const twitterMatch = link.match(/^.*(?:\btwitter\.com|\bx\.com)\/@?([^\/]+)\/?$/);
|
||||
if (twitterMatch && twitterMatch[1]) {
|
||||
return twitterMatch[1];
|
||||
}
|
||||
break;
|
||||
case "mastodon":
|
||||
const mastodonMatch = link.match(/^(?:https?\:\/\/)?([^\/]+)\/(?:@|users\/)([^\/]+)\/?$/);
|
||||
if (mastodonMatch && mastodonMatch[1] && mastodonMatch[2]) {
|
||||
return `${mastodonMatch[2]}@${mastodonMatch[1]}`;
|
||||
}
|
||||
break;
|
||||
case "bluesky":
|
||||
const bskyMatch = link.match(/^.*\bbsky\.app\/profile\/([^\/]+)\/?$/);
|
||||
if (bskyMatch && bskyMatch[1]) {
|
||||
return bskyMatch[1];
|
||||
}
|
||||
break;
|
||||
case "itaku":
|
||||
const itakuMatch = link.match(/^.*\bitaku\.ee\/profile\/([^\/]+)\/?$/);
|
||||
if (itakuMatch && itakuMatch[1]) {
|
||||
return itakuMatch[1];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
let _: never = website;
|
||||
throw new Error(`Unhandled website "${website}"`);
|
||||
}
|
||||
} else {
|
||||
return link[1].replace(/^@/, "");
|
||||
}
|
||||
if (link?.username) {
|
||||
return link.username;
|
||||
}
|
||||
throw new Error(`Cannot get "${website}" username for user "${user.id}"`);
|
||||
}
|
||||
|
|
@ -99,20 +35,21 @@ function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): b
|
|||
return !preferredLink || preferredLink == website;
|
||||
}
|
||||
|
||||
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName): string {
|
||||
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName, lang: Lang): string {
|
||||
const { links, preferredLink } = user.data;
|
||||
switch (website) {
|
||||
case "eka":
|
||||
if ("eka" in user.data.links) {
|
||||
if ("eka" in links) {
|
||||
return `:icon${getUsernameForWebsite(user, "eka")}:`;
|
||||
}
|
||||
break;
|
||||
case "furaffinity":
|
||||
if ("furaffinity" in user.data.links) {
|
||||
if ("furaffinity" in links) {
|
||||
return `:icon${getUsernameForWebsite(user, "furaffinity")}:`;
|
||||
}
|
||||
break;
|
||||
case "weasyl":
|
||||
if ("weasyl" in user.data.links) {
|
||||
if ("weasyl" in links) {
|
||||
return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
|
||||
} else if (isPreferredWebsite(user, "furaffinity")) {
|
||||
return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`;
|
||||
|
|
@ -123,7 +60,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
|
|||
}
|
||||
break;
|
||||
case "inkbunny":
|
||||
if ("inkbunny" in user.data.links) {
|
||||
if ("inkbunny" in links) {
|
||||
return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`;
|
||||
} else if (isPreferredWebsite(user, "furaffinity")) {
|
||||
return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`;
|
||||
|
|
@ -134,7 +71,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
|
|||
}
|
||||
break;
|
||||
case "sofurry":
|
||||
if ("sofurry" in user.data.links) {
|
||||
if ("sofurry" in links) {
|
||||
return `:icon${getUsernameForWebsite(user, "sofurry")}:`;
|
||||
} else if (isPreferredWebsite(user, "furaffinity")) {
|
||||
return `fa!${getUsernameForWebsite(user, "furaffinity")}`;
|
||||
|
|
@ -143,11 +80,12 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
|
|||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled ExportWebsite "${website}"`);
|
||||
const unknown: never = website;
|
||||
throw new Error(`Unhandled export website "${unknown}"`);
|
||||
}
|
||||
if (user.data.preferredLink) {
|
||||
const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
|
||||
return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`;
|
||||
if (preferredLink) {
|
||||
const preferred = links[preferredLink]!;
|
||||
return `[${getUsernameForLang(user, lang)}](${preferred.link})`;
|
||||
}
|
||||
throw new Error(
|
||||
`No matching "${website}" link for user "${user.id}" (consider setting their "preferredLink" property)`,
|
||||
|
|
@ -182,14 +120,14 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
|
|||
const description = Object.fromEntries(
|
||||
WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
|
||||
const u = (user: CollectionEntry<"users">) =>
|
||||
isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website);
|
||||
isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website, lang);
|
||||
const storyDescription = (
|
||||
[
|
||||
story.data.description,
|
||||
`*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}*`,
|
||||
t(
|
||||
lang,
|
||||
"export_story/writing",
|
||||
"export_story/authors",
|
||||
authorsList.map((author) => u(author)),
|
||||
),
|
||||
requestersList &&
|
||||
|
|
@ -216,12 +154,14 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
|
|||
/\[([^\]]+)\]\((\/[^\)]+)\)/g,
|
||||
(_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
|
||||
);
|
||||
if (exportFormat === "bbcode") {
|
||||
return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")];
|
||||
} else if (exportFormat === "markdown") {
|
||||
return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
|
||||
} else {
|
||||
throw new Error(`Unhandled export format "${exportFormat}"`);
|
||||
switch (exportFormat) {
|
||||
case "bbcode":
|
||||
return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")];
|
||||
case "markdown":
|
||||
return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
|
||||
default:
|
||||
const unknown: never = exportFormat;
|
||||
throw new Error(`Unknown export format "${unknown}"`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
|
@ -252,13 +192,12 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
|
|||
.replaceAll(/\n\n\n+/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
const headers = { "Content-Type": "application/json; charset=utf-8" };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
story: storyText,
|
||||
description,
|
||||
thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null,
|
||||
}),
|
||||
{ headers },
|
||||
{ headers: { "Content-Type": "application/json; charset=utf-8" } },
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import type { APIRoute } from "astro";
|
||||
|
||||
const content = { isAlive: true };
|
||||
|
||||
const headers = { "Content-Type": "application/json; charset=utf-8" };
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
if (import.meta.env.PROD) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
return new Response(JSON.stringify(content), { headers });
|
||||
return new Response(JSON.stringify({ isAlive: true }), {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ function toNoonUTCDate(date: Date) {
|
|||
const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
|
||||
const userName = getUsernameForLang(user, lang);
|
||||
if (user.data.preferredLink) {
|
||||
const link = user.data.links[user.data.preferredLink]!;
|
||||
return `<a href="${typeof link === "string" ? link : link[0]}">${userName}</a>`;
|
||||
return `<a href="${user.data.links[user.data.preferredLink]!.link}">${userName}</a>`;
|
||||
}
|
||||
return userName;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,17 +18,6 @@ export const getStaticPaths: GetStaticPaths = async () => {
|
|||
};
|
||||
|
||||
const game = Astro.props;
|
||||
if (!game.data.isDraft) {
|
||||
if (!game.data.pubDate) {
|
||||
throw new Error(`Missing "pubDate" for published game ${game.data.title} ("${game.slug}")`);
|
||||
}
|
||||
if (!game.data.thumbnail) {
|
||||
throw new Error(`Missing "thumbnail" for published game ${game.data.title} ("${game.slug}")`);
|
||||
}
|
||||
if (game.data.tags.length == 0) {
|
||||
throw new Error(`Missing "tags" for published game ${game.data.title} ("${game.slug}")`);
|
||||
}
|
||||
}
|
||||
const { Content } = await game.render();
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import { type CollectionEntry, getCollection, type CollectionKey } from "astro:content";
|
||||
import { type CollectionEntry, type CollectionKey, getCollection } from "astro:content";
|
||||
import { Image } from "astro:assets";
|
||||
import GalleryLayout from "../layouts/GalleryLayout.astro";
|
||||
import { t } from "../i18n";
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
import type { APIRoute } from "astro";
|
||||
|
||||
const licenses = `
|
||||
The briefcase logo and any unattributed characters are copyrighted and trademarked by me.
|
||||
|
||||
The source code of this website is licensed under the MIT License. The content hosted on it (i.e. stories, games, and respective thumbnails/images) is copyrighted by me and distributed under the CC BY-NC-ND 4.0 license.
|
||||
|
||||
The Noto Sans and Noto Serif typefaces are copyrighted to the Noto Project Authors and distributed under the SIL Open Font License v1.1.
|
||||
|
||||
The generic SVG icons were created by Font Awesome and are distributed under the CC-BY-4.0 license.
|
||||
|
||||
All third-party trademarks and attributed characters belong to their respective owners, and I'm not affiliated with any of them.
|
||||
`.trim();
|
||||
|
||||
const headers = { "Content-Type": "text/plain; charset=utf-8" };
|
||||
|
||||
export const GET: APIRoute = () => {
|
||||
return new Response(licenses, { headers });
|
||||
};
|
||||
|
|
@ -20,21 +20,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
|
|||
|
||||
const story = Astro.props;
|
||||
const readingTime = getReadingTime(story.body);
|
||||
if (!story.data.isDraft) {
|
||||
if (!story.data.wordCount) {
|
||||
throw new Error(`Missing "wordCount" for published story ${story.data.title} ("${story.slug}")`);
|
||||
}
|
||||
if (!story.data.pubDate) {
|
||||
throw new Error(`Missing "pubDate" for published story ${story.data.title} ("${story.slug}")`);
|
||||
}
|
||||
if (!story.data.thumbnail) {
|
||||
throw new Error(`Missing "thumbnail" for published story ${story.data.title} ("${story.slug}")`);
|
||||
}
|
||||
if (story.data.tags.length == 0) {
|
||||
throw new Error(`Missing "tags" for published story ${story.data.title} ("${story.slug}")`);
|
||||
}
|
||||
}
|
||||
if (story.data.wordCount && Math.abs(story.data.wordCount - readingTime.words) >= 135) {
|
||||
if (story.data.wordCount && Math.abs(story.data.wordCount - readingTime.words) >= 150) {
|
||||
console.warn(
|
||||
`"wordCount" differs greatly from actual word count for published story ${story.data.title} ("${story.slug}") ` +
|
||||
`(expected ~${story.data.wordCount}, found ${readingTime.words})`,
|
||||
|
|
|
|||
|
|
@ -10,65 +10,53 @@ interface Tag {
|
|||
description?: string;
|
||||
}
|
||||
|
||||
const [stories, games, tagCategories] = await Promise.all([
|
||||
const [stories, games, tagCategories, seriesCollection] = await Promise.all([
|
||||
getCollection("stories"),
|
||||
getCollection("games"),
|
||||
getCollection("tag-categories"),
|
||||
getCollection("series"),
|
||||
]);
|
||||
const tagsSet = new Set<string>();
|
||||
const uncategorizedTagsSet = new Set<string>();
|
||||
const draftOnlyTagsSet = new Set<string>();
|
||||
const seriesCollection = await getCollection("series");
|
||||
// Add tags from non-drafts to set; then, add tags only from drafts to separate set
|
||||
[stories, games]
|
||||
.flat()
|
||||
.sort((a, b) => (a.data.isDraft ? 1 : b.data.isDraft ? -1 : 0))
|
||||
.forEach((value) => {
|
||||
if (value.data.isDraft) {
|
||||
value.data.tags.forEach((tag) => {
|
||||
if (!tagsSet.has(tag)) {
|
||||
draftOnlyTagsSet.add(tag);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
value.data.tags.forEach((tag) => {
|
||||
tagsSet.add(tag);
|
||||
});
|
||||
}
|
||||
});
|
||||
[stories, games].flat().forEach(({ data: { isDraft, tags } }) => {
|
||||
if (isDraft) {
|
||||
tags.forEach((tag) => draftOnlyTagsSet.add(tag));
|
||||
} else {
|
||||
tags.forEach((tag) => uncategorizedTagsSet.add(tag));
|
||||
}
|
||||
});
|
||||
// Tags from published content shouldn't be included in drafts-only set
|
||||
uncategorizedTagsSet.forEach((tag) => draftOnlyTagsSet.delete(tag));
|
||||
|
||||
const uncategorizedTagsSet = new Set(tagsSet);
|
||||
const uniqueSlugs = new Set<string>();
|
||||
const categorizedTags = tagCategories
|
||||
.sort((a, b) => {
|
||||
if (a.data.index == b.data.index) {
|
||||
throw new Error(
|
||||
`Found tag categories with same index value ${a.data.index} ("${a.data.name}", "${b.data.name}")`,
|
||||
);
|
||||
throw new Error(`Found tag categories with same index value ${a.data.index} ("${a.id}", "${b.id}")`);
|
||||
}
|
||||
return a.data.index - b.data.index;
|
||||
})
|
||||
.map((category) => {
|
||||
const tagList = category.data.tags.map<Tag>(({ name, description }) => {
|
||||
description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " ");
|
||||
const tag = typeof name === "string" ? name : name["eng"];
|
||||
const id = slug(tag);
|
||||
return { id, name: tag, description };
|
||||
const tag = typeof name === "string" ? name : name.en;
|
||||
return { id: slug(tag), name: tag, description };
|
||||
});
|
||||
tagList.forEach(({ name }, index) => {
|
||||
if (index !== tagList.findLastIndex(({ name: otherTag }) => name == otherTag)) {
|
||||
throw new Error(`Duplicated tag "${name}" found in multiple tag-categories lists`);
|
||||
tagList.forEach(({ id, name }) => {
|
||||
if (uniqueSlugs.has(id)) {
|
||||
throw new Error(`Duplicated tag "${name}" found in multiple tag-categories entries`);
|
||||
}
|
||||
uniqueSlugs.add(id);
|
||||
});
|
||||
return {
|
||||
name: category.data.name,
|
||||
id: category.id,
|
||||
id: slug(category.data.name),
|
||||
tags: tagList.filter(({ name }) => {
|
||||
if (draftOnlyTagsSet.has(name)) {
|
||||
console.log(`Omitting draft-only tag "${name}"`);
|
||||
// console.log(`Omitting draft-only tag "${name}"`);
|
||||
return false;
|
||||
}
|
||||
if (tagsSet.has(name)) {
|
||||
uncategorizedTagsSet.delete(name);
|
||||
}
|
||||
uncategorizedTagsSet.delete(name);
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
import type { GetStaticPaths } from "astro";
|
||||
import { Image } from "astro:assets";
|
||||
import { type CollectionEntry, getCollection, type CollectionKey } from "astro:content";
|
||||
import { type CollectionEntry, type CollectionKey, getCollection } from "astro:content";
|
||||
import { Markdown } from "@astropub/md";
|
||||
import { slug } from "github-slugger";
|
||||
import GalleryLayout from "../../layouts/GalleryLayout.astro";
|
||||
|
|
@ -45,24 +45,22 @@ export const getStaticPaths: GetStaticPaths = async () => {
|
|||
const tagDescriptions = tagCategories.reduce(
|
||||
(acc, category) => {
|
||||
category.data.tags.forEach(({ name, description, related }) => {
|
||||
if (related) {
|
||||
related = related.filter((relatedTag) => {
|
||||
if (relatedTag == name) {
|
||||
console.warn(`Tag "${name}" should not have itself as a related tag; removing`);
|
||||
return false;
|
||||
}
|
||||
if (!tags.has(relatedTag)) {
|
||||
console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
const key = typeof name === "string" ? name : name["eng"];
|
||||
related = related.filter((relatedTag) => {
|
||||
if (relatedTag == name) {
|
||||
console.warn(`Tag "${name}" should not have itself as a related tag; removing`);
|
||||
return false;
|
||||
}
|
||||
if (!tags.has(relatedTag)) {
|
||||
console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const key = typeof name === "string" ? name : name.en;
|
||||
if (key in acc) {
|
||||
throw new Error(`Duplicated tag "${key}" found in multiple tag-categories lists`);
|
||||
}
|
||||
acc[key] = { description, related };
|
||||
acc[key] = { description, related: related.length > 0 ? related : undefined };
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@ import type { CollectionEntry } from "astro:content";
|
|||
import { DEFAULT_LANG, type Lang } from "../content/config";
|
||||
|
||||
export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): string {
|
||||
if (user.data.lang) {
|
||||
if (user.data.lang[lang]) {
|
||||
return user.data.lang[lang];
|
||||
const { name } = user.data;
|
||||
if (typeof name === "object") {
|
||||
if (name[lang]) {
|
||||
return name[lang];
|
||||
}
|
||||
throw new Error(`No "${lang}" translation for username "${user.data.name}" ("${user.id}")`);
|
||||
throw new Error(`No "${lang}" translation for user "${user.id}"`);
|
||||
}
|
||||
if (lang !== DEFAULT_LANG) {
|
||||
console.warn(`User "${user.data.name}" ("${user.id}") has no "lang" property for a "${lang}" translation`);
|
||||
console.warn(`Name "${name}" for user "${user.id}" isn't translated to "${lang}"; using default`);
|
||||
}
|
||||
return user.data.name;
|
||||
return name;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { CollectionEntry } from "astro:content";
|
||||
import { ANONYMOUS_USER_ID as ID } from "../content/config";
|
||||
import { ANONYMOUS_USER_ID } from "../content/config";
|
||||
|
||||
const ANONYMOUS_USER_ID: CollectionEntry<"users">["id"] = ID;
|
||||
const ID: CollectionEntry<"users">["id"] = ANONYMOUS_USER_ID;
|
||||
|
||||
export const isAnonymousUser = (user: CollectionEntry<"users">) => user.id == ANONYMOUS_USER_ID;
|
||||
export const isAnonymousUser = (user: CollectionEntry<"users">) => user.id === ID;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue