Improved search
This commit is contained in:
parent
815ca4d528
commit
122cfaf7eb
18 changed files with 275 additions and 72 deletions
|
|
@ -8,8 +8,8 @@ date = "2024"
|
|||
author = { name = "Bad Manners", url = "https://badmanners.xyz", email = "me@badmanners.xyz>" }
|
||||
source = "https://git.badmanners.xyz/badmanners/gallery.badmanners.xyz"
|
||||
license = { name = "MIT", url = "https://opensource.org/license/mit" }
|
||||
notes = """All rights reserved.
|
||||
The MIT License applies only to the source code; see additional copyrights for details."""
|
||||
notes = """All rights reserved. \
|
||||
The MIT License applies only to the source code; see additional copyrights for details."""
|
||||
|
||||
[[copyright.additional]]
|
||||
type = "logo"
|
||||
|
|
@ -18,7 +18,7 @@ notes = "The briefcase logo is copyrighted and trademarked by me. All rights res
|
|||
[[copyright.additional]]
|
||||
type = "characters"
|
||||
notes = """My characters, whether directly attributed to me or unattributed, are copyrighted by me. \
|
||||
All rights reserved."""
|
||||
All rights reserved."""
|
||||
|
||||
[[copyright.additional]]
|
||||
type = "content"
|
||||
|
|
@ -32,7 +32,7 @@ notes = "All rights reserved."
|
|||
[[copyright.additional]]
|
||||
type = "third-party trademarks"
|
||||
notes = """All third-party copyrights, trademarks, and attributed characters belong to their respective owners, \
|
||||
and I'm not affiliated with any of them."""
|
||||
and I'm not affiliated with any of them."""
|
||||
|
||||
[[attributions]]
|
||||
title = "Noto"
|
||||
|
|
@ -55,7 +55,7 @@ items = [
|
|||
"Weasyl",
|
||||
]
|
||||
notes = """All third-party copyrights and trademarks belong to their respective owners, \
|
||||
and I'm not affiliated with any of them."""
|
||||
and I'm not affiliated with any of them."""
|
||||
|
||||
[[attributions]]
|
||||
description = "Edited icons for other websites."
|
||||
|
|
@ -69,9 +69,9 @@ items = [
|
|||
"Inkbunny",
|
||||
"SoFurry",
|
||||
]
|
||||
notes = """Original icons edited for personal use and released under a permissive license.
|
||||
All third-party copyrights and trademarks belong to their respective owners, \
|
||||
and I'm not affiliated with any of them."""
|
||||
notes = """Original icons edited for personal use and released under a permissive license. \
|
||||
All third-party copyrights and trademarks belong to their respective owners, \
|
||||
and I'm not affiliated with any of them."""
|
||||
|
||||
[[attributions]]
|
||||
author = "Font Awesome"
|
||||
|
|
@ -106,6 +106,6 @@ type = "icons"
|
|||
source = "https://github.com/twitter/twemoji"
|
||||
license = { name = "CC-BY-4.0", url = "https://creativecommons.org/licenses/by/4.0/" }
|
||||
items = [
|
||||
"U+1F51E No One Under Eighteen Symbol",
|
||||
"U+2728 Sparkles",
|
||||
"🔞 U+1F51E No One Under Eighteen Symbol",
|
||||
"✨ U+2728 Sparkles",
|
||||
]
|
||||
|
|
|
|||
6
src/env.d.ts
vendored
6
src/env.d.ts
vendored
|
|
@ -5,3 +5,9 @@
|
|||
declare module "polywasm" {
|
||||
export const WebAssembly: typeof globalThis.WebAssembly;
|
||||
}
|
||||
|
||||
declare module "@pagefind/default-ui" {
|
||||
declare class PagefindUI {
|
||||
constructor(arg: any);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,33 @@ const UI_STRINGS = {
|
|||
tok: (count: number, nounSingular: string, nounPlural?: string) =>
|
||||
`${(count > 1 && nounPlural) || nounSingular} ${["ala", "wan", "tu"][count] || "mute"}`,
|
||||
},
|
||||
// Language functions
|
||||
"language/language": {
|
||||
en: (language?: Lang) => {
|
||||
if (!language || language === "en") {
|
||||
return "English";
|
||||
}
|
||||
switch (language) {
|
||||
case "tok":
|
||||
return "toki pona";
|
||||
default:
|
||||
let unknown: never = language;
|
||||
throw new Error(`Unknown language ${unknown}`);
|
||||
}
|
||||
},
|
||||
tok: (language?: Lang) => {
|
||||
if (!language || language === "tok") {
|
||||
return "toki pona";
|
||||
}
|
||||
switch (language) {
|
||||
case "en":
|
||||
return "toki Inli";
|
||||
default:
|
||||
let unknown: never = language;
|
||||
throw new Error(`Unknown language ${unknown}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
// export-story API functions
|
||||
"export_story/authors": {
|
||||
en: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`,
|
||||
|
|
|
|||
96
src/integrations/pagefind.ts
Normal file
96
src/integrations/pagefind.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { AstroIntegration } from "astro";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join } from "node:path";
|
||||
import sirv from "sirv";
|
||||
import * as pf from "pagefind";
|
||||
|
||||
type PagefindConfig = pf.PagefindServiceConfig;
|
||||
|
||||
export default function pagefind(config: PagefindConfig = {}): AstroIntegration {
|
||||
let outDir: string;
|
||||
let assets: string | null;
|
||||
return {
|
||||
name: "pagefind",
|
||||
hooks: {
|
||||
"astro:config:setup": ({ config, logger }) => {
|
||||
if (config.output === "server") {
|
||||
logger.warn(
|
||||
"Output type `server` does not produce static *.html pages in its output and thus will not work with astro-pagefind integration.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.adapter?.name.startsWith("@astrojs/vercel")) {
|
||||
outDir = fileURLToPath(new URL(".vercel/output/static/", config.root));
|
||||
} else if (config.adapter?.name === "@astrojs/cloudflare") {
|
||||
outDir = fileURLToPath(new URL(config.base?.replace(/^\//, ""), config.outDir));
|
||||
} else if (config.adapter?.name === "@astrojs/node" && config.output === "hybrid") {
|
||||
outDir = fileURLToPath(config.build.client!);
|
||||
} else {
|
||||
outDir = fileURLToPath(config.outDir);
|
||||
}
|
||||
if (config.build.assetsPrefix) {
|
||||
assets = null;
|
||||
} else {
|
||||
assets = config.build.assets;
|
||||
}
|
||||
},
|
||||
"astro:server:setup": ({ server, logger }) => {
|
||||
if (!outDir) {
|
||||
logger.warn(
|
||||
"astro-pagefind couldn't reliably determine the output directory. Search assets will not be served.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const serve = sirv(outDir, {
|
||||
dev: true,
|
||||
etag: true,
|
||||
});
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url?.startsWith("/pagefind/") || (assets && req.url?.startsWith(`/${assets}/`))) {
|
||||
serve(req, res, next);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
"astro:build:done": async ({ logger }) => {
|
||||
if (!outDir) {
|
||||
logger.warn(
|
||||
"astro-pagefind couldn't reliably determine the output directory. Search index will not be built.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { index, errors: createIndexErrors } = await pf.createIndex(config);
|
||||
if (createIndexErrors.length) {
|
||||
logger.warn(
|
||||
`astro-pagefind errored when creating index. Search index will not be built.\n\n${createIndexErrors.join("\n")}`,
|
||||
);
|
||||
await pf.close();
|
||||
return;
|
||||
}
|
||||
const { page_count, errors: addDirectoryErrors } = await index!.addDirectory({ path: outDir });
|
||||
if (addDirectoryErrors.length) {
|
||||
logger.warn(
|
||||
`astro-pagefind errored when adding the output directory. Search index will not be built.\n\n${addDirectoryErrors.join("\n")}`,
|
||||
);
|
||||
await pf.close();
|
||||
return;
|
||||
}
|
||||
logger.info(`astro-pagefind has indexed ${page_count} page(s).`);
|
||||
const { errors: writeFilesErrors } = await index!.writeFiles({
|
||||
outputPath: join(outDir, "pagefind"),
|
||||
});
|
||||
if (writeFilesErrors.length) {
|
||||
logger.warn(
|
||||
`astro-pagefind errored when writing files. Search index will not be built.\n\n${writeFilesErrors.join("\n")}`,
|
||||
);
|
||||
await pf.close();
|
||||
return;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -11,6 +11,16 @@ type Props = {
|
|||
};
|
||||
|
||||
const { pageTitle, lang = "en", isAgeRestricted } = Astro.props;
|
||||
const fonts = [
|
||||
"/fonts/noto-sans-latin-ext-wght-normal.woff2",
|
||||
"/fonts/noto-sans-latin-wght-normal.woff2",
|
||||
"/fonts/noto-sans-latin-ext-wght-italic.woff2",
|
||||
"/fonts/noto-sans-latin-wght-italic.woff2",
|
||||
"/fonts/noto-serif-latin-ext-wght-normal.woff2",
|
||||
"/fonts/noto-serif-latin-wght-normal.woff2",
|
||||
"/fonts/noto-serif-latin-ext-wght-italic.woff2",
|
||||
"/fonts/noto-serif-latin-wght-italic.woff2",
|
||||
];
|
||||
---
|
||||
|
||||
<html lang={lang}>
|
||||
|
|
@ -24,6 +34,7 @@ const { pageTitle, lang = "en", isAgeRestricted } = Astro.props;
|
|||
<meta name="msapplication-TileColor" content="#37b340" />
|
||||
<meta name="theme-color" content="#7DD05A" data-react-helmet="true" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
{fonts.map((font) => <link rel="preload" href={font} as="font" type="font/woff2" crossorigin="anonymous" />)}
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{pageTitle} | Bad Manners</title>
|
||||
<link rel="me" href="https://badmanners.xyz" />
|
||||
|
|
|
|||
|
|
@ -163,7 +163,9 @@ const returnTo = series
|
|||
<main
|
||||
class="h-entry mx-auto max-w-6xl rounded-lg bg-stone-50 px-2 pb-10 shadow-sm sm:px-6 md:px-32 lg:px-64 dark:bg-stone-900 print:max-w-full print:bg-none print:shadow-none"
|
||||
data-pagefind-body={props.isDraft ? undefined : ""}
|
||||
data-pagefind-meta={`type:${props.publishedContentType}`}
|
||||
data-language={t(props.lang, "language/language")}
|
||||
data-type={props.publishedContentType}
|
||||
data-pagefind-filter="type[data-type], language[data-language]"
|
||||
>
|
||||
{
|
||||
props.prev || props.next ? (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import GalleryLayout from "@layouts/GalleryLayout.astro";
|
|||
import UserComponent from "@components/UserComponent.astro";
|
||||
import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
|
||||
import { IconNoOneUnder18, IconSquareRSS } from "@components/icons";
|
||||
import { t } from "@i18n";
|
||||
|
||||
type PostWithPubDate = CollectionEntry<"blog"> & { data: { pubDate: Date } };
|
||||
|
||||
|
|
@ -35,7 +36,7 @@ const posts = await Promise.all(
|
|||
<ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
||||
{
|
||||
posts.map((post, i) => (
|
||||
<li class="h-entry" lang={post.data.lang}>
|
||||
<li class="h-entry">
|
||||
<a
|
||||
class="u-url text-link hover:underline focus:underline"
|
||||
href={`/blog/${post.slug}`}
|
||||
|
|
@ -79,6 +80,9 @@ const posts = await Promise.all(
|
|||
<p class="p-summary" aria-label="Summary">
|
||||
{post.data.description}
|
||||
</p>
|
||||
<p class="p-language" aria-label="Language">
|
||||
{t(post.data.lang, "language/language")}
|
||||
</p>
|
||||
<div aria-label="Authors">
|
||||
<span>{post.authors.length == 1 ? "Author:" : "Authors:"}</span>
|
||||
{post.authors.map((author) => (
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const games = await Promise.all(
|
|||
<ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
||||
{
|
||||
games.map((game, i) => (
|
||||
<li class="h-entry" lang={game.data.lang}>
|
||||
<li class="h-entry">
|
||||
<a
|
||||
class="u-url text-link hover:underline focus:underline"
|
||||
href={`/games/${game.slug}`}
|
||||
|
|
@ -79,6 +79,9 @@ const games = await Promise.all(
|
|||
<p class="p-summary" aria-label="Summary">
|
||||
{t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning)}
|
||||
</p>
|
||||
<p class="p-language" aria-label="Language">
|
||||
{t(game.data.lang, "language/language")}
|
||||
</p>
|
||||
<div aria-label="Authors">
|
||||
<span>{game.authors.length == 1 ? "Author:" : "Authors:"}</span>
|
||||
{game.authors.map((author) => (
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ if (featuredItems.length > MAX_FEATURED_ITEMS) {
|
|||
<ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
||||
{
|
||||
featuredItems.map((entry) => (
|
||||
<li class="h-entry break-inside-avoid" lang={entry.lang} aria-label={entry.title}>
|
||||
<li class="h-entry break-inside-avoid" aria-label={entry.title}>
|
||||
<a
|
||||
class="u-url text-link hover:underline focus:underline"
|
||||
href={entry.href}
|
||||
|
|
@ -235,6 +235,9 @@ if (featuredItems.length > MAX_FEATURED_ITEMS) {
|
|||
<p class="p-summary" aria-label="Summary">
|
||||
{entry.altText}
|
||||
</p>
|
||||
<p class="p-language" aria-label="Language">
|
||||
{t(entry.lang, "language/language")}
|
||||
</p>
|
||||
<div aria-label="Authors">
|
||||
<span>{entry.authors.length == 1 ? "Author:" : "Authors:"}</span>
|
||||
{entry.authors.map((author) => (
|
||||
|
|
@ -254,7 +257,7 @@ if (featuredItems.length > MAX_FEATURED_ITEMS) {
|
|||
<ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
||||
{
|
||||
latestItems.map((entry) => (
|
||||
<li class="h-entry break-inside-avoid" lang={entry.lang} aria-label={entry.title}>
|
||||
<li class="h-entry break-inside-avoid" aria-label={entry.title}>
|
||||
<a
|
||||
class="u-url text-link hover:underline focus:underline"
|
||||
href={entry.href}
|
||||
|
|
@ -301,6 +304,9 @@ if (featuredItems.length > MAX_FEATURED_ITEMS) {
|
|||
<p class="p-summary" aria-label="Summary">
|
||||
{entry.altText}
|
||||
</p>
|
||||
<p class="p-language" aria-label="Language">
|
||||
{t(entry.lang, "language/language")}
|
||||
</p>
|
||||
<div aria-label="Authors">
|
||||
<span>{entry.authors.length == 1 ? "Author:" : "Authors:"}</span>
|
||||
{entry.authors.map((author) => (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,30 @@
|
|||
---
|
||||
import SearchComponent from "astro-pagefind/components/Search";
|
||||
import GalleryLayout from "@layouts/GalleryLayout.astro";
|
||||
import "@pagefind/default-ui/css/ui.css";
|
||||
|
||||
const bundlePath = `${import.meta.env.BASE_URL}pagefind/`;
|
||||
---
|
||||
|
||||
<script>
|
||||
import { WebAssembly } from "polywasm";
|
||||
import { PagefindUI } from "@pagefind/default-ui";
|
||||
|
||||
globalThis.WebAssembly = globalThis.WebAssembly || WebAssembly;
|
||||
|
||||
function initPageFind() {
|
||||
const bundlePath = `${import.meta.env.BASE_URL}pagefind/`;
|
||||
new PagefindUI({
|
||||
element: "#search",
|
||||
bundlePath,
|
||||
resetStyles: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initPageFind);
|
||||
} else {
|
||||
initPageFind();
|
||||
}
|
||||
</script>
|
||||
|
||||
<GalleryLayout pageTitle="Search">
|
||||
|
|
@ -15,5 +34,5 @@ import GalleryLayout from "@layouts/GalleryLayout.astro";
|
|||
</Fragment>
|
||||
<h1 class="m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Search</h1>
|
||||
<hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" aria-hidden />
|
||||
<SearchComponent id="search" className="pagefind-ui my-4" />
|
||||
<div id="search" class="pagefind-ui pagefind-init my-4" data-pagefind-ui data-bundle-path={bundlePath}></div>
|
||||
</GalleryLayout>
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ const totalPages = Math.ceil(page.total / page.size);
|
|||
<ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
||||
{
|
||||
page.data.map((story, i) => (
|
||||
<li class="h-entry break-inside-avoid" lang={story.data.lang}>
|
||||
<li class="h-entry break-inside-avoid">
|
||||
<a
|
||||
class="u-url text-link hover:underline focus:underline"
|
||||
href={`/stories/${story.slug}`}
|
||||
|
|
@ -125,6 +125,9 @@ const totalPages = Math.ceil(page.total / page.size);
|
|||
<p class="p-summary" aria-label="Summary">
|
||||
{t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}
|
||||
</p>
|
||||
<p class="p-language" aria-label="Language">
|
||||
{t(story.data.lang, "language/language")}
|
||||
</p>
|
||||
<div aria-label="Authors">
|
||||
<span>{story.authors.length == 1 ? "Author:" : "Authors:"}</span>
|
||||
{story.authors.map((author) => (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Image } from "astro:assets";
|
|||
import GalleryLayout from "@layouts/GalleryLayout.astro";
|
||||
import mapImage from "@assets/images/tlotm_map.jpg";
|
||||
import { IconNoOneUnder18 } from "@components/icons";
|
||||
import { t } from "@i18n";
|
||||
|
||||
type StoryWithPubDate = CollectionEntry<"stories"> & { data: { pubDate: Date } };
|
||||
|
||||
|
|
@ -38,7 +39,9 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
|
|||
<h2
|
||||
id="main-chapters"
|
||||
class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100"
|
||||
data-pagefind-meta="type:series"
|
||||
data-language={t("en", "language/language")}
|
||||
data-type="series"
|
||||
data-pagefind-filter="type[data-type], language[data-language]"
|
||||
>
|
||||
Main chapters
|
||||
</h2>
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ const totalWorksWithTag = t(
|
|||
</h2>
|
||||
<ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
||||
{props.stories.map((story) => (
|
||||
<li class="h-entry break-inside-avoid" lang={story.data.lang}>
|
||||
<li class="h-entry break-inside-avoid">
|
||||
<a
|
||||
class="u-url text-link hover:underline focus:underline"
|
||||
href={`/stories/${story.slug}`}
|
||||
|
|
@ -211,6 +211,9 @@ const totalWorksWithTag = t(
|
|||
<p class="p-summary" aria-label="Summary">
|
||||
{t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}
|
||||
</p>
|
||||
<p class="p-language" aria-label="Language">
|
||||
{t(story.data.lang, "language/language")}
|
||||
</p>
|
||||
<div aria-label="Authors">
|
||||
<span>{story.authors.length == 1 ? "Author:" : "Authors:"}</span>
|
||||
{story.authors.map((author) => (
|
||||
|
|
@ -232,7 +235,7 @@ const totalWorksWithTag = t(
|
|||
</h2>
|
||||
<ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
||||
{props.games.map((game) => (
|
||||
<li class="h-entry break-inside-avoid" lang={game.data.lang}>
|
||||
<li class="h-entry break-inside-avoid">
|
||||
<a
|
||||
class="u-url text-link hover:underline focus:underline"
|
||||
href={`/games/${game.slug}`}
|
||||
|
|
@ -275,6 +278,9 @@ const totalWorksWithTag = t(
|
|||
<p class="p-summary" aria-label="Summary">
|
||||
{t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning)}
|
||||
</p>
|
||||
<p class="p-language" aria-label="Language">
|
||||
{t(game.data.lang, "language/language")}
|
||||
</p>
|
||||
<div aria-label="Authors">
|
||||
<span>{game.authors.length == 1 ? "Author:" : "Authors:"}</span>
|
||||
{game.authors.map((author) => (
|
||||
|
|
@ -296,7 +302,7 @@ const totalWorksWithTag = t(
|
|||
</h2>
|
||||
<ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
||||
{props.blogPosts.map((post) => (
|
||||
<li class="h-entry break-inside-avoid" lang={post.data.lang}>
|
||||
<li class="h-entry break-inside-avoid">
|
||||
<a
|
||||
class="u-url text-link hover:underline focus:underline"
|
||||
href={`/blog/${post.slug}`}
|
||||
|
|
@ -339,6 +345,9 @@ const totalWorksWithTag = t(
|
|||
<p class="p-summary" aria-label="Summary">
|
||||
{post.data.description}
|
||||
</p>
|
||||
<p class="p-language" aria-label="Language">
|
||||
{t(post.data.lang, "language/language")}
|
||||
</p>
|
||||
<div aria-label="Authors">
|
||||
<span>{post.authors.length == 1 ? "Author:" : "Authors:"}</span>
|
||||
{post.authors.map((author) => (
|
||||
|
|
|
|||
|
|
@ -28,6 +28,24 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Pagefind inputs */
|
||||
.pagefind-ui input::placeholder {
|
||||
color: theme(colors.stone.500) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.pagefind-ui input:checked {
|
||||
background-color: theme(colors.bm.600) !important;
|
||||
}
|
||||
@media not print {
|
||||
.dark .pagefind-ui input::placeholder {
|
||||
color: theme(colors.stone.400) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.dark .pagefind-ui input:checked {
|
||||
background-color: theme(colors.green.800) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.text-link,
|
||||
.pagefind-ui .pagefind-ui__result-link {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue