From 2173e62c2a18b1df777712819776e95e04f74b5e Mon Sep 17 00:00:00 2001 From: Daniel Jacobs Date: Wed, 13 Nov 2024 13:37:07 -0500 Subject: [PATCH 01/19] i18n: Proof of concept --- i18n/index.js | 14 +++++++ i18n/translations.en.json | 3 ++ i18n/translations.es.json | 3 ++ package-lock.json | 26 +++++++++++++ package.json | 1 + src/app/globals.css | 3 ++ src/app/layout.tsx | 3 +- src/app/page.tsx | 6 ++- src/components/header.module.css | 6 +++ src/components/header.tsx | 66 +++++++++++++++++++++++++++++++- 10 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 i18n/index.js create mode 100644 i18n/translations.en.json create mode 100644 i18n/translations.es.json diff --git a/i18n/index.js b/i18n/index.js new file mode 100644 index 00000000..d0b3ae96 --- /dev/null +++ b/i18n/index.js @@ -0,0 +1,14 @@ +const en = require("./translations.en.json"); +const es = require("./translations.es.json"); + +const i18n = { + translations: { + en, + es, + }, + defaultLang: "en", + useBrowserDefault: true, + languageDataStore: "localStorage", +}; + +module.exports = i18n; diff --git a/i18n/translations.en.json b/i18n/translations.en.json new file mode 100644 index 00000000..592ceea3 --- /dev/null +++ b/i18n/translations.en.json @@ -0,0 +1,3 @@ +{ + "tagline": "An open source Flash Player emulator" +} diff --git a/i18n/translations.es.json b/i18n/translations.es.json new file mode 100644 index 00000000..a45cc1ca --- /dev/null +++ b/i18n/translations.es.json @@ -0,0 +1,3 @@ +{ + "tagline": "Un emulador de Flash Player de código abierto" +} diff --git a/package-lock.json b/package-lock.json index 4d045b97..64cf1e25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "feed": "^4.2.2", "gray-matter": "^4.0.3", "jsdom": "^25.0.1", + "next-export-i18n": "^3.0.0", "octokit": "^4.0.2", "postcss": "^8.4.49", "postcss-preset-mantine": "^1.17.0", @@ -6389,6 +6390,16 @@ "dev": true, "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -6468,6 +6479,21 @@ } } }, + "node_modules/next-export-i18n": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/next-export-i18n/-/next-export-i18n-3.0.0.tgz", + "integrity": "sha512-OMp4L9rLD9OC7f2AcQj7XC2oi1sh4mIiAg61lO+gKBRubypjQ/UHXC8nGES4wJpweYH0HYwHcA6H4H8K2NXn5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mustache": "^4.2.0" + }, + "peerDependencies": { + "next": ">=13.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/package.json b/package.json index cf7517c3..1b608be1 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "feed": "^4.2.2", "gray-matter": "^4.0.3", "jsdom": "^25.0.1", + "next-export-i18n": "^3.0.0", "octokit": "^4.0.2", "postcss": "^8.4.49", "postcss-preset-mantine": "^1.17.0", diff --git a/src/app/globals.css b/src/app/globals.css index 31275902..4cd4a9cb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,3 +2,6 @@ a:link, a:visited { color: var(--ruffle-orange-3); } +span[data-language-switcher] { + display: none; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 87428efa..5d470c25 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { MantineProvider, ColorSchemeScript } from "@mantine/core"; import { cssResolver, theme } from "@/theme"; import { Header } from "@/components/header"; import { FooterSocial } from "@/components/footer"; +import { Suspense } from "react"; export const metadata: Metadata = { title: "Ruffle - Flash Emulator", @@ -42,7 +43,7 @@ export default function RootLayout({
- {children} + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index c1e2eee1..8bf7459e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,7 @@ "use client"; import dynamic from "next/dynamic"; +import { useTranslation } from "next-export-i18n"; import classes from "./index.module.css"; import { Container, @@ -26,6 +27,7 @@ const Installers = dynamic(() => import("./installers"), { }); export default function Home() { + const { t } = useTranslation(); const [latest, setLatest] = React.useState(null); React.useEffect(() => { @@ -45,8 +47,8 @@ export default function Home() { - - An open source Flash Player emulator + <Title className={classes.title} suppressHydrationWarning> + {t("tagline")}
= { + en: "English", + es: "Español", +}; export function Header() { + const [selectedLang, setSelectedLang] = useState("en"); + useEffect(() => { + if (typeof window !== "undefined") { + const browserLang = + window.navigator.language || window.navigator.languages + ? (window.navigator.language || window.navigator.languages[0]) + .split("-")[0] + .toLowerCase() + : "en"; + setSelectedLang(browserLang); + const selectedLang = window.localStorage + ? window.localStorage.getItem("next-export-i18n-lang") + : null; + if (selectedLang) { + setSelectedLang(selectedLang); + } + } + }, []); + + const handleLanguageChange = ( + event: React.ChangeEvent, + ) => { + const newLang = event.target.value; + setSelectedLang(newLang); + // Trigger the LanguageSwitcher programmatically + const languageSwitcher = document.querySelector( + `[data-language-switcher][aria-label='set language to ${newLang}']`, + ) as HTMLElement; + if (languageSwitcher) { + languageSwitcher.click(); + } + }; + const [opened, { toggle, close }] = useDisclosure(false); const pathname = usePathname(); @@ -43,7 +82,7 @@ export function Header() { return (
- + {items} + {" "} + {Object.keys(languages).map((langCode) => ( + + + {languages[langCode]} + + + ))} {items} + Date: Wed, 13 Nov 2024 16:56:08 -0500 Subject: [PATCH 02/19] i18n: Put content of entire home page in translation files --- i18n/translations.en.json | 30 +++++++++++++++++++++++++++++- i18n/translations.es.json | 4 +++- src/app/layout.tsx | 8 +++++--- src/app/page.tsx | 36 ++++++++++++++++++++++++------------ src/components/footer.tsx | 23 ++++++++++++++--------- src/components/header.tsx | 38 +++++++++++++++++++++----------------- 6 files changed, 96 insertions(+), 43 deletions(-) diff --git a/i18n/translations.en.json b/i18n/translations.en.json index 592ceea3..2aa273a8 100644 --- a/i18n/translations.en.json +++ b/i18n/translations.en.json @@ -1,3 +1,31 @@ { - "tagline": "An open source Flash Player emulator" + "home": { + "title": "An open source Flash Player emulator", + "intro": "Made to run natively on all modern operating systems and browsers, Ruffle brings Flash content back to life with no extra fuss.", + "safe": "Safe to use", + "safe-description": "Using the guarantees of Rust and WASM, we avoid the security pitfalls Flash was known for.", + "easy": "Easy to install", + "easy-description": "Whether you're a user or a website owner, we've made it as easy as possible to get up and running.", + "free": "Free and open source", + "free-description": "Licensed MIT/Apache 2.0, you're free to use Ruffle how you please!" + }, + "header": { + "about": "About Ruffle", + "downloads": "Downloads", + "compatibility": "Compatibility", + "contribute": "Get Involved", + "blog": "Blog", + "demo": "Demo", + "discord": "Discord", + "github": "GitHub" + }, + "footer": { + "github": "GitHub", + "social-x": "X", + "tiktok": "TikTok", + "instagram": "Instagram", + "mastodon": "Mastodon", + "discord": "Discord", + "tagline": "Putting Flash back on the web" + } } diff --git a/i18n/translations.es.json b/i18n/translations.es.json index a45cc1ca..1fe1a3cc 100644 --- a/i18n/translations.es.json +++ b/i18n/translations.es.json @@ -1,3 +1,5 @@ { - "tagline": "Un emulador de Flash Player de código abierto" + "home": { + "title": "Un emulador de Flash Player de código abierto" + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5d470c25..6cd709d1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -42,9 +42,11 @@ export default function RootLayout({ -
- {children} - + +
+ {children} + + diff --git a/src/app/page.tsx b/src/app/page.tsx index 8bf7459e..e8a00a62 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -48,7 +48,7 @@ export default function Home() { - {t("tagline")} + {t("home.title")}
- - Made to run natively on all modern operating systems and browsers, - Ruffle brings Flash content back to life with no extra fuss. + + {t("home.intro")} - Safe to use - Using the - guarantees of Rust and WASM, we avoid the security pitfalls - Flash was known for. + + {t("home.safe")} + {" "} + -{" "} + + {t("home.safe-description")} + - Easy to install - Whether - you're a user or a website owner, we've made it as - easy as possible to get up and running. + + {t("home.easy")} + {" "} + -{" "} + + {t("home.easy-description")} + - Free and open source - Licensed - MIT/Apache 2.0, you're free to use Ruffle how you please! + + {t("home.free")} + {" "} + -{" "} + + {t("home.free-description")} + diff --git a/src/components/footer.tsx b/src/components/footer.tsx index 3c5c7eef..51de5b01 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -1,5 +1,8 @@ +"use client"; + import { Container, Group, ActionIcon, rem, Text } from "@mantine/core"; import Link from "next/link"; +import { useTranslation } from "next-export-i18n"; import { IconBrandX, @@ -16,38 +19,40 @@ const allSocials = [ { type: IconBrandGithub, url: "https://github.com/ruffle-rs", - title: "GitHub", + titleKey: "footer.github", }, { type: IconBrandX, url: "https://twitter.com/ruffle_rs", - title: "X", + titleKey: "footer.social-x", }, { type: IconBrandTiktok, url: "https://www.tiktok.com/@ruffle.rs", - title: "Tiktok", + titleKey: "footer.tiktok", }, { type: IconBrandInstagram, url: "https://www.instagram.com/ruffle.rs/", - title: "Instagram", + titleKey: "footer.instagram", }, { type: IconBrandMastodon, url: "https://mastodon.gamedev.place/@ruffle", - title: "Mastodon", + titleKey: "footer.mastodon", }, { type: IconBrandDiscord, url: "https://discord.gg/ruffle", - title: "Discord", + titleKey: "footer.discord", }, ]; export function FooterSocial() { + const { t } = useTranslation(); const socials = allSocials.map((social, i) => ( @@ -72,8 +77,8 @@ export function FooterSocial() { width={91} priority /> - - Putting Flash back on the web + + {t("footer.tagline")} ( { close(); }} + suppressHydrationWarning > - {link.label} + {t(link.labelKey)} )); @@ -103,11 +109,9 @@ export function Header() { {" "} {Object.keys(languages).map((langCode) => ( - - - {languages[langCode]} - - + + {languages[langCode]} + ))} Date: Wed, 13 Nov 2024 23:01:54 -0500 Subject: [PATCH 03/19] Switch away from the pre-built hard to customize LanguageSwitcher --- src/app/globals.css | 3 --- src/components/header.tsx | 17 ++++------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 4cd4a9cb..31275902 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,6 +2,3 @@ a:link, a:visited { color: var(--ruffle-orange-3); } -span[data-language-switcher] { - display: none; -} diff --git a/src/components/header.tsx b/src/components/header.tsx index 793b0196..8a2566b9 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -6,7 +6,7 @@ import classes from "./header.module.css"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LanguageSwitcher, useTranslation } from "next-export-i18n"; +import { useTranslation } from "next-export-i18n"; import React, { useState, useEffect } from "react"; const links = [ @@ -57,13 +57,9 @@ export function Header() { ) => { const newLang = event.target.value; setSelectedLang(newLang); - // Trigger the LanguageSwitcher programmatically - const languageSwitcher = document.querySelector( - `[data-language-switcher][aria-label='set language to ${newLang}']`, - ) as HTMLElement; - if (languageSwitcher) { - languageSwitcher.click(); - } + window.localStorage.setItem("next-export-i18n-lang", newLang); + const langChangeEvent = new Event("localStorageLangChange"); + document.dispatchEvent(langChangeEvent); }; const [opened, { toggle, close }] = useDisclosure(false); @@ -108,11 +104,6 @@ export function Header() { ))} {" "} - {Object.keys(languages).map((langCode) => ( - - {languages[langCode]} - - ))} Date: Thu, 14 Nov 2024 15:22:09 -0500 Subject: [PATCH 04/19] Use own logic for translation --- i18n/index.js | 14 -- package-lock.json | 26 ---- package.json | 1 - src/app/layout.tsx | 9 +- src/app/page.tsx | 38 ++--- src/app/translate.tsx | 178 ++++++++++++++++++++++++ src/components/footer.tsx | 6 +- src/components/header.tsx | 59 +------- {i18n => src/i18n}/translations.en.json | 0 {i18n => src/i18n}/translations.es.json | 0 10 files changed, 196 insertions(+), 135 deletions(-) delete mode 100644 i18n/index.js create mode 100644 src/app/translate.tsx rename {i18n => src/i18n}/translations.en.json (100%) rename {i18n => src/i18n}/translations.es.json (100%) diff --git a/i18n/index.js b/i18n/index.js deleted file mode 100644 index d0b3ae96..00000000 --- a/i18n/index.js +++ /dev/null @@ -1,14 +0,0 @@ -const en = require("./translations.en.json"); -const es = require("./translations.es.json"); - -const i18n = { - translations: { - en, - es, - }, - defaultLang: "en", - useBrowserDefault: true, - languageDataStore: "localStorage", -}; - -module.exports = i18n; diff --git a/package-lock.json b/package-lock.json index 64cf1e25..4d045b97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,6 @@ "feed": "^4.2.2", "gray-matter": "^4.0.3", "jsdom": "^25.0.1", - "next-export-i18n": "^3.0.0", "octokit": "^4.0.2", "postcss": "^8.4.49", "postcss-preset-mantine": "^1.17.0", @@ -6390,16 +6389,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", - "dev": true, - "license": "MIT", - "bin": { - "mustache": "bin/mustache" - } - }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -6479,21 +6468,6 @@ } } }, - "node_modules/next-export-i18n": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/next-export-i18n/-/next-export-i18n-3.0.0.tgz", - "integrity": "sha512-OMp4L9rLD9OC7f2AcQj7XC2oi1sh4mIiAg61lO+gKBRubypjQ/UHXC8nGES4wJpweYH0HYwHcA6H4H8K2NXn5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mustache": "^4.2.0" - }, - "peerDependencies": { - "next": ">=13.0.0", - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/package.json b/package.json index 1b608be1..cf7517c3 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "feed": "^4.2.2", "gray-matter": "^4.0.3", "jsdom": "^25.0.1", - "next-export-i18n": "^3.0.0", "octokit": "^4.0.2", "postcss": "^8.4.49", "postcss-preset-mantine": "^1.17.0", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6cd709d1..87428efa 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,7 +5,6 @@ import { MantineProvider, ColorSchemeScript } from "@mantine/core"; import { cssResolver, theme } from "@/theme"; import { Header } from "@/components/header"; import { FooterSocial } from "@/components/footer"; -import { Suspense } from "react"; export const metadata: Metadata = { title: "Ruffle - Flash Emulator", @@ -42,11 +41,9 @@ export default function RootLayout({ - -
- {children} - - +
+ {children} + diff --git a/src/app/page.tsx b/src/app/page.tsx index e8a00a62..a8bd8d19 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,6 @@ "use client"; import dynamic from "next/dynamic"; -import { useTranslation } from "next-export-i18n"; import classes from "./index.module.css"; import { Container, @@ -16,6 +15,7 @@ import Image from "next/image"; import { IconCheck } from "@tabler/icons-react"; import React from "react"; import { getLatestReleases } from "@/app/downloads/github"; +import { t } from "@/app/translate"; import { GithubRelease } from "./downloads/config"; const InteractiveLogo = dynamic(() => import("../components/logo"), { @@ -27,7 +27,6 @@ const Installers = dynamic(() => import("./installers"), { }); export default function Home() { - const { t } = useTranslation(); const [latest, setLatest] = React.useState(null); React.useEffect(() => { @@ -47,9 +46,7 @@ export default function Home() { - - {t("home.title")} - + {t("home.title")}
- - {t("home.intro")} - + {t("home.intro")} - - {t("home.safe")} - {" "} - -{" "} - - {t("home.safe-description")} - + {t("home.safe")} -{" "} + {t("home.safe-description")} - - {t("home.easy")} - {" "} - -{" "} - - {t("home.easy-description")} - + {t("home.easy")} -{" "} + {t("home.easy-description")} - - {t("home.free")} - {" "} - -{" "} - - {t("home.free-description")} - + {t("home.free")} -{" "} + {t("home.free-description")} diff --git a/src/app/translate.tsx b/src/app/translate.tsx new file mode 100644 index 00000000..b04c84f7 --- /dev/null +++ b/src/app/translate.tsx @@ -0,0 +1,178 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import defaultTranslations from "@/i18n/translations.en.json"; + +const languages = { + en: "English", + es: "Español", + // ... +}; + +type TranslationObject = { + [key: string]: string | TranslationObject; +}; + +interface LanguageSelectorProps { + className?: string; +} + +async function getAvailableLanguage() { + const defaultLanguage = "en"; + const storedLanguage = window.localStorage.getItem("next-export-i18n-lang"); + const browserLanguage = + window.navigator.language || + (window.navigator.languages && window.navigator.languages[0]); + const language = storedLanguage || browserLanguage || defaultLanguage; + + // Helper function to check if a language file exists + const checkLanguageFileExists = async (lang: string) => { + try { + await import(`@/i18n/translations.${lang}.json`); + return lang; + } catch { + console.warn(`Translation file for language "${lang}" not found.`); + return null; + } + }; + + // Check for full and base language, then fallback to default language + const lang = + (await checkLanguageFileExists(language)) || + (await checkLanguageFileExists(language.split("-")[0])) || + defaultLanguage; + return lang; +} + +const getNestedTranslation = ( + obj: TranslationObject, + key: string, +): string | undefined => { + let acc: TranslationObject | string | undefined = obj; + for (let i = 0; i < key.split(".").length; i++) { + const part = key.split(".")[i]; + if (acc && typeof acc !== "string" && acc[part] !== undefined) { + acc = acc[part]; + } else { + acc = undefined; // If a part is not found, stop and return undefined + break; + } + } + if (typeof acc === "string") { + return acc; + } + return undefined; +}; + +async function translate(translationKey: string) { + const language = await getAvailableLanguage(); + + // Helper function to load translations + const loadTranslations = async (lang: string) => { + try { + return await import(`@/i18n/translations.${lang}.json`); + } catch { + console.warn(`Translation file for language "${lang}" not found.`); + return null; + } + }; + + // Load translations for the selected language and the default language + const translations = await loadTranslations(language); + + // Attempt to get the translation in the selected language, then fall back to default + const translation = + getNestedTranslation(translations, translationKey) || + getNestedTranslation(defaultTranslations, translationKey); + + // Render the translation if found; otherwise, return the key + return translation || translationKey; +} + +export const t = (translationKey: string): string => { + const defaultTranslation = + getNestedTranslation(defaultTranslations, translationKey) || translationKey; + const [translation, setTranslation] = useState(defaultTranslation); + + const updateTranslation = async () => { + const translatedText = await translate(translationKey); + setTranslation(translatedText); + }; + + useEffect(() => { + // Initial translation update + updateTranslation(); + + // Update translation when the language in localStorage changes from other browsing context + const handleStorageChange = (event: StorageEvent) => { + if (event.key === "next-export-i18n-lang") { + updateTranslation(); + } + }; + + // Update translation when the language in localStorage changes from current browsing context + const handleLocalStorageLangChange = () => { + updateTranslation(); + }; + + // Listen for localStorage changes + window.addEventListener("storage", handleStorageChange); + window.addEventListener( + "localStorageLangChange", + handleLocalStorageLangChange, + ); + + // Clean up listener on component unmount + return () => { + window.removeEventListener("storage", handleStorageChange); + window.removeEventListener( + "localStorageLangChange", + handleLocalStorageLangChange, + ); + }; + }, [translationKey]); + + return translation; +}; + +export const LanguageSelector: React.FC = ({ + className, +}) => { + const [selectedLang, setSelectedLang] = useState(""); + + useEffect(() => { + // Fetch and set the selected language + const fetchLanguage = async () => { + const lang = await getAvailableLanguage(); + setSelectedLang(lang); + }; + + fetchLanguage(); // Set the language initially + }, []); + + const handleLanguageChange = ( + event: React.ChangeEvent, + ) => { + const newLang = event.target.value; + setSelectedLang(newLang); + window.localStorage.setItem("next-export-i18n-lang", newLang); + + // Dispatch an event to notify other components or contexts of the language change + const langChangeEvent = new Event("localStorageLangChange"); + window.dispatchEvent(langChangeEvent); + }; + + return ( + + ); +}; diff --git a/src/components/footer.tsx b/src/components/footer.tsx index 51de5b01..9659b730 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -2,7 +2,7 @@ import { Container, Group, ActionIcon, rem, Text } from "@mantine/core"; import Link from "next/link"; -import { useTranslation } from "next-export-i18n"; +import { t } from "@/app/translate"; import { IconBrandX, @@ -49,10 +49,8 @@ const allSocials = [ ]; export function FooterSocial() { - const { t } = useTranslation(); const socials = allSocials.map((social, i) => ( - + {t("footer.tagline")} diff --git a/src/components/header.tsx b/src/components/header.tsx index 8a2566b9..4a205990 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -6,8 +6,8 @@ import classes from "./header.module.css"; import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useTranslation } from "next-export-i18n"; -import React, { useState, useEffect } from "react"; +import { LanguageSelector, t } from "@/app/translate"; +import React from "react"; const links = [ { link: "/", labelKey: "header.about" }, @@ -27,44 +27,10 @@ const links = [ target: "_blank", }, ]; -const languages: Record = { - en: "English", - es: "Español", -}; export function Header() { - const [selectedLang, setSelectedLang] = useState("en"); - useEffect(() => { - if (typeof window !== "undefined") { - const browserLang = - window.navigator.language || window.navigator.languages - ? (window.navigator.language || window.navigator.languages[0]) - .split("-")[0] - .toLowerCase() - : "en"; - setSelectedLang(browserLang); - const selectedLang = window.localStorage - ? window.localStorage.getItem("next-export-i18n-lang") - : null; - if (selectedLang) { - setSelectedLang(selectedLang); - } - } - }, []); - - const handleLanguageChange = ( - event: React.ChangeEvent, - ) => { - const newLang = event.target.value; - setSelectedLang(newLang); - window.localStorage.setItem("next-export-i18n-lang", newLang); - const langChangeEvent = new Event("localStorageLangChange"); - document.dispatchEvent(langChangeEvent); - }; - const [opened, { toggle, close }] = useDisclosure(false); const pathname = usePathname(); - const { t } = useTranslation(); const items = links.map((link) => ( { close(); }} - suppressHydrationWarning > {t(link.labelKey)} @@ -96,13 +61,7 @@ export function Header() { {items} - + {" "} {items} - + Date: Thu, 14 Nov 2024 15:55:21 -0500 Subject: [PATCH 05/19] i18n: Translate logo by rewriting it to a modern functional component --- src/components/logo.tsx | 108 ++++++++++++++-------------------- src/i18n/translations.en.json | 3 + 2 files changed, 47 insertions(+), 64 deletions(-) diff --git a/src/components/logo.tsx b/src/components/logo.tsx index b950a93c..8f19d3e4 100644 --- a/src/components/logo.tsx +++ b/src/components/logo.tsx @@ -1,9 +1,10 @@ "use client"; -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import Image from "next/image"; import Script from "next/script"; import classes from "../app/index.module.css"; +import { t } from "@/app/translate"; declare global { interface Window { @@ -27,44 +28,28 @@ interface LogoProps { className?: string; } -interface LogoState { - player: RufflePlayer | null; -} - -export default class InteractiveLogo extends React.Component< - LogoProps, - LogoState -> { - private readonly container: React.RefObject; - private player: RufflePlayer | null = null; +export default function InteractiveLogo({ className }: LogoProps) { + const container = useRef(null); + const [player, setPlayer] = useState(null); - constructor(props: LogoProps) { - super(props); - - this.container = React.createRef(); - this.state = { - player: null, - }; - } + const removeRufflePlayer = () => { + player?.remove(); + setPlayer(null); + }; - private removeRufflePlayer() { - this.player?.remove(); - this.player = null; - this.setState({ player: null }); - } - - private load() { - if (this.player) { - // Already loaded. + const loadPlayer = () => { + if (player) { return; } - this.player = (window.RufflePlayer as PublicAPI)?.newest()?.createPlayer(); + const rufflePlayer = (window.RufflePlayer as PublicAPI) + ?.newest() + ?.createPlayer(); - if (this.player) { - this.container.current!.appendChild(this.player); + if (rufflePlayer) { + container.current!.appendChild(rufflePlayer); - this.player + rufflePlayer .load({ url: "/logo-anim.swf", autoplay: "on", @@ -75,39 +60,34 @@ export default class InteractiveLogo extends React.Component< preferredRenderer: "canvas", }) .catch(() => { - this.removeRufflePlayer(); + removeRufflePlayer(); }); - this.player.style.width = "100%"; - this.player.style.height = "100%"; - this.setState({ player: this.player }); + rufflePlayer.style.width = "100%"; + rufflePlayer.style.height = "100%"; + setPlayer(rufflePlayer); } - } - - componentDidMount() { - this.load(); - } - - componentWillUnmount() { - this.removeRufflePlayer(); - } - - render() { - return ( - <> - '} - If you'd like to host it yourself, you can grab{" "} + {t("downloads.self-host-description-start")}{" "} - the latest self-hosted package + {t("downloads.self-host-description-link")} {" "} - and upload it to your server. Then, include it on your page like so: + {t("downloads.self-host-description-end")} {''} - For advanced usage, consult{" "} + {t("downloads.advanced-usage-description-start")}{" "} - our documentation + {t("downloads.advanced-usage-description-link")} {" "} - for our JavaScript API and installation options. + {t("downloads.advanced-usage-description-end")} ); } function DesktopDownload({ latest }: { latest: GithubRelease | null }) { + const { t } = useTranslation(); return ( - Desktop Application - - If you want to run Flash content on your computer without a browser - in-between, we have native applications that will take full advantage of - your GPU and system resources to get those extra frames when playing - intense games. - + {t("downloads.desktop-app")} + {t("downloads.desktop-app-description")} {desktopLinks .filter((link) => link.isRecommended) @@ -84,7 +80,7 @@ function DesktopDownload({ latest }: { latest: GithubRelease | null }) { title={url ? "" : "Unavailable"} > - {link.shortName} + {t(link.shortName)} ); })} @@ -93,12 +89,26 @@ function DesktopDownload({ latest }: { latest: GithubRelease | null }) { ); } -export default async function Page() { - const releases = await getLatestReleases(); - const latest = releases.length > 0 ? releases[0] : null; - const nightlies = releases - .filter((release) => release.prerelease) - .slice(0, maxNightlies); +export default function Page() { + const [latest, setLatest] = React.useState(null); + const [nightlies, setNightlies] = React.useState( + null, + ); + React.useEffect(() => { + const fetchReleases = async () => { + try { + const releases = await getLatestReleases(); + const nightlies = releases + .filter((release) => release.prerelease) + .slice(0, maxNightlies); + setNightlies(nightlies); + setLatest(releases.length > 0 ? releases[0] : null); + } catch (err) { + console.warn("Failed to fetch releases", err); + } + }; + fetchReleases(); + }, []); return ( diff --git a/src/app/installers.tsx b/src/app/installers.tsx index d53e0b08..547f167e 100644 --- a/src/app/installers.tsx +++ b/src/app/installers.tsx @@ -6,6 +6,7 @@ import { useDeviceSelectors } from "react-device-detect"; import classes from "./index.module.css"; import { Button, Group } from "@mantine/core"; import Link from "next/link"; +import { useTranslation } from "@/app/translate"; import { allLinks, CurrentDevice, GithubRelease } from "@/app/downloads/config"; interface RecommendedDownload { @@ -21,6 +22,7 @@ export default function Installers({ }: { release: GithubRelease | null; }) { + const { t } = useTranslation(); const [selectors] = useDeviceSelectors(window.navigator.userAgent); const recommended: RecommendedDownload[] = []; const currentDevice: CurrentDevice = { @@ -52,14 +54,14 @@ export default function Installers({ } recommended.push({ - name: "Website Package", + name: "installers.selfhosted-long-name", icon: IconBrandJavascript, url: "/downloads#website-package", }); recommended.push({ icon: IconList, - name: "Other Downloads", + name: "installers.other-downloads", url: "/downloads", className: classes.otherDownloadsButton, }); @@ -77,7 +79,7 @@ export default function Installers({ target={download.target} > - {download.name} + {t(download.name)} ))} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index a116d776..bcac48d9 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,13 +1,17 @@ +"use client"; + import classes from "./not-found.module.css"; +import { useTranslation } from "@/app/translate"; import { Stack, Text, Title } from "@mantine/core"; import React from "react"; export default function Home() { + const { t } = useTranslation(); return ( 404 - Page not found :( - The requested page could not be found. + {t("404.not-found")} :( + {t("404.not-found-description")} ); } diff --git a/src/i18n/translations.en.json b/src/i18n/translations.en.json index d1240934..6a31d0c6 100644 --- a/src/i18n/translations.en.json +++ b/src/i18n/translations.en.json @@ -30,5 +30,54 @@ }, "logo": { "alt-tag": "Ruffle Logo" + }, + "404": { + "not-found": "Page not found", + "not-found-description": "The requested page could not be found." + }, + "installers": { + "windows-64-short-name": "Windows (64-bit)", + "windows-long-name": "Windows Executable", + "windows-32-short-name": "Windows (32-bit)", + "macos-short-name": "macOS", + "macos-long-name": "Mac Application", + "flatpak-short-name": "Flatpak", + "flatpak-long-name": "Flatpak App", + "linux-short-name": "Linux", + "linux-long-name": "Linux Executable", + "chrome-short-name": "Chrome", + "chrome-long-name": "Chrome Extension", + "edge-short-name": "Edge", + "edge-long-name": "Edge Extension", + "firefox-short-name": "Firefox", + "firefox-long-name": "Firefox Extension", + "safari-short-name": "Safari", + "safari-long-name": "Safari Extension", + "selfhosted-short-name": "Self Hosted", + "selfhosted-long-name": "Website Package", + "other-downloads": "Other Downloads" + }, + "downloads": { + "version": "Version", + "desktop-app": "Desktop Application", + "desktop-app-description": "If you want to run Flash content on your computer without a browser in-between, we have native applications that will take full advantage of your GPU and system resources to get those extra frames when playing intense games.", + "browser-extension": "Browser Extension", + "browser-extension-description": "If you visit websites that have Flash content but aren't using Ruffle, or you want to ensure you're using the latest and greatest version of Ruffle on every website, then our browser extension is the perfect thing for you!", + "nightly-releases": "Nightly Releases", + "nightly-releases-description": "If none of the above are suitable for you, you can manually download the latest Nightly release. These are automatically built every day (approximately midnight UTC), unless there are no changes on that day. Older nightly releases are available on", + "web-package": "Web Package", + "web-package-description": "You can install Ruffle onto a website using one single line of code by using a CDN, no extra work required! It'll always stay up to date with the latest available version of Ruffle.", + "self-host-description-start": "If you'd like to host it yourself, you can grab", + "self-host-description-link": "the latest self-hosted package", + "self-host-description-end": "and upload it to your server. Then, include it on your page like so:", + "advanced-usage-description-start": "For advanced usage, consult", + "advanced-usage-description-link": "our documentation", + "advanced-usage-description-end": "for our JavaScript API and installation options.", + "chrome-extension-alt": "Available in the Chrome Web Store", + "firefox-extension-alt": "Get the Add-On for Firefox", + "edge-extension-alt": "Get it from Microsoft for Edge" + }, + "common": { + "line-ender": "." } } From ad8b03160f57d39e25345c7ed9cdffc3a7796e06 Mon Sep 17 00:00:00 2001 From: Daniel Jacobs Date: Fri, 15 Nov 2024 10:35:52 -0500 Subject: [PATCH 09/19] i18n: Create simple component to provide translations with placeholder --- src/app/downloads/nightlies.tsx | 24 ++++++++++++--------- src/app/downloads/page.tsx | 38 +++++++++++++++++++++------------ src/app/translate.tsx | 29 +++++++++++++++++++++++++ src/i18n/translations.en.json | 11 +++------- 4 files changed, 70 insertions(+), 32 deletions(-) diff --git a/src/app/downloads/nightlies.tsx b/src/app/downloads/nightlies.tsx index 77d1069d..a07231c7 100644 --- a/src/app/downloads/nightlies.tsx +++ b/src/app/downloads/nightlies.tsx @@ -24,7 +24,7 @@ import { githubReleasesUrl, webLinks, } from "@/app/downloads/config"; -import { useTranslation } from "@/app/translate"; +import { useTranslation, Trans } from "@/app/translate"; function DownloadLink({ link, @@ -130,15 +130,19 @@ export function NightlyList({ {t("downloads.nightly-releases")} - {t("downloads.nightly-releases-description")}{" "} - - {t("footer.github")} - - {t("common.line-ender")} + + {t("footer.github")} + , + ]} + /> '} - {t("downloads.self-host-description-start")}{" "} - - {t("downloads.self-host-description-link")} - {" "} - {t("downloads.self-host-description-end")} + + {t("downloads.self-host-description-link")} + , + ]} + /> {''} - {t("downloads.advanced-usage-description-start")}{" "} - - {t("downloads.advanced-usage-description-link")} - {" "} - {t("downloads.advanced-usage-description-end")} + + {t("downloads.advanced-usage-description-link")} + , + ]} + /> ); diff --git a/src/app/translate.tsx b/src/app/translate.tsx index aef2812d..5a113ef3 100644 --- a/src/app/translate.tsx +++ b/src/app/translate.tsx @@ -111,6 +111,35 @@ export function useTranslation() { return { t }; } +interface TransProps { + i18nKey: string; // Translation key + components?: React.ReactNode[]; // Components to inject into placeholders +} + +export const Trans: React.FC = ({ i18nKey, components = [] }) => { + const { t } = useTranslation(); + const translation = t(i18nKey); + + const renderWithPlaceholders = (template: string) => { + const parts = template.split(/({{.*?}})/g); // Split on placeholders like {{key}} + return parts.map((part) => { + const match = part.match(/{{(.*?)}}/); // Match placeholders + if (match) { + const placeholderKey = match[1]; + const component = components.find( + (comp) => React.isValidElement(comp) && comp.key === placeholderKey, + ); + if (component) { + return component; + } + } + return part; // Return plain text if no placeholder + }); + }; + + return <>{renderWithPlaceholders(translation)}; +}; + export const LanguageSelector: React.FC = ({ className, }) => { diff --git a/src/i18n/translations.en.json b/src/i18n/translations.en.json index 6a31d0c6..769eb527 100644 --- a/src/i18n/translations.en.json +++ b/src/i18n/translations.en.json @@ -64,20 +64,15 @@ "browser-extension": "Browser Extension", "browser-extension-description": "If you visit websites that have Flash content but aren't using Ruffle, or you want to ensure you're using the latest and greatest version of Ruffle on every website, then our browser extension is the perfect thing for you!", "nightly-releases": "Nightly Releases", - "nightly-releases-description": "If none of the above are suitable for you, you can manually download the latest Nightly release. These are automatically built every day (approximately midnight UTC), unless there are no changes on that day. Older nightly releases are available on", + "nightly-releases-description": "If none of the above are suitable for you, you can manually download the latest Nightly release. These are automatically built every day (approximately midnight UTC), unless there are no changes on that day. Older nightly releases are available on {{link}}.", "web-package": "Web Package", "web-package-description": "You can install Ruffle onto a website using one single line of code by using a CDN, no extra work required! It'll always stay up to date with the latest available version of Ruffle.", - "self-host-description-start": "If you'd like to host it yourself, you can grab", + "self-host-description": "If you'd like to host it yourself, you can grab {{link}} and upload it to your server. Then, include it on your page like so:", "self-host-description-link": "the latest self-hosted package", - "self-host-description-end": "and upload it to your server. Then, include it on your page like so:", - "advanced-usage-description-start": "For advanced usage, consult", + "advanced-usage-description": "For advanced usage, consult {{link}} for our JavaScript API and installation options.", "advanced-usage-description-link": "our documentation", - "advanced-usage-description-end": "for our JavaScript API and installation options.", "chrome-extension-alt": "Available in the Chrome Web Store", "firefox-extension-alt": "Get the Add-On for Firefox", "edge-extension-alt": "Get it from Microsoft for Edge" - }, - "common": { - "line-ender": "." } } From 6848620518c0e52251f3f0a2ac5e7d018a1fa49d Mon Sep 17 00:00:00 2001 From: Daniel Jacobs Date: Fri, 15 Nov 2024 14:23:03 -0500 Subject: [PATCH 10/19] i18n: Translate compatibility page --- src/app/compatibility/avm.tsx | 23 ++- src/app/compatibility/fetch-report/route.tsx | 25 +++ src/app/compatibility/page.tsx | 149 +++++++++--------- .../compatibility/weekly_contributions.tsx | 6 +- src/app/downloads/github.tsx | 1 + src/app/translate.tsx | 28 +++- src/i18n/translations.en.json | 20 +++ 7 files changed, 166 insertions(+), 86 deletions(-) create mode 100644 src/app/compatibility/fetch-report/route.tsx diff --git a/src/app/compatibility/avm.tsx b/src/app/compatibility/avm.tsx index 0acb110a..da50d41f 100644 --- a/src/app/compatibility/avm.tsx +++ b/src/app/compatibility/avm.tsx @@ -1,3 +1,5 @@ +"use client"; + import classes from "./avm.module.css"; import { Button, @@ -9,6 +11,7 @@ import { Title, } from "@mantine/core"; import Link from "next/link"; +import { useTranslation } from "@/app/translate"; interface AvmProgressProps { done: number; @@ -21,17 +24,18 @@ interface AvmProgressPropsFull extends AvmProgressProps { } function AvmProgress(props: AvmProgressPropsFull) { + const { t } = useTranslation(); return ( - {props.name}: {props.done}% + {t(props.name)}: {props.done}% {props.stubbed && ( )} @@ -57,10 +61,11 @@ interface AvmBlockProps { } export function AvmBlock(props: AvmBlockProps) { + const { t } = useTranslation(); return ( - {props.name} + {t(props.name)} {props.children} - - + + ); } diff --git a/src/app/compatibility/fetch-report/route.tsx b/src/app/compatibility/fetch-report/route.tsx new file mode 100644 index 00000000..9111b48c --- /dev/null +++ b/src/app/compatibility/fetch-report/route.tsx @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { fetchReport } from "@/app/downloads/github"; +import { AVM2Report } from "@/app/downloads/config"; + +let cachedReport: AVM2Report | undefined; + +export async function GET() { + if (cachedReport) { + return NextResponse.json(cachedReport); // Return cached result + } + + try { + const report = await fetchReport(); + cachedReport = report; // Cache the result + return NextResponse.json(report); + } catch (error) { + console.error("Error fetching report:", error); + return NextResponse.json( + { error: "Failed to fetch report" }, + { status: 500 }, + ); + } +} + +export const dynamic = "force-static"; diff --git a/src/app/compatibility/page.tsx b/src/app/compatibility/page.tsx index 2337e7fe..d550803d 100644 --- a/src/app/compatibility/page.tsx +++ b/src/app/compatibility/page.tsx @@ -1,38 +1,69 @@ +"use client"; + +import React, { useEffect, useState } from "react"; import { Container, Flex, Group, Stack, Text } from "@mantine/core"; import classes from "./compatibility.module.css"; import { AvmBlock } from "@/app/compatibility/avm"; import Image from "next/image"; -import React from "react"; import { Title } from "@mantine/core"; import { List, ListItem } from "@mantine/core"; import { WeeklyContributions } from "@/app/compatibility/weekly_contributions"; import { - fetchReport, getAVM1Progress, getWeeklyContributions, } from "@/app/downloads/github"; +import { useTranslation, Trans } from "@/app/translate"; + +interface DataPoint { + week: string; + Commits: number; +} + +export default function Downloads() { + const { t } = useTranslation(); + const [data, setData] = useState([]); + const [avm1ApiDone, setAvm1ApiDone] = useState(0); + const [avm2ApiDone, setAvm2ApiDone] = useState(0); + const [avm2ApiStubbed, setAvm2ApiStubbed] = useState(0); + useEffect(() => { + const fetchData = async () => { + try { + // Fetch weekly contributions + const contributionsRes = await getWeeklyContributions(); + const contributionsData = contributionsRes.data.map((item) => ({ + week: new Date(item.week * 1000).toISOString().split("T")[0], + Commits: item.total, + })); + setData(contributionsData); + + // Fetch AVM1 progress + const avm1ApiRes = await getAVM1Progress(); + setAvm1ApiDone(avm1ApiRes); -export default async function Downloads() { - const contributions = await getWeeklyContributions(); - const data = contributions.data.map((item) => { - return { - week: new Date(item.week * 1000).toISOString().split("T")[0], - Commits: item.total, + // Fetch report + const reportReq = await fetch("/compatibility/fetch-report"); + const reportRes = await reportReq.json(); + if (reportRes) { + const { summary } = reportRes; + const maxPoints = summary.max_points; + const implPoints = summary.impl_points; + const stubPenalty = summary.stub_penalty; + + const avm2ApiDone = Math.round( + ((implPoints - stubPenalty) / maxPoints) * 100, + ); + setAvm2ApiDone(avm2ApiDone); + + const avm2ApiStubbed = Math.round((stubPenalty / maxPoints) * 100); + setAvm2ApiStubbed(avm2ApiStubbed); + } + } catch (error) { + console.error("Error fetching data", error); + } }; - }); - const avm1ApiDone = await getAVM1Progress(); - const report = await fetchReport(); - if (!report) { - return; - } - const summary = report.summary; - const maxPoints = summary.max_points; - const implPoints = summary.impl_points; - const stubPenalty = summary.stub_penalty; - const avm2ApiDone = Math.round( - ((implPoints - stubPenalty) / maxPoints) * 100, - ); - const avm2ApiStubbed = Math.round((stubPenalty / maxPoints) * 100); + + fetchData(); + }, []); return ( @@ -47,27 +78,23 @@ export default async function Downloads() { className={classes.actionscriptImage} /> - ActionScript Compatibility - - The biggest factor in content compatibility is ActionScript; the - language that powers interactivity in games and applications made - with Flash. All Flash content falls in one of two categories, - depending on which version of the language was used to create it. - - - We track our progress in each AVM by splitting them up into two - different areas: - + {t("compatibility.title")} + {t("compatibility.description")} + {t("compatibility.tracking")} - The Language is the underlying virtual machine itself and - the language concepts that it understands, like variables and - classes and how they all interact together. + {t("compatibility.language")}, + ]} + /> - The API is the original built-in methods and classes that - are available for this AVM, like the ability to interact with - objects on the stage or make web requests. + {t("compatibility.api")}]} + /> @@ -81,53 +108,33 @@ export default async function Downloads() { className={classes.avms} > - - AVM 1 is the original ActionScript Virtual Machine. All movies - made before Flash Player 9 (June 2006) will be made with AVM 1, - and it remained supported & available to authors until the - release of Flash Professional CC (2013), after which point content - started moving to AVM 2. - - - We believe that most AVM 1 content will work, but we are aware of - some graphical inaccuracies and smaller bugs here and there. - Please feel free to report any issues you find that are not - present in the original Flash Player! - + {t("compatibility.avm1-description")} + {t("compatibility.avm1-support")} - - AVM 2 was introduced with Flash Player 9 (June 2006), to replace - the earlier AVM 1. After the release of Flash Professional CC - (2013), authors are required to use ActionScript 3 - making any - movie made after that date very likely to fall under this - category. - - - Ruffle now has decent support for AVM 2, and it's our - experience that most games will work well enough to be played. - We're still rapidly improving in this area though, so bug - reports about any broken content are always welcome! - + {t("compatibility.avm2-description")} + {t("compatibility.avm2-support")} - - Weekly Contributions - - + {data && ( + + {t("compatibility.weekly-contributions")} + + + )} ); diff --git a/src/app/compatibility/weekly_contributions.tsx b/src/app/compatibility/weekly_contributions.tsx index bbd4424c..f0406136 100644 --- a/src/app/compatibility/weekly_contributions.tsx +++ b/src/app/compatibility/weekly_contributions.tsx @@ -2,6 +2,7 @@ import { BarChart } from "@mantine/charts"; import { Paper, Text } from "@mantine/core"; import classes from "./weekly_contributions.module.css"; +import { Trans } from "@/app/translate"; interface DataPoint { week: string; @@ -23,7 +24,10 @@ function ChartTooltip({ label, payload }: ChartTooltipProps) { return ( - {commits.value} commits on the week of {label} + ); diff --git a/src/app/downloads/github.tsx b/src/app/downloads/github.tsx index b4668444..22047860 100644 --- a/src/app/downloads/github.tsx +++ b/src/app/downloads/github.tsx @@ -73,6 +73,7 @@ export async function getWeeklyContributions(): Promise< const octokit = new Octokit({ authStrategy: createGithubAuth }); return octokit.rest.repos.getCommitActivityStats(repository); } + export async function fetchReport(): Promise { const releases = await getLatestReleases(); const latest = releases.find( diff --git a/src/app/translate.tsx b/src/app/translate.tsx index 5a113ef3..66ff2892 100644 --- a/src/app/translate.tsx +++ b/src/app/translate.tsx @@ -113,24 +113,38 @@ export function useTranslation() { interface TransProps { i18nKey: string; // Translation key + values?: Record; // Placeholder values components?: React.ReactNode[]; // Components to inject into placeholders } -export const Trans: React.FC = ({ i18nKey, components = [] }) => { +export const Trans: React.FC = ({ + i18nKey, + values = {}, + components = [], +}) => { const { t } = useTranslation(); const translation = t(i18nKey); const renderWithPlaceholders = (template: string) => { const parts = template.split(/({{.*?}})/g); // Split on placeholders like {{key}} - return parts.map((part) => { + return parts.map((part, index) => { const match = part.match(/{{(.*?)}}/); // Match placeholders if (match) { const placeholderKey = match[1]; - const component = components.find( - (comp) => React.isValidElement(comp) && comp.key === placeholderKey, - ); - if (component) { - return component; + if (placeholderKey in values) { + const value = values[placeholderKey]; + return typeof value === "string" ? ( + {value} + ) : ( + value + ); + } else { + const component = components.find( + (comp) => React.isValidElement(comp) && comp.key === placeholderKey, + ); + if (component) { + return component; + } } } return part; // Return plain text if no placeholder diff --git a/src/i18n/translations.en.json b/src/i18n/translations.en.json index 769eb527..884dacf0 100644 --- a/src/i18n/translations.en.json +++ b/src/i18n/translations.en.json @@ -74,5 +74,25 @@ "chrome-extension-alt": "Available in the Chrome Web Store", "firefox-extension-alt": "Get the Add-On for Firefox", "edge-extension-alt": "Get it from Microsoft for Edge" + }, + "compatibility": { + "title": "ActionScript Compatibility", + "description": "The biggest factor in content compatibility is ActionScript; the language that powers interactivity in games and applications made with Flash. All Flash content falls in one of two categories, depending on which version of the language was used to create it.", + "tracking": "We track our progress in each AVM by splitting them up into two different areas:", + "language-description": "The {{language}} is the underlying virtual machine itself and the language concepts that it understands, like variables and classes and how they all interact together.", + "language": "Language", + "api-description": "The {{api}} is the underlying virtual machine itself and the language concepts that it understands, like variables and classes and how they all interact together.", + "api": "API", + "avm1": "AVM 1: ActionScript 1 & 2", + "avm1-description": "AVM 1 is the original ActionScript Virtual Machine. All movies made before Flash Player 9 (June 2006) will be made with AVM 1, and it remained supported & available to authors until the release of Flash Professional CC (2013), after which point content started moving to AVM 2.", + "avm1-support": "We believe that most AVM 1 content will work, but we are aware of some graphical inaccuracies and smaller bugs here and there. Please feel free to report any issues you find that are not present in the original Flash Player!", + "avm2": "AVM 2: ActionScript 3", + "avm2-description": "AVM 2 was introduced with Flash Player 9 (June 2006), to replace the earlier AVM 1. After the release of Flash Professional CC (2013), authors are required to use ActionScript 3 - making any movie made after that date very likely to fall under this category.", + "avm2-support": "Ruffle now has decent support for AVM 2, and it's our experience that most games will work well enough to be played. We're still rapidly improving in this area though, so bug reports about any broken content are always welcome!", + "weekly-contributions": "Weekly Contributions", + "done": "done", + "partial": "partially done", + "more": "More Info", + "commits-description": "{{commitNumber}} commits on the week of {{week}}" } } From ddbe5176645982a7371540a3a49a73caf37c1458 Mon Sep 17 00:00:00 2001 From: Daniel Jacobs Date: Fri, 15 Nov 2024 15:35:44 -0500 Subject: [PATCH 11/19] i18n: Translate avm2 page --- src/app/compatibility/avm2/class_box.tsx | 17 ++-- src/app/compatibility/avm2/icons.tsx | 65 ++++++++++++++ src/app/compatibility/avm2/page.tsx | 97 +++++++++++++-------- src/app/compatibility/avm2/report_utils.tsx | 60 +------------ src/app/compatibility/page.tsx | 4 +- src/i18n/translations.en.json | 25 +++++- 6 files changed, 167 insertions(+), 101 deletions(-) create mode 100644 src/app/compatibility/avm2/icons.tsx diff --git a/src/app/compatibility/avm2/class_box.tsx b/src/app/compatibility/avm2/class_box.tsx index 0d68b691..cfc26c05 100644 --- a/src/app/compatibility/avm2/class_box.tsx +++ b/src/app/compatibility/avm2/class_box.tsx @@ -14,11 +14,13 @@ import classes from "./avm2.module.css"; import React from "react"; import { ClassStatus, - ProgressIcon, displayedPercentage, } from "@/app/compatibility/avm2/report_utils"; +import { ProgressIcon } from "@/app/compatibility/avm2/icons"; +import { useTranslation } from "@/app/translate"; export function ClassBox(props: ClassStatus) { + const { t } = useTranslation(); const [opened, { toggle }] = useDisclosure(false); const pctDone = displayedPercentage( props.summary.impl_points - props.summary.stub_penalty, @@ -33,13 +35,15 @@ export function ClassBox(props: ClassStatus) { ); return ( - {props.name || "(Package level)"} + + {props.name || t("compatibility.avm2.package-level")} + {pctStub > 0 && ( )} @@ -58,7 +62,10 @@ export function ClassBox(props: ClassStatus) { className={classes.showMemberButton} onClick={toggle} > - {opened ? "Hide" : "Show"} Missing Members + {opened + ? t("compatibility.avm2.hide") + : t("compatibility.avm2.show")}{" "} + {t("compatibility.avm2.missing-members")}