Browse Source

Tidy up

- Add help docs for most sections
- Add translations documentation
- Fix up todos
- Remove german translation
Jamie Curnow 1 tháng trước cách đây
mục cha
commit
5d6916dcf0
38 tập tin đã thay đổi với 684 bổ sung113 xóa
  1. 4 17
      frontend/check-locales.cjs
  2. 1 0
      frontend/package.json
  3. 0 1
      frontend/src/api/backend/createAccessList.ts
  4. 0 1
      frontend/src/api/backend/createDeadHost.ts
  5. 0 1
      frontend/src/api/backend/createProxyHost.ts
  6. 0 1
      frontend/src/api/backend/createRedirectionHost.ts
  7. 0 1
      frontend/src/api/backend/createStream.ts
  8. 0 1
      frontend/src/api/backend/createUser.ts
  9. 0 7
      frontend/src/locale/IntlProvider.tsx
  10. 31 6
      frontend/src/locale/README.md
  11. 0 3
      frontend/src/locale/lang/de.json
  12. 4 0
      frontend/src/locale/lang/en.json
  13. 7 0
      frontend/src/locale/src/HelpDoc/en/AccessLists.md
  14. 32 0
      frontend/src/locale/src/HelpDoc/en/Certificates.md
  15. 10 0
      frontend/src/locale/src/HelpDoc/en/DeadHosts.md
  16. 7 0
      frontend/src/locale/src/HelpDoc/en/ProxyHosts.md
  17. 7 0
      frontend/src/locale/src/HelpDoc/en/RedirectionHosts.md
  18. 6 0
      frontend/src/locale/src/HelpDoc/en/Streams.md
  19. 6 0
      frontend/src/locale/src/HelpDoc/en/index.ts
  20. 20 0
      frontend/src/locale/src/HelpDoc/index.ts
  21. 0 5
      frontend/src/locale/src/de.json
  22. 12 0
      frontend/src/locale/src/en.json
  23. 21 1
      frontend/src/modals/DeleteConfirmModal.tsx
  24. 54 0
      frontend/src/modals/HelpModal.tsx
  25. 1 0
      frontend/src/modals/index.ts
  26. 4 8
      frontend/src/modules/Validations.tsx
  27. 1 1
      frontend/src/pages/Access/Table.tsx
  28. 14 8
      frontend/src/pages/Access/TableWrapper.tsx
  29. 14 7
      frontend/src/pages/Certificates/TableWrapper.tsx
  30. 0 5
      frontend/src/pages/Dashboard/index.tsx
  31. 1 1
      frontend/src/pages/Nginx/DeadHosts/Table.tsx
  32. 13 7
      frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx
  33. 13 7
      frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx
  34. 13 7
      frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.tsx
  35. 1 1
      frontend/src/pages/Nginx/Streams/Table.tsx
  36. 13 7
      frontend/src/pages/Nginx/Streams/TableWrapper.tsx
  37. 1 0
      frontend/vite.config.ts
  38. 373 9
      frontend/yarn.lock

+ 4 - 17
frontend/check-locales.cjs

@@ -8,14 +8,11 @@
 
 const allLocales = [
 	["en", "en-US"],
-	["de", "de-DE"],
 	["fa", "fa-IR"],
 ];
 
 const ignoreUnused = [
-	/^capability\..*$/,
-	/^status\..*$/,
-	/^type\..*$/,
+	/^.*$/,
 ];
 
 const { spawnSync } = require("child_process");
