Improve schema and update tags
- Make constants in schema explicit - Enumerate tag-categories - More i18n utilities - Better anonymous user support without special field - Remove most tuples and unchecked type-casting
This commit is contained in:
parent
579e5879e1
commit
17ef8c652c
34 changed files with 223 additions and 221 deletions
package-lock.jsonpackage.json
src
components
content
config.ts
games
stories
accommodation.mdaddictive-additions.mdbetter-in-bully-batter.mdlatest-catch.mdoverzealous-zenko.mdteam-effort.md
tag-categories
1-types-of-vore.yaml10-recurring-characters.yaml2-body-types.yaml3-genders.yaml4-relative-size.yaml5-willingness.yaml6-vore-related-scenarios.yaml7-sexual-content.yaml8-other-kinks.yaml9-type-of-content.yaml
users
i18n
layouts
pages
utils
4
package-lock.json
generated
4
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "gallery-badmanners-xyz",
|
"name": "gallery-badmanners-xyz",
|
||||||
"version": "1.5.4",
|
"version": "1.5.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gallery-badmanners-xyz",
|
"name": "gallery-badmanners-xyz",
|
||||||
"version": "1.5.4",
|
"version": "1.5.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.8.2",
|
"@astrojs/check": "^0.8.2",
|
||||||
"@astrojs/rss": "^4.0.7",
|
"@astrojs/rss": "^4.0.7",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "gallery-badmanners-xyz",
|
"name": "gallery-badmanners-xyz",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.5.4",
|
"version": "1.5.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import { type CollectionEntry, getEntry } from "astro:content";
|
import { type CollectionEntry } from "astro:content";
|
||||||
import { type Lang } from "../content/config";
|
import { type Lang } from "../content/config";
|
||||||
import { getUsernameForLang } from "../utils/get_username_for_lang";
|
import { getUsernameForLang } from "../utils/get_username_for_lang";
|
||||||
|
|
||||||
|
@ -9,13 +9,10 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
let { user, lang } = Astro.props;
|
let { user, lang } = Astro.props;
|
||||||
if (user.data.isAnonymous) {
|
|
||||||
user = await getEntry("users", "anonymous");
|
|
||||||
}
|
|
||||||
const username = getUsernameForLang(user, lang);
|
const username = getUsernameForLang(user, lang);
|
||||||
let link: string | null = null;
|
let link: string | null = null;
|
||||||
if (user.data.preferredLink) {
|
if (user.data.preferredLink) {
|
||||||
const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
|
const preferredLink = user.data.links[user.data.preferredLink]!;
|
||||||
if (typeof preferredLink === "string") {
|
if (typeof preferredLink === "string") {
|
||||||
link = preferredLink;
|
link = preferredLink;
|
||||||
} else {
|
} else {
|
||||||
|
@ -25,11 +22,11 @@ if (user.data.preferredLink) {
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
user.data.isAnonymous || !user.data.preferredLink ? (
|
link ? (
|
||||||
<span>{username}</span>
|
|
||||||
) : (
|
|
||||||
<a href={link} class="text-link underline" target="_blank">
|
<a href={link} class="text-link underline" target="_blank">
|
||||||
{username}
|
{username}
|
||||||
</a>
|
</a>
|
||||||
|
) : (
|
||||||
|
<span>{username}</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,8 @@ export const WEBSITE_LIST = [
|
||||||
] as const;
|
] as const;
|
||||||
export const GAME_PLATFORMS = ["web", "windows", "linux", "macos", "android", "ios"] as const;
|
export const GAME_PLATFORMS = ["web", "windows", "linux", "macos", "android", "ios"] as const;
|
||||||
export const DEFAULT_LANG = "eng";
|
export const DEFAULT_LANG = "eng";
|
||||||
|
export const DEFAULT_AUTHOR = "bad-manners";
|
||||||
|
export const ANONYMOUS_USER = "anonymous";
|
||||||
|
|
||||||
// Validators
|
// Validators
|
||||||
|
|
||||||
|
@ -27,13 +29,34 @@ const inkbunnyPostUrlRegex = /^(?:https:\/\/)(?:www\.)?inkbunny\.net\/s\/([1-9]\
|
||||||
const sofurryPostUrlRegex = /^(?:https:\/\/)www\.sofurry\.com\/view\/([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 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;
|
const refineAuthors = [
|
||||||
const refineCopyrightedCharacters = (value: Record<string, any>) => !("" in value) || Object.keys(value).length == 1;
|
(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
|
// Transformers
|
||||||
|
|
||||||
export const adjustDateForUTCOffset = (date: Date) =>
|
export const adjustDateForUTCOffset = (date: Date) =>
|
||||||
new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0);
|
new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0);
|
||||||
|
export 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]!,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
|
||||||
|
@ -46,27 +69,15 @@ const mastodonPost = z
|
||||||
user: z.string(),
|
user: z.string(),
|
||||||
postId: z.string(),
|
postId: z.string(),
|
||||||
})
|
})
|
||||||
.or(
|
.or(z.string().transform(parseMastodonPostUrl));
|
||||||
z.string().transform((mastodonPost, ctx) => {
|
const authors = z
|
||||||
const match = mastodonPostUrlRegex.exec(mastodonPost);
|
.union([reference("users"), z.array(reference("users"))])
|
||||||
if (!match) {
|
.default(DEFAULT_AUTHOR)
|
||||||
ctx.addIssue({
|
.refine(...refineAuthors);
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: `"mastodon" post contains an invalid URL`,
|
|
||||||
});
|
|
||||||
return z.NEVER;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
instance: match[1],
|
|
||||||
user: match[2],
|
|
||||||
postId: match[3],
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const copyrightedCharacters = z
|
const copyrightedCharacters = z
|
||||||
.record(z.string(), reference("users"))
|
.record(z.string(), reference("users"))
|
||||||
.default({})
|
.default({})
|
||||||
.refine(refineCopyrightedCharacters, `"copyrightedCharacters" cannot mix empty catch-all key with other keys`);
|
.refine(...refineCopyrightedCharacters);
|
||||||
|
|
||||||
export type Lang = z.output<typeof lang>;
|
export type Lang = z.output<typeof lang>;
|
||||||
export type Website = z.infer<typeof website>;
|
export type Website = z.infer<typeof website>;
|
||||||
|
@ -89,10 +100,7 @@ const storiesCollection = defineCollection({
|
||||||
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
|
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
|
||||||
isDraft: z.boolean().default(false),
|
isDraft: z.boolean().default(false),
|
||||||
shortTitle: z.string().optional(),
|
shortTitle: z.string().optional(),
|
||||||
authors: z
|
authors,
|
||||||
.union([reference("users"), z.array(reference("users"))])
|
|
||||||
.default("bad-manners")
|
|
||||||
.refine(refineAuthors, `"authors" cannot be empty`),
|
|
||||||
summary: z.string().optional(),
|
summary: z.string().optional(),
|
||||||
thumbnail: image().optional(),
|
thumbnail: image().optional(),
|
||||||
thumbnailWidth: z.number().int().optional(),
|
thumbnailWidth: z.number().int().optional(),
|
||||||
|
@ -108,13 +116,14 @@ const storiesCollection = defineCollection({
|
||||||
relatedGames: z.array(reference("games")).default([]),
|
relatedGames: z.array(reference("games")).default([]),
|
||||||
posts: z
|
posts: z
|
||||||
.object({
|
.object({
|
||||||
eka: z.string().regex(ekaPostUrlRegex).optional(),
|
eka: z.string().regex(ekaPostUrlRegex),
|
||||||
furaffinity: z.string().regex(furaffinityPostUrlRegex).optional(),
|
furaffinity: z.string().regex(furaffinityPostUrlRegex),
|
||||||
weasyl: z.string().regex(weasylPostUrlRegex).optional(),
|
weasyl: z.string().regex(weasylPostUrlRegex),
|
||||||
inkbunny: z.string().regex(inkbunnyPostUrlRegex).optional(),
|
inkbunny: z.string().regex(inkbunnyPostUrlRegex),
|
||||||
sofurry: z.string().regex(sofurryPostUrlRegex).optional(),
|
sofurry: z.string().regex(sofurryPostUrlRegex),
|
||||||
mastodon: mastodonPost.optional(),
|
mastodon: mastodonPost,
|
||||||
})
|
})
|
||||||
|
.partial()
|
||||||
.default({}),
|
.default({}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -131,10 +140,7 @@ const gamesCollection = defineCollection({
|
||||||
// Optional
|
// Optional
|
||||||
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
|
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
|
||||||
isDraft: z.boolean().default(false),
|
isDraft: z.boolean().default(false),
|
||||||
authors: z
|
authors,
|
||||||
.union([reference("users"), z.array(reference("users"))])
|
|
||||||
.default("bad-manners")
|
|
||||||
.refine(refineAuthors, `"authors" cannot be empty`),
|
|
||||||
thumbnail: image().optional(),
|
thumbnail: image().optional(),
|
||||||
thumbnailWidth: z.number().int().optional(),
|
thumbnailWidth: z.number().int().optional(),
|
||||||
thumbnailHeight: z.number().int().optional(),
|
thumbnailHeight: z.number().int().optional(),
|
||||||
|
@ -146,13 +152,14 @@ const gamesCollection = defineCollection({
|
||||||
relatedGames: z.array(reference("games")).default([]),
|
relatedGames: z.array(reference("games")).default([]),
|
||||||
posts: z
|
posts: z
|
||||||
.object({
|
.object({
|
||||||
eka: z.string().regex(ekaPostUrlRegex).optional(),
|
eka: z.string().regex(ekaPostUrlRegex),
|
||||||
furaffinity: z.string().regex(furaffinityPostUrlRegex).optional(),
|
furaffinity: z.string().regex(furaffinityPostUrlRegex),
|
||||||
weasyl: z.string().regex(weasylPostUrlRegex).optional(),
|
weasyl: z.string().regex(weasylPostUrlRegex),
|
||||||
inkbunny: z.string().regex(inkbunnyPostUrlRegex).optional(),
|
inkbunny: z.string().regex(inkbunnyPostUrlRegex),
|
||||||
sofurry: z.string().regex(sofurryPostUrlRegex).optional(),
|
sofurry: z.string().regex(sofurryPostUrlRegex),
|
||||||
mastodon: mastodonPost.optional(),
|
mastodon: mastodonPost,
|
||||||
})
|
})
|
||||||
|
.partial()
|
||||||
.default({}),
|
.default({}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -169,9 +176,8 @@ const usersCollection = defineCollection({
|
||||||
links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])),
|
links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])),
|
||||||
// Optional
|
// Optional
|
||||||
preferredLink: website.nullish(),
|
preferredLink: website.nullish(),
|
||||||
nameLang: z.record(lang, z.string()).default({}),
|
lang: z.record(lang, z.string()).default({}),
|
||||||
avatar: image().optional(),
|
avatar: image().optional(),
|
||||||
isAnonymous: z.boolean().default(false),
|
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
({ links, preferredLink }) => !preferredLink || preferredLink in links,
|
({ links, preferredLink }) => !preferredLink || preferredLink in links,
|
||||||
|
@ -187,7 +193,7 @@ const seriesCollection = defineCollection({
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
// Required
|
// Required
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
url: z.string().regex(/^(\/[a-z0-9_-]+)*\/?$/, `"url" must be a local URL`),
|
url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -199,10 +205,7 @@ const tagCategoriesCollection = defineCollection({
|
||||||
index: z.number().int(),
|
index: z.number().int(),
|
||||||
tags: z.array(
|
tags: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.union([
|
name: z.union([z.string(), z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string()))]),
|
||||||
z.string(),
|
|
||||||
z.intersection(z.object({ [DEFAULT_LANG]: z.string() }), z.record(lang, z.string())),
|
|
||||||
]),
|
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
related: z.array(z.string()).optional(),
|
related: z.array(z.string()).optional(),
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -38,7 +38,7 @@ tags:
|
||||||
- non-binary prey
|
- non-binary prey
|
||||||
- micro prey
|
- micro prey
|
||||||
- soul vore
|
- soul vore
|
||||||
- implied perma endo
|
- long-term endo
|
||||||
---
|
---
|
||||||
|
|
||||||
<iframe
|
<iframe
|
||||||
|
|
|
@ -27,7 +27,7 @@ tags:
|
||||||
- unwilling prey
|
- unwilling prey
|
||||||
- willing prey
|
- willing prey
|
||||||
- size difference
|
- size difference
|
||||||
- long-term endo
|
- implied perma endo
|
||||||
- straight sex
|
- straight sex
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
@ -29,7 +29,7 @@ tags:
|
||||||
- willing prey
|
- willing prey
|
||||||
- semi-willing prey
|
- semi-willing prey
|
||||||
- similar size
|
- similar size
|
||||||
- perma endo
|
- implied perma endo
|
||||||
- straight sex
|
- straight sex
|
||||||
- gay sex
|
- gay sex
|
||||||
- hyper
|
- hyper
|
||||||
|
|
|
@ -26,7 +26,7 @@ tags:
|
||||||
- willing predator
|
- willing predator
|
||||||
- unwilling prey
|
- unwilling prey
|
||||||
- similar size
|
- similar size
|
||||||
- perma endo
|
- implied perma endo
|
||||||
- straight sex
|
- straight sex
|
||||||
- gay sex
|
- gay sex
|
||||||
- orgy
|
- orgy
|
||||||
|
|
|
@ -26,7 +26,8 @@ tags:
|
||||||
- willing prey
|
- willing prey
|
||||||
- size difference
|
- size difference
|
||||||
- masturbation
|
- masturbation
|
||||||
- perma endo
|
- long-term endo
|
||||||
|
- implied perma endo
|
||||||
- flash fiction
|
- flash fiction
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,7 @@ tags:
|
||||||
- willing predator
|
- willing predator
|
||||||
- unwilling prey
|
- unwilling prey
|
||||||
- size difference
|
- size difference
|
||||||
- perma endo
|
- implied perma endo
|
||||||
- request
|
- request
|
||||||
requester: dee-lumeni
|
requester: dee-lumeni
|
||||||
copyrightedCharacters:
|
copyrightedCharacters:
|
||||||
|
|
|
@ -26,6 +26,7 @@ tags:
|
||||||
- semi-willing predator
|
- semi-willing predator
|
||||||
- willing predator
|
- willing predator
|
||||||
- willing prey
|
- willing prey
|
||||||
|
- long-term endo
|
||||||
- same size
|
- same size
|
||||||
- hyper
|
- hyper
|
||||||
- inflation
|
- inflation
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
name: Types of vore
|
name: Types of vore
|
||||||
index: 0
|
index: 1
|
||||||
tags:
|
tags:
|
||||||
- name: { eng: oral vore, tok: moku musi kepeken uta }
|
- name: { eng: oral vore, tok: moku musi kepeken uta }
|
||||||
description: Scenarios where prey are consumed by the predator through their mouth.
|
description: Scenarios where prey are consumed by the predator through their mouth.
|
|
@ -1,5 +1,5 @@
|
||||||
name: Recurring characters
|
name: Recurring characters
|
||||||
index: 9
|
index: 10
|
||||||
tags:
|
tags:
|
||||||
- name: Sam Brendan
|
- name: Sam Brendan
|
||||||
description: Content that includes my character and fursona [Sam, the mimic x maned wolf hybrid](https://badmanners.xyz/sam_brendan).
|
description: Content that includes my character and fursona [Sam, the mimic x maned wolf hybrid](https://badmanners.xyz/sam_brendan).
|
|
@ -1,5 +1,5 @@
|
||||||
name: Body types
|
name: Body types
|
||||||
index: 1
|
index: 2
|
||||||
tags:
|
tags:
|
||||||
- name: anthro predator
|
- name: anthro predator
|
||||||
description: Scenarios where at least one of the predators is an anthropomorphic animal, i.e. generally regarded as a "furry".
|
description: Scenarios where at least one of the predators is an anthropomorphic animal, i.e. generally regarded as a "furry".
|
|
@ -1,5 +1,5 @@
|
||||||
name: Genders
|
name: Genders
|
||||||
index: 2
|
index: 3
|
||||||
tags:
|
tags:
|
||||||
- name: male predator
|
- name: male predator
|
||||||
description: Scenarios where at least one of the predators is a man and/or male-presenting.
|
description: Scenarios where at least one of the predators is a man and/or male-presenting.
|
|
@ -1,5 +1,5 @@
|
||||||
name: Relative size
|
name: Relative size
|
||||||
index: 3
|
index: 4
|
||||||
tags:
|
tags:
|
||||||
- name: macro predator
|
- name: macro predator
|
||||||
description: Scenarios where at least one of the predators has a size/height one or more orders of magnitude larger than average.
|
description: Scenarios where at least one of the predators has a size/height one or more orders of magnitude larger than average.
|
|
@ -1,5 +1,5 @@
|
||||||
name: Willingness
|
name: Willingness
|
||||||
index: 4
|
index: 5
|
||||||
tags:
|
tags:
|
||||||
- name: { eng: willing predator, tok: jan pi wawa mute li wile e moku musi }
|
- name: { eng: 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.
|
description: Scenarios where at least one of the predators participates in vore willingly.
|
|
@ -1,5 +1,5 @@
|
||||||
name: Vore-related scenarios
|
name: Vore-related scenarios
|
||||||
index: 5
|
index: 6
|
||||||
tags:
|
tags:
|
||||||
- name: point of view
|
- name: point of view
|
||||||
description: Scenarios where the narration takes the perspective of one of the characters, generally in first person.
|
description: Scenarios where the narration takes the perspective of one of the characters, generally in first person.
|
|
@ -1,5 +1,5 @@
|
||||||
name: Sexual content
|
name: Sexual content
|
||||||
index: 6
|
index: 7
|
||||||
tags:
|
tags:
|
||||||
- name: nudity
|
- name: nudity
|
||||||
description: Scenarios where a character is nude and displaying their sexual features, without necessarily participating in a sexual situation.
|
description: Scenarios where a character is nude and displaying their sexual features, without necessarily participating in a sexual situation.
|
|
@ -1,5 +1,5 @@
|
||||||
name: Other kinks
|
name: Other kinks
|
||||||
index: 7
|
index: 8
|
||||||
tags:
|
tags:
|
||||||
- name: hyper
|
- name: hyper
|
||||||
description: Scenarios where a character has abnormally large features compared to their body, usually genitalia.
|
description: Scenarios where a character has abnormally large features compared to their body, usually genitalia.
|
|
@ -1,5 +1,5 @@
|
||||||
name: Type of content
|
name: Type of content
|
||||||
index: 8
|
index: 9
|
||||||
tags:
|
tags:
|
||||||
- name: request
|
- name: request
|
||||||
description: Stories made by someone else's request, as a gift.
|
description: Stories made by someone else's request, as a gift.
|
|
@ -1,6 +1,5 @@
|
||||||
name: Anonymous
|
name: Anonymous
|
||||||
nameLang:
|
lang:
|
||||||
eng: anonymous
|
eng: anonymous
|
||||||
tok: jan pi nimi ala
|
tok: jan pi nimi ala
|
||||||
isAnonymous: true
|
|
||||||
links: {}
|
links: {}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
name: Bad Manners
|
name: Bad Manners
|
||||||
nameLang:
|
lang:
|
||||||
eng: Bad Manners
|
eng: Bad Manners
|
||||||
tok: nasin ike Pemene
|
tok: nasin ike Pemene
|
||||||
avatar: /src/assets/images/logo_bm.png
|
avatar: /src/assets/images/logo_bm.png
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { type GamePlatform, type Lang } from "../content/config";
|
||||||
import { DEFAULT_LANG } from "../content/config";
|
import { DEFAULT_LANG } from "../content/config";
|
||||||
export { DEFAULT_LANG } from "../content/config";
|
export { DEFAULT_LANG } from "../content/config";
|
||||||
|
|
||||||
export const UI_STRINGS = {
|
const UI_STRINGS = {
|
||||||
"util/join_names": {
|
"util/join_names": {
|
||||||
eng: (names: string[]) =>
|
eng: (names: string[]) =>
|
||||||
names.length <= 1
|
names.length <= 1
|
||||||
|
@ -12,25 +12,24 @@ export const UI_STRINGS = {
|
||||||
: `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`,
|
: `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`,
|
||||||
tok: (names: string[]) => names.join(" en "),
|
tok: (names: string[]) => names.join(" en "),
|
||||||
},
|
},
|
||||||
|
"util/capitalize": {
|
||||||
|
eng: (text: string) => (text.length > 0 ? `${text[0].toUpperCase()}${text.slice(1)}` : ""),
|
||||||
|
},
|
||||||
|
"util/enumerate": {
|
||||||
|
eng: (count: number, nounSingular: string, nounPlural: string | undefined) =>
|
||||||
|
count !== 1 ? `${count !== 0 ? count : "zero"} ${nounPlural ?? nounSingular}` : `one ${nounSingular}`,
|
||||||
|
tok: (count: number, nounSingular: string, nounPlural: string | undefined) =>
|
||||||
|
`${(count > 1 && nounPlural) || nounSingular} ${["ala", "wan", "tu"][count] || "mute"}`,
|
||||||
|
},
|
||||||
"export_story/writing": {
|
"export_story/writing": {
|
||||||
eng: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`,
|
eng: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`,
|
||||||
tok: (authorsList: string[]) => `lipu ni li tan jan ni: ${authorsList.join(" en ")}`,
|
tok: (authorsList: string[]) => `lipu ni li tan jan ni: ${authorsList.join(" en ")}`,
|
||||||
},
|
},
|
||||||
"export_story/request_for": {
|
"export_story/request_for": {
|
||||||
eng: (requesterList: string | string[]) => {
|
eng: (requesterList: string | string[]) => `Request for: ${[requesterList].flat().join(" ")}`,
|
||||||
if (typeof requesterList === "string") {
|
|
||||||
requesterList = [requesterList];
|
|
||||||
}
|
|
||||||
return `Request for: ${requesterList.join(" ")}`;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"export_story/commissioned_by": {
|
"export_story/commissioned_by": {
|
||||||
eng: (commissionerList: string | string[]) => {
|
eng: (commissionerList: string | string[]) => `Commissioned by: ${[commissionerList].flat().join(" ")}`,
|
||||||
if (typeof commissionerList === "string") {
|
|
||||||
commissionerList = [commissionerList];
|
|
||||||
}
|
|
||||||
return `Commissioned by: ${commissionerList.join(" ")}`;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"story/return_to_stories": {
|
"story/return_to_stories": {
|
||||||
eng: "Return to stories",
|
eng: "Return to stories",
|
||||||
|
@ -48,8 +47,9 @@ export const UI_STRINGS = {
|
||||||
tok: "o ante e kule lipu",
|
tok: "o ante e kule lipu",
|
||||||
},
|
},
|
||||||
"story/warnings": {
|
"story/warnings": {
|
||||||
eng: (wordCount: number | string, contentWarning: string) => `Word count: ${wordCount}. ${contentWarning}`,
|
eng: (wordCount: number | string | undefined, contentWarning: string) =>
|
||||||
tok: (_wordCount: number | string, contentWarning: string) => `${contentWarning}`,
|
wordCount ? `Word count: ${wordCount}. ${contentWarning}` : contentWarning,
|
||||||
|
tok: (_wordCount: number | string | undefined, contentWarning: string) => contentWarning,
|
||||||
},
|
},
|
||||||
"story/publish_date": {
|
"story/publish_date": {
|
||||||
eng: (date: string) => date,
|
eng: (date: string) => date,
|
||||||
|
@ -91,20 +91,12 @@ export const UI_STRINGS = {
|
||||||
: `lipu ni li tan ${authorsList[0]}`,
|
: `lipu ni li tan ${authorsList[0]}`,
|
||||||
},
|
},
|
||||||
"story/commissioned_by": {
|
"story/commissioned_by": {
|
||||||
eng: (commissionersList: string | string[]) => {
|
eng: (commissionersList: string | string[]) =>
|
||||||
if (typeof commissionersList === "string") {
|
`Commissioned by ${UI_STRINGS["util/join_names"].eng([commissionersList].flat())}`,
|
||||||
commissionersList = [commissionersList];
|
|
||||||
}
|
|
||||||
return `Commissioned by ${UI_STRINGS["util/join_names"].eng(commissionersList)}`;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"story/requested_by": {
|
"story/requested_by": {
|
||||||
eng: (requestersList: string | string[]) => {
|
eng: (requestersList: string | string[]) =>
|
||||||
if (typeof requestersList === "string") {
|
`Requested by ${UI_STRINGS["util/join_names"].eng([requestersList].flat())}`,
|
||||||
requestersList = [requestersList];
|
|
||||||
}
|
|
||||||
return `Requested by ${UI_STRINGS["util/join_names"].eng(requestersList)}`;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"story/draft_warning": {
|
"story/draft_warning": {
|
||||||
eng: "DRAFT VERSION – DO NOT REDISTRIBUTE",
|
eng: "DRAFT VERSION – DO NOT REDISTRIBUTE",
|
||||||
|
@ -120,9 +112,16 @@ export const UI_STRINGS = {
|
||||||
},
|
},
|
||||||
"game/platforms": {
|
"game/platforms": {
|
||||||
eng: (platforms: GamePlatform[]) => {
|
eng: (platforms: GamePlatform[]) => {
|
||||||
const translatedPlatforms = platforms.map(
|
if (platforms.length == 0) {
|
||||||
(platform) => (UI_STRINGS[`game/platform_${platform}`].eng as string | undefined) || platform,
|
return "";
|
||||||
);
|
}
|
||||||
|
const translatedPlatforms = platforms.map((platform) => {
|
||||||
|
const platformLang = UI_STRINGS[`game/platform_${platform}`].eng;
|
||||||
|
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"].eng(translatedPlatforms)}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -146,7 +145,22 @@ export const UI_STRINGS = {
|
||||||
},
|
},
|
||||||
"game/warnings": {
|
"game/warnings": {
|
||||||
eng: (platforms: GamePlatform[], contentWarning: string) =>
|
eng: (platforms: GamePlatform[], contentWarning: string) =>
|
||||||
`${UI_STRINGS["game/platforms"].eng(platforms)}. ${contentWarning}`,
|
platforms.length > 0 ? `${UI_STRINGS["game/platforms"].eng(platforms)}. ${contentWarning}` : contentWarning,
|
||||||
|
},
|
||||||
|
"tag/total_works_with_tag": {
|
||||||
|
eng: (tag: string, storiesCount: number, gamesCount: number) => {
|
||||||
|
const content = [];
|
||||||
|
if (storiesCount > 0) {
|
||||||
|
content.push(UI_STRINGS["util/enumerate"].eng(storiesCount, "story", "stories"));
|
||||||
|
}
|
||||||
|
if (gamesCount > 0) {
|
||||||
|
content.push(UI_STRINGS["util/enumerate"].eng(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}".`);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
@ -27,17 +27,17 @@ const categorizedTags = Object.fromEntries(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const tags = props.tags.map<[string, string]>((tag) => {
|
const tags = props.tags.map<{ id: string; name: string }>((tag) => {
|
||||||
const tagSlug = slug(tag);
|
const id = slug(tag);
|
||||||
if (!(tag in categorizedTags)) {
|
if (!(tag in categorizedTags)) {
|
||||||
console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`);
|
console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`);
|
||||||
return [tagSlug, tag];
|
return { id, name: tag };
|
||||||
}
|
}
|
||||||
if (categorizedTags[tag] == null) {
|
if (categorizedTags[tag] == null) {
|
||||||
console.warn(`No "${props.lang}" translation for tag "${tag}"`);
|
console.warn(`No "${props.lang}" translation for tag "${tag}"`);
|
||||||
return [tagSlug, tag];
|
return { id, name: tag };
|
||||||
}
|
}
|
||||||
return [tagSlug, categorizedTags[tag]!];
|
return { id, name: categorizedTags[tag]! };
|
||||||
});
|
});
|
||||||
const thumbnail =
|
const thumbnail =
|
||||||
props.thumbnail &&
|
props.thumbnail &&
|
||||||
|
@ -217,10 +217,10 @@ const thumbnail =
|
||||||
Tags
|
Tags
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
|
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
|
||||||
{tags.map(([tagSlug, tagText]) => (
|
{tags.map(({ id, name }) => (
|
||||||
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
|
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
|
||||||
<a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}>
|
<a class="hover:underline focus:underline" href={`/tags/${id}`}>
|
||||||
{tagText}
|
{name}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -39,22 +39,22 @@ const categorizedTags = Object.fromEntries(
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const tags = props.tags.map<[string, string]>((tag) => {
|
const tags = props.tags.map<{ id: string; name: string }>((tag) => {
|
||||||
const tagSlug = slug(tag);
|
const tagSlug = slug(tag);
|
||||||
if (!(tag in categorizedTags)) {
|
if (!(tag in categorizedTags)) {
|
||||||
console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`);
|
console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`);
|
||||||
return [tagSlug, tag];
|
return { id: tagSlug, name: tag };
|
||||||
}
|
}
|
||||||
if (categorizedTags[tag] == null) {
|
if (categorizedTags[tag] == null) {
|
||||||
console.warn(`No "${props.lang}" translation for tag "${tag}"`);
|
console.warn(`No "${props.lang}" translation for tag "${tag}"`);
|
||||||
return [tagSlug, tag];
|
return { id: tagSlug, name: tag };
|
||||||
}
|
}
|
||||||
return [tagSlug, categorizedTags[tag]!];
|
return { id: tagSlug, name: categorizedTags[tag]! };
|
||||||
});
|
});
|
||||||
const thumbnail =
|
const thumbnail =
|
||||||
props.thumbnail &&
|
props.thumbnail &&
|
||||||
(await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight }));
|
(await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight }));
|
||||||
const wordCount = props.wordCount ? `${props.wordCount}` : "???";
|
const wordCount = props.wordCount?.toString();
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout pageTitle={props.title}>
|
<BaseLayout pageTitle={props.title}>
|
||||||
|
@ -352,10 +352,10 @@ const wordCount = props.wordCount ? `${props.wordCount}` : "???";
|
||||||
{t(props.lang, "story/tags")}
|
{t(props.lang, "story/tags")}
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
|
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
|
||||||
{tags.map(([tagSlug, tagText]) => (
|
{tags.map(({ id, name }) => (
|
||||||
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
|
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
|
||||||
<a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}>
|
<a class="hover:underline focus:underline" href={`/tags/${id}`}>
|
||||||
{tagText}
|
{name}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import type { APIRoute, GetStaticPaths } from "astro";
|
import type { APIRoute, GetStaticPaths } from "astro";
|
||||||
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
|
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
|
||||||
import type { Lang, Website } from "../../../content/config";
|
import type { Website } from "../../../content/config";
|
||||||
import { t } from "../../../i18n";
|
import { t } from "../../../i18n";
|
||||||
import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
|
import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
|
||||||
import { markdownToBbcode } from "../../../utils/markdown_to_bbcode";
|
import { markdownToBbcode } from "../../../utils/markdown_to_bbcode";
|
||||||
import { getUsernameForLang } from "../../../utils/get_username_for_lang";
|
import { getUsernameForLang } from "../../../utils/get_username_for_lang";
|
||||||
|
import { isAnonymousUser } from "../../../utils/is_anonymous_user";
|
||||||
|
|
||||||
interface ExportWebsiteInfo {
|
interface ExportWebsiteInfo {
|
||||||
website: Website;
|
website: Website;
|
||||||
|
@ -97,10 +98,7 @@ function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): b
|
||||||
return !preferredLink || preferredLink == website;
|
return !preferredLink || preferredLink == website;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName, anonymousFallback: string): string {
|
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName): string {
|
||||||
if (user.data.isAnonymous) {
|
|
||||||
return anonymousFallback;
|
|
||||||
}
|
|
||||||
switch (website) {
|
switch (website) {
|
||||||
case "eka":
|
case "eka":
|
||||||
if ("eka" in user.data.links) {
|
if ("eka" in user.data.links) {
|
||||||
|
@ -155,13 +153,6 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string {
|
|
||||||
if (user.data.isAnonymous) {
|
|
||||||
return getUsernameForLang(anonymousUser, lang);
|
|
||||||
}
|
|
||||||
return getUsernameForLang(user, lang);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
story: CollectionEntry<"stories">;
|
story: CollectionEntry<"stories">;
|
||||||
};
|
};
|
||||||
|
@ -180,23 +171,21 @@ export const getStaticPaths: GetStaticPaths = async () => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const ANONYMOUS_USER = await getEntry("users", "anonymous");
|
|
||||||
|
|
||||||
export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => {
|
export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => {
|
||||||
const { lang } = story.data;
|
const { lang } = story.data;
|
||||||
const copyrightedCharacters = await formatCopyrightedCharacters(story.data.copyrightedCharacters);
|
const copyrightedCharacters = await formatCopyrightedCharacters(story.data.copyrightedCharacters);
|
||||||
const authorsList = await getEntries([story.data.authors].flat());
|
const authorsList = await getEntries([story.data.authors].flat());
|
||||||
const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner));
|
const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner));
|
||||||
const requester = story.data.requester && (await getEntry(story.data.requester));
|
const requester = story.data.requester && (await getEntry(story.data.requester));
|
||||||
const anonymousFallback = getNameForUser(ANONYMOUS_USER, ANONYMOUS_USER, lang);
|
|
||||||
|
|
||||||
const description = Object.fromEntries(
|
const description = Object.fromEntries(
|
||||||
WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
|
WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
|
||||||
const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback);
|
const u = (user: CollectionEntry<"users">) =>
|
||||||
|
isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website);
|
||||||
const storyDescription = (
|
const storyDescription = (
|
||||||
[
|
[
|
||||||
story.data.description,
|
story.data.description,
|
||||||
`*${t(lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim())}*`,
|
`*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}*`,
|
||||||
t(
|
t(
|
||||||
lang,
|
lang,
|
||||||
"export_story/writing",
|
"export_story/writing",
|
||||||
|
@ -236,10 +225,10 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
|
||||||
`${t(
|
`${t(
|
||||||
lang,
|
lang,
|
||||||
"story/authors",
|
"story/authors",
|
||||||
authorsList.map((author) => getNameForUser(author, ANONYMOUS_USER, lang)),
|
authorsList.map((author) => getUsernameForLang(author, lang)),
|
||||||
)}\n` +
|
)}\n` +
|
||||||
(commissioner ? `${t(lang, "story/commissioned_by", getNameForUser(commissioner, ANONYMOUS_USER, lang))}\n` : "") +
|
(commissioner ? `${t(lang, "story/commissioned_by", getUsernameForLang(commissioner, lang))}\n` : "") +
|
||||||
(requester ? `${t(lang, "story/requested_by", getNameForUser(requester, ANONYMOUS_USER, lang))}\n` : "");
|
(requester ? `${t(lang, "story/requested_by", getUsernameForLang(requester, lang))}\n` : "");
|
||||||
|
|
||||||
const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}`
|
const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}`
|
||||||
.replaceAll(/\n\n\n+/g, "\n\n")
|
.replaceAll(/\n\n\n+/g, "\n\n")
|
||||||
|
|
|
@ -49,7 +49,7 @@ export const GET: APIRoute = async ({ site }) => {
|
||||||
pubDate: toNoonUTCDate(data.pubDate!),
|
pubDate: toNoonUTCDate(data.pubDate!),
|
||||||
link: `/stories/${slug}`,
|
link: `/stories/${slug}`,
|
||||||
description:
|
description:
|
||||||
`${t(data.lang, "story/warnings", data.wordCount || "???", data.contentWarning.trim())}\n\n${markdownToPlaintext(data.description)}`
|
`${t(data.lang, "story/warnings", data.wordCount, data.contentWarning.trim())}\n\n${markdownToPlaintext(data.description)}`
|
||||||
.replaceAll(/[\n ]+/g, " ")
|
.replaceAll(/[\n ]+/g, " ")
|
||||||
.trim(),
|
.trim(),
|
||||||
categories: ["story"],
|
categories: ["story"],
|
||||||
|
|
|
@ -28,7 +28,7 @@ const latestItems: LatestItemsEntry[] = [
|
||||||
thumbnail: story.data.thumbnail,
|
thumbnail: story.data.thumbnail,
|
||||||
href: `/stories/${story.slug}`,
|
href: `/stories/${story.slug}`,
|
||||||
title: story.data.title,
|
title: story.data.title,
|
||||||
altText: t(story.data.lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim()),
|
altText: t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim()),
|
||||||
pubDate: story.data.pubDate!,
|
pubDate: story.data.pubDate!,
|
||||||
})),
|
})),
|
||||||
games.map<LatestItemsEntry>((game) => ({
|
games.map<LatestItemsEntry>((game) => ({
|
||||||
|
|
|
@ -71,12 +71,7 @@ const totalPages = Math.ceil(page.total / page.size);
|
||||||
<a
|
<a
|
||||||
class="text-link hover:underline focus:underline"
|
class="text-link hover:underline focus:underline"
|
||||||
href={`/stories/${story.slug}`}
|
href={`/stories/${story.slug}`}
|
||||||
title={t(
|
title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}
|
||||||
story.data.lang,
|
|
||||||
"story/warnings",
|
|
||||||
story.data.wordCount || "???",
|
|
||||||
story.data.contentWarning.trim(),
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{story.data.thumbnail ? (
|
{story.data.thumbnail ? (
|
||||||
<div class="flex aspect-square max-w-[192px] justify-center">
|
<div class="flex aspect-square max-w-[192px] justify-center">
|
||||||
|
|
|
@ -4,6 +4,12 @@ import { slug } from "github-slugger";
|
||||||
import GalleryLayout from "../layouts/GalleryLayout.astro";
|
import GalleryLayout from "../layouts/GalleryLayout.astro";
|
||||||
import { markdownToPlaintext } from "../utils/markdown_to_plaintext";
|
import { markdownToPlaintext } from "../utils/markdown_to_plaintext";
|
||||||
|
|
||||||
|
interface Tag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const [stories, games, tagCategories] = await Promise.all([
|
const [stories, games, tagCategories] = await Promise.all([
|
||||||
getCollection("stories"),
|
getCollection("stories"),
|
||||||
getCollection("games"),
|
getCollection("games"),
|
||||||
|
@ -32,24 +38,30 @@ const seriesCollection = await getCollection("series");
|
||||||
|
|
||||||
const uncategorizedTagsSet = new Set(tagsSet);
|
const uncategorizedTagsSet = new Set(tagsSet);
|
||||||
const categorizedTags = tagCategories
|
const categorizedTags = tagCategories
|
||||||
.sort((a, b) => a.data.index - b.data.index)
|
.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}")`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return a.data.index - b.data.index;
|
||||||
|
})
|
||||||
.map((category) => {
|
.map((category) => {
|
||||||
const tagList = category.data.tags.map(({ name, description }) => {
|
const tagList = category.data.tags.map<Tag>(({ name, description }) => {
|
||||||
description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " ").trim();
|
description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " ").trim();
|
||||||
return (typeof name === "string" ? { name, description } : { name: name["eng"]!, description }) as {
|
const tag = typeof name === "string" ? name : name["eng"];
|
||||||
name: string;
|
const id = slug(tag);
|
||||||
description?: string;
|
return { id, name: tag, description };
|
||||||
};
|
|
||||||
});
|
});
|
||||||
tagList.forEach(({ name }, index) => {
|
tagList.forEach(({ name }, index) => {
|
||||||
if (index !== tagList.findLastIndex(({ name: otherTag }) => name == otherTag)) {
|
if (index !== tagList.findLastIndex(({ name: otherTag }) => name == otherTag)) {
|
||||||
throw new Error(`Duplicated tag "${name}" found in multiple tag-categories lists`);
|
throw new Error(`Duplicated tag "${name}" found in multiple tag-categories lists`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return [
|
return {
|
||||||
category.data.name,
|
name: category.data.name,
|
||||||
category.id,
|
id: category.id,
|
||||||
tagList.filter(({ name }) => {
|
tags: tagList.filter(({ name }) => {
|
||||||
if (draftOnlyTagsSet.has(name)) {
|
if (draftOnlyTagsSet.has(name)) {
|
||||||
console.log(`Omitting draft-only tag "${name}"`);
|
console.log(`Omitting draft-only tag "${name}"`);
|
||||||
return false;
|
return false;
|
||||||
|
@ -59,7 +71,7 @@ const categorizedTags = tagCategories
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
] as [string, string, { name: string; description?: string }[]];
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (uncategorizedTagsSet.size > 0) {
|
if (uncategorizedTagsSet.size > 0) {
|
||||||
|
@ -92,16 +104,16 @@ if (uncategorizedTagsSet.size > 0) {
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
{
|
{
|
||||||
categorizedTags.map(([category, categorySlug, tagList]) =>
|
categorizedTags.map(({ name: category, id: categoryId, tags: tagList }) =>
|
||||||
tagList.length > 0 ? (
|
tagList.length > 0 ? (
|
||||||
<section class="my-2" aria-labelledby={`category-${categorySlug}`}>
|
<section class="my-2" aria-labelledby={`category-${categoryId}`}>
|
||||||
<h2 id={`category-${categorySlug}`} class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">
|
<h2 id={`category-${categoryId}`} class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">
|
||||||
{category}
|
{category}
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-2 px-2">
|
<ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-2 px-2">
|
||||||
{tagList.map(({ name, description }) => (
|
{tagList.map(({ id, name, description }) => (
|
||||||
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white">
|
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white">
|
||||||
<a class="hover:underline focus:underline" href={`/tags/${slug(name)}`} title={description}>
|
<a class="hover:underline focus:underline" href={`/tags/${id}`} title={description}>
|
||||||
{name}
|
{name}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { slug } from "github-slugger";
|
||||||
import GalleryLayout from "../../layouts/GalleryLayout.astro";
|
import GalleryLayout from "../../layouts/GalleryLayout.astro";
|
||||||
import Prose from "../../components/Prose.astro";
|
import Prose from "../../components/Prose.astro";
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
|
import { DEFAULT_LANG } from "../../i18n";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
tag: string;
|
tag: string;
|
||||||
|
@ -39,31 +40,31 @@ export const getStaticPaths: GetStaticPaths = async () => {
|
||||||
tags.add(tag);
|
tags.add(tag);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const tagDescriptions = Object.fromEntries(
|
const tagDescriptions = tagCategories.reduce(
|
||||||
tagCategories.flatMap((category) =>
|
(acc, category) => {
|
||||||
category.data.tags.reduce(
|
category.data.tags.forEach(({ name, description, related }) => {
|
||||||
(acc, { name, description, related }) => {
|
if (related) {
|
||||||
if (related) {
|
related = related.filter((relatedTag) => {
|
||||||
related = related.filter((relatedTag) => {
|
if (relatedTag == name) {
|
||||||
if (relatedTag == name) {
|
console.warn(`Tag "${name}" should not have itself as a related tag; removing`);
|
||||||
console.warn(`Tag "${name}" should not have itself as a related tag; removing`);
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
if (!tags.has(relatedTag)) {
|
||||||
if (!tags.has(relatedTag)) {
|
console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`);
|
||||||
console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`);
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
return true;
|
||||||
return true;
|
});
|
||||||
});
|
}
|
||||||
}
|
const key = typeof name === "string" ? name : name["eng"];
|
||||||
acc.push(
|
if (key in acc) {
|
||||||
typeof name === "string" ? [name, { description, related }] : [name["eng"]!, { description, related }],
|
throw new Error(`Duplicated tag "${key}" found in multiple tag-categories lists`);
|
||||||
);
|
}
|
||||||
return acc;
|
acc[key] = { description, related };
|
||||||
},
|
});
|
||||||
[] as [string, { description?: string; related?: string[] }][],
|
return acc;
|
||||||
),
|
},
|
||||||
),
|
{} as Record<string, { description?: string; related?: string[] }>,
|
||||||
);
|
);
|
||||||
return [...tags]
|
return [...tags]
|
||||||
.filter((tag) => !seriesTags.has(tag))
|
.filter((tag) => !seriesTags.has(tag))
|
||||||
|
@ -71,8 +72,8 @@ export const getStaticPaths: GetStaticPaths = async () => {
|
||||||
params: { slug: slug(tag) } satisfies Params,
|
params: { slug: slug(tag) } satisfies Params,
|
||||||
props: {
|
props: {
|
||||||
tag,
|
tag,
|
||||||
description: tagDescriptions[tag].description,
|
description: tagDescriptions[tag]?.description,
|
||||||
related: tagDescriptions[tag].related,
|
related: tagDescriptions[tag]?.related,
|
||||||
stories: stories
|
stories: stories
|
||||||
.filter((story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag))
|
.filter((story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag))
|
||||||
.sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime()),
|
.sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime()),
|
||||||
|
@ -85,23 +86,9 @@ export const getStaticPaths: GetStaticPaths = async () => {
|
||||||
|
|
||||||
const { tag, description, stories, games, related } = Astro.props;
|
const { tag, description, stories, games, related } = Astro.props;
|
||||||
if (!description) {
|
if (!description) {
|
||||||
console.log(`Tag "${tag}" has no description`);
|
console.warn(`Tag "${tag}" has no description`);
|
||||||
}
|
|
||||||
const count = stories.length + games.length;
|
|
||||||
let totalWorksWithTag: string = "";
|
|
||||||
if (count == 1) {
|
|
||||||
if (stories.length == 1) {
|
|
||||||
totalWorksWithTag = `One story tagged with "${tag}".`;
|
|
||||||
} else if (games.length == 1) {
|
|
||||||
totalWorksWithTag = `One game tagged with "${tag}".`;
|
|
||||||
}
|
|
||||||
} else if (stories.length == 0) {
|
|
||||||
totalWorksWithTag = `${games.length} games tagged with "${tag}".`;
|
|
||||||
} else if (games.length == 0) {
|
|
||||||
totalWorksWithTag = `${stories.length} stories tagged with "${tag}".`;
|
|
||||||
} else {
|
|
||||||
totalWorksWithTag = `${stories.length == 1 ? "One story" : `${stories.length} stories`} and ${games.length == 1 ? "one game" : `${games.length} games`} tagged with "${tag}".`;
|
|
||||||
}
|
}
|
||||||
|
const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stories.length, games.length);
|
||||||
---
|
---
|
||||||
|
|
||||||
<GalleryLayout pageTitle={`Works tagged "${tag}"`}>
|
<GalleryLayout pageTitle={`Works tagged "${tag}"`}>
|
||||||
|
@ -132,12 +119,7 @@ if (count == 1) {
|
||||||
<a
|
<a
|
||||||
class="text-link hover:underline focus:underline"
|
class="text-link hover:underline focus:underline"
|
||||||
href={`/stories/${story.slug}`}
|
href={`/stories/${story.slug}`}
|
||||||
title={t(
|
title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}
|
||||||
story.data.lang,
|
|
||||||
"story/warnings",
|
|
||||||
story.data.wordCount || "???",
|
|
||||||
story.data.contentWarning.trim(),
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{story.data.thumbnail ? (
|
{story.data.thumbnail ? (
|
||||||
<div class="flex aspect-square max-w-[192px] justify-center">
|
<div class="flex aspect-square max-w-[192px] justify-center">
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
import type { CollectionEntry } from "astro:content";
|
import type { CollectionEntry } from "astro:content";
|
||||||
import type { Lang } from "../content/config";
|
import { DEFAULT_LANG, type Lang } from "../content/config";
|
||||||
|
|
||||||
export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): string {
|
export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): string {
|
||||||
if (user.data.nameLang) {
|
if (user.data.lang[DEFAULT_LANG]) {
|
||||||
if (user.data.nameLang[lang]) {
|
if (user.data.lang[lang]) {
|
||||||
return user.data.nameLang[lang];
|
return user.data.lang[lang];
|
||||||
}
|
}
|
||||||
throw new Error(`No "${lang}" translation for username "${user.data.name}" ("${user.id}")`);
|
throw new Error(`No "${lang}" translation for username "${user.data.name}" ("${user.id}")`);
|
||||||
}
|
}
|
||||||
|
if (lang !== DEFAULT_LANG) {
|
||||||
|
console.warn(`User "${user.data.name}" ("${user.id}") has no "lang" property`);
|
||||||
|
}
|
||||||
return user.data.name;
|
return user.data.name;
|
||||||
}
|
}
|
||||||
|
|
6
src/utils/is_anonymous_user.ts
Normal file
6
src/utils/is_anonymous_user.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
import { ANONYMOUS_USER } from "../content/config";
|
||||||
|
|
||||||
|
const ANONYMOUS_USER_ID: CollectionEntry<"users">["id"] = ANONYMOUS_USER;
|
||||||
|
|
||||||
|
export const isAnonymousUser = (user: CollectionEntry<"users">) => user.id == ANONYMOUS_USER_ID;
|
Loading…
Add table
Add a link
Reference in a new issue