Add description exports and collapse characters from same user

This commit is contained in:
Bad Manners 2024-03-21 22:24:58 -03:00
parent 2990644f87
commit d4a9dc9dbc
78 changed files with 693 additions and 247 deletions

View file

@ -7,5 +7,5 @@
```bash
npm install
npm run build
scp -r dist/ my-ssh-server:./gallery.badmanners.xyz
scp -r ./dist/* my-ssh-server:./gallery.badmanners.xyz/
```

27
package-lock.json generated
View file

@ -13,9 +13,12 @@
"@astrojs/tailwind": "^5.1.0",
"@astropub/md": "^0.4.0",
"@tailwindcss/typography": "^0.5.10",
"@types/he": "^1.2.3",
"astro": "^4.5.4",
"date-fns": "^3.5.0",
"github-slugger": "^2.0.0",
"he": "^1.2.0",
"marked": "^12.0.1",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.2"
},
@ -1278,6 +1281,11 @@
"@types/unist": "*"
}
},
"node_modules/@types/he": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz",
"integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA=="
},
"node_modules/@types/mdast": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz",
@ -3040,6 +3048,14 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"bin": {
"he": "bin/he"
}
},
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@ -3519,6 +3535,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/marked": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.1.tgz",
"integrity": "sha512-Y1/V2yafOcOdWQCX0XpAKXzDakPOpn6U0YLxTJs3cww6VxOzZV1BTOOYWLvH3gX38cq+iLwljHHTnMtlDfg01Q==",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mdast-util-definitions": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz",

View file

@ -16,9 +16,12 @@
"@astrojs/tailwind": "^5.1.0",
"@astropub/md": "^0.4.0",
"@tailwindcss/typography": "^0.5.10",
"@types/he": "^1.2.3",
"astro": "^4.5.4",
"date-fns": "^3.5.0",
"github-slugger": "^2.0.0",
"he": "^1.2.0",
"marked": "^12.0.1",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.2"
},

View file

@ -1,9 +1,10 @@
---
import { type Lang, type User } from "../content/config";
import { type CollectionEntry } from "astro:content";
import { type Lang } from "../content/config";
import UserComponent from "./UserComponent.astro";
type Props = {
authors: User | User[];
authors: CollectionEntry<"users"> | CollectionEntry<"users">[];
lang: Lang;
};
@ -20,18 +21,19 @@ const authorsArray = [authors].flat();
by{" "}
{authorsArray.slice(0, authorsArray.length - 1).map((author) => (
<Fragment>
<UserComponent user={author} />,
<UserComponent lang="eng" user={author} />,
</Fragment>
))}
and <UserComponent user={authorsArray[authorsArray.length - 1]} />
and <UserComponent lang="eng" user={authorsArray[authorsArray.length - 1]} />
</span>
) : authorsArray.length > 1 ? (
<span>
by <UserComponent user={authorsArray[0]} /> and <UserComponent user={authorsArray[1]} />
by <UserComponent lang="eng" user={authorsArray[0]} /> and{" "}
<UserComponent lang="eng" user={authorsArray[1]} />
</span>
) : (
<span>
by <UserComponent user={authorsArray[0]} />
by <UserComponent lang="eng" user={authorsArray[0]} />
</span>
))}
{lang === "tok" &&
@ -40,15 +42,15 @@ const authorsArray = [authors].flat();
lipu ni li tan ni:{" "}
{authorsArray.slice(0, authorsArray.length - 1).map((author) => (
<Fragment>
<UserComponent user={author} />
<UserComponent lang="tok" user={author} />
{" en "}
</Fragment>
))}
<UserComponent user={authorsArray[authorsArray.length - 1]} />
<UserComponent lang="tok" user={authorsArray[authorsArray.length - 1]} />
</span>
) : (
<span>
lipu ni li tan <UserComponent user={authorsArray[0]} />
lipu ni li tan <UserComponent lang="tok" user={authorsArray[0]} />
</span>
))}
</p>

View file

@ -1,27 +1,67 @@
---
import { type Lang, type User } from "../content/config";
import { type CollectionEntry } from "astro:content";
import { type Lang } from "../content/config";
import UserComponent from "./UserComponent.astro";
type Props = {
copyrightedCharacters?: Record<string, User>;
copyrightedCharacters?: Record<string, CollectionEntry<"users">>;
lang: Lang;
};
const { copyrightedCharacters, lang } = Astro.props;
if (copyrightedCharacters && "" in copyrightedCharacters && Object.keys(copyrightedCharacters).length > 1) {
throw new Error("copyrightedCharacter cannot use empty key (catch-all) with other keys");
}
const charactersPerUser =
copyrightedCharacters &&
Object.keys(copyrightedCharacters).reduce(
(acc, character) => {
const key = copyrightedCharacters[character].id;
if (!(key in acc)) {
acc[key] = [];
}
acc[key].push(character);
return acc;
},
{} as Record<
CollectionEntry<"users">["id"],
(typeof copyrightedCharacters extends Record<infer K, any> ? K : never)[]
>,
);
---
{
copyrightedCharacters ? (
charactersPerUser ? (
<section id="copyrighted-characters">
{lang === "eng" && (
{lang === "eng" ? (
<ul>
{Object.entries(copyrightedCharacters).map(([character, user]) => (
{Object.values(charactersPerUser).map((characterList) => (
<li>
{character} is &copy; <UserComponent user={user} />
{characterList[0] === "" ? (
<span>
All characters are &copy; <UserComponent lang={lang} user={copyrightedCharacters[""]} />
</span>
) : characterList.length > 2 ? (
<span>
{characterList.slice(0, characterList.length - 1).join(", ")}, and{" "}
{characterList[characterList.length - 1]} are &copy;{" "}
<UserComponent lang={lang} user={copyrightedCharacters[characterList[0]]} />
</span>
) : characterList.length > 1 ? (
<span>
{characterList[0]} and {characterList[1]} are &copy;{" "}
<UserComponent lang={lang} user={copyrightedCharacters[characterList[0]]} />
</span>
) : (
<span>
{characterList[0]} is &copy;{" "}
<UserComponent lang={lang} user={copyrightedCharacters[characterList[0]]} />
</span>
)}
</li>
))}
</ul>
)}
) : null}
</section>
) : null
}

View file

@ -1,21 +1,35 @@
---
import { type User } from "../content/config";
import { type CollectionEntry } from "astro:content";
import { type Lang } from "../content/config";
type Props = {
user: User;
lang: Lang;
user: CollectionEntry<"users">;
};
const { user } = Astro.props;
const { user, lang } = Astro.props;
const username = user.data.nameLang[lang] || user.data.name;
let link: string | null = null;
if (user.data.preferredLink) {
if (user.data.preferredLink in user.data.links) {
const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
if (typeof preferredLink === "string") {
link = preferredLink;
} else {
link = preferredLink[0];
}
} else {
throw new Error(`No preferredLink "${user.data.preferredLink}" for user ${user.id}`);
}
}
---
{
typeof user === "string" ? (
<span>{user}</span>
user.data.preferredLink == null ? (
<span>{username}</span>
) : (
Object.entries(user).map(([k, v]) => (
<a href={v} class="text-link underline" target="_blank">
<span>{k}</span>
</a>
))[0]
<a href={link} class="text-link underline" target="_blank">
{username}
</a>
)
}

View file

@ -1,39 +1,52 @@
import { defineCollection, reference, z } from "astro:content";
const user = z.union([z.string(), z.record(z.string().url())]);
export const WEBSITE_LIST = [
"website",
"eka",
"furaffinity",
"weasyl",
"inkbunny",
"sofurry",
"twitter",
"mastodon",
"bluesky",
] as const;
const lang = z.enum(["eng", "tok"]).default("eng");
const website = z.enum(WEBSITE_LIST);
export type User = z.output<typeof user>;
export type Lang = z.output<typeof lang>;
export type Website = z.infer<typeof website>;
const storiesCollection = defineCollection({
type: "content",
schema: ({ image }) =>
z.object({
// Required
title: z.string(),
shortTitle: z.string().optional(),
pubDate: z.date(),
isDraft: z.boolean().default(false),
authors: z.union([user, z.array(user)]).default("Bad Manners"),
wordCount: z.number().int(),
contentWarning: z.string(),
description: z.string(),
tags: z.array(z.string()),
// Optional
isDraft: z.boolean().default(false),
shortTitle: z.string().optional(),
authors: z.union([reference("users"), z.array(reference("users"))]).default("bad-manners"),
descriptionPlaintext: z.string().optional(),
summary: z.string().optional(),
thumbnail: image().optional(),
thumbnailWidth: z.number().int().optional(),
thumbnailHeight: z.number().int().optional(),
tags: z.array(z.string()),
series: z.record(z.string(), z.string()).optional(),
commissioner: user.optional(),
requester: user.optional(),
copyrightedCharacters: z.record(z.string(), user).optional(),
commissioner: reference("users").optional(),
requester: reference("users").optional(),
copyrightedCharacters: z.record(z.string(), reference("users")).default({}),
lang,
prev: reference("stories").nullable().optional(),
next: reference("stories").nullable().optional(),
relatedStories: z.array(reference("stories")).optional(),
relatedGames: z.array(reference("games")).optional(),
relatedStories: z.array(reference("stories")).default([]),
relatedGames: z.array(reference("games")).default([]),
}),
});
@ -41,26 +54,43 @@ const gamesCollection = defineCollection({
type: "content",
schema: ({ image }) =>
z.object({
// Required
title: z.string(),
pubDate: z.date(),
isDraft: z.boolean().default(false),
authors: z.union([user, z.array(user)]).default("Bad Manners"),
contentWarning: z.string(),
description: z.string(),
tags: z.array(z.string()),
// Optional
isDraft: z.boolean().default(false),
authors: z.union([reference("users"), z.array(reference("users"))]).default("bad-manners"),
descriptionPlaintext: z.string().optional(),
thumbnail: image().optional(),
thumbnailWidth: z.number().int().optional(),
thumbnailHeight: z.number().int().optional(),
tags: z.array(z.string()),
series: z.record(z.string(), z.string()).optional(),
copyrightedCharacters: z.record(z.string(), user).optional(),
copyrightedCharacters: z.record(z.string(), reference("users")).default({}),
lang,
relatedStories: z.array(reference("stories")).optional(),
relatedGames: z.array(reference("games")).optional(),
relatedStories: z.array(reference("stories")).default([]),
relatedGames: z.array(reference("games")).default([]),
}),
});
const usersCollection = defineCollection({
type: "data",
schema: ({ image }) =>
z.object({
// Required
name: z.string(),
links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])),
preferredLink: website.nullable(),
// Optional
nameLang: z.record(lang, z.string()).default({}),
avatar: image().optional(),
}),
});
export const collections = {
stories: storiesCollection,
games: gamesCollection,
users: usersCollection,
};

View file

@ -1,7 +1,7 @@
---
title: Crossing Over
pubDate: 2024-02-28
authors: Bad Manners
authors: bad-manners
contentWarning: >
This visual novel is a game about death, fishing, and vore. It contains purely fictional content deemed inappropriate for minors. It also deals with heavy subject matters like depression, abuse, and suicide, which may be unsuitable for some audiences. If you continue, you acknowledge that you're an adult, and accept responsibility for your actions.
thumbnail: /src/assets/thumbnails/game_crossing_over_cover.png

View file

@ -1,7 +1,7 @@
---
title: Accommodation
pubDate: 2023-01-03
authors: Bad Manners
authors: bad-manners
wordCount: 4800
contentWarning: >
Contains: Non-fatal size difference anal vore, with unwilling to willing female okapi predator, unwilling to willing male gray wolf prey, and long-term endosoma. Also includes straight sexual situations.

View file

@ -1,7 +1,7 @@
---
title: Addictive Additions
pubDate: 2022-12-27
authors: Bad Manners
authors: bad-manners
wordCount: 11200
contentWarning: >
Contains: Non-fatal oral vore and anal vore, with willing pred and multiple consensual similar sized prey (both willing, and semi-willing to unwilling in partial vore), and implied perma endo with trait theft. Also includes consensual sexual situations (M/M, M/F), hyper, male pregnancy worship, marriage play, clothing play, and semi-public lewdness.
@ -31,9 +31,9 @@ tags:
"netorare",
"commission",
]
commissioner: { "Scion": "https://www.furaffinity.net/user/scionic" }
commissioner: scion
copyrightedCharacters:
"": { "Scion": "https://www.furaffinity.net/user/scionic" }
"": scion
---
"A'ight, this place should be quiet enough." A resounding, confident manly voice boomed in the dark backroom. Music from a party kept playing far away, only the beats being discernible at this distance.

View file

@ -1,7 +1,7 @@
---
title: Annivoresary
pubDate: 2022-08-08
authors: Bad Manners
authors: bad-manners
wordCount: 3000
contentWarning: >
Contains: willing, non-fatal oral vore, with smaller male anthro fox pred, and larger male anthro wolf prey. Also includes bondage and sexual situations.

View file

@ -1,7 +1,7 @@
---
title: Better in Bully Batter
pubDate: 2023-02-20
authors: Bad Manners
authors: bad-manners
wordCount: 19100
contentWarning: >
Contains: Non-fatal similar size cock vore, with willing pred, multiple unwilling/semi-willing prey, and implied perma endo with trait theft. Also includes consensual sexual situations (M/M, M/F), hyper, cock sizeplay, netorare/cuckoldry and marriage play, cum inflation and weight gain, auto-fellatio, public sexual situations, and public vore.
@ -29,9 +29,9 @@ tags:
"netorare",
"commission",
]
commissioner: { "Scion": "https://www.furaffinity.net/user/scionic" }
commissioner: scion
copyrightedCharacters:
"": { "Scion": "https://www.furaffinity.net/user/scionic" }
"": scion
---
## The first day

View file

@ -1,7 +1,7 @@
---
title: Big Haul
pubDate: 2023-11-20
authors: Bad Manners
authors: bad-manners
wordCount: 9100
contentWarning: >
Contains: Non-fatal size difference unbirth, with semi-willing trans male anthro lemur pred, and unwilling cis male anthro sergal prey. Also includes gay sex with trans and cis characters, fatfur, daddy play, implied long-term endosoma, booze, and rudeness.

View file

@ -1,7 +1,7 @@
---
title: Birdroom
pubDate: 2023-05-17
authors: Bad Manners
authors: bad-manners
wordCount: 3000
contentWarning: >
Contains: non-fatal similar size anal vore, willing male feral gryphon pred, willing male anthro mimic hybrid prey, gay sex.
@ -31,8 +31,8 @@ tags:
"gay sex",
]
copyrightedCharacters:
Beetle: Bad Manners
"Sam Brendan": Bad Manners
Beetle: bad-manners
"Sam Brendan": bad-manners
---
"Staaaaan!" The gryphon's voice squawked across rooms, as Beetle happily strutted with his lion paws. "Where areeee yooooou~?" He sang his question, letting his wagging tail follow his orange body as he checked room after room. "Oh, there you are, Stan!"

View file

