feat(frontend): internationalization
This commit is contained in:
		
							parent
							
								
									2323810b06
								
							
						
					
					
						commit
						498d79de4e
					
				
					 16 changed files with 1092 additions and 167 deletions
				
			
		|  | @ -7,6 +7,7 @@ import Nav from "react-bootstrap/Nav"; | |||
| import Navbar from "react-bootstrap/Navbar"; | ||||
| import NavDropdown from "react-bootstrap/NavDropdown"; | ||||
| import { BrightnessHigh, BrightnessHighFill, MoonFill } from "react-bootstrap-icons"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| 
 | ||||
| export default function MainNavbar({ | ||||
| 	user, | ||||
|  | @ -17,23 +18,26 @@ export default function MainNavbar({ | |||
| 	settings: UserSettings; | ||||
| }) { | ||||
| 	const fetcher = useFetcher(); | ||||
| 	const { t } = useTranslation(); | ||||
| 
 | ||||
| 	const userMenu = user ? ( | ||||
| 		<NavDropdown title={<>@{user.username}</>} align="end"> | ||||
| 			<NavDropdown.Item as={Link} to={`/@${user.username}`}> | ||||
| 				View profile | ||||
| 				{t("navbar.view-profile")} | ||||
| 			</NavDropdown.Item> | ||||
| 			<NavDropdown.Item as={Link} to="/settings"> | ||||
| 				Settings | ||||
| 				{t("navbar.settings")} | ||||
| 			</NavDropdown.Item> | ||||
| 			<NavDropdown.Divider /> | ||||
| 			<NavDropdown.Item as={Link} to="/auth/logout"> | ||||
| 				Log out | ||||
| 			</NavDropdown.Item> | ||||
| 			<fetcher.Form method="POST" action="/auth/log-out"> | ||||
| 				<NavDropdown.Item as="button" type="submit"> | ||||
| 					{t("navbar.log-out")} | ||||
| 				</NavDropdown.Item> | ||||
| 			</fetcher.Form> | ||||
| 		</NavDropdown> | ||||
| 	) : ( | ||||
| 		<Nav.Link to="/auth/login" as={Link}> | ||||
| 			Log in or sign up | ||||
| 			{t("navbar.log-in")} | ||||
| 		</Nav.Link> | ||||
| 	); | ||||
| 
 | ||||
|  | @ -59,19 +63,19 @@ export default function MainNavbar({ | |||
| 						<NavDropdown | ||||
| 							title={ | ||||
| 								<> | ||||
| 									<ThemeIcon /> Theme | ||||
| 									<ThemeIcon /> {t("navbar.theme")} | ||||
| 								</> | ||||
| 							} | ||||
| 							align="end" | ||||
| 						> | ||||
| 							<NavDropdown.Item as="button" name="theme" value="auto" type="submit"> | ||||
| 								Automatic | ||||
| 								{t("navbar.theme-auto")} | ||||
| 							</NavDropdown.Item> | ||||
| 							<NavDropdown.Item as="button" name="theme" value="dark" type="submit"> | ||||
| 								Dark mode | ||||
| 								{t("navbar.theme-dark")} | ||||
| 							</NavDropdown.Item> | ||||
| 							<NavDropdown.Item as="button" name="theme" value="light" type="submit"> | ||||
| 								Light mode | ||||
| 								{t("navbar.theme-light")} | ||||
| 							</NavDropdown.Item> | ||||
| 						</NavDropdown> | ||||
| 					</fetcher.Form> | ||||
|  |  | |||
|  | @ -1,18 +1,50 @@ | |||
| /** | ||||
|  * By default, Remix will handle hydrating your app on the client for you. | ||||
|  * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ | ||||
|  * For more information, see https://remix.run/file-conventions/entry.client
 | ||||
|  */ | ||||
| 
 | ||||
| import { RemixBrowser } from "@remix-run/react"; | ||||
| import { startTransition, StrictMode } from "react"; | ||||
| import { hydrateRoot } from "react-dom/client"; | ||||
| import i18n from "./i18n"; | ||||
| import i18next from "i18next"; | ||||
| import { I18nextProvider, initReactI18next } from "react-i18next"; | ||||
| import LanguageDetector from "i18next-browser-languagedetector"; | ||||
| import Backend from "i18next-http-backend"; | ||||
| import { getInitialNamespaces } from "remix-i18next/client"; | ||||
| 
 | ||||
| startTransition(() => { | ||||
| 	hydrateRoot( | ||||
| 		document, | ||||
| 		<StrictMode> | ||||
| 			<RemixBrowser /> | ||||
| 		</StrictMode>, | ||||
| 	); | ||||
| }); | ||||
| async function hydrate() { | ||||
| 	await i18next | ||||
| 		.use(initReactI18next) // Tell i18next to use the react-i18next plugin
 | ||||
| 		.use(LanguageDetector) // Set up a client-side language detector
 | ||||
| 		.use(Backend) // Setup your backend
 | ||||
| 		.init({ | ||||
| 			...i18n, // spread the configuration
 | ||||
| 			// This function detects the namespaces your routes rendered while SSR use
 | ||||
| 			ns: getInitialNamespaces(), | ||||
| 			backend: { loadPath: "/locales/{{lng}}.json" }, | ||||
| 			detection: { | ||||
| 				// Here only enable htmlTag detection, we'll detect the language only
 | ||||
| 				// server-side with remix-i18next, by using the `<html lang>` attribute
 | ||||
| 				// we can communicate to the client the language detected server-side
 | ||||
| 				order: ["htmlTag"], | ||||
| 				// Because we only use htmlTag, there's no reason to cache the language
 | ||||
| 				// on the browser, so we disable it
 | ||||
| 				caches: [], | ||||
| 			}, | ||||
| 		}); | ||||
| 
 | ||||
| 	startTransition(() => { | ||||
| 		hydrateRoot( | ||||
| 			document, | ||||
| 			<I18nextProvider i18n={i18next}> | ||||
| 				<StrictMode> | ||||
| 					<RemixBrowser /> | ||||
| 				</StrictMode> | ||||
| 			</I18nextProvider>, | ||||
| 		); | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| if (window.requestIdleCallback) { | ||||
| 	window.requestIdleCallback(hydrate); | ||||
| } else { | ||||
| 	// Safari doesn't support requestIdleCallback
 | ||||
| 	// https://caniuse.com/requestidlecallback
 | ||||
| 	window.setTimeout(hydrate, 1); | ||||
| } | ||||
|  |  | |||
|  | @ -1,56 +1,56 @@ | |||
| /** | ||||
|  * By default, Remix will handle generating the HTTP Response for you. | ||||
|  * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ | ||||
|  * For more information, see https://remix.run/file-conventions/entry.server
 | ||||
|  */ | ||||
| 
 | ||||
| import { PassThrough } from "node:stream"; | ||||
| 
 | ||||
| import type { AppLoadContext, EntryContext } from "@remix-run/node"; | ||||
| import { createReadableStreamFromReadable } from "@remix-run/node"; | ||||
| import { PassThrough } from "stream"; | ||||
| import { createReadableStreamFromReadable, type EntryContext } from "@remix-run/node"; | ||||
| import { RemixServer } from "@remix-run/react"; | ||||
| import { isbot } from "isbot"; | ||||
| import { renderToPipeableStream } from "react-dom/server"; | ||||
| import { createInstance } from "i18next"; | ||||
| import i18next from "./i18next.server"; | ||||
| import { I18nextProvider, initReactI18next } from "react-i18next"; | ||||
| import Backend from "i18next-fs-backend"; | ||||
| import i18n from "./i18n"; // your i18n configuration file
 | ||||
| import { resolve } from "node:path"; | ||||
| 
 | ||||
| const ABORT_DELAY = 5_000; | ||||
| const ABORT_DELAY = 5000; | ||||
| 
 | ||||
| export default function handleRequest( | ||||
| 	request: Request, | ||||
| 	responseStatusCode: number, | ||||
| 	responseHeaders: Headers, | ||||
| 	remixContext: EntryContext, | ||||
| 	// This is ignored so we can keep it in the template for visibility.  Feel
 | ||||
| 	// free to delete this parameter in your app if you're not using it!
 | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
| 	loadContext: AppLoadContext, | ||||
| ) { | ||||
| 	return isbot(request.headers.get("user-agent") || "") | ||||
| 		? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) | ||||
| 		: handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); | ||||
| } | ||||
| 
 | ||||
| function handleBotRequest( | ||||
| export default async function handleRequest( | ||||
| 	request: Request, | ||||
| 	responseStatusCode: number, | ||||
| 	responseHeaders: Headers, | ||||
| 	remixContext: EntryContext, | ||||
| ) { | ||||
| 	const callbackName = isbot(request.headers.get("user-agent")) ? "onAllReady" : "onShellReady"; | ||||
| 
 | ||||
| 	const instance = createInstance(); | ||||
| 	const lng = await i18next.getLocale(request); | ||||
| 	const ns = i18next.getRouteNamespaces(remixContext); | ||||
| 
 | ||||
| 	await instance | ||||
| 		.use(initReactI18next) // Tell our instance to use react-i18next
 | ||||
| 		.use(Backend) // Set up our backend
 | ||||
| 		.init({ | ||||
| 			...i18n, // spread the configuration
 | ||||
| 			lng, // The locale we detected above
 | ||||
| 			ns, // The namespaces the routes about to render wants to use
 | ||||
| 			backend: { loadPath: resolve("./public/locales/{{lng}}.json") }, | ||||
| 		}); | ||||
| 
 | ||||
| 	return new Promise((resolve, reject) => { | ||||
| 		let shellRendered = false; | ||||
| 		let didError = false; | ||||
| 
 | ||||
| 		const { pipe, abort } = renderToPipeableStream( | ||||
| 			<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />, | ||||
| 			<I18nextProvider i18n={instance}> | ||||
| 				<RemixServer context={remixContext} url={request.url} /> | ||||
| 			</I18nextProvider>, | ||||
| 			{ | ||||
| 				onAllReady() { | ||||
| 					shellRendered = true; | ||||
| 				[callbackName]: () => { | ||||
| 					const body = new PassThrough(); | ||||
| 					const stream = createReadableStreamFromReadable(body); | ||||
| 
 | ||||
| 					responseHeaders.set("Content-Type", "text/html"); | ||||
| 
 | ||||
| 					resolve( | ||||
| 						new Response(stream, { | ||||
| 							headers: responseHeaders, | ||||
| 							status: responseStatusCode, | ||||
| 							status: didError ? 500 : responseStatusCode, | ||||
| 						}), | ||||
| 					); | ||||
| 
 | ||||
|  | @ -60,59 +60,9 @@ function handleBotRequest( | |||
| 					reject(error); | ||||
| 				}, | ||||
| 				onError(error: unknown) { | ||||
| 					responseStatusCode = 500; | ||||
| 					// Log streaming rendering errors from inside the shell.  Don't log
 | ||||
| 					// errors encountered during initial shell rendering since they'll
 | ||||
| 					// reject and get logged in handleDocumentRequest.
 | ||||
| 					if (shellRendered) { | ||||
| 						console.error(error); | ||||
| 					} | ||||
| 				}, | ||||
| 			}, | ||||
| 		); | ||||
| 
 | ||||
| 		setTimeout(abort, ABORT_DELAY); | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| function handleBrowserRequest( | ||||
| 	request: Request, | ||||
| 	responseStatusCode: number, | ||||
| 	responseHeaders: Headers, | ||||
| 	remixContext: EntryContext, | ||||
| ) { | ||||
| 	return new Promise((resolve, reject) => { | ||||
| 		let shellRendered = false; | ||||
| 		const { pipe, abort } = renderToPipeableStream( | ||||
| 			<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />, | ||||
| 			{ | ||||
| 				onShellReady() { | ||||
| 					shellRendered = true; | ||||
| 					const body = new PassThrough(); | ||||
| 					const stream = createReadableStreamFromReadable(body); | ||||
| 
 | ||||
| 					responseHeaders.set("Content-Type", "text/html"); | ||||
| 
 | ||||
| 					resolve( | ||||
| 						new Response(stream, { | ||||
| 							headers: responseHeaders, | ||||
| 							status: responseStatusCode, | ||||
| 						}), | ||||
| 					); | ||||
| 
 | ||||
| 					pipe(body); | ||||
| 				}, | ||||
| 				onShellError(error: unknown) { | ||||
| 					reject(error); | ||||
| 				}, | ||||
| 				onError(error: unknown) { | ||||
| 					responseStatusCode = 500; | ||||
| 					// Log streaming rendering errors from inside the shell.  Don't log
 | ||||
| 					// errors encountered during initial shell rendering since they'll
 | ||||
| 					// reject and get logged in handleDocumentRequest.
 | ||||
| 					if (shellRendered) { | ||||
| 						console.error(error); | ||||
| 					} | ||||
| 					didError = true; | ||||
| 
 | ||||
| 					console.error(error); | ||||
| 				}, | ||||
| 			}, | ||||
| 		); | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| import { env } from "node:process"; | ||||
| 
 | ||||
| export const API_BASE = env.API_BASE || "https://pronouns.localhost/api"; | ||||
| export const LANGUAGE = env.LANGUAGE || "en"; | ||||
|  |  | |||
							
								
								
									
										5
									
								
								Foxnouns.Frontend/app/i18n.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								Foxnouns.Frontend/app/i18n.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | |||
| export default { | ||||
| 	supportedLngs: ["en", "en-XX"], | ||||
| 	fallbackLng: "en", | ||||
| 	defaultNS: "common", | ||||
| }; | ||||
							
								
								
									
										28
									
								
								Foxnouns.Frontend/app/i18next.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								Foxnouns.Frontend/app/i18next.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| import Backend from "i18next-fs-backend"; | ||||
| import { resolve } from "node:path"; | ||||
| import { RemixI18Next } from "remix-i18next/server"; | ||||
| import i18n from "~/i18n"; | ||||
| import { LANGUAGE } from "~/env.server"; | ||||
| 
 | ||||
| const i18next = new RemixI18Next({ | ||||
| 	detection: { | ||||
| 		supportedLanguages: [LANGUAGE], | ||||
| 		fallbackLanguage: LANGUAGE, | ||||
| 	}, | ||||
| 	// This is the configuration for i18next used
 | ||||
| 	// when translating messages server-side only
 | ||||
| 	i18next: { | ||||
| 		...i18n, | ||||
| 		fallbackLng: LANGUAGE, | ||||
| 		lng: LANGUAGE, | ||||
| 		backend: { | ||||
| 			loadPath: resolve("./public/locales/{{lng}}.json"), | ||||
| 		}, | ||||
| 	}, | ||||
| 	// The i18next plugins you want RemixI18next to use for `i18n.getFixedT` inside loaders and actions.
 | ||||
| 	// E.g. The Backend plugin for loading translations from the file system
 | ||||
| 	// Tip: You could pass `resources` to the `i18next` configuration and avoid a backend here
 | ||||
| 	plugins: [Backend], | ||||
| }); | ||||
| 
 | ||||
| export default i18next; | ||||
|  | @ -9,6 +9,8 @@ import { | |||
| } from "@remix-run/react"; | ||||
| import { LoaderFunction } from "@remix-run/node"; | ||||
| import SSRProvider from "react-bootstrap/SSRProvider"; | ||||
| import { useChangeLanguage } from "remix-i18next/react"; | ||||
| import { useTranslation } from "react-i18next"; | ||||
| 
 | ||||
| import serverRequest, { getCookie, writeCookie } from "./lib/request.server"; | ||||
| import Meta from "./lib/api/meta"; | ||||
|  | @ -18,6 +20,7 @@ import { ApiError, ErrorCode } from "./lib/api/error"; | |||
| 
 | ||||
| import "./app.scss"; | ||||
| import getLocalSettings from "./lib/settings.server"; | ||||
| import { LANGUAGE } from "~/env.server"; | ||||
| 
 | ||||
| export const loader: LoaderFunction = async ({ request }) => { | ||||
| 	const meta = await serverRequest<Meta>("GET", "/meta"); | ||||
|  | @ -29,8 +32,7 @@ export const loader: LoaderFunction = async ({ request }) => { | |||
| 	let settings = getLocalSettings(request); | ||||
| 	if (token) { | ||||
| 		try { | ||||
| 			const user = await serverRequest<User>("GET", "/users/@me", { token }); | ||||
| 			meUser = user; | ||||
| 			meUser = await serverRequest<User>("GET", "/users/@me", { token }); | ||||
| 
 | ||||
| 			settings = await serverRequest<UserSettings>("GET", "/users/@me/settings", { token }); | ||||
| 		} catch (e) { | ||||
|  | @ -42,7 +44,7 @@ export const loader: LoaderFunction = async ({ request }) => { | |||
| 	} | ||||
| 
 | ||||
| 	return json( | ||||
| 		{ meta, meUser, settings }, | ||||
| 		{ meta, meUser, settings, locale: LANGUAGE }, | ||||
| 		{ | ||||
| 			headers: { "Set-Cookie": setCookie }, | ||||
| 		}, | ||||
|  | @ -50,10 +52,13 @@ export const loader: LoaderFunction = async ({ request }) => { | |||
| }; | ||||
| 
 | ||||
| export function Layout({ children }: { children: React.ReactNode }) { | ||||
| 	const { settings } = useLoaderData<typeof loader>(); | ||||
| 	const { settings, locale } = useLoaderData<typeof loader>(); | ||||
| 	const { i18n } = useTranslation(); | ||||
| 	i18n.language = locale; | ||||
| 	useChangeLanguage(locale); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<html lang="en" data-bs-theme={settings.dark_mode ? "dark" : "light"}> | ||||
| 		<html lang={locale} data-bs-theme={settings.dark_mode ? "dark" : "light"} dir={i18n.dir()}> | ||||
| 			<head> | ||||
| 				<meta charSet="utf-8" /> | ||||
| 				<meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||
|  |  | |||
|  | @ -6,40 +6,8 @@ export const meta: MetaFunction = () => { | |||
| 
 | ||||
| export default function Index() { | ||||
| 	return ( | ||||
| 		<div className="font-sans p-4"> | ||||
| 			<h1 className="text-3xl">Welcome to Remix</h1> | ||||
| 			<ul className="list-disc mt-4 pl-6 space-y-2"> | ||||
| 				<li> | ||||
| 					<a | ||||
| 						className="text-blue-700 underline visited:text-purple-900" | ||||
| 						target="_blank" | ||||
| 						href="https://remix.run/start/quickstart" | ||||
| 						rel="noreferrer" | ||||
| 					> | ||||
| 						5m Quick Start | ||||
| 					</a> | ||||
| 				</li> | ||||
| 				<li> | ||||
| 					<a | ||||
| 						className="text-blue-700 underline visited:text-purple-900" | ||||
| 						target="_blank" | ||||
| 						href="https://remix.run/start/tutorial" | ||||
| 						rel="noreferrer" | ||||
| 					> | ||||
| 						30m Tutorial | ||||
| 					</a> | ||||
| 				</li> | ||||
| 				<li> | ||||
| 					<a | ||||
| 						className="text-blue-700 underline visited:text-purple-900" | ||||
| 						target="_blank" | ||||
| 						href="https://remix.run/docs" | ||||
| 						rel="noreferrer" | ||||
| 					> | ||||
| 						Remix Docs | ||||
| 					</a> | ||||
| 				</li> | ||||
| 			</ul> | ||||
| 		<div> | ||||
| 			<h1>pronouns.cc</h1> | ||||
| 		</div> | ||||
| 	); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										11
									
								
								Foxnouns.Frontend/app/routes/auth.log-out/route.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								Foxnouns.Frontend/app/routes/auth.log-out/route.tsx
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | |||
| import { ActionFunction } from "@remix-run/node"; | ||||
| import { writeCookie } from "~/lib/request.server"; | ||||
| 
 | ||||
| export const action: ActionFunction = async () => { | ||||
| 	return new Response(null, { | ||||
| 		headers: { | ||||
| 			"Set-Cookie": writeCookie("pronounscc-token", "token", 0), | ||||
| 		}, | ||||
| 		status: 204, | ||||
| 	}); | ||||
| }; | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue