Integrate basic I18N text

This commit is contained in:
Sam Becker 2025-05-10 00:22:17 -05:00
parent f0a90172f5
commit 24a2877d82
16 changed files with 138 additions and 64 deletions

View File

@ -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
-

View File

@ -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() {
<form action={() => signOutAction()
.then(clearAuthStateAndRedirectIfNecessary)}>
<SubmitButtonWithStatus styleAs="link">
Sign out
{APP_TEXT.auth.signOut}
</SubmitButtonWithStatus>
</form>
</>
@ -60,7 +60,7 @@ export default function Footer() {
: SHOW_REPO_LINK
? <RepoLink />
: <Link href={PATH_ADMIN_PHOTOS}>
Admin
{APP_TEXT.footer.admin}
</Link>}
</div>
<div className="flex items-center h-10">

View File

@ -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={<BiDesktop size={16} />}
onClick={() => setTheme('system')}
active={theme === 'system'}
tooltip={{ content: 'System' }}
tooltip={{ content: APP_TEXT.footer.system }}
/>
<SwitcherItem
icon={<BiSun size={18} />}
onClick={() => setTheme('light')}
active={theme === 'light'}
tooltip={{ content: 'Light Mode' }}
tooltip={{ content: APP_TEXT.footer.light }}
/>
<SwitcherItem
icon={<BiMoon size={16} />}
onClick={() => setTheme('dark')}
active={theme === 'dark'}
tooltip={{ content: 'Dark Mode' }}
tooltip={{ content: APP_TEXT.footer.dark }}
/>
</Switcher>
);

View File

@ -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,
);

View File

@ -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({
)}>
<IconLock className="text-main translate-y-[0.5px]" />
<span className="text-main">
Sign in
{APP_TEXT.auth.signIn}
</span>
</h1>}
<form action={action} className="w-full">
<div className="space-y-5 w-full -translate-y-0.5">
{response === KEY_CREDENTIALS_SIGN_IN_ERROR &&
<ErrorNote>
Invalid email/password
{APP_TEXT.auth.invalidEmailPassword}
</ErrorNote>}
<div className="space-y-4 w-full">
<FieldSetWithStatus
id="email"
inputRef={emailRef}
label="Admin Email"
label={APP_TEXT.auth.email}
type="email"
value={email}
onChange={setEmail}
/>
<FieldSetWithStatus
id="password"
label="Admin Password"
label={APP_TEXT.auth.password}
type="password"
value={password}
onChange={setPassword}
@ -112,7 +113,7 @@ export default function SignInForm({
/>}
</div>
<SubmitButtonWithStatus disabled={!isFormValid}>
Sign in
{APP_TEXT.auth.signIn}
</SubmitButtonWithStatus>
</div>
</form>

View File

@ -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: <IconCamera size={14} />,
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: <IconLens size={14} className="translate-y-[0.5px]" />,
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: <IconTag
size={13}
className="translate-x-[1px] translate-y-[0.75px]"
@ -333,7 +337,7 @@ export default function CommandKClient({
})),
};
case 'recipes': return {
heading: 'Recipes',
heading: APP_TEXT.category.recipePlural,
accessory: <IconRecipe
size={15}
className="translate-x-[-1px]"
@ -346,7 +350,7 @@ export default function CommandKClient({
})),
};
case 'films': return {
heading: 'Films',
heading: APP_TEXT.category.filmPlural,
accessory: <IconFilm size={14} />,
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: <IconFocalLength className="text-[14px]" />,
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 &&

View File

@ -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 (
<LoaderButton
tooltip="Download Original File"
tooltip={APP_TEXT.tooltip.download}
className={clsx(
className,
'text-medium',

View File

@ -1,4 +1,4 @@
import { TEMPLATE_REPO_NAME, TEMPLATE_REPO_URL } from '@/app/config';
import { APP_TEXT, TEMPLATE_REPO_NAME, TEMPLATE_REPO_URL } from '@/app/config';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
import { BiLogoGithub } from 'react-icons/bi';
@ -7,7 +7,7 @@ export default function RepoLink() {
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap">
<span className="hidden sm:inline-block">
Made with
{APP_TEXT.footer.repo}
</span>
<Link
href={TEMPLATE_REPO_URL}

View File

@ -2,16 +2,29 @@ import US_EN from './languages/us-en';
export type I18N = typeof US_EN;
export type I18NDeepPartial = {
[key in keyof I18N]?: Partial<I18N[key]>;
}
export const LANGUAGES: Record<
string,
(() => Promise<Partial<I18N>>) | undefined
(() => Promise<I18NDeepPartial>) | undefined
> = {
'pt-br': () => import('./languages/pt-br').then(module => module.default),
};
export const getContentForLanguage = async (
export const getTextForLanguage = async (
language = '',
): Promise<I18N> => ({
...US_EN,
...await LANGUAGES[language.toLocaleLowerCase()]?.(),
});
): Promise<I18N> => {
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;
};

View File

@ -1,6 +1,6 @@
import { I18N } from '..';
import { I18NDeepPartial } from '..';
const language: Partial<I18N> = {
const TEXT: I18NDeepPartial = {
core: {
photo: 'Foto',
photoPlural: 'Fotos',
@ -16,6 +16,45 @@ const language: Partial<I18N> = {
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;

View File

@ -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;

View File

@ -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
? <HeaderList
key="cameras"
title="Cameras"
title={APP_TEXT.category.cameraPlural}
icon={<IconCamera
size={15}
className="translate-x-[0.5px]"
@ -107,7 +107,7 @@ export default function PhotoGridSidebar({
const lensesContent = lenses.length > 0
? <HeaderList
key="lenses"
title="Lenses"
title={APP_TEXT.category.lensPlural}
icon={<IconLens size={15} />}
maxItems={maxItemsPerCategory}
items={lenses
@ -127,7 +127,7 @@ export default function PhotoGridSidebar({
const tagsContent = tags.length > 0
? <HeaderList
key="tags"
title='Tags'
title={APP_TEXT.category.tagPlural}
icon={<IconTag
size={14}
className="translate-x-[1px] translate-y-[1px]"
@ -172,7 +172,7 @@ export default function PhotoGridSidebar({
const recipesContent = recipes.length > 0
? <HeaderList
key="recipes"
title="Recipes"
title={APP_TEXT.category.recipePlural}
icon={<IconRecipe
size={16}
className="translate-x-[-1px]"
@ -195,7 +195,7 @@ export default function PhotoGridSidebar({
const filmsContent = films.length > 0
? <HeaderList
key="films"
title="Films"
title={APP_TEXT.category.filmPlural}
icon={<IconFilm size={15} />}
maxItems={maxItemsPerCategory}
items={films
@ -213,7 +213,7 @@ export default function PhotoGridSidebar({
const focalLengthsContent = focalLengths.length > 0
? <HeaderList
key="focal-lengths"
title="Focal Lengths"
title={APP_TEXT.category.focalLengthPlural}
icon={<IconFocalLength size={13} />}
maxItems={maxItemsPerCategory}
items={focalLengths.map(({ focal, count }) =>

View File

@ -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,

View File

@ -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({
<>
{' '}
<Tooltip
content="35mm equivalent"
content={APP_TEXT.tooltip['35mm']}
sideOffset={3}
supportMobile
>
@ -434,7 +435,7 @@ export default function PhotoLarge({
)}>
{showZoomControls &&
<LoaderButton
tooltip="Zoom In"
tooltip={APP_TEXT.tooltip.zoom}
icon={<LuExpand size={15} />}
onClick={() => refZoomControls.current?.open()}
styleAs="link"
@ -443,7 +444,7 @@ export default function PhotoLarge({
/>}
{shouldShare &&
<ShareButton
tooltip="Share Photo"
tooltip={APP_TEXT.tooltip.sharePhoto}
photo={photo}
tag={shouldShareTag
? primaryTag

View File

@ -25,6 +25,7 @@ import { isPhotoFav } from '@/tag';
import Tooltip from '@/components/Tooltip';
import {
ALLOW_PUBLIC_DOWNLOADS,
APP_TEXT,
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
} from '@/app/config';
import { downloadFileFromBrowser } from '@/utility/url';
@ -200,7 +201,7 @@ export default function PhotoPrevNextActions({
'*:select-none',
)}>
<Tooltip {...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: 'Previous',
content: APP_TEXT.nav.prev,
keyCommand: KEY_COMMANDS.prev[0],
}}>
<PhotoLink
@ -213,14 +214,16 @@ export default function PhotoPrevNextActions({
prefetch
>
<FiChevronLeft className="sm:hidden text-[1.1rem]" />
<span className="hidden sm:inline-block">PREV</span>
<span className="hidden sm:inline-block uppercase">
{APP_TEXT.nav.prevShort}
</span>
</PhotoLink>
</Tooltip>
<span className="text-extra-extra-dim">
/
</span>
<Tooltip {...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: 'Next',
content: APP_TEXT.nav.next,
keyCommand: KEY_COMMANDS.next[0],
}}>
<PhotoLink
@ -233,7 +236,9 @@ export default function PhotoPrevNextActions({
prefetch
>
<FiChevronRight className="sm:hidden text-[1.1rem]" />
<span className="hidden sm:inline-block">NEXT</span>
<span className="hidden sm:inline-block uppercase">
{APP_TEXT.nav.nextShort}
</span>
</PhotoLink>
</Tooltip>
</div>

View File

@ -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<HTMLButtonElement>(null);
return (
<Tooltip content="Recipe Info">
<Tooltip content={APP_TEXT.tooltip.recipeInfo}>
<button
ref={ref}
onClick={() => {