@ -1,7 +1,7 @@
---
title: Bladder Filler
pubDate: 2023-11-08
authors: Bad Manners
authors: bad-manners
wordCount: 1900
contentWarning: >
Contains: non-fatal size difference cock vore to clean bladder, with willing male feral gryphon pred, and willing 2nd person PoV feral dragon prey. Also includes implied long-term endosoma.
@ -35,7 +35,7 @@ tags:
"flash fiction",
]
copyrightedCharacters:
Beetle: Bad Manners
Beetle: bad-manners
---
"...You wanna go WHERE?" The gryphon looming over you squawked. It was hard to tell from his high-pitched tone if he was being incredulous, or making sure that he'd heard you right.

View file

@ -1,7 +1,7 @@
---
title: Bottom of the Food Chain
pubDate: 2023-09-27
authors: Bad Manners
authors: bad-manners
wordCount: 4500
contentWarning: >
Contains: Non-fatal size difference oral vore, with semi-willing male feral snake pred and semi-willing feral vole prey.
@ -28,7 +28,7 @@ tags:
]
prev: ruffling-some-feathers
copyrightedCharacters:
Muno: Bad Manners
Muno: bad-manners
---
Dark gray plates slithered across the track. It was hard for an outside observer to tell, but the snake that the plates belonged to was in a hurry. Muno's destination was still a couple of days away, but his recent altercation with a rather hungry predator pressed him even more for time. He lamented how his mint-hued scales still smelled like owl breath even after a quick bath down the river, but he could only blame himself and he had a job to worry about instead.

View file

@ -1,7 +1,7 @@
---
title: Butting into Their Plans
pubDate: 2023-02-18
authors: Bad Manners
authors: bad-manners
wordCount: 1400
contentWarning: >
Contains: first person point-of-view, non-fatal size difference anal vore, willing male feral drake pred, willing PoV ambiguous anthro prey, rimming.

View file

@ -1,7 +1,7 @@
---
title: Delicacy's Dare
pubDate: 2023-01-10
authors: Bad Manners
authors: bad-manners
wordCount: 8000
contentWarning: >
Contains: Non-fatal micro oral vore, with willing male deer predator, willing to unwilling male dragon prey, and messy stomach with food digestion.

View file

@ -1,7 +1,7 @@
---
title: Eggs for Months
pubDate: 2022-07-20
authors: Bad Manners
authors: bad-manners
wordCount: 7700
contentWarning: >
Contains: size difference, non-fatal sheath vore, with male feral gryphon pred and female anthro crow prey. Also includes dubious consent sex scenes, and lots of egg play and insertion (oral, cock, vaginal).

View file

@ -1,7 +1,7 @@
---
title: Engaging Contacts
pubDate: 2023-08-08
authors: Bad Manners
authors: bad-manners
wordCount: 6000
contentWarning: >
Contains: Non-fatal size difference oral vore and unbirth, with willing female wyvern pred and multiple unwilling/semi-willing/willing female anthro prey. Also includes lesbian sex, pet play, and implied full tour.

View file

@ -1,7 +1,7 @@
---
title: Flavorful Favor
pubDate: 2023-04-14
authors: Bad Manners
authors: bad-manners
wordCount: 9400
contentWarning: >
Contains: Non-fatal oral vore, cock vore, and unbirth, with willing male gryphon predator, willing/semi-willing smaller female kobold predator/prey, and unwilling/semi-willing micro male mouse prey. Also includes full tour, prey transfer (cock to womb), and straight/gay sexual situations.
@ -37,7 +37,7 @@ tags:
"gay sex",
]
copyrightedCharacters:
Beetle: Bad Manners
Beetle: bad-manners
---
Rondo scampered towards his friend's cave. The mouse's cheeks were still blushing from his determination to profess his love for Sonatina. Sure, he may have been a tiny rodent, as big as the already small kobold's fist... But still! Love had flourished within stranger pairings, before it certainly did in all sorts of romance novels he'd devour. And he and Tina had known each other for years, spending some time together in this woods whenever they had the chance...but just as friends. The orange mouse hoped to rectify that.

View file

@ -1,7 +1,7 @@
---
title: For the Night
pubDate: 2022-12-26
authors: Bad Manners
authors: bad-manners
wordCount: 1500
contentWarning: >
Contains: non-fatal same size anal vore, willing anthro male dog pred, willing anthro female pony prey, straight anal sex, threesome, sexuality play.

View file

@ -1,7 +1,7 @@
---
title: Gentle and Cruel
pubDate: 2023-10-31
authors: Bad Manners
authors: bad-manners
wordCount: 5200
contentWarning: >
Contains: Non-fatal size difference oral vore, with gentle male anthro badger pred, cruel monster pred, and willing to unwilling male anthro lynx prey. Also includes regurgitation, aftercare, thriller/horror scenes, and implied transformation.

View file

@ -1,7 +1,7 @@
---
title: Hate to Sea It
pubDate: 2023-02-18
authors: Bad Manners
authors: bad-manners
wordCount: 1200
contentWarning: >
Contains: non-fatal size difference unbirth, willing female feral orca pred, unwilling to semi-willing male feral dolphin prey, straight sex, hate sex.

View file

@ -1,7 +1,7 @@
---
title: Hungry for Love
pubDate: 2023-02-14
authors: Bad Manners
authors: bad-manners
wordCount: 5900
contentWarning: >
Contains: Non-fatal size difference oral vore, with willing male spider predator, and willing female badger prey. Also includes straight sexual situations.

View file

@ -1,7 +1,7 @@
---
title: Hyper Hunger
pubDate: 2022-12-05
authors: Bad Manners
authors: bad-manners
wordCount: 1300
contentWarning: >
Contains: non-fatal size difference oral vore, willing anthro ambiguous male pred, unwilling feral dog prey, food stuffing, messy stomach with smells, hyper cock, auto-fellatio.

View file

@ -1,7 +1,7 @@
---
title: Insistence and Assistance
pubDate: 2022-12-05
authors: Bad Manners
authors: bad-manners
wordCount: 1200
contentWarning: >
Contains: non-fatal same size oral vore, semi-willing anthro male cat pred, unwilling anthro male mouse prey, burping, regurgitation, force feeding, voyeurism.

View file

@ -1,7 +1,7 @@
---
title: Lactation Action
pubDate: 2022-12-26
authors: Bad Manners
authors: bad-manners
wordCount: 1400
contentWarning: >
Contains: non-fatal micro nipple vore and oral vore, willing anthro female ferret pred, willing anthro male brown bear prey/pred, willing/asleep anthro female seagull prey, breast play, shrinking and growing, lactation, breastfeeding, prey transfer, growing in stomach to same size, burping.

View file

@ -1,7 +1,7 @@
---
title: Latest Catch
pubDate: 2022-12-26
authors: Bad Manners
authors: bad-manners
wordCount: 1500
contentWarning: >
Contains: non-fatal size difference cock vore, willing to semi-willing anthro non-binary rabbit pred, willing feral snake prey, masturbation, mouthplay, implied perma endo.

View file

@ -1,7 +1,7 @@
---
title: Never Too Late
pubDate: 2022-12-05
authors: Bad Manners
authors: bad-manners
wordCount: 1100
contentWarning: >
Contains: non-fatal same size cock vore, asleep anthro male horse pred, willing anthro female aardwolf prey, masturbation, fellatio, alcohol.

View file

@ -1,7 +1,7 @@
---
title: Noble Fire
pubDate: 2023-09-20
authors: Bad Manners
authors: bad-manners
wordCount: 6900
contentWarning: >
Contains: Non-fatal same size oral vore, with willing male anthro lion pred and semi-willing male anthro dog prey. Also includes sexual nudity and heavy themes like violence, blood, trauma, abuse, and fear.

View file

@ -1,7 +1,7 @@
---
title: Overzealous Zenko
pubDate: 2023-08-08
authors: Bad Manners
authors: bad-manners
wordCount: 4900
contentWarning: >
Contains: Non-fatal size difference chest maw vore, with willing male kitsune-human centaur pred, unwilling female human prey, and implied perma endo.
@ -24,9 +24,9 @@ tags:
"perma endo",
"request",
]
requester: { "Dee Lumeni": "https://aryion.com/g4/user/KeeperofLillies" }
requester: dee-lumeni
copyrightedCharacters:
Kuronosuke: { "Dee Lumeni": "https://aryion.com/g4/user/KeeperofLillies" }
Kuronosuke: dee-lumeni
---
"Come on, open up." Kuronosuke banged on the apartment's door again a few times. "I know you're in there. You don't wanna get on my bad side."

View file

@ -1,7 +1,7 @@
---
title: Part of the Show
pubDate: 2023-06-13
authors: Bad Manners
authors: bad-manners
wordCount: 2000
contentWarning: >
Contains: non-fatal same size public oral vore, with willing male anthro mimic/maned wolf hybrid pred, semi-willing 2nd person PoV anthro prey. Also includes pole-dancing and mentions of alcohol.
@ -29,7 +29,7 @@ tags:
"flash fiction",
]
copyrightedCharacters:
"Sam Brendan": Bad Manners
"Sam Brendan": bad-manners
---
You make up your mind, and finally decide to visit this nightclub you've been told about. It was a 'one-in-a-lifetime experience', according to your friend. And everyone else who went there seems to agree with that sentiment. But honestly, what can be so interesting about people dancing around a pole? It isn't even a strip club performance... No matter. You already cleared up your agenda for tonight, and your interest is mildly piqued. You might as well visit the place. Alone, of course you don't need any nosy witnesses; and if it turns out to be as boring as expected, you can just leave, no strings attached.

View file

@ -1,7 +1,7 @@
---
title: Pet-Sit Saturday
pubDate: 2022-07-30
authors: Bad Manners
authors: bad-manners
wordCount: 11000
contentWarning: >
Contains: same size, non-fatal anal vore, with female anthro elephant pred, female anthro anteater unwilling prey, and female feral zorgoia pred/willing prey. Also includes object vore (anal), prey transfer, and masturbation.

View file

@ -1,7 +1,7 @@
---
title: Reaching for the Full Moon
pubDate: 2023-02-18
authors: Bad Manners
authors: bad-manners
wordCount: 1300
contentWarning: >
Contains: non-fatal oral vore, smaller unwilling anthro male rabbit pred, bigger willing werewolf prey, forced vore, role reversal.

View file

@ -1,7 +1,7 @@
---
title: Ruffling Some Feathers
pubDate: 2023-02-18
authors: Bad Manners
authors: bad-manners
wordCount: 1000
contentWarning: >
Contains: non-fatal size difference oral vore, willing feral male owl pred, semi-willing feral male snake prey.
@ -29,7 +29,7 @@ tags:
]
next: bottom-of-the-food-chain
copyrightedCharacters:
Muno: Bad Manners
Muno: bad-manners
---
Sovinne shifted in his sleep, feeling some minor discomfort. The brown saw-whet owl had been slumbering inside of the tree trunk hollow for the day, standing up with puffed up feathers. But he woke up when he felt something slick brushing against his skin, and yawned.

View file

@ -1,7 +1,7 @@
---
title: Spontaneous Sleepover
pubDate: 2022-12-26
authors: Bad Manners
authors: bad-manners
wordCount: 1300
contentWarning: >
Contains: non-fatal same size tail vore, willing anthro male squirrel pred, willing anthro female stoat prey, unwilling anthro female pangolin prey, public vore.

View file

@ -1,8 +1,7 @@
---
title: Taken In
pubDate: 2024-01-22
authors:
- Bad Manners
authors: bad-manners
wordCount: 5900
contentWarning: >
Contains: Non-fatal same size oral vore, with willing male feral hybrid pred (mimic x maned wolf), unwilling PoV anthro prey, and full tour.
@ -23,7 +22,7 @@ tags:
"point of view",
]
copyrightedCharacters:
"Sam Brendan": Bad Manners
"Sam Brendan": bad-manners
---
Clank! Shuffle! Crunch! The sounds outside are too loud to be stopped by the walls in your room, and you jolt awake.

View file

@ -1,7 +1,7 @@
---
title: Tasting High Consequences
pubDate: 2023-04-20
authors: Bad Manners
authors: bad-manners
wordCount: 6000
contentWarning: >
Contains: non-fatal oral vore, with willing feral female boar pred, unwilling similar size anthro female moth-dragon hybrid prey, and unwilling micro anthro female serpent prey. Also includes object vore, fantasy combat, and cannabis.

View file

@ -1,7 +1,7 @@
---
title: Team Building
pubDate: 2024-01-07
authors: Bad Manners
authors: bad-manners
wordCount: 15100
contentWarning: >
Contains: Non-fatal same size cock vore and anal vore, with willing male anthro monkey pred, willing male anthro gorilla pred, multiple same-size willing male anthro prey. Also includes casual public mass vore, prey transfer, long-term endosoma, hyper cock and balls, hyper and muscle growth, hyper cum inflation, cock worship, casual public gay sex, size difference play, bench-pressing, and voyeurism.
@ -33,9 +33,9 @@ tags:
"gay sex",
"commission",
]
commissioner: { "YolkMonkey": "https://furaffinity.net/user/Vampire101" }
commissioner: yolkmonkey
copyrightedCharacters:
Yolk: { "YolkMonkey": "https://furaffinity.net/user/Vampire101" }
Yolk: yolkmonkey
prev: team-effort
---

View file

@ -1,7 +1,7 @@
---
title: Team Effort
pubDate: 2023-08-08
authors: Bad Manners
authors: bad-manners
wordCount: 11600
contentWarning: >
Contains: Non-fatal same size cock vore, with semi-willing to willing male anthro monkey pred, multiple willing male anthro prey, and long-term endosoma. Also includes hyper cock growth, cock worship, hyper cum inflation, public vore, casual gay sex (oral and anal sex; same size, size difference), and public sex.
@ -27,9 +27,9 @@ tags:
"gay sex",
"request",
]
requester: { "YolkMonkey": "https://furaffinity.net/user/Vampire101" }
requester: yolkmonkey
copyrightedCharacters:
Yolk: { "YolkMonkey": "https://furaffinity.net/user/Vampire101" }
Yolk: yolkmonkey
next: team-building
---

View file

@ -1,7 +1,7 @@
---
title: The Last Livestream
pubDate: 2022-12-05
authors: Bad Manners
authors: bad-manners
wordCount: 1400
contentWarning: >
Contains: non-fatal similar size unbirth, willing anthro female coatimundi pred, willing anthro female fennec fox prey, masturbation, livestreamed vore.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Bonus: Quince's Fantasy"
shortTitle: "Bonus Quince's Fantasy"
pubDate: 2023-01-18
authors: Bad Manners
authors: bad-manners
wordCount: 5800
contentWarning: >
Contains: macro and size difference, non-fatal oral vore. Also includes dream scenarios, role reversal, and self-vore.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Chapter 1: Found"
shortTitle: "Chapter 1 Found"
pubDate: 2022-06-04
authors: Bad Manners
authors: bad-manners
wordCount: 7300
contentWarning: >
Contains: macro, non-fatal oral vore.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Chapter 10: Memory"
shortTitle: "Chapter 10 Memory"
pubDate: 2023-05-27
authors: Bad Manners
authors: bad-manners
wordCount: 14600
contentWarning: >
Contains: macro with non-fatal oral vore, anal vore, cock vore, and slit vore. Also includes sexual situations, slight blood, and heavy themes.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Chapter 11: Familiar"
shortTitle: "Chapter 11 Familiar"
pubDate: 2023-11-15
authors: Bad Manners
authors: bad-manners
wordCount: 13600
contentWarning: >
Contains: non-fatal oral vore and cock vore, with size difference and macro. Also includes gay sexual situations.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Chapter 2: Trust"
shortTitle: "Chapter 2 Trust"
pubDate: 2022-06-09
authors: Bad Manners
authors: bad-manners
wordCount: 6900
contentWarning: >
Contains: macro, non-fatal oral vore, minor nudity.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Chapter 3: Home"
shortTitle: "Chapter 3 Home"
pubDate: 2022-06-19
authors: Bad Manners
authors: bad-manners
wordCount: 10800
contentWarning: >
Contains: macro and size difference, non-fatal oral vore, role reversal.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Chapter 4: Change"
shortTitle: "Chapter 4 Change"
pubDate: 2022-07-12
authors: Bad Manners
authors: bad-manners
wordCount: 12000
contentWarning: >
Contains: size difference, non-fatal oral vore, live feeding, sexual nudity.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Chapter 5: Intersection"
shortTitle: "Chapter 5 Intersection"
pubDate: 2022-08-04
authors: Bad Manners
authors: bad-manners
wordCount: 8600
contentWarning: >
Contains: size difference, non-fatal oral vore. Also includes heavy themes.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Chapter 6: Pleasure"
shortTitle: "Chapter 6 Pleasure"
pubDate: 2022-10-22
authors: Bad Manners
authors: bad-manners
wordCount: 9000
contentWarning: >
Contains: size difference, non-fatal oral vore and unbirth, gay sex, masturbation.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Chapter 7: Honesty"
shortTitle: "Chapter 7 Honesty"
pubDate: 2022-11-23
authors: Bad Manners
authors: bad-manners
wordCount: 6500
contentWarning: >
Contains: macro and size difference, non-fatal oral and anal vore, gay sex.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Chapter 8: Estranged"
shortTitle: "Chapter 8 Estranged"
pubDate: 2022-12-08
authors: Bad Manners
authors: bad-manners
wordCount: 7500
contentWarning: >
Contains: macro and size difference, non-fatal oral and anal vore.

View file

@ -2,7 +2,7 @@
title: "The Lost of the Marshes Chapter 9: Stuck"
shortTitle: "Chapter 9 Stuck"
pubDate: 2023-01-31
authors: Bad Manners
authors: bad-manners
wordCount: 11100
contentWarning: >
Contains: macro and size difference, non-fatal oral vore and slit vore. Also includes gay sexual situations, slight vomit, slight blood, and heavy themes.

View file

@ -1,7 +1,7 @@
---
title: tomo moku
pubDate: 2023-04-01
authors: nasin ike Pemene
authors: bad-manners
wordCount: 1200
contentWarning: >
nanpa nimi li mute li kijetesantakalu kijetesantakalu kijetesantakalu kijetesantakalu kijetesantakalu. lipu li jo e ijo tu tu ni: moku musi pi moli ala kepeken uta; akesi li moku musi e soweli; akesi li wile e ni; soweli li wile ala e ni.

View file

@ -1,7 +1,7 @@
---
title: Trouble Sleeping
pubDate: 2023-09-20
authors: Bad Manners
authors: bad-manners
wordCount: 4800
contentWarning: >
Contains: Non-fatal size difference unbirth, with asleep female anthro wolf pred and willing male feral sparrow prey. Also includes straight sex and accidental long-term endo.

View file

@ -1,7 +1,7 @@
---
title: Warped Friendship
pubDate: 2023-08-08
authors: Bad Manners
authors: bad-manners
wordCount: 7800
contentWarning: >
Contains: Non-fatal same size oral vore, with willing male anthro fennec fox pred, unwilling to willing male anthro red panda prey, and long-term endo.
@ -24,10 +24,10 @@ tags:
"long-term endo",
"request",
]
requester: { "Avour Inden": "https://furaffinity.net/user/pppp0000" }
requester: avour-inden
copyrightedCharacters:
Avour: { "Avour Inden": "https://furaffinity.net/user/pppp0000" }
Buster: { "Holi": "https://furaffinity.net/user/CinnamonStars" }
Avour: avour-inden
Buster: holi
---
This wasn't Avour's usual life-or-death assignment, where he had to hunt down supernatural entities. The red panda was skilled in both combat and magic, and had made a name for himself by facing off against formidable foes. But this time, it was just a simple side job. He was on his way to some town that he had never heard of, where there had been several complaints about some sort of pest that kept stealing people's foods and wreaking havoc.

View file

@ -1,7 +1,7 @@
---
title: Within Limits
pubDate: 2023-12-05
authors: Bad Manners
authors: bad-manners
wordCount: 14500
contentWarning: >
Contains: non-fatal vore, with female taur unbirth (mass vore, hammerspace), male anthro cock vore, and multiple anthro + human willing prey. Also includes bigger prey, similar size prey, size difference, nested vore, prey transfer, hyper genitals, alien genitals, and a sci-fi orgy setting.
@ -35,9 +35,9 @@ tags:
"orgy",
"commission",
]
commissioner: { "Asof Yeun": "https://www.furaffinity.net/user/AsofYeun/" }
commissioner: asofyeun
copyrightedCharacters:
Ushitora: { "Asof Yeun": "https://www.furaffinity.net/user/AsofYeun/" }
Ushitora: asofyeun
---
Tonight was going to be Ushitora's big night, but she showed no signs of nervousness. She had no reason to. The holographic readings on her bracelet were all nominal, of course, but she checked on them to make sure that all sensors were on. The technician didn't want to lose a single bit of data for her research.

View file

@ -1,7 +1,7 @@
---
title: You're Home
pubDate: 2022-11-10
authors: Bad Manners
authors: bad-manners
wordCount: 11300
contentWarning: >
Contains: Unwilling, non-fatal oral vore, with similar size preds/preys, and implied permanent endosoma. Also includes sexual situations and masturbation, slight description of vomit, and a bunch of social anxiety.

View file

@ -0,0 +1,11 @@
{
"name": "Asof Yeun",
"links": {
"eka": "https://aryion.com/g4/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"
},
"preferredLink": "furaffinity"
}

View file

@ -0,0 +1,7 @@
{
"name": "Avour Inden",
"links": {
"furaffinity": "https://furaffinity.net/user/pppp0000"
},
"preferredLink": "furaffinity"
}

View file

@ -0,0 +1,20 @@
{
"name": "Bad Manners",
"nameLang": {
"eng": "Bad Manners",
"tok": "nasin ike Pemene"
},
"avatar": "/src/assets/images/logo_bm.png",
"links": {
"website": "https://badmanners.xyz",
"eka": ["https://aryion.com/g4/user/BadManners", "BadManners"],
"furaffinity": ["https://www.furaffinity.net/user/BadManners", "BadManners"],
"inkbunny": ["https://inkbunny.net/BadManners", "BadManners"],
"sofurry": ["https://bad-manners.sofurry.com/", "Bad Manners"],
"weasyl": ["https://www.weasyl.com/~BadManners", "BadManners"],
"twitter": "https://twitter.com/BadManners__",
"mastodon": "https://meow.social/@BadManners",
"bluesky": "https://bsky.app/profile/badmanners.xyz"
},
"preferredLink": null
}

View file

@ -0,0 +1,7 @@
{
"name": "Dee Lumeni",
"links": {
"eka": ["https://aryion.com/g4/user/KeeperofLillies", "KeeperofLillies"]
},
"preferredLink": "eka"
}

View file

@ -0,0 +1,7 @@
{
"name": "Holi",
"links": {
"furaffinity": ["https://furaffinity.net/user/CinnamonStars", "CinnamonStars"]
},
"preferredLink": "furaffinity"
}

View file

@ -0,0 +1,8 @@
{
"name": "Scion",
"links": {
"eka": "https://aryion.com/g4/user/Scion",
"furaffinity": ["https://www.furaffinity.net/user/Scionic", "Scionic"]
},
"preferredLink": "eka"
}

