Bring over improvements from badmanners.xyz
This commit is contained in:
parent
d022fab5d6
commit
c55c82633d
24 changed files with 542 additions and 444 deletions
|
@ -1,3 +1,4 @@
|
|||
src/components/AutoDarkMode.astro
|
||||
src/components/AgeRestrictedScriptInline.astro
|
||||
src/components/DarkModeScriptInline.astro
|
||||
.astro/
|
||||
dist/
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { join, normalize as n } from "node:path/posix";
|
||||
import { program, Option } from "commander";
|
||||
|
||||
interface DeployLftpOptions {
|
||||
|
@ -18,8 +18,8 @@ async function deployLftp({ host, user, password, targetFolder, sourceFolder, as
|
|||
"-c",
|
||||
[
|
||||
`open -u ${user},${password} ${host}`,
|
||||
`mirror --reverse --include-glob ${join(assetsFolder, "*")} --delete --only-missing --no-perms --verbose ${sourceFolder} ${targetFolder}`,
|
||||
`mirror --reverse --exclude-glob ${join(assetsFolder, "*")} --delete --no-perms --verbose ${sourceFolder} ${targetFolder}`,
|
||||
`mirror --reverse --include-glob ${join(assetsFolder, "*")} --delete --only-missing --no-perms --verbose ${n(sourceFolder)} ${n(targetFolder)}`,
|
||||
`mirror --reverse --exclude-glob ${join(assetsFolder, "*")} --delete --no-perms --verbose ${n(sourceFolder)} ${n(targetFolder)}`,
|
||||
`bye`,
|
||||
].join("\n"),
|
||||
],
|
||||
|
@ -28,13 +28,9 @@ async function deployLftp({ host, user, password, targetFolder, sourceFolder, as
|
|||
},
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
process.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(`deploy-lftp failed with code ${code}`);
|
||||
}
|
||||
});
|
||||
process.on("close", (code) =>
|
||||
(code === 0) ? resolve() : reject(`lftp failed with code ${code}`),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { type ChildProcess, exec, spawnSync } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { readdir, mkdir, mkdtemp, writeFile, readFile, copyFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join as pathJoin, normalize } from "node:path";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { join, normalize } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { program } from "commander";
|
||||
import fetchRetryWrapper from "fetch-retry";
|
||||
|
||||
|
@ -31,26 +31,16 @@ function getRTFStyles(rtfSource: string) {
|
|||
|
||||
const fetchRetry = fetchRetryWrapper(global.fetch);
|
||||
|
||||
interface AstroApiResponse {
|
||||
story: string;
|
||||
description: Record<string, string>;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
const isLibreOfficeRunning = async () =>
|
||||
new Promise<boolean>((res, rej) => {
|
||||
exec("ps -ax", (err, stdout) => {
|
||||
if (err) {
|
||||
rej(err);
|
||||
return;
|
||||
new Promise<boolean>((res) => {
|
||||
spawn("ps", ["-ax"], {stdio: 'pipe'});
|
||||
const lines = createInterface({ input: spawn("ps", ["-ax"], {stdio: 'pipe'}).stdout });
|
||||
lines.on("line", (line) => {
|
||||
if (line.includes("libreoffice") && line.includes("--writer")) {
|
||||
res(true);
|
||||
}
|
||||
res(
|
||||
stdout
|
||||
.toLowerCase()
|
||||
.split("\n")
|
||||
.some((line) => line.includes("libreoffice") && line.includes("--writer")),
|
||||
);
|
||||
});
|
||||
lines.on("close", () => res(false));
|
||||
});
|
||||
|
||||
async function exportStory(slug: string, options: { outputDir: string }) {
|
||||
|
@ -72,53 +62,53 @@ async function exportStory(slug: string, options: { outputDir: string }) {
|
|||
console.error(`ERROR: Directory ${outputDir} is not empty!`);
|
||||
process.exit(1);
|
||||
}
|
||||
/* Check if Astro development server needs to be spawned */
|
||||
const healthcheckURL = `http://localhost:4321/api/healthcheck`;
|
||||
let devServerProcess: ChildProcess | null = null;
|
||||
|
||||
/* Spawn Astro dev server */
|
||||
console.log("Starting Astro development server...");
|
||||
const devServerProcess = spawn("./node_modules/.bin/astro", ["dev"], { stdio: 'pipe' });
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
const localServerRegex = /Local\s+(http\S+)/
|
||||
const lines = createInterface({ input: devServerProcess.stdout });
|
||||
lines.on("line", (line) => {
|
||||
const match = localServerRegex.exec(line);
|
||||
if (match && match[1]) {
|
||||
resolve(match[1]);
|
||||
}
|
||||
});
|
||||
lines.on("close", reject);
|
||||
});
|
||||
const astroURL = await promise;
|
||||
console.log(`Astro listening on ${astroURL}`);
|
||||
try {
|
||||
const response = await fetchRetry(healthcheckURL, { retries: 3, retryDelay: 1000 });
|
||||
const response = await fetchRetry(new URL(`/api/healthcheck`, astroURL), { retries: 5, retryDelay: 2000 });
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
const healthcheck = await response.json();
|
||||
if (!healthcheck.isAlive) {
|
||||
throw new Error();
|
||||
const healthcheck: { isAlive: boolean } = await response.json();
|
||||
if (healthcheck.isAlive !== true) {
|
||||
throw new Error(JSON.stringify(healthcheck));
|
||||
}
|
||||
} catch {
|
||||
/* Spawn Astro dev server */
|
||||
console.log("Starting Astro development server...");
|
||||
devServerProcess = exec("./node_modules/.bin/astro dev");
|
||||
await setTimeout(2000);
|
||||
try {
|
||||
const response = await fetchRetry(healthcheckURL, { retries: 5, retryDelay: 2000 });
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
}
|
||||
const healthcheck = await response.json();
|
||||
if (!healthcheck.isAlive) {
|
||||
throw new Error();
|
||||
}
|
||||
} catch {
|
||||
console.error("ERROR: Astro dev server didn't respond in time!");
|
||||
devServerProcess && devServerProcess.kill();
|
||||
devServerProcess = null;
|
||||
process.exit(1);
|
||||
console.error("ERROR: Astro dev server didn't respond in time!");
|
||||
if (!devServerProcess.kill("SIGTERM")) {
|
||||
console.error("WARNING: Unable to shut down Astro dev server!");
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/* Get data (story, thumbnail, descriptions) from Astro development server */
|
||||
let storyText = "";
|
||||
try {
|
||||
console.log("Getting data from Astro...");
|
||||
|
||||
const response = await fetch(`http://localhost:4321/api/export-story/${slug}`);
|
||||
const response = await fetch(new URL(`/api/export-story/${slug}`, astroURL));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to reach API (status code ${response.status})`);
|
||||
}
|
||||
const data: AstroApiResponse = await response.json();
|
||||
const data: { story: string, description: Record<string, string>, thumbnail: string | null } = await response.json();
|
||||
await Promise.all(
|
||||
Object.entries(data.description).map(async ([website, description]) => {
|
||||
return await writeFile(
|
||||
pathJoin(outputDir, `description_${website}.${website === "weasyl" ? "md" : "txt"}`),
|
||||
join(outputDir, `description_${website}.${website === "weasyl" ? "md" : "txt"}`),
|
||||
description,
|
||||
);
|
||||
}),
|
||||
|
@ -128,42 +118,41 @@ async function exportStory(slug: string, options: { outputDir: string }) {
|
|||
const thumbnailPath = data.thumbnail
|
||||
.replace(/^\/@fs/, "")
|
||||
.replace(/\?(&?[a-z][a-zA-Z0-9_-]+=[a-zA-Z0-9_-]*)*$/, "");
|
||||
await copyFile(thumbnailPath, pathJoin(outputDir, `thumbnail${thumbnailPath.match(/\.[^.]+$/)![0]}`));
|
||||
await copyFile(thumbnailPath, join(outputDir, `thumbnail${thumbnailPath.match(/\.[^.]+$/)![0]}`));
|
||||
} else {
|
||||
const thumbnail = await fetch(data.thumbnail);
|
||||
if (!thumbnail.ok) {
|
||||
throw new Error("Failed to get thumbnail");
|
||||
}
|
||||
const thumbnailExt = thumbnail.headers.get("Content-Type")?.startsWith("image/png") ? "png" : "jpg";
|
||||
await writeFile(pathJoin(outputDir, `thumbnail.${thumbnailExt}`), Buffer.from(await thumbnail.arrayBuffer()));
|
||||
await writeFile(join(outputDir, `thumbnail.${thumbnailExt}`), Buffer.from(await thumbnail.arrayBuffer()));
|
||||
}
|
||||
}
|
||||
storyText = data.story;
|
||||
writeFile(pathJoin(outputDir, `${slug}.txt`), storyText);
|
||||
writeFile(join(outputDir, `${slug}.txt`), storyText);
|
||||
} finally {
|
||||
if (devServerProcess) {
|
||||
console.log("Shutting down the Astro development server...");
|
||||
if (!devServerProcess.kill("SIGTERM")) {
|
||||
console.error("WARNING: Unable to shut down Astro dev server!");
|
||||
}
|
||||
devServerProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* Parse story into output formats */
|
||||
console.log("Parsing story into output formats...");
|
||||
await writeFile(pathJoin(outputDir, `${slug}.md`), storyText.replaceAll(/=(?==)/g, "= ").replaceAll("*", "\\*"));
|
||||
const tempDir = await mkdtemp(pathJoin(tmpdir(), "export-story-"));
|
||||
await writeFile(pathJoin(tempDir, "temp.txt"), storyText.replaceAll(/\n\n+/g, "\n"));
|
||||
await writeFile(join(outputDir, `${slug}.md`), storyText.replaceAll(/=(?==)/g, "= ").replaceAll("*", "\\*"));
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "export-story-"));
|
||||
await writeFile(join(tempDir, "temp.txt"), storyText.replaceAll(/\n\n+/g, "\n"));
|
||||
spawnSync(
|
||||
"libreoffice",
|
||||
["--convert-to", "rtf:Rich Text Format", "--outdir", tempDir, pathJoin(tempDir, "temp.txt")],
|
||||
["--convert-to", "rtf:Rich Text Format", "--outdir", tempDir, join(tempDir, "temp.txt")],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
const rtfText = await readFile(pathJoin(tempDir, "temp.rtf"), "utf-8");
|
||||
const rtfText = await readFile(join(tempDir, "temp.rtf"), "utf-8");
|
||||
const rtfStyles = getRTFStyles(rtfText);
|
||||
await writeFile(
|
||||
pathJoin(outputDir, `${slug}.rtf`),
|
||||
join(outputDir, `${slug}.rtf`),
|
||||
rtfText.replaceAll(rtfStyles["Preformatted Text"], rtfStyles["Normal"]),
|
||||
);
|
||||
console.log("Success!");
|
||||
|
|
|
@ -1,86 +1,87 @@
|
|||
---
|
||||
import AgeRestrictedScriptInline from "./AgeRestrictedScriptInline.astro";
|
||||
import IconTriangleExclamation from "./icons/IconTriangleExclamation.astro";
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<template id="template-modal-age-restricted">
|
||||
<div
|
||||
id="modal-age-restricted"
|
||||
class="fixed inset-0 bg-stone-100 dark:bg-stone-900"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="false"
|
||||
>
|
||||
<div class="mx-auto flex min-h-screen max-w-3xl flex-col items-center justify-center text-center tracking-tight">
|
||||
<div class="text-bm-500 dark:text-bm-400">
|
||||
<svg style={{ width: "3rem", height: "3rem", fill: "currentColor" }} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24V296c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="pb-3 pt-2 text-3xl font-light text-stone-700 sm:pb-4 sm:pt-2 dark:text-stone-50">
|
||||
Age verification
|
||||
</div>
|
||||
<div
|
||||
class="mx-6 mb-4 max-w-xl border-b border-stone-300 pb-4 text-xl text-stone-700 dark:border-stone-300 dark:text-stone-50"
|
||||
<div
|
||||
style={{display: "none"}}
|
||||
id="modal-age-restricted"
|
||||
class="fixed inset-0 bg-stone-100 dark:bg-stone-900"
|
||||
role="dialog"
|
||||
aria-labelledby="title-age-restricted"
|
||||
>
|
||||
<div class="mx-auto flex min-h-screen max-w-3xl flex-col items-center justify-center text-center tracking-tight">
|
||||
<div class="text-bm-500 dark:text-bm-400">
|
||||
<IconTriangleExclamation width="3rem" height="3rem" />
|
||||
</div>
|
||||
<div id="title-age-restricted" class="pb-3 pt-2 text-3xl font-light text-stone-700 sm:pb-4 sm:pt-2 dark:text-stone-50">
|
||||
Age verification
|
||||
</div>
|
||||
<div
|
||||
class="mx-6 mb-4 max-w-xl border-b border-stone-300 pb-4 text-xl text-stone-700 dark:border-stone-300 dark:text-stone-50"
|
||||
>
|
||||
You must be 18+ to access this page.
|
||||
</div>
|
||||
<p class="px-8 text-lg font-light leading-snug text-stone-700 sm:max-w-2xl dark:text-stone-50">
|
||||
By confirming that you are at least 18 years old, your selection will be saved to your browser to prevent this
|
||||
screen from appearing in the future.
|
||||
</p>
|
||||
<div class="flex w-full max-w-md flex-col-reverse justify-evenly gap-y-5 px-6 pt-5 sm:max-w-2xl sm:flex-row">
|
||||
<button
|
||||
data-modal-reject
|
||||
id="age-verification-reject"
|
||||
class="rounded bg-stone-400 py-3 text-lg text-stone-900 hover:bg-stone-500 hover:text-stone-50 focus:bg-stone-500 focus:text-stone-50 sm:px-9 dark:bg-stone-300 dark:text-stone-900 dark:hover:bg-stone-600 dark:hover:text-stone-50 dark:focus:bg-stone-600 dark:focus:text-stone-50"
|
||||
>
|
||||
You must be 18+ to access this page.
|
||||
</div>
|
||||
<p class="px-8 text-lg font-light leading-snug text-stone-700 sm:max-w-2xl dark:text-stone-50">
|
||||
By confirming that you are at least 18 years old, your selection will be saved to your browser to prevent this
|
||||
screen from appearing in the future.
|
||||
</p>
|
||||
<div class="flex w-full max-w-md flex-col-reverse justify-evenly gap-y-5 px-6 pt-5 sm:max-w-2xl sm:flex-row">
|
||||
<button
|
||||
data-modal-reject
|
||||
id="age-verification-reject"
|
||||
class="rounded bg-stone-400 py-3 text-lg text-stone-900 hover:bg-stone-500 hover:text-stone-50 focus:bg-stone-500 focus:text-stone-50 sm:px-9 dark:bg-stone-300 dark:text-stone-900 dark:hover:bg-stone-600 dark:hover:text-stone-50 dark:focus:bg-stone-600 dark:focus:text-stone-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
data-modal-accept
|
||||
id="age-verification-accept"
|
||||
class="rounded bg-bm-500 py-3 text-lg text-stone-900 hover:bg-stone-500 hover:text-stone-50 focus:bg-stone-500 focus:text-stone-50 sm:px-9 dark:bg-bm-400 dark:text-stone-900 dark:hover:bg-stone-600 dark:hover:text-stone-50 dark:focus:bg-stone-600 dark:focus:text-stone-50"
|
||||
>
|
||||
I'm at least 18 years old
|
||||
</button>
|
||||
</div>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
data-modal-accept
|
||||
id="age-verification-accept"
|
||||
class="rounded bg-bm-500 py-3 text-lg text-stone-900 hover:bg-stone-500 hover:text-stone-50 focus:bg-stone-500 focus:text-stone-50 sm:px-9 dark:bg-bm-400 dark:text-stone-900 dark:hover:bg-stone-600 dark:hover:text-stone-50 dark:focus:bg-stone-600 dark:focus:text-stone-50"
|
||||
>
|
||||
I'm at least 18 years old
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<AgeRestrictedScriptInline />
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
if (localStorage.getItem("ageVerified") !== "true") {
|
||||
const fragment = document
|
||||
.querySelector<HTMLElementTagNameMap["template"]>("template#template-modal-age-restricted")!
|
||||
.content.cloneNode(true) as DocumentFragment;
|
||||
const modal = fragment.firstElementChild as HTMLElementTagNameMap["div"];
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!.addEventListener(
|
||||
"click",
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
location.href = "about:blank";
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
const ENABLE_VIEW_TRANSITIONS = false;
|
||||
|
||||
const ageRestrictedModalSetup = () => {
|
||||
const modal = document.querySelector<HTMLElementTagNameMap["div"]>("body > div#modal-age-restricted");
|
||||
if (!modal) {
|
||||
throw new Error("Missing #modal-age-restricted element! Make sure that it's a direct child of body.");
|
||||
}
|
||||
let ageVerified: "true" | undefined = localStorage.ageVerified;
|
||||
if (ageVerified !== "true") {
|
||||
const rejectButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!;
|
||||
const onRejectButtonClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
location.href = "about:blank";
|
||||
};
|
||||
rejectButton.addEventListener("click", onRejectButtonClick);
|
||||
modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!.addEventListener(
|
||||
"click",
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
controller.abort();
|
||||
localStorage.setItem("ageVerified", "true");
|
||||
rejectButton.removeEventListener("click", onRejectButtonClick);
|
||||
ageVerified = "true";
|
||||
localStorage.ageVerified = ageVerified;
|
||||
document.body.style.overflow = "auto";
|
||||
modal.remove();
|
||||
document.querySelectorAll("body > :not(#modal-age-restricted)").forEach((el) => el.removeAttribute("inert"));
|
||||
modal.style.display = "none";
|
||||
},
|
||||
{ signal },
|
||||
{ once: true },
|
||||
);
|
||||
document.body.style.overflow = "hidden";
|
||||
document.body.appendChild(fragment);
|
||||
document.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")?.focus();
|
||||
rejectButton.focus();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
};
|
||||
if (ENABLE_VIEW_TRANSITIONS) {
|
||||
document.addEventListener("astro:page-load", ageRestrictedModalSetup);
|
||||
} else {
|
||||
ageRestrictedModalSetup();
|
||||
}
|
||||
</script>
|
4
src/components/AgeRestrictedScriptInline.astro
Normal file
4
src/components/AgeRestrictedScriptInline.astro
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
---
|
||||
|
||||
<script is:inline>function a(){let b=document,c="#modal-age-restricted",d="true";localStorage.ageVerified!==d&&((b.body.style.overflow="hidden"),b.querySelectorAll("body > :not("+c+")").forEach(e=>e.setAttribute("inert",d)),(b.querySelector("body > "+c).style.display="block"));}document.addEventListener("astro:after-swap",a);a()</script>
|
|
@ -1,4 +0,0 @@
|
|||
---
|
||||
---
|
||||
|
||||
<script is:inline>!function(){var a="dark",b="auto",c="colorScheme",d=document.body.classList,e=localStorage,f=e.getItem(c);f&&f!==b?f===a&&d.add(a):(e.setItem(c,b),matchMedia("(prefers-color-scheme: dark)").matches&&d.add(a))}();</script>
|
|
@ -1,30 +1,39 @@
|
|||
---
|
||||
import DarkModeInline from "./AutoDarkMode.astro";
|
||||
import DarkModeScriptInline from "./DarkModeScriptInline.astro";
|
||||
---
|
||||
|
||||
<DarkModeInline />
|
||||
<DarkModeScriptInline />
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
let colorScheme = localStorage.getItem("colorScheme");
|
||||
if (colorScheme == null || colorScheme === "auto") {
|
||||
const ENABLE_VIEW_TRANSITIONS = false;
|
||||
type ColorScheme = "auto" | "dark" | "light";
|
||||
|
||||
const colorSchemeSetup = () => {
|
||||
let colorScheme: ColorScheme | undefined = localStorage.colorScheme;
|
||||
if (!colorScheme || colorScheme === "auto") {
|
||||
colorScheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
const toggleColorScheme = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (colorScheme === "dark") {
|
||||
colorScheme = "light";
|
||||
document.body.classList.remove("dark");
|
||||
} else {
|
||||
colorScheme = "dark";
|
||||
document.body.classList.add("dark");
|
||||
}
|
||||
localStorage.colorScheme = colorScheme;
|
||||
};
|
||||
document.querySelectorAll<HTMLElementTagNameMap["button"]>("button[data-dark-mode]").forEach((button) => {
|
||||
button.classList.remove("hidden");
|
||||
button.style.removeProperty("display");
|
||||
button.setAttribute("aria-hidden", "false");
|
||||
button.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (colorScheme === "dark") {
|
||||
colorScheme = "light";
|
||||
document.body.classList.remove("dark");
|
||||
} else {
|
||||
colorScheme = "dark";
|
||||
document.body.classList.add("dark");
|
||||
}
|
||||
localStorage.setItem("colorScheme", colorScheme);
|
||||
});
|
||||
button.addEventListener("click", toggleColorScheme);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
};
|
||||
if (ENABLE_VIEW_TRANSITIONS) {
|
||||
document.addEventListener("astro:page-load", colorSchemeSetup);
|
||||
} else {
|
||||
colorSchemeSetup();
|
||||
}
|
||||
</script>
|
4
src/components/DarkModeScriptInline.astro
Normal file
4
src/components/DarkModeScriptInline.astro
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
---
|
||||
|
||||
<script is:inline>function a(){var b="dark",c="auto",d="colorScheme",e=document.body.classList,f=localStorage,g=f&&f[d];g&&g!==c?g===b&&e.add(b):(f&&(f[d]=c),matchMedia("(prefers-color-scheme: dark)").matches&&e.add(b))};document.addEventListener("astro:after-swap",a);a()</script>
|
|
@ -1,5 +1,7 @@
|
|||
---
|
||||
import type { Lang } from "../i18n";
|
||||
import IconFavorites from "./icons/IconFavorites.astro";
|
||||
import IconReblogs from "./icons/IconReblogs.astro";
|
||||
|
||||
type Props = {
|
||||
lang: Lang;
|
||||
|
@ -108,19 +110,11 @@ const { link, instance, user, postId } = Astro.props;
|
|||
<div class="ml-1 flex flex-row pb-2 pt-1">
|
||||
<div class="flex" aria-label="Favorites">
|
||||
<span data-favorites></span>
|
||||
<svg style={{ width: "1.25rem", fill: "currentColor" }} class="ml-2" viewBox="0 0 576 512" aria-hidden="true">
|
||||
<path
|
||||
d="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z"
|
||||
></path>
|
||||
</svg>
|
||||
<IconFavorites width="1.25rem" height="1.25rem" class="ml-2" />
|
||||
</div>
|
||||
<div class="ml-4 flex" aria-label="Reblogs">
|
||||
<span data-reblogs></span>
|
||||
<svg style={{ width: "1.25rem", fill: "currentColor" }} class="ml-2" viewBox="0 0 512 512" aria-hidden="true">
|
||||
<path
|
||||
d="M0 224c0 17.7 14.3 32 32 32s32-14.3 32-32c0-53 43-96 96-96H320v32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S320 19.1 320 32V64H160C71.6 64 0 135.6 0 224zm512 64c0-17.7-14.3-32-32-32s-32 14.3-32 32c0 53-43 96-96 96H192V352c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6V448H352c88.4 0 160-71.6 160-160z"
|
||||
></path>
|
||||
</svg>
|
||||
<IconReblogs width="1.25rem" height="1.25rem" class="ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div data-comment-thread class="-mb-2" aria-hidden="true" aria-label="Replies"></div>
|
||||
|
@ -128,6 +122,8 @@ const { link, instance, user, postId } = Astro.props;
|
|||
</template>
|
||||
|
||||
<script>
|
||||
const ENABLE_VIEW_TRANSITIONS = false;
|
||||
|
||||
interface MastodonPost {
|
||||
link: string;
|
||||
instance: string;
|
||||
|
@ -162,170 +158,172 @@ const { link, instance, user, postId } = Astro.props;
|
|||
emojis: CustomEmoji[];
|
||||
}
|
||||
|
||||
interface Context {
|
||||
interface StatusContext {
|
||||
ancestors: Status[];
|
||||
descendants: Status[];
|
||||
}
|
||||
|
||||
(function () {
|
||||
async function renderComments(section: Element, post: MastodonPost) {
|
||||
const commentsDescription = section.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!;
|
||||
const loadCommentsButton = section.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!;
|
||||
const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
|
||||
try {
|
||||
const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
|
||||
async function renderComments(section: Element, post: MastodonPost) {
|
||||
const commentsDescription = section.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!;
|
||||
const loadCommentsButton = section.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!;
|
||||
try {
|
||||
const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
|
||||
}
|
||||
const data: StatusContext = await response.json();
|
||||
|
||||
const emojiTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
|
||||
"template#template-comment-emoji",
|
||||
)!;
|
||||
const emojiMap: Record<string, string> = {};
|
||||
const replaceEmojis = (text: string, emojis: CustomEmoji[]) =>
|
||||
emojis.reduce((acc, emoji) => {
|
||||
let emojiHTML = emojiMap[emoji.url];
|
||||
if (!emojiHTML) {
|
||||
const emojiPicture = emojiTemplate.content.cloneNode(true) as DocumentFragment;
|
||||
const emojiStatic = emojiPicture.querySelector("source")!;
|
||||
emojiStatic.srcset = emoji.static_url;
|
||||
const emojiImg = emojiPicture.querySelector("img")!;
|
||||
emojiImg.src = emoji.url;
|
||||
emojiImg.alt = `:${emoji.shortcode}: emoji`;
|
||||
emojiHTML = emojiPicture.firstElementChild!.outerHTML;
|
||||
emojiMap[emoji.url] = emojiHTML;
|
||||
}
|
||||
return acc.replaceAll(`:${emoji.shortcode}:`, emojiHTML);
|
||||
}, text);
|
||||
const commentsList: DocumentFragment[] = [];
|
||||
const commentMap: Record<string, number> = {};
|
||||
const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
|
||||
"template#template-comment-box",
|
||||
)!;
|
||||
|
||||
data.descendants.forEach((comment) => {
|
||||
const commentBox = commentTemplate.content.cloneNode(true) as DocumentFragment;
|
||||
commentBox.firstElementChild!.id = `comment-${comment.id}`;
|
||||
|
||||
const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!;
|
||||
commentBoxAuthor.href = comment.account.url;
|
||||
commentBoxAuthor.title = comment.account.acct;
|
||||
commentBoxAuthor.setAttribute("aria-label", comment.account.acct);
|
||||
const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!;
|
||||
avatar.src = comment.account.avatar;
|
||||
avatar.alt = `Avatar of @${comment.account.acct}`;
|
||||
const avatarStatic =
|
||||
commentBoxAuthor.querySelector<HTMLElementTagNameMap["source"]>("source[data-avatar-static]")!;
|
||||
avatarStatic.srcset = comment.account.avatar_static;
|
||||
const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!;
|
||||
displayName.insertAdjacentHTML(
|
||||
"afterbegin",
|
||||
replaceEmojis(comment.account.display_name, comment.account.emojis),
|
||||
);
|
||||
|
||||
const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!;
|
||||
commentBoxPostLink.href = comment.url;
|
||||
const publishDate =
|
||||
commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-published-date]")!;
|
||||
publishDate.dateTime = comment.created_at;
|
||||
publishDate.insertAdjacentText(
|
||||
"afterbegin",
|
||||
new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
);
|
||||
|
||||
if (comment.edited_at) {
|
||||
const edited = commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-edited-date]")!;
|
||||
edited.dateTime = comment.edited_at;
|
||||
edited.title = comment.edited_at;
|
||||
edited.classList.remove("hidden");
|
||||
edited.classList.add("dt-updated");
|
||||
edited.style.removeProperty("display");
|
||||
}
|
||||
const data: Context = await response.json();
|
||||
|
||||
const emojiTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
|
||||
"template#template-comment-emoji",
|
||||
)!;
|
||||
const emojiMap: Record<string, string> = {};
|
||||
const replaceEmojis = (text: string, emojis: CustomEmoji[]) =>
|
||||
emojis.reduce((acc, emoji) => {
|
||||
let emojiHTML = emojiMap[emoji.url];
|
||||
if (!emojiHTML) {
|
||||
const emojiPicture = emojiTemplate.content.cloneNode(true) as DocumentFragment;
|
||||
const emojiStatic = emojiPicture.querySelector("source")!;
|
||||
emojiStatic.srcset = emoji.static_url;
|
||||
const emojiImg = emojiPicture.querySelector("img")!;
|
||||
emojiImg.src = emoji.url;
|
||||
emojiImg.alt = `:${emoji.shortcode}: emoji`;
|
||||
emojiHTML = emojiPicture.firstElementChild!.outerHTML;
|
||||
emojiMap[emoji.url] = emojiHTML;
|
||||
}
|
||||
return acc.replaceAll(`:${emoji.shortcode}:`, emojiHTML);
|
||||
}, text);
|
||||
const commentsList: DocumentFragment[] = [];
|
||||
const commentMap: Record<string, number> = {};
|
||||
const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
|
||||
"template#template-comment-box",
|
||||
)!;
|
||||
const commentBoxContent = commentBox.querySelector<HTMLElementTagNameMap["div"]>("div[data-content]")!;
|
||||
commentBoxContent.insertAdjacentHTML("afterbegin", replaceEmojis(comment.content, comment.emojis));
|
||||
|
||||
data.descendants.forEach((comment) => {
|
||||
const commentBox = commentTemplate.content.cloneNode(true) as DocumentFragment;
|
||||
commentBox.firstElementChild!.id = `comment-${comment.id}`;
|
||||
const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!;
|
||||
commentBoxFavorites.insertAdjacentText("afterbegin", comment.favourites_count.toString());
|
||||
|
||||
const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!;
|
||||
commentBoxAuthor.href = comment.account.url;
|
||||
commentBoxAuthor.title = comment.account.acct;
|
||||
commentBoxAuthor.setAttribute("aria-label", comment.account.acct);
|
||||
const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!;
|
||||
avatar.src = comment.account.avatar;
|
||||
avatar.alt = `Avatar of @${comment.account.acct}`;
|
||||
const avatarStatic =
|
||||
commentBoxAuthor.querySelector<HTMLElementTagNameMap["source"]>("source[data-avatar-static]")!;
|
||||
avatarStatic.srcset = comment.account.avatar_static;
|
||||
const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!;
|
||||
displayName.insertAdjacentHTML(
|
||||
"afterbegin",
|
||||
replaceEmojis(comment.account.display_name, comment.account.emojis),
|
||||
);
|
||||
const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!;
|
||||
commentBoxReblogs.insertAdjacentText("afterbegin", comment.reblogs_count.toString());
|
||||
|
||||
const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!;
|
||||
commentBoxPostLink.href = comment.url;
|
||||
const publishDate =
|
||||
commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-published-date]")!;
|
||||
publishDate.dateTime = comment.created_at;
|
||||
publishDate.insertAdjacentText(
|
||||
"afterbegin",
|
||||
new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
);
|
||||
|
||||
if (comment.edited_at) {
|
||||
const edited = commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-edited-date]")!;
|
||||
edited.dateTime = comment.edited_at;
|
||||
edited.title = comment.edited_at;
|
||||
edited.classList.remove("hidden");
|
||||
edited.classList.add("dt-updated");
|
||||
edited.style.removeProperty("display");
|
||||
}
|
||||
|
||||
const commentBoxContent = commentBox.querySelector<HTMLElementTagNameMap["div"]>("div[data-content]")!;
|
||||
commentBoxContent.insertAdjacentHTML("afterbegin", replaceEmojis(comment.content, comment.emojis));
|
||||
|
||||
const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!;
|
||||
commentBoxFavorites.insertAdjacentText("afterbegin", comment.favourites_count.toString());
|
||||
|
||||
const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!;
|
||||
commentBoxReblogs.insertAdjacentText("afterbegin", comment.reblogs_count.toString());
|
||||
|
||||
if (comment.in_reply_to_id === post.postId || !(comment.in_reply_to_id in commentMap)) {
|
||||
commentMap[comment.id] = commentsList.length;
|
||||
commentsList.push(commentBox);
|
||||
} else {
|
||||
const commentsIndex = commentMap[comment.in_reply_to_id];
|
||||
commentMap[comment.id] = commentsIndex;
|
||||
const parentThreadDiv =
|
||||
commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
|
||||
parentThreadDiv.setAttribute("aria-hidden", "false");
|
||||
parentThreadDiv.appendChild(commentBox);
|
||||
}
|
||||
});
|
||||
if (commentsList.length) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
commentsList.forEach((comment) => fragment.appendChild(comment));
|
||||
commentsDiv.appendChild(fragment);
|
||||
commentsDescription
|
||||
.querySelector<HTMLElementTagNameMap["span"]>("span[data-comments]")!
|
||||
.style.removeProperty("display");
|
||||
commentsDiv.style.removeProperty("display");
|
||||
if (comment.in_reply_to_id === post.postId || !(comment.in_reply_to_id in commentMap)) {
|
||||
commentMap[comment.id] = commentsList.length;
|
||||
commentsList.push(commentBox);
|
||||
} else {
|
||||
commentsDescription
|
||||
.querySelector<HTMLElementTagNameMap["span"]>("span[data-no-comments]")!
|
||||
.style.removeProperty("display");
|
||||
const commentsIndex = commentMap[comment.in_reply_to_id];
|
||||
commentMap[comment.id] = commentsIndex;
|
||||
const parentThreadDiv =
|
||||
commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
|
||||
parentThreadDiv.setAttribute("aria-hidden", "false");
|
||||
parentThreadDiv.appendChild(commentBox);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch Mastodon comments error", e);
|
||||
});
|
||||
if (commentsList.length) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
commentsList.forEach((comment) => fragment.appendChild(comment));
|
||||
commentsDescription
|
||||
.querySelector<HTMLElementTagNameMap["span"]>("span[data-error]")!
|
||||
.querySelector<HTMLElementTagNameMap["span"]>("span[data-comments]")!
|
||||
.style.removeProperty("display");
|
||||
const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
|
||||
commentsDiv.appendChild(fragment);
|
||||
commentsDiv.style.removeProperty("display");
|
||||
} else {
|
||||
commentsDescription
|
||||
.querySelector<HTMLElementTagNameMap["span"]>("span[data-no-comments]")!
|
||||
.style.removeProperty("display");
|
||||
} finally {
|
||||
loadCommentsButton.style.display = "none";
|
||||
loadCommentsButton.blur();
|
||||
commentsDescription.style.removeProperty("display");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch Mastodon comments error", e);
|
||||
commentsDescription
|
||||
.querySelector<HTMLElementTagNameMap["span"]>("span[data-error]")!
|
||||
.style.removeProperty("display");
|
||||
} finally {
|
||||
loadCommentsButton.style.display = "none";
|
||||
loadCommentsButton.blur();
|
||||
commentsDescription.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
|
||||
function initCommentSection() {
|
||||
const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comments-section");
|
||||
if (!commentSection) {
|
||||
return;
|
||||
}
|
||||
const post = {
|
||||
link: commentSection.dataset.link,
|
||||
instance: commentSection.dataset.instance,
|
||||
user: commentSection.dataset.user,
|
||||
postId: commentSection.dataset.postId,
|
||||
};
|
||||
if (!post.link || !post.instance || !post.user || !post.postId) {
|
||||
return;
|
||||
}
|
||||
const loadCommentsButton =
|
||||
commentSection.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!;
|
||||
loadCommentsButton.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
loadCommentsButton.disabled = true;
|
||||
renderComments(commentSection, post as MastodonPost);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const commentsDescription = commentSection.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!;
|
||||
commentsDescription.style.display = "none";
|
||||
commentsDescription.querySelector<HTMLElementTagNameMap["span"]>("span[data-noscript]")!.style.display = "none";
|
||||
loadCommentsButton.style.removeProperty("display");
|
||||
function initCommentSection() {
|
||||
const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comments-section");
|
||||
if (!commentSection) {
|
||||
return;
|
||||
}
|
||||
const post = {
|
||||
link: commentSection.dataset.link,
|
||||
instance: commentSection.dataset.instance,
|
||||
user: commentSection.dataset.user,
|
||||
postId: commentSection.dataset.postId,
|
||||
};
|
||||
if (!post.link || !post.instance || !post.user || !post.postId) {
|
||||
return;
|
||||
}
|
||||
const loadCommentsButton =
|
||||
commentSection.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!;
|
||||
loadCommentsButton.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
loadCommentsButton.disabled = true;
|
||||
renderComments(commentSection, post as MastodonPost);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const commentsDescription = commentSection.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!;
|
||||
commentsDescription.style.display = "none";
|
||||
commentsDescription.querySelector<HTMLElementTagNameMap["span"]>("span[data-noscript]")!.style.display = "none";
|
||||
loadCommentsButton.style.removeProperty("display");
|
||||
}
|
||||
|
||||
if (ENABLE_VIEW_TRANSITIONS) {
|
||||
document.addEventListener("astro:page-load", initCommentSection);
|
||||
} else {
|
||||
initCommentSection();
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
|
|
15
src/components/icons/IconArrowBack.astro
Normal file
15
src/components/icons/IconArrowBack.astro
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M48.5 224H40c-13.3 0-24-10.7-24-24V72c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2L98.6 96.6c87.6-86.5 228.7-86.2 315.8 1c87.5 87.5 87.5 229.3 0 316.8s-229.3 87.5-316.8 0c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0c62.5 62.5 163.8 62.5 226.3 0s62.5-163.8 0-226.3c-62.2-62.2-162.7-62.5-225.3-1L185 183c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8H48.5z"
|
||||
></path>
|
||||
</SVGIcon>
|
15
src/components/icons/IconArrowUp.astro
Normal file
15
src/components/icons/IconArrowUp.astro
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 384 512">
|
||||
<path
|
||||
d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.2L329.4 246.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z"
|
||||
></path>
|
||||
</SVGIcon>
|
13
src/components/icons/IconChevronLeft.astro
Normal file
13
src/components/icons/IconChevronLeft.astro
Normal file
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 320 512">
|
||||
<path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z" />
|
||||
</SVGIcon>
|
13
src/components/icons/IconChevronRight.astro
Normal file
13
src/components/icons/IconChevronRight.astro
Normal file
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 320 512">
|
||||
<path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" />
|
||||
</SVGIcon>
|
15
src/components/icons/IconFavorites.astro
Normal file
15
src/components/icons/IconFavorites.astro
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 576 512">
|
||||
<path
|
||||
d="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z"
|
||||
></path>
|
||||
</SVGIcon>
|
15
src/components/icons/IconHome.astro
Normal file
15
src/components/icons/IconHome.astro
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 576 512">
|
||||
<path
|
||||
d="M575.8 255.5c0 18-15 32.1-32 32.1h-32l.7 160.2c0 2.7-.2 5.4-.5 8.1V472c0 22.1-17.9 40-40 40H456c-1.1 0-2.2 0-3.3-.1c-1.4 .1-2.8 .1-4.2 .1H416 392c-22.1 0-40-17.9-40-40V448 384c0-17.7-14.3-32-32-32H256c-17.7 0-32 14.3-32 32v64 24c0 22.1-17.9 40-40 40H160 128.1c-1.5 0-3-.1-4.5-.2c-1.2 .1-2.4 .2-3.6 .2H104c-22.1 0-40-17.9-40-40V360c0-.9 0-1.9 .1-2.8V287.6H32c-18 0-32-14-32-32.1c0-9 3-17 10-24L266.4 8c7-7 15-8 22-8s15 2 21 7L564.8 231.5c8 7 12 15 11 24z"
|
||||
></path>
|
||||
</SVGIcon>
|
15
src/components/icons/IconInformation.astro
Normal file
15
src/components/icons/IconInformation.astro
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"
|
||||
></path>
|
||||
</SVGIcon>
|
15
src/components/icons/IconMoon.astro
Normal file
15
src/components/icons/IconMoon.astro
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 384 512">
|
||||
<path
|
||||
d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"
|
||||
></path>
|
||||
</SVGIcon>
|
15
src/components/icons/IconReblogs.astro
Normal file
15
src/components/icons/IconReblogs.astro
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M0 224c0 17.7 14.3 32 32 32s32-14.3 32-32c0-53 43-96 96-96H320v32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S320 19.1 320 32V64H160C71.6 64 0 135.6 0 224zm512 64c0-17.7-14.3-32-32-32s-32 14.3-32 32c0 53-43 96-96 96H192V352c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6V448H352c88.4 0 160-71.6 160-160z"
|
||||
></path>
|
||||
</SVGIcon>
|
15
src/components/icons/IconSquareRSS.astro
Normal file
15
src/components/icons/IconSquareRSS.astro
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 448 512">
|
||||
<path
|
||||
d="M64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H64zM96 136c0-13.3 10.7-24 24-24c137 0 248 111 248 248c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-110.5-89.5-200-200-200c-13.3 0-24-10.7-24-24zm0 96c0-13.3 10.7-24 24-24c83.9 0 152 68.1 152 152c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-57.4-46.6-104-104-104c-13.3 0-24-10.7-24-24zm0 120a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"
|
||||
></path>
|
||||
</SVGIcon>
|
15
src/components/icons/IconSun.astro
Normal file
15
src/components/icons/IconSun.astro
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M361.5 1.2c5 2.1 8.6 6.6 9.6 11.9L391 121l107.9 19.8c5.3 1 9.8 4.6 11.9 9.6s1.5 10.7-1.6 15.2L446.9 256l62.3 90.3c3.1 4.5 3.7 10.2 1.6 15.2s-6.6 8.6-11.9 9.6L391 391 371.1 498.9c-1 5.3-4.6 9.8-9.6 11.9s-10.7 1.5-15.2-1.6L256 446.9l-90.3 62.3c-4.5 3.1-10.2 3.7-15.2 1.6s-8.6-6.6-9.6-11.9L121 391 13.1 371.1c-5.3-1-9.8-4.6-11.9-9.6s-1.5-10.7 1.6-15.2L65.1 256 2.8 165.7c-3.1-4.5-3.7-10.2-1.6-15.2s6.6-8.6 11.9-9.6L121 121 140.9 13.1c1-5.3 4.6-9.8 9.6-11.9s10.7-1.5 15.2 1.6L256 65.1 346.3 2.8c4.5-3.1 10.2-3.7 15.2-1.6zM160 256a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zm224 0a128 128 0 1 0 -256 0 128 128 0 1 0 256 0z"
|
||||
></path>
|
||||
</SVGIcon>
|
15
src/components/icons/IconTriangleExclamation.astro
Normal file
15
src/components/icons/IconTriangleExclamation.astro
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import SVGIcon from "./SVGIcon.astro";
|
||||
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
class?: string;
|
||||
};
|
||||
---
|
||||
|
||||
<SVGIcon {...Astro.props} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24V296c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
|
||||
></path>
|
||||
</SVGIcon>
|
16
src/components/icons/SVGIcon.astro
Normal file
16
src/components/icons/SVGIcon.astro
Normal file
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
type Props = {
|
||||
width: string;
|
||||
height: string;
|
||||
viewBox: string;
|
||||
class?: string;
|
||||
fill?: string;
|
||||
children: any;
|
||||
};
|
||||
|
||||
const { width, height, class: className, fill = "currentColor", viewBox } = Astro.props;
|
||||
---
|
||||
|
||||
<svg style={{ width, height, fill }} class={className} viewBox={viewBox} aria-hidden="true">
|
||||
<slot />
|
||||
</svg>
|
|
@ -4,6 +4,10 @@ import BaseLayout from "./BaseLayout.astro";
|
|||
import Navigation from "../components/Navigation.astro";
|
||||
import logoBM from "../assets/images/logo_bm.png";
|
||||
import { t } from "../i18n";
|
||||
import IconHome from "../components/icons/IconHome.astro";
|
||||
import IconSquareRSS from "../components/icons/IconSquareRSS.astro";
|
||||
import IconSun from "../components/icons/IconSun.astro";
|
||||
import IconMoon from "../components/icons/IconMoon.astro";
|
||||
|
||||
type Props = {
|
||||
pageTitle: string;
|
||||
|
@ -28,16 +32,16 @@ const currentYear = new Date().getFullYear().toString();
|
|||
class="flex min-h-screen flex-col bg-stone-200 text-stone-800 md:flex-row dark:bg-stone-800 dark:text-stone-200 print:bg-none"
|
||||
>
|
||||
<div
|
||||
class="static mb-4 flex flex-col items-center bg-bm-300 pt-10 text-center text-stone-900 shadow-xl md:fixed md:inset-y-0 md:left-0 md:mb-0 md:w-60 md:pt-20 dark:bg-green-900 dark:text-stone-100 print:bg-none print:shadow-none"
|
||||
class="h-card static mb-4 flex flex-col items-center bg-bm-300 pt-10 text-center text-stone-900 shadow-xl md:fixed md:inset-y-0 md:left-0 md:mb-0 md:w-60 md:pt-20 dark:bg-green-900 dark:text-stone-100 print:bg-none print:shadow-none"
|
||||
>
|
||||
<img
|
||||
loading="eager"
|
||||
src={logo.src}
|
||||
alt="Logo for Bad Manners"
|
||||
class="my-4 w-full max-w-[192px] rounded-sm border-2 border-green-950 shadow-md"
|
||||
class="u-logo my-4 w-full max-w-[192px] rounded-sm border-2 border-green-950 shadow-md"
|
||||
width={192}
|
||||
/>
|
||||
<span class="my-2 text-2xl font-semibold">Bad Manners</span>
|
||||
<span class="p-name my-2 text-2xl font-semibold">Bad Manners</span>
|
||||
<Navigation />
|
||||
<div class="pt-4 text-center text-xs text-black dark:text-white">
|
||||
<span
|
||||
|
@ -54,28 +58,12 @@ const currentYear = new Date().getFullYear().toString();
|
|||
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank">Licenses</a>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-x-1 pb-10">
|
||||
<a class="text-link p-1" href="https://badmanners.xyz/" target="_blank" aria-labelledby="label-main-website">
|
||||
<svg
|
||||
style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }}
|
||||
viewBox="0 0 576 512"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M575.8 255.5c0 18-15 32.1-32 32.1h-32l.7 160.2c0 2.7-.2 5.4-.5 8.1V472c0 22.1-17.9 40-40 40H456c-1.1 0-2.2 0-3.3-.1c-1.4 .1-2.8 .1-4.2 .1H416 392c-22.1 0-40-17.9-40-40V448 384c0-17.7-14.3-32-32-32H256c-17.7 0-32 14.3-32 32v64 24c0 22.1-17.9 40-40 40H160 128.1c-1.5 0-3-.1-4.5-.2c-1.2 .1-2.4 .2-3.6 .2H104c-22.1 0-40-17.9-40-40V360c0-.9 0-1.9 .1-2.8V287.6H32c-18 0-32-14-32-32.1c0-9 3-17 10-24L266.4 8c7-7 15-8 22-8s15 2 21 7L564.8 231.5c8 7 12 15 11 24z"
|
||||
></path>
|
||||
</svg>
|
||||
<a class="u-url text-link p-1" href="https://badmanners.xyz/" target="_blank" aria-labelledby="label-main-website">
|
||||
<IconHome width="1.5rem" height="1.5rem" />
|
||||
<span id="label-main-website" class="sr-only">Main website</span>
|
||||
</a>
|
||||
<a class="text-link p-1" href="/feed.xml" target="_blank" aria-labelledby="label-rss-feed">
|
||||
<svg
|
||||
style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }}
|
||||
viewBox="0 0 448 512"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H64zM96 136c0-13.3 10.7-24 24-24c137 0 248 111 248 248c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-110.5-89.5-200-200-200c-13.3 0-24-10.7-24-24zm0 96c0-13.3 10.7-24 24-24c83.9 0 152 68.1 152 152c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-57.4-46.6-104-104-104c-13.3 0-24-10.7-24-24zm0 120a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<IconSquareRSS width="1.5rem" height="1.5rem" />
|
||||
<span id="label-rss-feed" class="sr-only">RSS feed</span>
|
||||
</a>
|
||||
<button
|
||||
|
@ -84,26 +72,8 @@ const currentYear = new Date().getFullYear().toString();
|
|||
class="text-link p-1"
|
||||
aria-labelledby="label-toggle-dark-mode"
|
||||
>
|
||||
<svg
|
||||
style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }}
|
||||
viewBox="0 0 512 512"
|
||||
class="hidden dark:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M361.5 1.2c5 2.1 8.6 6.6 9.6 11.9L391 121l107.9 19.8c5.3 1 9.8 4.6 11.9 9.6s1.5 10.7-1.6 15.2L446.9 256l62.3 90.3c3.1 4.5 3.7 10.2 1.6 15.2s-6.6 8.6-11.9 9.6L391 391 371.1 498.9c-1 5.3-4.6 9.8-9.6 11.9s-10.7 1.5-15.2-1.6L256 446.9l-90.3 62.3c-4.5 3.1-10.2 3.7-15.2 1.6s-8.6-6.6-9.6-11.9L121 391 13.1 371.1c-5.3-1-9.8-4.6-11.9-9.6s-1.5-10.7 1.6-15.2L65.1 256 2.8 165.7c-3.1-4.5-3.7-10.2-1.6-15.2s6.6-8.6 11.9-9.6L121 121 140.9 13.1c1-5.3 4.6-9.8 9.6-11.9s10.7-1.5 15.2 1.6L256 65.1 346.3 2.8c4.5-3.1 10.2-3.7 15.2-1.6zM160 256a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zm224 0a128 128 0 1 0 -256 0 128 128 0 1 0 256 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }}
|
||||
viewBox="0 0 512 512"
|
||||
class="block dark:hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"
|
||||
></path>
|
||||
</svg>
|
||||
<IconSun width="1.5rem" height="1.5rem" class="hidden dark:block" />
|
||||
<IconMoon width="1.5rem" height="1.5rem" class="block dark:hidden" />
|
||||
<span id="label-toggle-dark-mode" class="sr-only">{t("en", "published_content/toggle_dark_mode")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -11,6 +11,13 @@ import Prose from "../components/Prose.astro";
|
|||
import MastodonComments from "../components/MastodonComments.astro";
|
||||
import type { CopyrightedCharacters as CopyrightedCharactersType } from "../content/config";
|
||||
import { qualifyLocalURLsInMarkdown } from "../utils/qualify_local_urls_in_markdown";
|
||||
import IconSun from "../components/icons/IconSun.astro";
|
||||
import IconMoon from "../components/icons/IconMoon.astro";
|
||||
import IconInformation from "../components/icons/IconInformation.astro";
|
||||
import IconArrowBack from "../components/icons/IconArrowBack.astro";
|
||||
import IconChevronLeft from "../components/icons/IconChevronLeft.astro";
|
||||
import IconChevronRight from "../components/icons/IconChevronRight.astro";
|
||||
import IconArrowUp from "../components/icons/IconArrowUp.astro";
|
||||
|
||||
interface RelatedContent {
|
||||
link: string;
|
||||
|
@ -117,15 +124,7 @@ const thumbnail =
|
|||
class="text-link my-1 p-2"
|
||||
aria-labelledby="label-return-to"
|
||||
>
|
||||
<svg
|
||||
style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
|
||||
viewBox="0 0 512 512"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M48.5 224H40c-13.3 0-24-10.7-24-24V72c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2L98.6 96.6c87.6-86.5 228.7-86.2 315.8 1c87.5 87.5 87.5 229.3 0 316.8s-229.3 87.5-316.8 0c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0c62.5 62.5 163.8 62.5 226.3 0s62.5-163.8 0-226.3c-62.2-62.2-162.7-62.5-225.3-1L185 183c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8H48.5z"
|
||||
></path>
|
||||
</svg>
|
||||
<IconArrowBack width="1.25rem" height="1.25rem" />
|
||||
<span class="sr-only" id="label-return-to"
|
||||
>{
|
||||
series ? t(props.lang, "published_content/return_to_series", series.data.name) : props.labelReturnTo.title
|
||||
|
@ -137,15 +136,7 @@ const thumbnail =
|
|||
class="text-link my-1 border-l border-stone-300 p-2 dark:border-stone-700"
|
||||
aria-labelledby="label-go-to-description"
|
||||
>
|
||||
<svg
|
||||
style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
|
||||
viewBox="0 0 512 512"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"
|
||||
></path>
|
||||
</svg>
|
||||
<IconInformation width="1.25rem" height="1.25rem" />
|
||||
<span class="sr-only" id="label-go-to-description"
|
||||
>{t(props.lang, "published_content/go_to_description")}</span
|
||||
>
|
||||
|
@ -156,26 +147,8 @@ const thumbnail =
|
|||
class="text-link my-1 border-l border-stone-300 p-2 dark:border-stone-700"
|
||||
aria-labelledby="label-toggle-dark-mode"
|
||||
>
|
||||
<svg
|
||||
style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
|
||||
viewBox="0 0 512 512"
|
||||
class="hidden dark:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M361.5 1.2c5 2.1 8.6 6.6 9.6 11.9L391 121l107.9 19.8c5.3 1 9.8 4.6 11.9 9.6s1.5 10.7-1.6 15.2L446.9 256l62.3 90.3c3.1 4.5 3.7 10.2 1.6 15.2s-6.6 8.6-11.9 9.6L391 391 371.1 498.9c-1 5.3-4.6 9.8-9.6 11.9s-10.7 1.5-15.2-1.6L256 446.9l-90.3 62.3c-4.5 3.1-10.2 3.7-15.2 1.6s-8.6-6.6-9.6-11.9L121 391 13.1 371.1c-5.3-1-9.8-4.6-11.9-9.6s-1.5-10.7 1.6-15.2L65.1 256 2.8 165.7c-3.1-4.5-3.7-10.2-1.6-15.2s6.6-8.6 11.9-9.6L121 121 140.9 13.1c1-5.3 4.6-9.8 9.6-11.9s10.7-1.5 15.2 1.6L256 65.1 346.3 2.8c4.5-3.1 10.2-3.7 15.2-1.6zM160 256a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zm224 0a128 128 0 1 0 -256 0 128 128 0 1 0 256 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
|
||||
viewBox="0 0 512 512"
|
||||
class="block dark:hidden"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"
|
||||
></path>
|
||||
</svg>
|
||||
<IconSun width="1.25rem" height="1.25rem" class="hidden dark:block" />
|
||||
<IconMoon width="1.25rem" height="1.25rem" class="block dark:hidden" />
|
||||
<span class="sr-only" id="label-toggle-dark-mode">{t(props.lang, "published_content/toggle_dark_mode")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -195,14 +168,7 @@ const thumbnail =
|
|||
class="text-link flex items-center justify-center border-r border-stone-400 px-1 py-3 font-light underline dark:border-stone-600"
|
||||
aria-label={props.labelPreviousContent}
|
||||
>
|
||||
<svg
|
||||
style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
|
||||
class="mr-1"
|
||||
viewBox="0 0 320 512"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z" />
|
||||
</svg>
|
||||
<IconChevronLeft width="1.25rem" height="1.25rem" />
|
||||
<span>{props.prev.title}</span>
|
||||
</a>
|
||||
) : (
|
||||
|
@ -215,14 +181,7 @@ const thumbnail =
|
|||
aria-label={props.labelNextContent}
|
||||
>
|
||||
<span>{props.next.title}</span>
|
||||
<svg
|
||||
style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
|
||||
class="ml-1"
|
||||
viewBox="0 0 320 512"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" />
|
||||
</svg>
|
||||
<IconChevronRight width="1.25rem" height="1.25rem" />
|
||||
</a>
|
||||
) : (
|
||||
<div aria-hidden="true" />
|
||||
|
@ -327,17 +286,10 @@ const thumbnail =
|
|||
) : null
|
||||
}
|
||||
<div class="pr-3 text-right print:hidden">
|
||||
<a href="#top" class="text-link inline-flex items-center underline" aria-labelledby="label-to-top"
|
||||
><svg
|
||||
style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
|
||||
class="mr-1"
|
||||
viewBox="0 0 384 512"
|
||||
aria-hidden="true"
|
||||
><path
|
||||
d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.2L329.4 246.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z"
|
||||
></path></svg
|
||||
><span id="label-to-top">{t(props.lang, "published_content/to_top")}</span></a
|
||||
>
|
||||
<a href="#top" class="text-link inline-flex items-center underline" aria-labelledby="label-to-top">
|
||||
<IconArrowUp width="1.25rem" height="1.25rem" />
|
||||
<span id="label-to-top">{t(props.lang, "published_content/to_top")}</span>
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
props.prev || props.next ? (
|
||||
|
@ -350,14 +302,7 @@ const thumbnail =
|
|||
class="text-link flex items-center justify-center border-r border-stone-400 px-1 py-3 font-light underline dark:border-stone-600"
|
||||
aria-label={props.labelPreviousContent}
|
||||
>
|
||||
<svg
|
||||
style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
|
||||
class="mr-1"
|
||||
viewBox="0 0 320 512"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z" />
|
||||
</svg>
|
||||
<IconChevronLeft width="1.25rem" height="1.25rem" />
|
||||
<span>{props.prev.title}</span>
|
||||
</a>
|
||||
) : (
|
||||
|
@ -370,14 +315,7 @@ const thumbnail =
|
|||
aria-label={props.labelNextContent}
|
||||
>
|
||||
<span>{props.next.title}</span>
|
||||
<svg
|
||||
style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
|
||||
class="ml-1"
|
||||
viewBox="0 0 320 512"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" />
|
||||
</svg>
|
||||
<IconChevronRight width="1.25rem" height="1.25rem" />
|
||||
</a>
|
||||
) : (
|
||||
<div />
|
||||
|
|
Loading…
Reference in a new issue