@@ -119,19 +116,9 @@ const compareLocale = (locale) => {
 const checkForMissing = (locale) => {
 	allKeys.forEach((key) => {
 		if (typeof locale.data[key] === "undefined") {
-			let ignored = false;
-			ignoreMissing.map((regex) => {
-				if (key.match(regex)) {
-					ignored = true;
-				}
-				return null;
-			});
-
-			if (!ignored) {
-				allWarnings.push(
-					"WARN: `" + locale[0] + "` does not contain item: `" + key + "`",
-				);
-			}
+			allWarnings.push(
+				"WARN: `" + locale[0] + "` does not contain item: `" + key + "`",
+			);
 		}
 		return null;
 	});

+ 1 - 0
frontend/package.json

@@ -33,6 +33,7 @@
 		"react-bootstrap": "^2.10.10",
 		"react-dom": "^19.2.0",
 		"react-intl": "^7.1.14",
+		"react-markdown": "^10.1.0",
 		"react-router-dom": "^7.9.4",
 		"react-select": "^5.10.2",
 		"react-toastify": "^11.0.5",

+ 0 - 1
frontend/src/api/backend/createAccessList.ts

@@ -4,7 +4,6 @@ import type { AccessList } from "./models";
 export async function createAccessList(item: AccessList): Promise<AccessList> {
 	return await api.post({
 		url: "/nginx/access-lists",
-		// todo: only use whitelist of fields for this data
 		data: item,
 	});
 }

+ 0 - 1
frontend/src/api/backend/createDeadHost.ts

@@ -4,7 +4,6 @@ import type { DeadHost } from "./models";
 export async function createDeadHost(item: DeadHost): Promise<DeadHost> {
 	return await api.post({
 		url: "/nginx/dead-hosts",
-		// todo: only use whitelist of fields for this data
 		data: item,
 	});
 }

+ 0 - 1
frontend/src/api/backend/createProxyHost.ts

@@ -4,7 +4,6 @@ import type { ProxyHost } from "./models";
 export async function createProxyHost(item: ProxyHost): Promise<ProxyHost> {
 	return await api.post({
 		url: "/nginx/proxy-hosts",
-		// todo: only use whitelist of fields for this data
 		data: item,
 	});
 }

+ 0 - 1
frontend/src/api/backend/createRedirectionHost.ts

@@ -4,7 +4,6 @@ import type { RedirectionHost } from "./models";
 export async function createRedirectionHost(item: RedirectionHost): Promise<RedirectionHost> {
 	return await api.post({
 		url: "/nginx/redirection-hosts",
-		// todo: only use whitelist of fields for this data
 		data: item,
 	});
 }

+ 0 - 1
frontend/src/api/backend/createStream.ts

@@ -4,7 +4,6 @@ import type { Stream } from "./models";
 export async function createStream(item: Stream): Promise<Stream> {
 	return await api.post({
 		url: "/nginx/streams",
-		// todo: only use whitelist of fields for this data
 		data: item,
 	});
 }

+ 0 - 1
frontend/src/api/backend/createUser.ts

@@ -18,7 +18,6 @@ export interface NewUser {
 export async function createUser(item: NewUser, noAuth?: boolean): Promise<User> {
 	return await api.post({
 		url: "/users",
-		// todo: only use whitelist of fields for this data
 		data: item,
 		noAuth,
 	});

+ 0 - 7
frontend/src/locale/IntlProvider.tsx

@@ -1,5 +1,4 @@
 import { createIntl, createIntlCache } from "react-intl";
-import langDe from "./lang/de.json";
 import langEn from "./lang/en.json";
 import langFa from "./lang/fa.json";
 import langList from "./lang/lang-list.json";
@@ -9,15 +8,12 @@ import langList from "./lang/lang-list.json";
 // Remember when adding to this list, also update check-locales.js script
 const localeOptions = [
 	["en", "en-US"],
-	["de", "de-DE"],
 	["fa", "fa-IR"],
 ];
 
 const loadMessages = (locale?: string): typeof langList & typeof langEn => {
 	const thisLocale = locale || "en";
 	switch (thisLocale.slice(0, 2)) {
-		case "de":
-			return Object.assign({}, langList, langEn, langDe);
 		case "fa":
 			return Object.assign({}, langList, langEn, langFa);
 		default:
@@ -27,9 +23,6 @@ const loadMessages = (locale?: string): typeof langList & typeof langEn => {
 
 const getFlagCodeForLocale = (locale?: string) => {
 	switch (locale) {
-		case "de-DE":
-		case "de":
-			return "DE";
 		case "fa-IR":
 		case "fa":
 			return "IR";

+ 31 - 6
frontend/src/locale/README.md

@@ -1,23 +1,48 @@
 # Internationalisation support
 
+## Before you start
+
+It's highly recommended that you spin up a development instance of this project
+on your docker capable server. It's pretty easy:
+
+```bash
+git clone https://github.com/NginxProxyManager/nginx-proxy-manager.git
+cd nginx-proxy-manager
+./scripts/start-dev -f
+```
+
+Then after a while, you can access http://yourserverip:3081
+
+This stack will watch the file system for changes, especially to language files,
+and reload the site you have open in the browser.
+
+
 ## Adding new translations
 
 Modify the files in the `src` folder. Follow the conventions already there.
 
+When the development stack is running, it will sort the locale lang files
+for you when you save.
+
 
 ## After making changes
 
-You will need to run `yarn locale-compile` in this frontend folder for
+If you're NOT running the development stack, you will need to run
+`yarn locale-compile` in the `frontend` folder for
 the new translations to be compiled into the `lang` folder.
 
-When running in dev mode, this should automatically happen within Vite.
 
+## Adding a whole new language
 
-## Checking for missing translations in other languages
+There's a fair bit you'll need to touch. Here's a list that may
+not be complete by the time you're reading this:
 
-Run `node check-locales.cjs` in this frontend folder.
+- frontend/src/locale/src/[yourlang].json
+- frontend/src/locale/src/lang-list.json
+- frontend/src/locale/src/HelpDoc/*
+- frontend/src/locale/IntlProvider.tsx
 
 
-## Adding new languages
+## Checking for missing translations in languages
 
-todo
+Run `node check-locales.cjs` in this frontend folder.

+ 0 - 3
frontend/src/locale/lang/de.json

@@ -1,3 +0,0 @@
-{
-  "dashboard": "Armaturenbrett"
-}

+ 4 - 0
frontend/src/locale/lang/en.json

@@ -100,7 +100,11 @@
   "error.invalid-auth": "Invalid email or password",
   "error.invalid-domain": "Invalid domain: {domain}",
   "error.invalid-email": "Invalid email address",
+  "error.max-character-length": "Maximum length is {max} character{max, plural, one {} other {s}}",
   "error.max-domains": "Too many domains, max is {max}",
+  "error.maximum": "Maximum is {max}",
+  "error.min-character-length": "Minimum length is {min} character{min, plural, one {} other {s}}",
+  "error.minimum": "Minimum is {min}",
   "error.passwords-must-match": "Passwords must match",
   "error.required": "This is required",
   "expires.on": "Expires: {date}",

+ 7 - 0
frontend/src/locale/src/HelpDoc/en/AccessLists.md

@@ -0,0 +1,7 @@
+## What is an Access List?
+
+Access Lists provide a blacklist or whitelist of specific client IP addresses along with authentication for the Proxy Hosts via Basic HTTP Authentication.
+
+You can configure multiple client rules, usernames and passwords for a single Access List and then apply that to one or more _Proxy Hosts_.
+
+This is most useful for forwarded web services that do not have authentication mechanisms built in or when you want to protect from unknown clients.

+ 32 - 0
frontend/src/locale/src/HelpDoc/en/Certificates.md

@@ -0,0 +1,32 @@
+## Certificates Help
+
+### HTTP Certificate
+
+A HTTP validated certificate means Let's Encrypt servers will
+attempt to reach your domains over HTTP (not HTTPS!) and if successful, they
+will issue your certificate.
+
+For this method, you will have to have a _Proxy Host_ created for your domains(s) that
+is accessible with HTTP and pointing to this Nginx installation. After a certificate
+has been given, you can modify the _Proxy Host_ to also use this certificate for HTTPS
+connections. However, the _Proxy Host_ will still need to be configured for HTTP access
+in order for the certificate to renew.
+
+This process _does not_ support wildcard domains.
+
+### DNS Certificate
+
+A DNS validated certificate requires you to use a DNS Provider plugin. This DNS
+Provider will be used to create temporary records on your domain and then Let's
+Encrypt will query those records to be sure you're the owner and if successful, they
+will issue your certificate.
+
+You do not need a _Proxy Host_ to be created prior to requesting this type of
+certificate. Nor do you need to have your _Proxy Host_ configured for HTTP access.
+
+This process _does_ support wildcard domains.
+
+### Custom Certificate
+
+Use this option to upload your own SSL Certificate, as provided by your own
+Certificate Authority.

+ 10 - 0
frontend/src/locale/src/HelpDoc/en/DeadHosts.md

@@ -0,0 +1,10 @@
+## What is a 404 Host?
+
+A 404 Host is simply a host setup that shows a 404 page.
+
+This can be useful when your domain is listed in search engines and you want
+to provide a nicer error page or specifically to tell the search indexers that
+the domain pages no longer exist.
+
+Another benefit of having this host is to track the logs for hits to it and
+view the referrers.

+ 7 - 0
frontend/src/locale/src/HelpDoc/en/ProxyHosts.md

@@ -0,0 +1,7 @@
+## What is a Proxy Host?
+
+A Proxy Host is the incoming endpoint for a web service that you want to forward.
+
+It provides optional SSL termination for your service that might not have SSL support built in.
+
+Proxy Hosts are the most common use for the Nginx Proxy Manager.

+ 7 - 0
frontend/src/locale/src/HelpDoc/en/RedirectionHosts.md

@@ -0,0 +1,7 @@
+## What is a Redirection Host?
+
+A Redirection Host will redirect requests from the incoming domain and push the
+viewer to another domain.
+
+The most common reason to use this type of host is when your website changes
+domains but you still have search engine or referrer links pointing to the old domain.

+ 6 - 0
frontend/src/locale/src/HelpDoc/en/Streams.md

@@ -0,0 +1,6 @@
+## What is a Stream?
+
+A relatively new feature for Nginx, a Stream will serve to forward TCP/UDP
+traffic directly to another computer on the network.
+
+If you're running game servers, FTP or SSH servers this can come in handy.

+ 6 - 0
frontend/src/locale/src/HelpDoc/en/index.ts

@@ -0,0 +1,6 @@
+export * as AccessLists from "./AccessLists.md";
+export * as Certificates from "./Certificates.md";
+export * as DeadHosts from "./DeadHosts.md";
+export * as ProxyHosts from "./ProxyHosts.md";
+export * as RedirectionHosts from "./RedirectionHosts.md";
+export * as Streams from "./Streams.md";

+ 20 - 0
frontend/src/locale/src/HelpDoc/index.ts

@@ -0,0 +1,20 @@
+// import * as de from "./de/index";
+// import * as fa from "./fa/index";
+import * as en from "./en/index";
+
+const items: any = { en };
+
+const fallbackLang = "en";
+
+export const getHelpFile = (lang: string, section: string): string => {
+	if (typeof items[lang] !== "undefined" && typeof items[lang][section] !== "undefined") {
+		return items[lang][section].default;
+	}
+	// Fallback to English
+	if (typeof items[fallbackLang] !== "undefined" && typeof items[fallbackLang][section] !== "undefined") {
+		return items[fallbackLang][section].default;
+	}
+	throw new Error(`Cannot load help doc for ${lang}-${section}`);
+};
+
+export default items;

+ 0 - 5
frontend/src/locale/src/de.json

@@ -1,5 +0,0 @@
-{
-	"dashboard": {
-		"defaultMessage": "Armaturenbrett"
-	}
-}

+ 12 - 0
frontend/src/locale/src/en.json

@@ -302,9 +302,21 @@
 	"error.invalid-email": {
 		"defaultMessage": "Invalid email address"
 	},
+	"error.max-character-length": {
+		"defaultMessage": "Maximum length is {max} character{max, plural, one {} other {s}}"
+	},
 	"error.max-domains": {
 		"defaultMessage": "Too many domains, max is {max}"
 	},
+	"error.maximum": {
+		"defaultMessage": "Maximum is {max}"
+	},
+	"error.min-character-length": {
+		"defaultMessage": "Minimum length is {min} character{min, plural, one {} other {s}}"
+	},
+	"error.minimum": {
+		"defaultMessage": "Minimum is {min}"
+	},
 	"error.passwords-must-match": {
 		"defaultMessage": "Passwords must match"
 	},

+ 21 - 1
frontend/src/modals/DeleteConfirmModal.tsx

@@ -52,7 +52,27 @@ const DeleteConfirmModal = EasyModal.create(
 					<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
 						{error}
 					</Alert>
-					{children}
+					<div className="text-center mb-3">
+						<svg
+							role="img"
+							aria-label="warning icon"
+							xmlns="http://www.w3.org/2000/svg"
+							className="icon mb-2 text-danger icon-lg"
+							width="24"
+							height="24"
+							viewBox="0 0 24 24"
+							stroke-width="2"
+							stroke="currentColor"
+							fill="none"
+							stroke-linecap="round"
+							stroke-linejoin="round"
+						>
+							<path stroke="none" d="M0 0h24v24H0z" fill="none" />
+							<path d="M12 9v2m0 4v.01" />
+							<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75" />
+						</svg>
+					</div>
+					<div className="text-center mb-3">{children}</div>
 				</Modal.Body>
 				<Modal.Footer>
 					<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>

+ 54 - 0
frontend/src/modals/HelpModal.tsx

@@ -0,0 +1,54 @@
+import cn from "classnames";
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
+import { useEffect, useState } from "react";
+import Modal from "react-bootstrap/Modal";
+import ReactMarkdown from "react-markdown";
+import { Button } from "src/components";
+import { getLocale, T } from "src/locale";
+import { getHelpFile } from "src/locale/src/HelpDoc";
+
+interface Props extends InnerModalProps {
+	section: string;
+	color?: string;
+}
+
+const showHelpModal = (section: string, color?: string) => {
+	EasyModal.show(HelpModal, { section, color });
+};
+
+const HelpModal = EasyModal.create(({ section, color, visible, remove }: Props) => {
+	const [markdownText, setMarkdownText] = useState("");
+	const lang = getLocale(true);
+
+	useEffect(() => {
+		try {
+			const docFile = getHelpFile(lang, section) as any;
+			fetch(docFile)
+				.then((response) => response.text())
+				.then(setMarkdownText);
+		} catch (ex: any) {
+			setMarkdownText(`**ERROR:** ${ex.message}`);
+		}
+	}, [lang, section]);
+
+	return (
+		<Modal show={visible} onHide={remove}>
+			<Modal.Body>
+				<ReactMarkdown>{markdownText}</ReactMarkdown>
+			</Modal.Body>
+			<Modal.Footer>
+				<Button
+					type="button"
+					actionType="primary"
+					className={cn("ms-auto", color ? `btn-${color}` : null)}
+					data-bs-dismiss="modal"
+					onClick={remove}
+				>
+					<T id="action.close" />
+				</Button>
+			</Modal.Footer>
+		</Modal>
+	);
+});
+
+export { showHelpModal };

+ 1 - 0
frontend/src/modals/index.ts

@@ -5,6 +5,7 @@ export * from "./DeadHostModal";
 export * from "./DeleteConfirmModal";
 export * from "./DNSCertificateModal";
 export * from "./EventDetailsModal";
+export * from "./HelpModal";
 export * from "./HTTPCertificateModal";
 export * from "./PermissionsModal";
 export * from "./ProxyHostModal";

+ 4 - 8
frontend/src/modules/Validations.tsx

@@ -11,12 +11,10 @@ const validateString = (minLength = 0, maxLength = 0) => {
 			return intl.formatMessage({ id: "error.required" });
 		}
 		if (minLength && value.length < minLength) {
-			// TODO: i18n
-			return `Minimum length is ${minLength} character${minLength === 1 ? "" : "s"}`;
+			return intl.formatMessage({ id: "error.min-character-length" }, { min: minLength });
 		}
 		if (maxLength && (typeof value === "undefined" || value.length > maxLength)) {
-			// TODO: i18n
-			return `Maximum length is ${maxLength} character${maxLength === 1 ? "" : "s"}`;
+			return intl.formatMessage({ id: "error.max-character-length" }, { max: maxLength });
 		}
 	};
 };
@@ -33,12 +31,10 @@ const validateNumber = (min = -1, max = -1) => {
 			return intl.formatMessage({ id: "error.required" });
 		}
 		if (min > -1 && int < min) {
-			// TODO: i18n
-			return `Minimum is ${min}`;
+			return intl.formatMessage({ id: "error.minimum" }, { min });
 		}
 		if (max > -1 && int > max) {
-			// TODO: i18n
-			return `Maximum is ${max}`;
+			return intl.formatMessage({ id: "error.maximum" }, { max });
 		}
 	};
 };

+ 1 - 1
frontend/src/pages/Access/Table.tsx

@@ -53,7 +53,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
 				cell: (info: any) => <T id="proxy-hosts.count" data={{ count: info.getValue() }} />,
 			}),
 			columnHelper.display({
-				id: "id", // todo: not needed for a display?
+				id: "id",
 				cell: (info: any) => {
 					return (
 						<span className="dropdown">

+ 14 - 8
frontend/src/pages/Access/TableWrapper.tsx

@@ -1,11 +1,11 @@
-import { IconSearch } from "@tabler/icons-react";
+import { IconHelp, IconSearch } from "@tabler/icons-react";
 import { useState } from "react";
 import Alert from "react-bootstrap/Alert";
 import { deleteAccessList } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useAccessLists } from "src/hooks";
 import { T } from "src/locale";
-import { showAccessListModal, showDeleteConfirmModal } from "src/modals";
+import { showAccessListModal, showDeleteConfirmModal, showHelpModal } from "src/modals";
 import { showObjectSuccess } from "src/notifications";
 import Table from "./Table";
 
@@ -47,9 +47,10 @@ export default function TableWrapper() {
 								<T id="access-lists" />
 							</h2>
 						</div>
-						{data?.length ? (
-							<div className="col-md-auto col-sm-12">
-								<div className="ms-auto d-flex flex-wrap btn-list">
+
+						<div className="col-md-auto col-sm-12">
+							<div className="ms-auto d-flex flex-wrap btn-list">
+								{data?.length ? (
 									<div className="input-group input-group-flat w-auto">
 										<span className="input-group-text input-group-text-sm">
 											<IconSearch size={16} />
@@ -62,12 +63,17 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
+								) : null}
+								<Button size="sm" onClick={() => showHelpModal("AccessLists", "cyan")}>
+									<IconHelp size={20} />
+								</Button>
+								{data?.length ? (
 									<Button size="sm" className="btn-cyan" onClick={() => showAccessListModal("new")}>
 										<T id="object.add" tData={{ object: "access-list" }} />
 									</Button>
-								</div>
+								) : null}
 							</div>
-						) : null}
+						</div>
 					</div>
 				</div>
 				<Table
@@ -77,7 +83,7 @@ export default function TableWrapper() {
 					onEdit={(id: number) => showAccessListModal(id)}
 					onDelete={(id: number) =>
 						showDeleteConfirmModal({
-							title: "access.delete.title",
+							title: <T id="object.delete" tData={{ object: "access-list" }} />,
 							onConfirm: () => handleDelete(id),
 							invalidations: [["access-lists"], ["access-list", id]],
 							children: <T id="object.delete.content" tData={{ object: "access-list" }} />,

+ 14 - 7
frontend/src/pages/Certificates/TableWrapper.tsx

@@ -1,14 +1,15 @@
-import { IconSearch } from "@tabler/icons-react";
+import { IconHelp, IconSearch } from "@tabler/icons-react";
 import { useState } from "react";
 import Alert from "react-bootstrap/Alert";
 import { deleteCertificate, downloadCertificate } from "src/api/backend";
-import { LoadingPage } from "src/components";
+import { Button, LoadingPage } from "src/components";
 import { useCertificates } from "src/hooks";
 import { T } from "src/locale";
 import {
 	showCustomCertificateModal,
 	showDeleteConfirmModal,
 	showDNSCertificateModal,
+	showHelpModal,
 	showHTTPCertificateModal,
 	showRenewCertificateModal,
 } from "src/modals";
@@ -69,9 +70,10 @@ export default function TableWrapper() {
 								<T id="certificates" />
 							</h2>
 						</div>
-						{data?.length ? (
-							<div className="col-md-auto col-sm-12">
-								<div className="ms-auto d-flex flex-wrap btn-list">
+
+						<div className="col-md-auto col-sm-12">
+							<div className="ms-auto d-flex flex-wrap btn-list">
+								{data?.length ? (
 									<div className="input-group input-group-flat w-auto">
 										<span className="input-group-text input-group-text-sm">
 											<IconSearch size={16} />
@@ -84,6 +86,11 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
+								) : null}
+								<Button size="sm" onClick={() => showHelpModal("Certificates", "pink")}>
+									<IconHelp size={20} />
+								</Button>
+								{data?.length ? (
 									<div className="dropdown">
 										<button
 											type="button"
@@ -126,9 +133,9 @@ export default function TableWrapper() {
 											</a>
 										</div>
 									</div>
-								</div>
+								) : null}
 							</div>
-						) : null}
+						</div>
 					</div>
 				</div>
 				<Table

+ 0 - 5
frontend/src/pages/Dashboard/index.tsx

@@ -116,12 +116,7 @@ const Dashboard = () => {
 				<code>{`Todo:
 
 - check mobile
-- use statuses for table formatters where applicable: https://docs.tabler.io/ui/components/statuses
-- add help docs for host types
 - REDO SCREENSHOTS in docs folder
-- search codebase for "TODO"
-- update documentation to add development notes for translations
-- double check output of access field selection on proxy host dialog, after access lists are completed
 - check permissions in all places
 
 More for api, then implement here:

+ 1 - 1
frontend/src/pages/Nginx/DeadHosts/Table.tsx

@@ -58,7 +58,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 				},
 			}),
 			columnHelper.display({
-				id: "id", // todo: not needed for a display?
+				id: "id",
 				cell: (info: any) => {
 					return (
 						<span className="dropdown">

+ 13 - 7
frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx

@@ -1,4 +1,4 @@
-import { IconSearch } from "@tabler/icons-react";
+import { IconHelp, IconSearch } from "@tabler/icons-react";
 import { useQueryClient } from "@tanstack/react-query";
 import { useState } from "react";
 import Alert from "react-bootstrap/Alert";
@@ -6,7 +6,7 @@ import { deleteDeadHost, toggleDeadHost } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useDeadHosts } from "src/hooks";
 import { T } from "src/locale";
-import { showDeadHostModal, showDeleteConfirmModal } from "src/modals";
+import { showDeadHostModal, showDeleteConfirmModal, showHelpModal } from "src/modals";
 import { showObjectSuccess } from "src/notifications";
 import Table from "./Table";
 
@@ -56,9 +56,10 @@ export default function TableWrapper() {
 								<T id="dead-hosts" />
 							</h2>
 						</div>
-						{data?.length ? (
-							<div className="col-md-auto col-sm-12">
-								<div className="ms-auto d-flex flex-wrap btn-list">
+
+						<div className="col-md-auto col-sm-12">
+							<div className="ms-auto d-flex flex-wrap btn-list">
+								{data?.length ? (
 									<div className="input-group input-group-flat w-auto">
 										<span className="input-group-text input-group-text-sm">
 											<IconSearch size={16} />
@@ -71,12 +72,17 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
+								) : null}
+								<Button size="sm" onClick={() => showHelpModal("DeadHosts", "red")}>
+									<IconHelp size={20} />
+								</Button>
+								{data?.length ? (
 									<Button size="sm" className="btn-red" onClick={() => showDeadHostModal("new")}>
 										<T id="object.add" tData={{ object: "dead-host" }} />
 									</Button>
-								</div>
+								) : null}
 							</div>
-						) : null}
+						</div>
 					</div>
 				</div>
 				<Table

+ 13 - 7
frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx

@@ -1,4 +1,4 @@
-import { IconSearch } from "@tabler/icons-react";
+import { IconHelp, IconSearch } from "@tabler/icons-react";
 import { useQueryClient } from "@tanstack/react-query";
 import { useState } from "react";
 import Alert from "react-bootstrap/Alert";
@@ -6,7 +6,7 @@ import { deleteProxyHost, toggleProxyHost } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useProxyHosts } from "src/hooks";
 import { T } from "src/locale";
-import { showDeleteConfirmModal, showProxyHostModal } from "src/modals";
+import { showDeleteConfirmModal, showHelpModal, showProxyHostModal } from "src/modals";
 import { showObjectSuccess } from "src/notifications";
 import Table from "./Table";
 
@@ -59,9 +59,10 @@ export default function TableWrapper() {
 								<T id="proxy-hosts" />
 							</h2>
 						</div>
-						{data?.length ? (
-							<div className="col-md-auto col-sm-12">
-								<div className="ms-auto d-flex flex-wrap btn-list">
+
+						<div className="col-md-auto col-sm-12">
+							<div className="ms-auto d-flex flex-wrap btn-list">
+								{data?.length ? (
 									<div className="input-group input-group-flat w-auto">
 										<span className="input-group-text input-group-text-sm">
 											<IconSearch size={16} />
@@ -74,12 +75,17 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
+								) : null}
+								<Button size="sm" onClick={() => showHelpModal("ProxyHosts", "lime")}>
+									<IconHelp size={20} />
+								</Button>
+								{data?.length ? (
 									<Button size="sm" className="btn-lime" onClick={() => showProxyHostModal("new")}>
 										<T id="object.add" tData={{ object: "proxy-host" }} />
 									</Button>
-								</div>
+								) : null}
 							</div>
-						) : null}
+						</div>
 					</div>
 				</div>
 				<Table

+ 13 - 7
frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.tsx

@@ -1,4 +1,4 @@
-import { IconSearch } from "@tabler/icons-react";
+import { IconHelp, IconSearch } from "@tabler/icons-react";
 import { useQueryClient } from "@tanstack/react-query";
 import { useState } from "react";
 import Alert from "react-bootstrap/Alert";
@@ -6,7 +6,7 @@ import { deleteRedirectionHost, toggleRedirectionHost } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useRedirectionHosts } from "src/hooks";
 import { T } from "src/locale";
-import { showDeleteConfirmModal, showRedirectionHostModal } from "src/modals";
+import { showDeleteConfirmModal, showHelpModal, showRedirectionHostModal } from "src/modals";
 import { showObjectSuccess } from "src/notifications";
 import Table from "./Table";
 
@@ -59,9 +59,10 @@ export default function TableWrapper() {
 								<T id="redirection-hosts" />
 							</h2>
 						</div>
-						{data?.length ? (
-							<div className="col-md-auto col-sm-12">
-								<div className="ms-auto d-flex flex-wrap btn-list">
+
+						<div className="col-md-auto col-sm-12">
+							<div className="ms-auto d-flex flex-wrap btn-list">
+								{data?.length ? (
 									<div className="input-group input-group-flat w-auto">
 										<span className="input-group-text input-group-text-sm">
 											<IconSearch size={16} />
@@ -74,6 +75,11 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
+								) : null}
+								<Button size="sm" onClick={() => showHelpModal("RedirectionHosts", "yellow")}>
+									<IconHelp size={20} />
+								</Button>
+								{data?.length ? (
 									<Button
 										size="sm"
 										className="btn-yellow"
@@ -81,9 +87,9 @@ export default function TableWrapper() {
 									>
 										<T id="object.add" tData={{ object: "redirection-host" }} />
 									</Button>
-								</div>
+								) : null}
 							</div>
-						) : null}
+						</div>
 					</div>
 				</div>
 				<Table

+ 1 - 1
frontend/src/pages/Nginx/Streams/Table.tsx

@@ -87,7 +87,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
 				},
 			}),
 			columnHelper.display({
-				id: "id", // todo: not needed for a display?
+				id: "id",
 				cell: (info: any) => {
 					return (
 						<span className="dropdown">

+ 13 - 7
frontend/src/pages/Nginx/Streams/TableWrapper.tsx

@@ -1,4 +1,4 @@
-import { IconSearch } from "@tabler/icons-react";
+import { IconHelp, IconSearch } from "@tabler/icons-react";
 import { useQueryClient } from "@tanstack/react-query";
 import { useState } from "react";
 import Alert from "react-bootstrap/Alert";
@@ -6,7 +6,7 @@ import { deleteStream, toggleStream } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useStreams } from "src/hooks";
 import { T } from "src/locale";
-import { showDeleteConfirmModal, showStreamModal } from "src/modals";
+import { showDeleteConfirmModal, showHelpModal, showStreamModal } from "src/modals";
 import { showObjectSuccess } from "src/notifications";
 import Table from "./Table";
 
@@ -61,9 +61,10 @@ export default function TableWrapper() {
 								<T id="streams" />
 							</h2>
 						</div>
-						{data?.length ? (
-							<div className="col-md-auto col-sm-12">
-								<div className="ms-auto d-flex flex-wrap btn-list">
+
+						<div className="col-md-auto col-sm-12">
+							<div className="ms-auto d-flex flex-wrap btn-list">
+								{data?.length ? (
 									<div className="input-group input-group-flat w-auto">
 										<span className="input-group-text input-group-text-sm">
 											<IconSearch size={16} />
@@ -76,12 +77,17 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
+								) : null}
+								<Button size="sm" onClick={() => showHelpModal("Streams", "blue")}>
+									<IconHelp size={20} />
+								</Button>
+								{data?.length ? (
 									<Button size="sm" className="btn-blue" onClick={() => showStreamModal("new")}>
 										<T id="object.add" tData={{ object: "stream" }} />
 									</Button>
-								</div>
+								) : null}
 							</div>
-						) : null}
+						</div>
 					</div>
 				</div>
 				<Table

+ 1 - 0
frontend/vite.config.ts

@@ -47,4 +47,5 @@ export default defineConfig({
 		environment: "happy-dom",
 		setupFiles: ["./vitest-setup.js"],
 	},
+	assetsInclude: ["**/*.md", "**/*.png", "**/*.svg"],
 });

+ 373 - 9
frontend/yarn.lock

@@ -977,12 +977,26 @@
   resolved "https://registry.yarnpkg.com/@types/country-flag-icons/-/country-flag-icons-1.2.2.tgz#8f51089cab857f0f700feabd38b3960d006d64f2"
   integrity sha512-CefEn/J336TBDp7NX8JqzlDtCBOsm8M3r1Li0gEOt0HOMHF1XemNyrx9lSHjsafcb1yYWybU0N8ZAXuyCaND0w==
 
+"@types/debug@^4.0.0":
+  version "4.1.12"
+  resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917"
+  integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==
+  dependencies:
+    "@types/ms" "*"
+
 "@types/deep-eql@*":
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd"
   integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==
 
-"@types/[email protected]", "@types/estree@^1.0.0":
+"@types/estree-jsx@^1.0.0":
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz#858a88ea20f34fe65111f005a689fa1ebf70dc18"
+  integrity sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==
+  dependencies:
+    "@types/estree" "*"
+
+"@types/estree@*", "@types/[email protected]", "@types/estree@^1.0.0":
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
   integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
@@ -1020,6 +1034,11 @@
   dependencies:
     "@types/unist" "*"
 
+"@types/ms@*":
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78"
+  integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==
+
 "@types/node@^20.0.0":
   version "20.19.11"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.11.tgz#728cab53092bd5f143beed7fbba7ba99de3c16c4"
@@ -1356,6 +1375,13 @@ date-fns@^4.1.0:
   resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14"
   integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==
 
+debug@^4.0.0, debug@^4.4.3:
+  version "4.4.3"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
+  integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
+  dependencies:
+    ms "^2.1.3"
+
 debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
   version "4.4.1"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b"
@@ -1363,13 +1389,6 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
   dependencies:
     ms "^2.1.3"
 
-debug@^4.4.3:
-  version "4.4.3"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
-  integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
-  dependencies:
-    ms "^2.1.3"
-
 decimal.js@^10.4.3:
   version "10.6.0"
   resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a"
@@ -1491,6 +1510,11 @@ escape-string-regexp@^4.0.0:
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
   integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
 
+estree-util-is-identifier-name@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz#0b5ef4c4ff13508b34dcd01ecfa945f61fce5dbd"
+  integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==
+
 estree-walker@^3.0.3:
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d"
@@ -1655,6 +1679,27 @@ hast-util-to-html@^9.0.0:
     stringify-entities "^4.0.0"
     zwitch "^2.0.4"
 
+hast-util-to-jsx-runtime@^2.0.0:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz#ff31897aae59f62232e21594eac7ef6b63333e98"
+  integrity sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==
+  dependencies:
+    "@types/estree" "^1.0.0"
+    "@types/hast" "^3.0.0"
+    "@types/unist" "^3.0.0"
+    comma-separated-tokens "^2.0.0"
+    devlop "^1.0.0"
+    estree-util-is-identifier-name "^3.0.0"
+    hast-util-whitespace "^3.0.0"
+    mdast-util-mdx-expression "^2.0.0"
+    mdast-util-mdx-jsx "^3.0.0"
+    mdast-util-mdxjs-esm "^2.0.0"
+    property-information "^7.0.0"
+    space-separated-tokens "^2.0.0"
+    style-to-js "^1.0.0"
+    unist-util-position "^5.0.0"
+    vfile-message "^4.0.0"
+
 hast-util-to-string@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz#a4f15e682849326dd211c97129c94b0c3e76527c"
@@ -1698,6 +1743,11 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-
   dependencies:
     react-is "^16.7.0"
 
+html-url-attributes@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87"
+  integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==
+
 html-void-elements@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7"
@@ -1731,6 +1781,11 @@ indent-string@^4.0.0:
   resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
   integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
 
[email protected]:
+  version "0.2.4"
+  resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.4.tgz#f4af5fe72e612839fcd453d989a586566d695f22"
+  integrity sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==
+
 [email protected]:
   version "10.7.18"
   resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.7.18.tgz#51a6f387afbca9b0f881b2ec081566db8c540b0d"
@@ -1845,6 +1900,11 @@ lodash@^4.17.21:
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
+longest-streak@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4"
+  integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==
+
 loose-envify@^1.0.0, loose-envify@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
@@ -1871,6 +1931,74 @@ magic-string@^0.30.19:
   dependencies:
     "@jridgewell/sourcemap-codec" "^1.5.5"
 
+mdast-util-from-markdown@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz#4850390ca7cf17413a9b9a0fbefcd1bc0eb4160a"
+  integrity sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==
+  dependencies:
+    "@types/mdast" "^4.0.0"
+    "@types/unist" "^3.0.0"
+    decode-named-character-reference "^1.0.0"
+    devlop "^1.0.0"
+    mdast-util-to-string "^4.0.0"
+    micromark "^4.0.0"
+    micromark-util-decode-numeric-character-reference "^2.0.0"
+    micromark-util-decode-string "^2.0.0"
+    micromark-util-normalize-identifier "^2.0.0"
+    micromark-util-symbol "^2.0.0"
+    micromark-util-types "^2.0.0"
+    unist-util-stringify-position "^4.0.0"
+
+mdast-util-mdx-expression@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz#43f0abac9adc756e2086f63822a38c8d3c3a5096"
+  integrity sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==
+  dependencies:
+    "@types/estree-jsx" "^1.0.0"
+    "@types/hast" "^3.0.0"
+    "@types/mdast" "^4.0.0"
+    devlop "^1.0.0"
+    mdast-util-from-markdown "^2.0.0"
+    mdast-util-to-markdown "^2.0.0"
+
+mdast-util-mdx-jsx@^3.0.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz#fd04c67a2a7499efb905a8a5c578dddc9fdada0d"
+  integrity sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==
+  dependencies:
+    "@types/estree-jsx" "^1.0.0"
+    "@types/hast" "^3.0.0"
+    "@types/mdast" "^4.0.0"
+    "@types/unist" "^3.0.0"
+    ccount "^2.0.0"
+    devlop "^1.1.0"
+    mdast-util-from-markdown "^2.0.0"
+    mdast-util-to-markdown "^2.0.0"
+    parse-entities "^4.0.0"
+    stringify-entities "^4.0.0"
+    unist-util-stringify-position "^4.0.0"
+    vfile-message "^4.0.0"
+
+mdast-util-mdxjs-esm@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz#019cfbe757ad62dd557db35a695e7314bcc9fa97"
+  integrity sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==
+  dependencies:
+    "@types/estree-jsx" "^1.0.0"
+    "@types/hast" "^3.0.0"
+    "@types/mdast" "^4.0.0"
+    devlop "^1.0.0"
+    mdast-util-from-markdown "^2.0.0"
+    mdast-util-to-markdown "^2.0.0"
+
+mdast-util-phrasing@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz#7cc0a8dec30eaf04b7b1a9661a92adb3382aa6e3"
+  integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==
+  dependencies:
+    "@types/mdast" "^4.0.0"
+    unist-util-is "^6.0.0"
+
 mdast-util-to-hast@^13.0.0:
   version "13.2.0"
   resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz#5ca58e5b921cc0a3ded1bc02eed79a4fe4fe41f4"
@@ -1886,11 +2014,102 @@ mdast-util-to-hast@^13.0.0:
     unist-util-visit "^5.0.0"
     vfile "^6.0.0"
 
+mdast-util-to-markdown@^2.0.0:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz#f910ffe60897f04bb4b7e7ee434486f76288361b"
+  integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==
+  dependencies:
+    "@types/mdast" "^4.0.0"
+    "@types/unist" "^3.0.0"
+    longest-streak "^3.0.0"
+    mdast-util-phrasing "^4.0.0"
+    mdast-util-to-string "^4.0.0"
+    micromark-util-classify-character "^2.0.0"
+    micromark-util-decode-string "^2.0.0"
+    unist-util-visit "^5.0.0"
+    zwitch "^2.0.0"
+
+mdast-util-to-string@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz#7a5121475556a04e7eddeb67b264aae79d312814"
+  integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==
+  dependencies:
+    "@types/mdast" "^4.0.0"
+
 memoize-one@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
   integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==
 
+micromark-core-commonmark@^2.0.0:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz#c691630e485021a68cf28dbc2b2ca27ebf678cd4"
+  integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==
+  dependencies:
+    decode-named-character-reference "^1.0.0"
+    devlop "^1.0.0"
+    micromark-factory-destination "^2.0.0"
+    micromark-factory-label "^2.0.0"
+    micromark-factory-space "^2.0.0"
+    micromark-factory-title "^2.0.0"
+    micromark-factory-whitespace "^2.0.0"
+    micromark-util-character "^2.0.0"
+    micromark-util-chunked "^2.0.0"
+    micromark-util-classify-character "^2.0.0"
+    micromark-util-html-tag-name "^2.0.0"
+    micromark-util-normalize-identifier "^2.0.0"
+    micromark-util-resolve-all "^2.0.0"
+    micromark-util-subtokenize "^2.0.0"
+    micromark-util-symbol "^2.0.0"
+    micromark-util-types "^2.0.0"
+
+micromark-factory-destination@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz#8fef8e0f7081f0474fbdd92deb50c990a0264639"
+  integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==
+  dependencies:
+    micromark-util-character "^2.0.0"
+    micromark-util-symbol "^2.0.0"
+    micromark-util-types "^2.0.0"
+
+micromark-factory-label@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz#5267efa97f1e5254efc7f20b459a38cb21058ba1"
+  integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==
+  dependencies:
+    devlop "^1.0.0"
+    micromark-util-character "^2.0.0"
+    micromark-util-symbol "^2.0.0"
+    micromark-util-types "^2.0.0"
+
+micromark-factory-space@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz#36d0212e962b2b3121f8525fc7a3c7c029f334fc"
+  integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==
+  dependencies:
+    micromark-util-character "^2.0.0"
+    micromark-util-types "^2.0.0"
+
+micromark-factory-title@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz#237e4aa5d58a95863f01032d9ee9b090f1de6e94"
+  integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==
+  dependencies:
+    micromark-factory-space "^2.0.0"
+    micromark-util-character "^2.0.0"
+    micromark-util-symbol "^2.0.0"
+    micromark-util-types "^2.0.0"
+
+micromark-factory-whitespace@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz#06b26b2983c4d27bfcc657b33e25134d4868b0b1"
+  integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==
+  dependencies:
+    micromark-factory-space "^2.0.0"
+    micromark-util-character "^2.0.0"
+    micromark-util-symbol "^2.0.0"
+    micromark-util-types "^2.0.0"
+
 micromark-util-character@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6"
@@ -1899,11 +2118,71 @@ micromark-util-character@^2.0.0:
     micromark-util-symbol "^2.0.0"
     micromark-util-types "^2.0.0"
 
+micromark-util-chunked@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz#47fbcd93471a3fccab86cff03847fc3552db1051"
+  integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==
+  dependencies:
+    micromark-util-symbol "^2.0.0"
+
+micromark-util-classify-character@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz#d399faf9c45ca14c8b4be98b1ea481bced87b629"
+  integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==
+  dependencies:
+    micromark-util-character "^2.0.0"
+    micromark-util-symbol "^2.0.0"
+    micromark-util-types "^2.0.0"
+
+micromark-util-combine-extensions@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz#2a0f490ab08bff5cc2fd5eec6dd0ca04f89b30a9"
+  integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==
+  dependencies:
+    micromark-util-chunked "^2.0.0"
+    micromark-util-types "^2.0.0"
+
+micromark-util-decode-numeric-character-reference@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz#fcf15b660979388e6f118cdb6bf7d79d73d26fe5"
+  integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==
+  dependencies:
+    micromark-util-symbol "^2.0.0"
+
+micromark-util-decode-string@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz#6cb99582e5d271e84efca8e61a807994d7161eb2"
+  integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==
+  dependencies:
+    decode-named-character-reference "^1.0.0"
+    micromark-util-character "^2.0.0"
+    micromark-util-decode-numeric-character-reference "^2.0.0"
+    micromark-util-symbol "^2.0.0"
+
 micromark-util-encode@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8"
   integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==
 
+micromark-util-html-tag-name@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz#e40403096481986b41c106627f98f72d4d10b825"
+  integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==
+
+micromark-util-normalize-identifier@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz#c30d77b2e832acf6526f8bf1aa47bc9c9438c16d"
+  integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==
+  dependencies:
+    micromark-util-symbol "^2.0.0"
+
+micromark-util-resolve-all@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz#e1a2d62cdd237230a2ae11839027b19381e31e8b"
+  integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==
+  dependencies:
+    micromark-util-types "^2.0.0"
+
 micromark-util-sanitize-uri@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7"
@@ -1913,6 +2192,16 @@ micromark-util-sanitize-uri@^2.0.0:
     micromark-util-encode "^2.0.0"
     micromark-util-symbol "^2.0.0"
 
+micromark-util-subtokenize@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz#d8ade5ba0f3197a1cf6a2999fbbfe6357a1a19ee"
+  integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==
+  dependencies:
+    devlop "^1.0.0"
+    micromark-util-chunked "^2.0.0"
+    micromark-util-symbol "^2.0.0"
+    micromark-util-types "^2.0.0"
+
 micromark-util-symbol@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8"
@@ -1923,6 +2212,29 @@ micromark-util-types@^2.0.0:
   resolved "https://registry.yarnpkg.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e"
   integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==
 
+micromark@^4.0.0:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/micromark/-/micromark-4.0.2.tgz#91395a3e1884a198e62116e33c9c568e39936fdb"
+  integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==
+  dependencies:
+    "@types/debug" "^4.0.0"
+    debug "^4.0.0"
+    decode-named-character-reference "^1.0.0"
+    devlop "^1.0.0"
+    micromark-core-commonmark "^2.0.0"
+    micromark-factory-space "^2.0.0"
+    micromark-util-character "^2.0.0"
+    micromark-util-chunked "^2.0.0"
+    micromark-util-combine-extensions "^2.0.0"
+    micromark-util-decode-numeric-character-reference "^2.0.0"
+    micromark-util-encode "^2.0.0"
+    micromark-util-normalize-identifier "^2.0.0"
+    micromark-util-resolve-all "^2.0.0"
+    micromark-util-sanitize-uri "^2.0.0"
+    micromark-util-subtokenize "^2.0.0"
+    micromark-util-symbol "^2.0.0"
+    micromark-util-types "^2.0.0"
+
 micromatch@^4.0.5:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
@@ -2184,6 +2496,23 @@ react-lifecycles-compat@^3.0.4:
   resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
   integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
 
+react-markdown@^10.1.0:
+  version "10.1.0"
+  resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca"
+  integrity sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==
+  dependencies:
+    "@types/hast" "^3.0.0"
+    "@types/mdast" "^4.0.0"
+    devlop "^1.0.0"
+    hast-util-to-jsx-runtime "^2.0.0"
+    html-url-attributes "^3.0.0"
+    mdast-util-to-hast "^13.0.0"
+    remark-parse "^11.0.0"
+    remark-rehype "^11.0.0"
+    unified "^11.0.0"
+    unist-util-visit "^5.0.0"
+    vfile "^6.0.0"
+
 react-refresh@^0.18.0:
   version "0.18.0"
   resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062"
@@ -2304,6 +2633,27 @@ rehype@~13.0.0:
     rehype-stringify "^10.0.0"
     unified "^11.0.0"
 
+remark-parse@^11.0.0:
+  version "11.0.0"
+  resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-11.0.0.tgz#aa60743fcb37ebf6b069204eb4da304e40db45a1"
+  integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==
+  dependencies:
+    "@types/mdast" "^4.0.0"
+    mdast-util-from-markdown "^2.0.0"
+    micromark-util-types "^2.0.0"
+    unified "^11.0.0"
+
+remark-rehype@^11.0.0:
+  version "11.1.2"
+  resolved "https://registry.yarnpkg.com/remark-rehype/-/remark-rehype-11.1.2.tgz#2addaadda80ca9bd9aa0da763e74d16327683b37"
+  integrity sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==
+  dependencies:
+    "@types/hast" "^3.0.0"
+    "@types/mdast" "^4.0.0"
+    mdast-util-to-hast "^13.0.0"
+    unified "^11.0.0"
+    vfile "^6.0.0"
+
 resolve-from@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
@@ -2439,6 +2789,20 @@ strip-indent@^3.0.0:
   dependencies:
     min-indent "^1.0.0"
 
+style-to-js@^1.0.0:
+  version "1.1.18"
+  resolved "https://registry.yarnpkg.com/style-to-js/-/style-to-js-1.1.18.tgz#3e6c13bd4c4db079bd2c2c94571cce5c758bc2ff"
+  integrity sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==
+  dependencies:
+    style-to-object "1.0.11"
+
[email protected]:
+  version "1.0.11"
+  resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-1.0.11.tgz#cf252c4051758b7acb18a5efb296f91fb79bb9c4"
+  integrity sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==
+  dependencies:
+    inline-style-parser "0.2.4"
+
 [email protected]:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51"
@@ -2757,7 +3121,7 @@ yaml@^1.10.0:
   resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
   integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
 
-zwitch@^2.0.4:
+zwitch@^2.0.0, zwitch@^2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"
   integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==