View file

@ -0,0 +1,8 @@
{
"name": "YolkMonkey",
"links": {
"furaffinity": ["https://furaffinity.net/user/Vampire101", "Vampire101"],
"sofurry": ["https://vampire101.sofurry.com/", "Vampire101"]
},
"preferredLink": "furaffinity"
}

View file

@ -1,6 +1,6 @@
---
import { Image } from "astro:assets";
import { type CollectionEntry } from "astro:content";
import { type CollectionEntry, getEntry, getEntries } from "astro:content";
import { Markdown } from "@astropub/md";
import { format as formatDate } from "date-fns";
import { enUS as enUSLocale } from "date-fns/locale/en-US";
@ -13,8 +13,17 @@ import Prose from "../components/Prose.astro";
type Props = CollectionEntry<"games">["data"];
const { props } = Astro;
//const relatedStories = (await Promise.all((props.relatedStories || []).map(story => getEntry(story)))).filter(story => !story.data.isDraft)
// const relatedGames = (await Promise.all((props.relatedGames || []).map(game => getEntry(game)))).filter(game => !game.data.isDraft)
const authors = await getEntries([props.authors].flat());
const copyrightedCharacters: Record<string, CollectionEntry<"users">> = {};
Object.keys(props.copyrightedCharacters).forEach(async (character) => {
copyrightedCharacters[character] = await getEntry(props.copyrightedCharacters[character]);
});
// const relatedStories = (await getEntries(props.relatedStories)).filter(
// (story) => !story.data.isDraft,
// );
// const relatedGames = (await getEntries(props.relatedGames)).filter(
// (game) => !game.data.isDraft,
// );
---
<AgeRestrictedBaseLayout pageTitle={props.title}>
@ -74,7 +83,7 @@ const { props } = Astro;
id="game-information"
class="mt-1 space-y-2 px-2 font-serif font-light italic text-stone-600 dark:text-stone-200"
>
<Authors authors={props.authors} lang={props.lang} />
<Authors authors={authors} lang={props.lang} />
{
props.isDraft ? (
<p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
@ -131,7 +140,7 @@ const { props } = Astro;
</h2>
<Prose>
<Markdown of={props.description} />
<CopyrightedCharacters copyrightedCharacters={props.copyrightedCharacters} lang={props.lang} />
<CopyrightedCharacters copyrightedCharacters={copyrightedCharacters} lang={props.lang} />
</Prose>
</section>
<div class="pr-3 text-right print:hidden">

View file

@ -1,6 +1,6 @@
---
import { Image } from "astro:assets";
import { getEntry, type CollectionEntry } from "astro:content";
import { type CollectionEntry, getEntry, getEntries } from "astro:content";
import { Markdown } from "@astropub/md";
import { format as formatDate } from "date-fns";
import { enUS as enUSLocale } from "date-fns/locale/en-US";
@ -22,10 +22,15 @@ let next = props.next && (await getEntry(props.next));
if (next && next.data.isDraft) {
next = undefined;
}
const relatedStories = (await Promise.all((props.relatedStories || []).map((story) => getEntry(story)))).filter(
(story) => !story.data.isDraft,
);
// const relatedGames = (await Promise.all((props.relatedGames || []).map(game => getEntry(game)))).filter(
const authors = await getEntries([props.authors].flat());
const commissioner = props.commissioner && (await getEntry(props.commissioner));
const requester = props.requester && (await getEntry(props.requester));
const copyrightedCharacters: Record<string, CollectionEntry<"users">> = {};
Object.keys(props.copyrightedCharacters).forEach(async (character) => {
copyrightedCharacters[character] = await getEntry(props.copyrightedCharacters[character]);
});
const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
// const relatedGames = (await getEntries(props.relatedGames)).filter(
// (game) => !game.data.isDraft,
// );
---
@ -122,7 +127,7 @@ const relatedStories = (await Promise.all((props.relatedStories || []).map((stor
id="story-information"
class="mt-1 space-y-2 px-2 font-serif font-light italic text-stone-600 dark:text-stone-200"
>
<Authors authors={props.authors} lang={props.lang} />
<Authors authors={authors} lang={props.lang} />
{
props.isDraft ? (
<p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
@ -131,16 +136,16 @@ const relatedStories = (await Promise.all((props.relatedStories || []).map((stor
) : null
}
{
props.commissioner && (
commissioner && (
<p id="commissioner">
Commissioned by <UserComponent user={props.commissioner} />
Commissioned by <UserComponent user={commissioner} lang={props.lang} />
</p>
)
}
{
props.requester && (
requester && (
<p id="requester">
Requested by <UserComponent user={props.requester} />
Requested by <UserComponent user={requester} lang={props.lang} />
</p>
)
}
@ -196,7 +201,7 @@ const relatedStories = (await Promise.all((props.relatedStories || []).map((stor
</h2>
<Prose>
<Markdown of={props.description} />
<CopyrightedCharacters copyrightedCharacters={props.copyrightedCharacters} lang={props.lang} />
<CopyrightedCharacters copyrightedCharacters={copyrightedCharacters} lang={props.lang} />
</Prose>
</section>
<div class="pr-3 text-right print:hidden">
@ -281,7 +286,9 @@ const relatedStories = (await Promise.all((props.relatedStories || []).map((stor
</main>
<div class="pt-6 text-center text-xs text-black dark:text-white">
<span>&copy; {formatDate(props.pubDate, "yyyy")} | </span>
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank">Licenses</a>
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
>{props.lang === "eng" ? "Licenses" : props.lang === "tok" ? "lipu lawa" : null}</a
>
</div>
</div>
</AgeRestrictedBaseLayout>

View file

@ -1,36 +0,0 @@
---
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import { getUnixTime } from "date-fns";
import GalleryLayout from "../layouts/GalleryLayout.astro";
const stories = (await getCollection("stories"))
.filter((story) => !story.data.isDraft)
.sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate));
---
<GalleryLayout pageTitle="Stories">
<h1>Stories</h1>
<p>Lorem ipsum.</p>
<ul>
{
stories.map((story) => (
<li>
<a href={`/stories/${story.slug}`}>
{story.data.thumbnail && (
<Image
src={story.data.thumbnail}
alt={`Thumbnail for ${story.data.title}`}
width={story.data.thumbnailWidth}
height={story.data.thumbnailHeight}
/>
)}
<span>
{story.data.pubDate} - {story.data.title}
</span>
</a>
</li>
))
}
</ul>
</GalleryLayout>

View file

@ -8,11 +8,11 @@ type FeedItem = RSSFeedItem & {
};
export const GET: APIRoute = async ({ site }) => {
const stories = (await getCollection("stories")).filter((story) => !story.data.isDraft);
const games = (await getCollection("games")).filter((game) => !game.data.isDraft);
const stories = await getCollection("stories", (story) => !story.data.isDraft);
const games = await getCollection("games", (game) => !game.data.isDraft);
return rss({
title: "Gallery | Bad Manners",
description: "Stories, games, and more by Bad Manners",
description: "Stories, games, and (possibly) more by Bad Manners",
site: site as URL,
items: [
stories.map<FeedItem>((story) => ({
@ -21,7 +21,7 @@ export const GET: APIRoute = async ({ site }) => {
link: `/stories/${story.slug}`,
description:
`Word count: ${story.data.wordCount}. ${story.data.contentWarning} ${story.data.descriptionPlaintext || story.data.description}`
.replaceAll(/\n+| +/g, " ")
.replaceAll(/[\n ]+/g, " ")
.trim(),
categories: ["story"],
})),
@ -30,7 +30,7 @@ export const GET: APIRoute = async ({ site }) => {
pubDate: addHours(game.data.pubDate, 12),
link: `/games/${game.slug}`,
description: `${game.data.contentWarning} ${game.data.descriptionPlaintext || game.data.description}`
.replaceAll(/\n+| +/g, " ")
.replaceAll(/[\n ]+/g, " ")
.trim(),
categories: ["game"],
})),

View file

@ -5,9 +5,9 @@ import { getUnixTime, format as formatDate } from "date-fns";
import { enUS as enUSLocale } from "date-fns/locale/en-US";
import GalleryLayout from "../layouts/GalleryLayout.astro";
const games = (await getCollection("games"))
.filter((game) => !game.data.isDraft)
.sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate));
const games = (await getCollection("games", (game) => !game.data.isDraft)).sort(
(a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate),
);
---
<GalleryLayout pageTitle="Games">
@ -18,7 +18,7 @@ const games = (await getCollection("games"))
games.map((game) => (
<li>
<a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
{game.data.thumbnail && (
{game.data.thumbnail ? (
<Image
class="max-w-72"
src={game.data.thumbnail}
@ -26,7 +26,7 @@ const games = (await getCollection("games"))
width={game.data.thumbnailWidth}
height={game.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-72 text-sm">
<>
<span>{game.data.title}</span>

View file

@ -1,20 +1,21 @@
---
import type { GetStaticPaths } from "astro";
import { type CollectionEntry, getCollection } from "astro:content";
import GameLayout from "../../layouts/GameLayout.astro";
export async function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = async () => {
const games = await getCollection("games");
return games.map((story) => ({
params: { slug: story.slug },
props: story,
return games.map((game) => ({
params: { slug: game.slug },
props: game,
}));
}
};
type Props = CollectionEntry<"games">;
const story = Astro.props;
const { Content } = await story.render();
const game = Astro.props;
const { Content } = await game.render();
---
<GameLayout {...story.data}>
<GameLayout {...game.data}>
<Content />
</GameLayout>

View file

@ -1,14 +1,15 @@
---
import type { GetStaticPaths } from "astro";
import { type CollectionEntry, getCollection } from "astro:content";
import StoryLayout from "../../layouts/StoryLayout.astro";
export async function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = async () => {
const stories = await getCollection("stories");
return stories.map((story) => ({
params: { slug: story.slug },
props: story,
}));
}
};
type Props = CollectionEntry<"stories">;
const story = Astro.props;

View file

@ -1,17 +1,23 @@
---
import type { GetStaticPathsOptions } from "astro";
import type { GetStaticPaths, Page } from "astro";
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import { getUnixTime, format as formatDate } from "date-fns";
import { enUS as enUSLocale } from "date-fns/locale/en-US";
import GalleryLayout from "../../layouts/GalleryLayout.astro";
import type { CollectionEntry } from "astro:content";
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
const stories = (await getCollection("stories"))
.filter((story) => !story.data.isDraft)
.sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate));
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
const stories = (await getCollection("stories", (story) => !story.data.isDraft)).sort(
(a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate),
);
return paginate(stories, { pageSize: 30 });
}
};
type Props = {
page: Page<CollectionEntry<"stories">>;
};
const { page } = Astro.props;
const totalPages = Math.ceil(page.total / page.size);
---
@ -60,7 +66,7 @@ const totalPages = Math.ceil(page.total / page.size);
page.data.map((story) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
{story.data.thumbnail && (
{story.data.thumbnail ? (
<Image
class="w-48"
src={story.data.thumbnail}
@ -68,7 +74,7 @@ const totalPages = Math.ceil(page.total / page.size);
width={story.data.thumbnailWidth}
height={story.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-48 text-sm">
<>
<span>{story.data.title}</span>

View file

@ -0,0 +1,274 @@
import type { APIRoute, GetStaticPaths } from "astro";
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
import { marked, type RendererApi } from "marked";
import he from "he";
import { type Website } from "../../../../content/config";
const WEBSITE_LIST = ["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"] as const satisfies Website[];
type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<infer K> ? K : never;
const bbcodeRenderer: RendererApi = {
strong: (text) => `[b]${text}[/b]`,
em: (text) => `[i]${text}[/i]`,
codespan: (code) => code,
br: () => `\n\n`,
link: (href, _, text) => `[url=${href}]${text}[/url]`,
image: (href) => `[img]${href}[/img]`,
text: (text) => text,
paragraph: (text) => `\n${text}\n`,
list: (body, ordered) => (ordered ? `\n[ol]\n${body}[/ol]\n` : `\n[ul]\n${body}[/ul]\n`),
listitem: (text) => `[li]${text}[/li]\n`,
blockquote: (quote) => `\n[quote]${quote}[/quote]\n`,
code: (code) => `\n[code]${code}[/code]\n`,
heading: (heading) => `\n${heading}\n`,
table: (header, body) => `\n[table]\n${header}${body}[/table]\n`,
tablerow: (content) => `[tr]\n${content}[/tr]\n`,
tablecell: (content, { header }) => (header ? `[th]${content}[/th]\n` : `[td]${content}[/td]\n`),
hr: () => `\n===\n`,
del: () => {
throw new Error("Not supported by bbcodeRenderer: del");
},
html: () => {
throw new Error("Not supported by bbcodeRenderer: html");
},
checkbox: () => {
throw new Error("Not supported by bbcodeRenderer: checkbox");
},
};
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;
default:
throw new Error(`Unhandled website "${website}" in getUsernameForWebsite`);
}
} else {
return link[1].replace(/^@/, "");
}
}
throw new Error(`Cannot get "${website}" username for user "${user.id}"`);
}
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite): string {
switch (website) {
case "eka":
if (user.data.links.eka) {
return `:icon${getUsernameForWebsite(user, "eka")}:`;
}
break;
case "furaffinity":
if (user.data.links.furaffinity) {
return `:icon${getUsernameForWebsite(user, "furaffinity")}:`;
}
break;
case "weasyl":
if (user.data.links.weasyl) {
return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
} else if (
user.data.links.furaffinity &&
!(["inkbunny", "sofurry"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `<fa:${getUsernameForWebsite(user, "furaffinity")}>`;
} else if (
user.data.links.inkbunny &&
!(["furaffinity", "sofurry"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`;
} else if (
user.data.links.sofurry &&
!(["furaffinity", "inkbunny"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `<sf:${getUsernameForWebsite(user, "sofurry")}>`;
}
break;
case "inkbunny":
if (user.data.links.inkbunny) {
return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`;
} else if (
user.data.links.furaffinity &&
!(["sofurry", "weasyl"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`;
} else if (
user.data.links.sofurry &&
!(["furaffinity", "weasyl"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `[sf]${getUsernameForWebsite(user, "sofurry")}[/sf]`;
} else if (
user.data.links.weasyl &&
!(["furaffinity", "sofurry"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `[weasyl]${getUsernameForWebsite(user, "weasyl")}[/weasyl]`;
}
break;
case "sofurry":
if (user.data.links.sofurry) {
return `:icon${getUsernameForWebsite(user, "sofurry")}:`;
} else if (
user.data.links.furaffinity &&
!(["inkbunny"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `fa!${getUsernameForWebsite(user, "furaffinity")}`;
} else if (
user.data.links.inkbunny &&
!(["furaffinity"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `ib!${getUsernameForWebsite(user, "inkbunny")}`;
}
break;
default:
throw new Error(`Unhandled website "${website}" in getLinkForUser`);
}
if (user.data.preferredLink) {
if (user.data.preferredLink in user.data.links) {
const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`;
} else {
throw new Error(`No preferredLink "${user.data.preferredLink}" for user ${user.id}`);
}
}
throw new Error(`No "${website}"-supported link for user "${user.id}" without preferredLink`);
}
type Props = {
story: CollectionEntry<"stories">;
};
type Params = {
website: ExportWebsite;
slug: CollectionEntry<"stories">["slug"];
};
export const getStaticPaths: GetStaticPaths = async () => {
if (import.meta.env.PROD) {
return [];
}
return (await getCollection("stories"))
.map((story) =>
WEBSITE_LIST.map((website) => ({
params: { website, slug: story.slug } satisfies Params,
props: { story } satisfies Props,
})),
)
.flat();
};
export const GET: APIRoute<Props, Params> = async ({ props: { story }, params: { website }, site }) => {
const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website);
if (
story.data.copyrightedCharacters &&
"" in story.data.copyrightedCharacters &&
Object.keys(story.data.copyrightedCharacters).length > 1
) {
throw new Error("copyrightedCharacter cannot use empty key (catch-all) with other keys");
}
const charactersPerUser =
story.data.copyrightedCharacters &&
Object.keys(story.data.copyrightedCharacters).reduce(
(acc, character) => {
const key = story.data.copyrightedCharacters[character].id;
if (!(key in acc)) {
acc[key] = [];
}
acc[key].push(character);
return acc;
},
{} as Record<
CollectionEntry<"users">["id"],
(typeof story.data.copyrightedCharacters extends Record<infer K, any> ? K : never)[]
>,
);
let storyDescription = (
[
story.data.description,
`*Word count: ${story.data.wordCount}. ${story.data.contentWarning.trim()}*`,
"Writing: " + (await getEntries([story.data.authors].flat())).map((author) => u(author)).join(" , "),
story.data.requester && "Request for: " + u(await getEntry(story.data.requester)),
story.data.commissioner && "Commissioned by: " + u(await getEntry(story.data.commissioner)),
...(await Promise.all(
(Object.keys(charactersPerUser) as CollectionEntry<"users">["id"][]).map(async (id) => {
const user = u(await getEntry("users", id));
const characterList = charactersPerUser[id];
if (characterList[0] == "") {
return `All characters are © ${user}`;
} else if (characterList.length > 2) {
return `${characterList.slice(0, characterList.length - 1).join(", ")}, and ${characterList[characterList.length - 1]} are © ${user}`;
} else if (characterList.length > 1) {
return `${characterList[0]} and ${characterList[1]} are © ${user}`;
}
return `${characterList[0]} is © ${user}`;
}),
)),
].filter((data) => data) as string[]
)
.join("\n\n")
.replaceAll(
/\[([^\]]+)\]\((\.[^\)]+)\)/g,
(_, group1, group2) => `[${group1}](${new URL(group2, new URL(`/stories/${story.slug}`, site)).toString()})`,
);
const headers = { "Content-Type": "text/markdown; charset=utf-8" };
// BBCode exports
if ((["eka", "furaffinity", "inkbunny", "sofurry"] satisfies ExportWebsite[] as ExportWebsite[]).includes(website)) {
storyDescription = he.decode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription));
headers["Content-Type"] = "text/plain; charset=utf-8";
// Markdown exports (no-op)
} else if (!(["weasyl"] satisfies ExportWebsite[] as ExportWebsite[]).includes(website)) {
return new Response(null, { status: 404 });
}
return new Response(`${storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()}\n`, { headers });
};

View file

@ -5,7 +5,8 @@ import { getUnixTime } from "date-fns";
import GalleryLayout from "../../layouts/GalleryLayout.astro";
import mapImage from "../../assets/images/tlotm_map.jpg";
const stories = (await getCollection("stories")).filter(
const stories = await getCollection(
"stories",
(story) => !story.data.isDraft && story.slug.startsWith("the-lost-of-the-marshes/"),
);
const mainChapters = stories
@ -50,7 +51,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
mainChapters.map((story) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
{story.data.thumbnail && (
{story.data.thumbnail ? (
<Image
class="w-48"
src={story.data.thumbnail}
@ -58,7 +59,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
width={story.data.thumbnailWidth}
height={story.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-48 text-sm">{story.data.title}</div>
</a>
</li>
@ -73,7 +74,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
bonusChapters.map((story) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
{story.data.thumbnail && (
{story.data.thumbnail ? (
<Image
class="w-48"
src={story.data.thumbnail}
@ -81,7 +82,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
width={story.data.thumbnailWidth}
height={story.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-48 text-sm">{story.data.title}</div>
</a>
</li>

View file

@ -3,47 +3,46 @@ import { getCollection } from "astro:content";
import { slug } from "github-slugger";
import GalleryLayout from "../layouts/GalleryLayout.astro";
const [stories, games] = await Promise.all([getCollection("stories"), getCollection("games")]);
const [stories, games] = await Promise.all([
getCollection("stories", (story) => !story.data.isDraft),
getCollection("games", (game) => !game.data.isDraft),
]);
const tagsSet = new Set<string>();
const seriesList: Record<string, string> = {};
stories
.filter((story) => !story.data.isDraft)
.forEach((story) => {
story.data.tags.forEach((tag) => {
tagsSet.add(tag);
});
if (story.data.series) {
const [series, url] = Object.entries(story.data.series)[0];
if (seriesList[series]) {
if (seriesList[series] !== url) {
throw new Error(
`Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
);
}
} else {
seriesList[series] = url;
}
}
stories.forEach((story) => {
story.data.tags.forEach((tag) => {
tagsSet.add(tag);
});
games
.filter((game) => !game.data.isDraft)
.forEach((game) => {
game.data.tags.forEach((tag) => {
tagsSet.add(tag);
});
if (game.data.series) {
const [series, url] = Object.entries(game.data.series)[0];
if (seriesList[series]) {
if (seriesList[series] !== url) {
throw new Error(
`Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
);
}
} else {
seriesList[series] = url;
if (story.data.series) {
const [series, url] = Object.entries(story.data.series)[0];
if (seriesList[series]) {
if (seriesList[series] !== url) {
throw new Error(
`Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
);
}
} else {
seriesList[series] = url;
}
}
});
games.forEach((game) => {
game.data.tags.forEach((tag) => {
tagsSet.add(tag);
});
if (game.data.series) {
const [series, url] = Object.entries(game.data.series)[0];
if (seriesList[series]) {
if (seriesList[series] !== url) {
throw new Error(
`Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
);
}
} else {
seriesList[series] = url;
}
}
});
const categorizedTags: Record<string, string[]> = {
"Types of vore": [

View file

@ -1,11 +1,12 @@
---
import type { GetStaticPaths } from "astro";
import { Image } from "astro:assets";
import { type CollectionEntry, getCollection } from "astro:content";
import { slug } from "github-slugger";
import { getUnixTime } from "date-fns";
import GalleryLayout from "../../layouts/GalleryLayout.astro";
export async function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = async () => {
const [stories, games] = await Promise.all([getCollection("stories"), getCollection("games")]);
const tags = new Set<string>();
stories.forEach((story) => {
@ -32,7 +33,7 @@ export async function getStaticPaths() {
.sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)),
},
}));
}
};
type Props = {
tag: string;
@ -55,7 +56,7 @@ const { tag, stories, games } = Astro.props;
{stories.map((story) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
{story.data.thumbnail && (
{story.data.thumbnail ? (
<Image
class="w-48"
src={story.data.thumbnail}
@ -63,7 +64,7 @@ const { tag, stories, games } = Astro.props;
width={story.data.thumbnailWidth}
height={story.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-48 text-sm">{story.data.title}</div>
</a>
</li>
@ -82,7 +83,7 @@ const { tag, stories, games } = Astro.props;
{games.map((game) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
{game.data.thumbnail && (
{game.data.thumbnail ? (
<Image
class="w-48"
src={game.data.thumbnail}
@ -90,7 +91,7 @@ const { tag, stories, games } = Astro.props;
width={game.data.thumbnailWidth}
height={game.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-48 text-sm">{game.data.title}</div>
</a>
</li>