From f0a90172f53455550d429eb65049dca3a7004159 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Tue, 6 May 2025 22:15:31 -0700 Subject: [PATCH 01/10] Create core I18N types + content --- README.md | 10 +++++++ src/app/AppViewSwitcher.tsx | 11 ++++---- src/app/config.ts | 5 ++++ src/i18n/index.ts | 17 ++++++++++++ src/i18n/languages/pt-br.ts | 21 +++++++++++++++ src/i18n/languages/us-en.ts | 52 +++++++++++++++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/languages/pt-br.ts create mode 100644 src/i18n/languages/us-en.ts diff --git a/README.md b/README.md index 17bca479..27557abd 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,16 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider 1. Ensure connection string is set to "Transaction Mode" via port `6543` 2. Disable SSL by setting `DISABLE_POSTGRES_SSL = 1` +đź’¬   I18N +- + +Partial internationalization (non-admin, user-facing text) provided for a handful of languages. If you'd like to add support for a new language, [open a PR](https://github.com/sambecker/exif-photo-blog/compare) using [`ES_EN`](https://github.com/sambecker/exif-photo-blog) for reference. + +### Supported Languages +- `ES_ES` +- `PT_BR` +- `PT_PT` + đź“–  FAQ - #### How do I receive template updates? diff --git a/src/app/AppViewSwitcher.tsx b/src/app/AppViewSwitcher.tsx index 3ba9247d..cf03b5b8 100644 --- a/src/app/AppViewSwitcher.tsx +++ b/src/app/AppViewSwitcher.tsx @@ -9,6 +9,7 @@ import { import IconSearch from '../components/icons/IconSearch'; import { useAppState } from '@/state/AppState'; import { + APP_TEXT, GRID_HOMEPAGE_ENABLED, SHOW_KEYBOARD_SHORTCUT_TOOLTIPS, } from './config'; @@ -66,7 +67,7 @@ export default function AppViewSwitcher({ hrefRef={refHrefFeed} active={currentSelection === 'feed'} tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { - content: 'Feed', + content: APP_TEXT.nav.feed, keyCommand: KEY_COMMANDS.feed, }}} noPadding @@ -79,7 +80,7 @@ export default function AppViewSwitcher({ hrefRef={refHrefGrid} active={currentSelection === 'grid'} tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { - content: 'Grid', + content: APP_TEXT.nav.grid, keyCommand: KEY_COMMANDS.grid, }}} noPadding @@ -103,7 +104,7 @@ export default function AppViewSwitcher({ noPadding tooltip={{ ...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { - content: 'Admin Menu', + content: APP_TEXT.nav.admin, keyCommand: KEY_COMMANDS.admin, }, }} @@ -116,7 +117,7 @@ export default function AppViewSwitcher({ />} tooltip={{ ...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { - content: 'Admin Menu', + content: APP_TEXT.nav.admin, keyCommand: KEY_COMMANDS.admin, }, }} @@ -128,7 +129,7 @@ export default function AppViewSwitcher({ icon={} onClick={() => setIsCommandKOpen?.(true)} tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { - content: 'Search', + content: APP_TEXT.nav.search, keyCommandModifier: KEY_COMMANDS.search[0], keyCommand: KEY_COMMANDS.search[1], }}} diff --git a/src/app/config.ts b/src/app/config.ts index fcda18b7..eef94bf4 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -5,6 +5,7 @@ import { import { getOrderedCategoriesFromString } from '@/category'; import type { StorageType } from '@/platforms/storage'; import { makeUrlAbsolute, shortenUrl } from '@/utility/url'; +import { getContentForLanguage } from '@/i18n'; // HARD-CODED GLOBAL CONFIGURATION @@ -98,6 +99,10 @@ const SITE_DOMAIN_SHORT = shortenUrl(SITE_DOMAIN); // SITE META +export const APP_TEXT = await getContentForLanguage( + process.env.NEXT_PUBLIC_LANGUAGE, +); + export const NAV_TITLE = process.env.NEXT_PUBLIC_NAV_TITLE; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 00000000..1d1b65e4 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,17 @@ +import US_EN from './languages/us-en'; + +export type I18N = typeof US_EN; + +export const LANGUAGES: Record< + string, + (() => Promise>) | undefined +> = { + 'pt-br': () => import('./languages/pt-br').then(module => module.default), +}; + +export const getContentForLanguage = async ( + language = '', +): Promise => ({ + ...US_EN, + ...await LANGUAGES[language.toLocaleLowerCase()]?.(), +}); diff --git a/src/i18n/languages/pt-br.ts b/src/i18n/languages/pt-br.ts new file mode 100644 index 00000000..b0473f29 --- /dev/null +++ b/src/i18n/languages/pt-br.ts @@ -0,0 +1,21 @@ +import { I18N } from '..'; + +const language: Partial = { + core: { + photo: 'Foto', + photoPlural: 'Fotos', + }, + nav: { + home: 'InĂ­cio', + feed: 'Feed', + grid: 'Grade', + admin: 'Menu de Admin', + search: 'Pesquisar', + prev: 'Anterior', + prevShort: 'Ant', + next: 'PrĂłximo', + nextShort: 'Prox', + }, +}; + +export default language; diff --git a/src/i18n/languages/us-en.ts b/src/i18n/languages/us-en.ts new file mode 100644 index 00000000..86c99252 --- /dev/null +++ b/src/i18n/languages/us-en.ts @@ -0,0 +1,52 @@ +const language = { + core: { + photo: 'Photo', + photoPlural: 'Photos', + }, + nav: { + home: 'Home', + feed: 'Feed', + grid: 'Grid', + admin: 'Admin', + search: 'Search', + prev: 'Previous', + prevShort: 'Prev', + next: 'Next', + nextShort: 'Next', + }, + categories: { + camera: 'Camera', + cameraPlural: 'Cameras', + lens: 'Lens', + lensPlural: 'Lenses', + tag: 'Tag', + tagPlural: 'Tags', + recipe: 'Recipe', + recipePlural: 'Recipes', + film: 'Film', + filmPlural: 'Films', + focalLength: 'Focal Length', + focalLengthPlural: 'Focal Lengths', + }, + footer: { + repo: 'Made with', + system: 'System', + light: 'Light', + dark: 'Dark', + }, + auth: { + signIn: 'Sign in', + signOut: 'Sign out', + email: 'Admin Email', + password: 'Admin Password', + }, + tooltips: { + '35mm': '35mm Equivalent', + imageViewer: 'Open Image Viewer', + sharePhoto: 'Share Photo', + recipeInfo: 'Recipe Info', + recipeCopy: 'Copy Recipe Text', + }, +}; + +export default language; From 24a2877d822b6b69e2c9219b509f65810a28e7c1 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Sat, 10 May 2025 00:22:17 -0500 Subject: [PATCH 02/10] Integrate basic I18N text --- README.md | 9 ++--- src/app/Footer.tsx | 6 ++-- src/app/ThemeSwitcher.tsx | 7 ++-- src/app/config.ts | 4 +-- src/auth/SignInForm.tsx | 11 +++--- src/cmdk/CommandKClient.tsx | 20 ++++++----- src/components/DownloadButton.tsx | 3 +- src/components/RepoLink.tsx | 4 +-- src/i18n/index.ts | 25 ++++++++++---- src/i18n/languages/pt-br.ts | 45 +++++++++++++++++++++++-- src/i18n/languages/us-en.ts | 28 +++++++++------ src/photo/PhotoGridSidebar.tsx | 14 ++++---- src/photo/PhotoHeader.tsx | 3 +- src/photo/PhotoLarge.tsx | 7 ++-- src/photo/PhotoPrevNextActions.tsx | 13 ++++--- src/recipe/PhotoRecipeOverlayButton.tsx | 3 +- 16 files changed, 138 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 27557abd..8a84fb2c 100644 --- a/README.md +++ b/README.md @@ -257,12 +257,13 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider đź’¬   I18N - -Partial internationalization (non-admin, user-facing text) provided for a handful of languages. If you'd like to add support for a new language, [open a PR](https://github.com/sambecker/exif-photo-blog/compare) using [`ES_EN`](https://github.com/sambecker/exif-photo-blog) for reference. +Partial internationalization (non-admin, user-facing text) provided for a handful of languages. If you'd like to add support for a new language, open a PR [using `US_EN`](https://github.com/sambecker/exif-photo-blog/main/src/i18n/languages/us-en.ts) for reference. ### Supported Languages -- `ES_ES` -- `PT_BR` -- `PT_PT` +- `US_EN` +- `ES_ES` (coming soon) +- `PT_BR` (coming soon) +- `PT_PT` (coming soon) đź“–  FAQ - diff --git a/src/app/Footer.tsx b/src/app/Footer.tsx index baebe14e..c2cc5ede 100644 --- a/src/app/Footer.tsx +++ b/src/app/Footer.tsx @@ -4,7 +4,7 @@ import { clsx } from 'clsx/lite'; import AppGrid from '../components/AppGrid'; import ThemeSwitcher from '@/app/ThemeSwitcher'; import Link from 'next/link'; -import { SHOW_REPO_LINK } from '@/app/config'; +import { APP_TEXT, SHOW_REPO_LINK } from '@/app/config'; import RepoLink from '../components/RepoLink'; import { usePathname } from 'next/navigation'; import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths'; @@ -51,7 +51,7 @@ export default function Footer() {
signOutAction() .then(clearAuthStateAndRedirectIfNecessary)}> - Sign out + {APP_TEXT.auth.signOut}
@@ -60,7 +60,7 @@ export default function Footer() { : SHOW_REPO_LINK ? : - Admin + {APP_TEXT.footer.admin} }
diff --git a/src/app/ThemeSwitcher.tsx b/src/app/ThemeSwitcher.tsx index 6ef0d021..d0975cc3 100644 --- a/src/app/ThemeSwitcher.tsx +++ b/src/app/ThemeSwitcher.tsx @@ -5,6 +5,7 @@ import { useTheme } from 'next-themes'; import Switcher from '@/components/Switcher'; import SwitcherItem from '@/components/SwitcherItem'; import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi'; +import { APP_TEXT } from './config'; export default function ThemeSwitcher () { const [mounted, setMounted] = useState(false); @@ -25,19 +26,19 @@ export default function ThemeSwitcher () { icon={} onClick={() => setTheme('system')} active={theme === 'system'} - tooltip={{ content: 'System' }} + tooltip={{ content: APP_TEXT.footer.system }} /> } onClick={() => setTheme('light')} active={theme === 'light'} - tooltip={{ content: 'Light Mode' }} + tooltip={{ content: APP_TEXT.footer.light }} /> } onClick={() => setTheme('dark')} active={theme === 'dark'} - tooltip={{ content: 'Dark Mode' }} + tooltip={{ content: APP_TEXT.footer.dark }} /> ); diff --git a/src/app/config.ts b/src/app/config.ts index eef94bf4..856a0d41 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -5,7 +5,7 @@ import { import { getOrderedCategoriesFromString } from '@/category'; import type { StorageType } from '@/platforms/storage'; import { makeUrlAbsolute, shortenUrl } from '@/utility/url'; -import { getContentForLanguage } from '@/i18n'; +import { getTextForLanguage } from '@/i18n'; // HARD-CODED GLOBAL CONFIGURATION @@ -99,7 +99,7 @@ const SITE_DOMAIN_SHORT = shortenUrl(SITE_DOMAIN); // SITE META -export const APP_TEXT = await getContentForLanguage( +export const APP_TEXT = await getTextForLanguage( process.env.NEXT_PUBLIC_LANGUAGE, ); diff --git a/src/auth/SignInForm.tsx b/src/auth/SignInForm.tsx index 4fda5f64..70ee6d1b 100644 --- a/src/auth/SignInForm.tsx +++ b/src/auth/SignInForm.tsx @@ -21,6 +21,7 @@ import { useAppState } from '@/state/AppState'; import { clsx } from 'clsx/lite'; import { PATH_ADMIN_PHOTOS } from '@/app/paths'; import IconLock from '@/components/icons/IconLock'; +import { APP_TEXT } from '@/app/config'; export default function SignInForm({ includeTitle = true, @@ -79,27 +80,27 @@ export default function SignInForm({ )}> - Sign in + {APP_TEXT.auth.signIn} }
{response === KEY_CREDENTIALS_SIGN_IN_ERROR && - Invalid email/password + {APP_TEXT.auth.invalidEmailPassword} }
}
- Sign in + {APP_TEXT.auth.signIn}
diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx index 5b81f1ca..4c261ecf 100644 --- a/src/cmdk/CommandKClient.tsx +++ b/src/cmdk/CommandKClient.tsx @@ -52,7 +52,11 @@ import { FaCheck } from 'react-icons/fa6'; import { addHiddenToTags, formatTag, isTagFavs, isTagHidden } from '@/tag'; import { formatCount, formatCountDescriptive } from '@/utility/string'; import CommandKItem from './CommandKItem'; -import { CATEGORY_VISIBILITY, GRID_HOMEPAGE_ENABLED } from '@/app/config'; +import { + APP_TEXT, + CATEGORY_VISIBILITY, + GRID_HOMEPAGE_ENABLED, +} from '@/app/config'; import { DialogDescription, DialogTitle } from '@radix-ui/react-dialog'; import * as VisuallyHidden from '@radix-ui/react-visually-hidden'; import InsightsIndicatorDot from '@/admin/insights/InsightsIndicatorDot'; @@ -285,7 +289,7 @@ export default function CommandKClient({ .map(category => { switch (category) { case 'cameras': return { - heading: 'Cameras', + heading: APP_TEXT.category.cameraPlural, accessory: , items: cameras.map(({ camera, count }) => ({ label: formatCameraText(camera), @@ -295,7 +299,7 @@ export default function CommandKClient({ })), }; case 'lenses': return { - heading: 'Lenses', + heading: APP_TEXT.category.lensPlural, accessory: , items: lenses.map(({ lens, count }) => ({ label: formatLensText(lens, 'medium'), @@ -306,7 +310,7 @@ export default function CommandKClient({ })), }; case 'tags': return { - heading: 'Tags', + heading: APP_TEXT.category.tagPlural, accessory: , items: films.map(({ film, count }) => ({ label: labelForFilm(film).medium, @@ -356,7 +360,7 @@ export default function CommandKClient({ })), }; case 'focal-lengths': return { - heading: 'Focal Lengths', + heading: APP_TEXT.category.focalLengthPlural, accessory: , items: focalLengths.map(({ focal, count }) => ({ label: formatFocalLength(focal)!, @@ -588,7 +592,7 @@ export default function CommandKClient({ 'focus:outline-hidden', isPending && 'opacity-20', )} - placeholder="Search photos, views, settings ..." + placeholder={APP_TEXT.cmdk.placeholder} disabled={isPending} /> {isLoading && !isPending && diff --git a/src/components/DownloadButton.tsx b/src/components/DownloadButton.tsx index ed45dea6..677e695c 100644 --- a/src/components/DownloadButton.tsx +++ b/src/components/DownloadButton.tsx @@ -4,6 +4,7 @@ import { downloadFileNameForPhoto, Photo } from '@/photo'; import LoaderButton from './primitives/LoaderButton'; import { useState } from 'react'; import { downloadFileFromBrowser } from '@/utility/url'; +import { APP_TEXT } from '@/app/config'; export default function DownloadButton({ photo, @@ -16,7 +17,7 @@ export default function DownloadButton({ return ( - Made with + {APP_TEXT.footer.repo} ; +} + export const LANGUAGES: Record< string, - (() => Promise>) | undefined + (() => Promise) | undefined > = { 'pt-br': () => import('./languages/pt-br').then(module => module.default), }; -export const getContentForLanguage = async ( +export const getTextForLanguage = async ( language = '', -): Promise => ({ - ...US_EN, - ...await LANGUAGES[language.toLocaleLowerCase()]?.(), -}); +): Promise => { + const text = US_EN; + + Object.entries(await LANGUAGES[language.toLocaleLowerCase()]?.() ?? {}) + .forEach(([key, value]) => { + text[key as keyof I18N] = { + ...text[key as keyof I18N], + ...value, + } as any; + }); + + return text; +}; diff --git a/src/i18n/languages/pt-br.ts b/src/i18n/languages/pt-br.ts index b0473f29..2785cac8 100644 --- a/src/i18n/languages/pt-br.ts +++ b/src/i18n/languages/pt-br.ts @@ -1,6 +1,6 @@ -import { I18N } from '..'; +import { I18NDeepPartial } from '..'; -const language: Partial = { +const TEXT: I18NDeepPartial = { core: { photo: 'Foto', photoPlural: 'Fotos', @@ -16,6 +16,45 @@ const language: Partial = { next: 'Próximo', nextShort: 'Prox', }, + footer: { + admin: 'Admin', + repo: 'Feito com', + system: 'Sistema', + light: 'Modo Claro', + dark: 'Modo Escuro', + }, + cmdk: { + placeholder: 'Pesquisar fotos, visualizações, configurações ...', + }, + category: { + camera: 'Câmera', + cameraPlural: 'Câmeras', + lens: 'Lente', + lensPlural: 'Lentes', + tag: 'Tag', + tagPlural: 'Tags', + recipe: 'Receita', + recipePlural: 'Receitas', + film: 'Filme', + filmPlural: 'Filmes', + focalLength: 'Distância Focal', + focalLengthPlural: 'Distâncias Focais', + }, + auth: { + signIn: 'Entrar', + signOut: 'Sair', + email: 'Email do Admin', + password: 'Senha do Admin', + invalidEmailPassword: 'Email/senha inválidos', + }, + tooltip: { + '35mm': 'Equivalente 35mm', + zoom: 'Aumentar Zoom', + sharePhoto: 'Compartilhar Foto', + recipeInfo: 'Informações da Receita', + recipeCopy: 'Copiar Texto da Receita', + download: 'Baixar Arquivo Original', + }, }; -export default language; +export default TEXT; diff --git a/src/i18n/languages/us-en.ts b/src/i18n/languages/us-en.ts index 86c99252..05e165c4 100644 --- a/src/i18n/languages/us-en.ts +++ b/src/i18n/languages/us-en.ts @@ -1,4 +1,4 @@ -const language = { +const TEXT = { core: { photo: 'Photo', photoPlural: 'Photos', @@ -14,7 +14,17 @@ const language = { next: 'Next', nextShort: 'Next', }, - categories: { + footer: { + admin: 'Admin', + repo: 'Made with', + system: 'System', + light: 'Light Mode', + dark: 'Dark Mode', + }, + cmdk: { + placeholder: 'Search photos, views, settings ...', + }, + category: { camera: 'Camera', cameraPlural: 'Cameras', lens: 'Lens', @@ -28,25 +38,21 @@ const language = { focalLength: 'Focal Length', focalLengthPlural: 'Focal Lengths', }, - footer: { - repo: 'Made with', - system: 'System', - light: 'Light', - dark: 'Dark', - }, auth: { signIn: 'Sign in', signOut: 'Sign out', email: 'Admin Email', password: 'Admin Password', + invalidEmailPassword: 'Invalid email/password', }, - tooltips: { + tooltip: { '35mm': '35mm Equivalent', - imageViewer: 'Open Image Viewer', + zoom: 'Zoom In', sharePhoto: 'Share Photo', recipeInfo: 'Recipe Info', recipeCopy: 'Copy Recipe Text', + download: 'Download Original File', }, }; -export default language; +export default TEXT; diff --git a/src/photo/PhotoGridSidebar.tsx b/src/photo/PhotoGridSidebar.tsx index 4fe03c94..3273b0d2 100644 --- a/src/photo/PhotoGridSidebar.tsx +++ b/src/photo/PhotoGridSidebar.tsx @@ -10,7 +10,7 @@ import FavsTag from '../tag/FavsTag'; import { useAppState } from '@/state/AppState'; import { useMemo, useRef } from 'react'; import HiddenTag from '@/tag/HiddenTag'; -import { CATEGORY_VISIBILITY } from '@/app/config'; +import { APP_TEXT, CATEGORY_VISIBILITY } from '@/app/config'; import { clsx } from 'clsx/lite'; import PhotoRecipe from '@/recipe/PhotoRecipe'; import IconCamera from '@/components/icons/IconCamera'; @@ -83,7 +83,7 @@ export default function PhotoGridSidebar({ const camerasContent = cameras.length > 0 ? 0 ? } maxItems={maxItemsPerCategory} items={lenses @@ -127,7 +127,7 @@ export default function PhotoGridSidebar({ const tagsContent = tags.length > 0 ? 0 ? 0 ? } maxItems={maxItemsPerCategory} items={films @@ -213,7 +213,7 @@ export default function PhotoGridSidebar({ const focalLengthsContent = focalLengths.length > 0 ? } maxItems={maxItemsPerCategory} items={focalLengths.map(({ focal, count }) => diff --git a/src/photo/PhotoHeader.tsx b/src/photo/PhotoHeader.tsx index 68f386f6..b2215fca 100644 --- a/src/photo/PhotoHeader.tsx +++ b/src/photo/PhotoHeader.tsx @@ -17,12 +17,13 @@ import PhotoLink from './PhotoLink'; import ResponsiveText from '@/components/primitives/ResponsiveText'; import { useAppState } from '@/state/AppState'; import { GRID_GAP_CLASSNAME } from '@/components'; +import { APP_TEXT } from '@/app/config'; export default function PhotoHeader({ photos, selectedPhoto, entity, - entityVerb = 'PHOTO', + entityVerb = APP_TEXT.core.photo.toLocaleUpperCase(), entityDescription, indexNumber, count, diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index 8225c799..adea75da 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -31,6 +31,7 @@ import { SHOW_TAKEN_AT_TIME, MATTE_COLOR, MATTE_COLOR_DARK, + APP_TEXT, } from '@/app/config'; import AdminPhotoMenu from '@/admin/AdminPhotoMenu'; import { RevalidatePhoto } from './InfinitePhotoScroll'; @@ -378,7 +379,7 @@ export default function PhotoLarge({ <> {' '} @@ -434,7 +435,7 @@ export default function PhotoLarge({ )}> {showZoomControls && } onClick={() => refZoomControls.current?.open()} styleAs="link" @@ -443,7 +444,7 @@ export default function PhotoLarge({ />} {shouldShare && - PREV + + {APP_TEXT.nav.prevShort} + / - NEXT + + {APP_TEXT.nav.nextShort} +
diff --git a/src/recipe/PhotoRecipeOverlayButton.tsx b/src/recipe/PhotoRecipeOverlayButton.tsx index b6a39df0..f1d213f2 100644 --- a/src/recipe/PhotoRecipeOverlayButton.tsx +++ b/src/recipe/PhotoRecipeOverlayButton.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx/lite'; import { FaPlus } from 'react-icons/fa6'; import Tooltip from '@/components/Tooltip'; import { useRef } from 'react'; +import { APP_TEXT } from '@/app/config'; export default function PhotoRecipeOverlayButton({ className, @@ -17,7 +18,7 @@ export default function PhotoRecipeOverlayButton({ const ref = useRef(null); return ( - +