{isCheckingAuth
- ? 'Loading ...'
+ ? APP_TEXT.misc.loading
: isUserSignedIn
- ? 'Add your first photo'
- : 'Sign in to upload photos'}
+ ? APP_TEXT.onboarding.setupFirstPhoto
+ : APP_TEXT.onboarding.setupSignIn}
{!isCheckingAuth && isUserSignedIn === false &&
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/Footer.tsx b/src/app/Footer.tsx
index baebe14e..fef82514 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() {
>
@@ -60,7 +60,7 @@ export default function Footer() {
: SHOW_REPO_LINK
?
:
- Admin
+ {APP_TEXT.nav.admin}
}
diff --git a/src/app/ThemeSwitcher.tsx b/src/app/ThemeSwitcher.tsx
index 6ef0d021..8ab1d881 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.theme.system }}
/>
}
onClick={() => setTheme('light')}
active={theme === 'light'}
- tooltip={{ content: 'Light Mode' }}
+ tooltip={{ content: APP_TEXT.theme.light }}
/>
}
onClick={() => setTheme('dark')}
active={theme === 'dark'}
- tooltip={{ content: 'Dark Mode' }}
+ tooltip={{ content: APP_TEXT.theme.dark }}
/>
);
diff --git a/src/app/config.ts b/src/app/config.ts
index fcda18b7..7f52ab4c 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 { getTextForLocale } from '@/i18n';
// HARD-CODED GLOBAL CONFIGURATION
@@ -98,6 +99,10 @@ const SITE_DOMAIN_SHORT = shortenUrl(SITE_DOMAIN);
// SITE META
+export const APP_TEXT = getTextForLocale(
+ process.env.NEXT_PUBLIC_LOCALE,
+);
+
export const NAV_TITLE =
process.env.NEXT_PUBLIC_NAV_TITLE;
@@ -338,6 +343,8 @@ export const APP_CONFIGURATION = {
Boolean(process.env.ADMIN_PASSWORD)
),
// Domain
+ locale: process.env.NEXT_PUBLIC_LOCALE ?? 'US-EN',
+ hasLocale: Boolean(process.env.NEXT_PUBLIC_LOCALE),
hasDomain: Boolean(
process.env.NEXT_PUBLIC_DOMAIN ||
// Legacy environment variable
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}
}
diff --git a/src/camera/CameraOGTile.tsx b/src/camera/CameraOGTile.tsx
index 5e74795f..1c67e989 100644
--- a/src/camera/CameraOGTile.tsx
+++ b/src/camera/CameraOGTile.tsx
@@ -1,11 +1,9 @@
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForCameraImage, pathForCamera } from '@/app/paths';
-import OGTile from '@/components/OGTile';
+import OGTile, { OGLoadingState } from '@/components/OGTile';
import { Camera } from '.';
import { descriptionForCameraPhotos, titleForCamera } from './meta';
-export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
-
export default function CameraOGTile({
camera,
photos,
diff --git a/src/camera/meta.ts b/src/camera/meta.ts
index 3fd38bc4..9d7b3ce8 100644
--- a/src/camera/meta.ts
+++ b/src/camera/meta.ts
@@ -9,6 +9,7 @@ import {
absolutePathForCamera,
absolutePathForCameraImage,
} from '@/app/paths';
+import { APP_TEXT } from '@/app/config';
// Meta functions moved to separate file to avoid
// dependencies (camelcase-keys) found in photo/index.ts
@@ -19,8 +20,9 @@ export const titleForCamera = (
photos: Photo[],
explicitCount?: number,
) => [
- 'Shot on',
- formatCameraText(cameraFromPhoto(photos[0], camera)),
+ APP_TEXT.category.cameraTitle(
+ formatCameraText(cameraFromPhoto(photos[0], camera)),
+ ),
photoQuantityText(explicitCount ?? photos.length),
].join(' ');
@@ -28,10 +30,9 @@ export const shareTextForCamera = (
camera: Camera,
photos: Photo[],
) =>
- [
- 'Photos shot on',
+ APP_TEXT.category.cameraShare(
formatCameraText(cameraFromPhoto(photos[0], camera)),
- ].join(' ');
+ );
export const descriptionForCameraPhotos = (
photos: Photo[],
diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx
index 5b81f1ca..a91bd02f 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)!,
@@ -371,21 +375,21 @@ export default function CommandKClient({
, [tagsIncludingHidden, cameras, lenses, recipes, films, focalLengths]);
const clientSections: CommandKSection[] = [{
- heading: 'Theme',
+ heading: APP_TEXT.theme.theme,
accessory:
,
items: [{
- label: 'Use System',
+ label: APP_TEXT.theme.system,
annotation:
,
action: () => setTheme('system'),
}, {
- label: 'Light Mode',
+ label: APP_TEXT.theme.light,
annotation:
,
action: () => setTheme('light'),
}, {
- label: 'Dark Mode',
+ label: APP_TEXT.theme.dark,
annotation:
,
action: () => setTheme('dark'),
}],
@@ -436,12 +440,16 @@ export default function CommandKClient({
}
const pageFeed: CommandKItem = {
- label: GRID_HOMEPAGE_ENABLED ? 'Feed' : 'Feed (Home)',
+ label: GRID_HOMEPAGE_ENABLED
+ ? APP_TEXT.nav.feed
+ : `${APP_TEXT.nav.feed} (${APP_TEXT.nav.home})`,
path: PATH_FEED_INFERRED,
};
const pageGrid: CommandKItem = {
- label: GRID_HOMEPAGE_ENABLED ? 'Grid (Home)' : 'Grid',
+ label: GRID_HOMEPAGE_ENABLED
+ ? `${APP_TEXT.nav.grid} (${APP_TEXT.nav.home})`
+ : APP_TEXT.nav.grid,
path: PATH_GRID_INFERRED,
};
@@ -463,40 +471,40 @@ export default function CommandKClient({
if (isUserSignedIn) {
adminSection.items.push({
- label: 'Upload Photos',
+ label: APP_TEXT.admin.uploadPhotos,
annotation:
,
action: startUpload,
});
if (uploadsCount) {
adminSection.items.push({
- label: `Uploads (${uploadsCount})`,
+ label: `${APP_TEXT.admin.uploadPlural} (${uploadsCount})`,
annotation:
,
path: PATH_ADMIN_UPLOADS,
});
}
adminSection.items.push({
- label: `Manage Photos (${photosCountTotal})`,
+ label: `${APP_TEXT.admin.managePhotos} (${photosCountTotal})`,
annotation:
,
path: PATH_ADMIN_PHOTOS,
});
if (tagsCount) {
adminSection.items.push({
- label: `Manage Tags (${tagsCount})`,
+ label: `${APP_TEXT.admin.manageTags} (${tagsCount})`,
annotation:
,
path: PATH_ADMIN_TAGS,
});
}
if (recipesCount) {
adminSection.items.push({
- label: `Manage Recipes (${recipesCount})`,
+ label: `${APP_TEXT.admin.manageRecipes} (${recipesCount})`,
annotation:
,
path: PATH_ADMIN_RECIPES,
});
}
adminSection.items.push({
label: selectedPhotoIds === undefined
- ? 'Batch Edit Photos ...'
- : 'Exit Batch Edit',
+ ? APP_TEXT.admin.batchEdit
+ : APP_TEXT.admin.batchExitEdit,
annotation:
,
path: selectedPhotoIds === undefined
? PATH_GRID_INFERRED
@@ -506,7 +514,7 @@ export default function CommandKClient({
: () => setSelectedPhotoIds?.(undefined),
}, {
label:
- App Insights
+ {APP_TEXT.admin.appInsights}
{insightsIndicatorStatus &&
}
,
@@ -514,7 +522,7 @@ export default function CommandKClient({
annotation:
,
path: PATH_ADMIN_INSIGHTS,
}, {
- label: 'App Config',
+ label: APP_TEXT.admin.appConfig,
annotation:
,
path: PATH_ADMIN_CONFIGURATION,
});
@@ -530,14 +538,14 @@ export default function CommandKClient({
});
}
adminSection.items.push({
- label: 'Sign Out',
+ label: APP_TEXT.auth.signOut,
action: () => signOutAction()
.then(clearAuthStateAndRedirectIfNecessary)
.then(() => setIsOpen?.(false)),
});
} else {
adminSection.items.push({
- label: 'Sign In',
+ label: APP_TEXT.auth.signIn,
path: PATH_SIGN_IN,
});
}
@@ -588,7 +596,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/CopyButton.tsx b/src/components/CopyButton.tsx
index 5c6dd3ba..e9e18f6c 100644
--- a/src/components/CopyButton.tsx
+++ b/src/components/CopyButton.tsx
@@ -3,6 +3,7 @@ import LoaderButton from './primitives/LoaderButton';
import clsx from 'clsx/lite';
import { toastSuccess } from '@/toast';
import { ComponentProps } from 'react';
+import { APP_TEXT } from '@/app/config';
export default function CopyButton({
label,
@@ -29,7 +30,7 @@ export default function CopyButton({
onClick={text
? () => {
navigator.clipboard.writeText(text);
- toastSuccess(`${label} copied to clipboard`);
+ toastSuccess(APP_TEXT.misc.copyPhrase(label));
}
: undefined}
styleAs="link"
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 (
{isUploading
? filesLength > 1
- ? `Uploading ${fileUploadIndex + 1} of ${filesLength}`
- : 'Uploading'
- : 'Upload Photos'}
+ ? APP_TEXT.utility.paginate(
+ fileUploadIndex + 1,
+ filesLength,
+ APP_TEXT.admin.uploading,
+ )
+ : APP_TEXT.admin.uploading
+ : APP_TEXT.admin.uploadPhotos}
}
- Made with
+ {APP_TEXT.misc.repo}
Promise | undefined)
diff --git a/src/film/FilmOGTile.tsx b/src/film/FilmOGTile.tsx
index 399f7d08..b0db04b8 100644
--- a/src/film/FilmOGTile.tsx
+++ b/src/film/FilmOGTile.tsx
@@ -3,11 +3,9 @@ import {
absolutePathForFilmImage,
pathForFilm,
} from '@/app/paths';
-import OGTile from '@/components/OGTile';
+import OGTile, { OGLoadingState } from '@/components/OGTile';
import { descriptionForFilmPhotos, titleForFilm } from '.';
-export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
-
export default function FilmOGTile({
film,
photos,
diff --git a/src/film/index.tsx b/src/film/index.tsx
index 36162e8a..14ead1e1 100644
--- a/src/film/index.tsx
+++ b/src/film/index.tsx
@@ -19,6 +19,7 @@ import {
} from '@/utility/string';
import { AnnotatedTag } from '@/photo/form';
import PhotoFilmIcon from './PhotoFilmIcon';
+import { APP_TEXT } from '@/app/config';
export type FilmWithCount = {
film: string
@@ -67,7 +68,7 @@ export const titleForFilm = (
export const shareTextForFilm = (
film: string,
) =>
- `Photos shot on ${labelForFilm(film).large}`;
+ APP_TEXT.category.filmShare(labelForFilm(film).large);
export const descriptionForFilmPhotos = (
photos: Photo[],
diff --git a/src/focal/FocalLengthOGTile.tsx b/src/focal/FocalLengthOGTile.tsx
index 6262bd31..c3ebd695 100644
--- a/src/focal/FocalLengthOGTile.tsx
+++ b/src/focal/FocalLengthOGTile.tsx
@@ -3,11 +3,9 @@ import {
absolutePathForFocalLengthImage,
pathForFocalLength,
} from '@/app/paths';
-import OGTile from '@/components/OGTile';
+import OGTile, { OGLoadingState } from '@/components/OGTile';
import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.';
-export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
-
export default function FocalLengthOGTile({
focal,
photos,
diff --git a/src/focal/index.ts b/src/focal/index.ts
index 609a1836..ee5569e5 100644
--- a/src/focal/index.ts
+++ b/src/focal/index.ts
@@ -8,6 +8,7 @@ import {
absolutePathForFocalLength,
absolutePathForFocalLengthImage,
} from '@/app/paths';
+import { APP_TEXT } from '@/app/config';
export type FocalLengths = {
focal: number
@@ -31,12 +32,12 @@ export const titleForFocalLength = (
photos: Photo[],
explicitCount?: number,
) => [
- `${formatFocalLength(focal)} Focal Length`,
+ APP_TEXT.category.focalLengthTitle(formatFocalLengthSafe(focal)),
photoQuantityText(explicitCount ?? photos.length),
].join(' ');
export const shareTextFocalLength = (focal: number) =>
- `Photos shot at ${formatFocalLength(focal)}`;
+ APP_TEXT.category.focalLengthShare(formatFocalLengthSafe(focal));
export const descriptionForFocalLengthPhotos = (
photos: Photo[],
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 00000000..ba4be42d
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,29 @@
+import US_EN from './locales/us-en';
+import PT_BR from './locales/pt-br';
+
+export type I18N = typeof US_EN;
+
+export type I18NDeepPartial = {
+ [key in keyof I18N]?: Partial;
+}
+
+export const LOCALE_TEXT: Record = {
+ 'pt-br': PT_BR,
+};
+
+export const getTextForLocale = (
+ locale = '',
+): I18N => {
+ const text = US_EN;
+
+ Object.entries(LOCALE_TEXT[locale.toLocaleLowerCase()] ?? {})
+ .forEach(([key, value]) => {
+ // Fall back to English for missing keys
+ text[key as keyof I18N] = {
+ ...text[key as keyof I18N],
+ ...value,
+ } as any;
+ });
+
+ return text;
+};
diff --git a/src/i18n/locales/pt-br.ts b/src/i18n/locales/pt-br.ts
new file mode 100644
index 00000000..cc645f83
--- /dev/null
+++ b/src/i18n/locales/pt-br.ts
@@ -0,0 +1,123 @@
+import { I18NDeepPartial } from '..';
+import { ptBR } from 'date-fns/locale';
+
+const TEXT: I18NDeepPartial = {
+ photo: {
+ photo: 'Foto',
+ photoPlural: 'Fotos',
+ taken: 'Capturado',
+ created: 'Criado',
+ updated: 'Atualizado',
+ copied: 'Link para foto copiado',
+ },
+ category: {
+ camera: 'Câmera',
+ cameraPlural: 'Câmeras',
+ cameraTitle: (camera: string) => `Tirado com ${camera}`,
+ cameraShare: (camera: string) => `Fotos tiradas com ${camera}`,
+ lens: 'Lente',
+ lensPlural: 'Lentes',
+ tag: 'Tag',
+ tagPlural: 'Tags',
+ taggedPhotos: 'Fotos Marcadas',
+ taggedPhrase: (tag: string) => `Fotos marcadas com '${tag}'`,
+ taggedFavs: 'Fotos Favoritas',
+ recipe: 'Receita',
+ recipePlural: 'Receitas',
+ recipeShare: (recipe: string) => `Fotos da receita ${recipe}`,
+ film: 'Filme',
+ filmPlural: 'Filmes',
+ filmShare: (film: string) => `Fotos tiradas com ${film}`,
+ focalLength: 'Distância Focal',
+ focalLengthPlural: 'Distâncias Focais',
+ focalLengthTitle: (focal: string) => `Distância Focal ${focal}`,
+ focalLengthShare: (focal: string) => `Fotos tiradas em ${focal}`,
+ },
+ nav: {
+ home: 'Início',
+ feed: 'Feed',
+ grid: 'Grade',
+ admin: 'Menu de Admin',
+ search: 'Pesquisar',
+ prev: 'Anterior',
+ prevShort: 'Ant',
+ next: 'Próximo',
+ nextShort: 'Prox',
+ },
+ cmdk: {
+ placeholder: 'Pesquisar fotos, visualizações, configurações ...',
+ },
+ tooltip: {
+ '35mm': 'Equivalente 35mm',
+ zoom: 'Aumentar Zoom',
+ sharePhoto: 'Compartilhar Foto',
+ recipeInfo: 'Informações da Receita',
+ recipeCopy: 'Copiar Texto da Receita',
+ download: 'Baixar Arquivo Original',
+ },
+ theme: {
+ theme: 'Tema',
+ system: 'Sistema',
+ light: 'Modo Claro',
+ dark: 'Modo Escuro',
+ },
+ auth: {
+ signIn: 'Entrar',
+ signOut: 'Sair',
+ email: 'Email do Admin',
+ password: 'Senha do Admin',
+ invalidEmailPassword: 'Email/senha inválidos',
+ },
+ admin: {
+ uploadPhotos: 'Enviar Fotos',
+ upload: 'Enviar',
+ uploadPlural: 'Envios',
+ uploading: 'Enviando',
+ updates: 'Atualizações',
+ managePhotos: 'Gerenciar Fotos',
+ manageCameras: 'Gerenciar Câmeras',
+ manageLenses: 'Gerenciar Lentes',
+ manageTags: 'Gerenciar Tags',
+ manageRecipes: 'Gerenciar Receitas',
+ batchEdit: 'Editar Fotos em Lote ...',
+ batchEditShort: 'Editar em Lote ...',
+ batchExitEdit: 'Sair da Edição em Lote',
+ appInsights: 'Insights do App',
+ appConfig: 'Configuração do App',
+ edit: 'Editar',
+ favorite: 'Favoritar',
+ unfavorite: 'Remover dos Favoritos',
+ download: 'Baixar',
+ sync: 'Sincronizar',
+ delete: 'Excluir',
+ deleteConfirm: (photoTitle: string) =>
+ `Tem certeza que deseja excluir "${photoTitle}"?`,
+ },
+ onboarding: {
+ setupComplete: 'Configuração Concluída!',
+ setupIncomplete: 'Finalizar Configuração',
+ setupSignIn: 'Entre para enviar fotos',
+ setupFirstPhoto: 'Adicione sua primeira foto',
+ // eslint-disable-next-line max-len
+ setupConfig: 'Altere o nome do site e outras configurações editando as variáveis de ambiente referenciadas em',
+ },
+ misc: {
+ loading: 'Carregando ...',
+ finishing: 'Finalizando ...',
+ uploading: 'Enviando',
+ repo: 'Feito com',
+ copyPhrase: (label: string) => `${label} copiado`,
+ },
+ utility: {
+ paginate: (
+ index: number,
+ count: number,
+ action?: string,
+ ) => action
+ ? `${action} ${index} de ${count}`
+ : `${index} de ${count}`,
+ },
+ dateLocale: ptBR,
+};
+
+export default TEXT;
diff --git a/src/i18n/locales/us-en.ts b/src/i18n/locales/us-en.ts
new file mode 100644
index 00000000..7b57b67e
--- /dev/null
+++ b/src/i18n/locales/us-en.ts
@@ -0,0 +1,122 @@
+import { enUS } from 'date-fns/locale';
+
+const TEXT = {
+ photo: {
+ photo: 'Photo',
+ photoPlural: 'Photos',
+ taken: 'Taken',
+ created: 'Created',
+ updated: 'Updated',
+ copied: 'Link to photo copied',
+ },
+ category: {
+ camera: 'Camera',
+ cameraPlural: 'Cameras',
+ cameraTitle: (camera: string) => `Shot on ${camera}`,
+ cameraShare: (camera: string) => `Photos shot on ${camera}`,
+ lens: 'Lens',
+ lensPlural: 'Lenses',
+ tag: 'Tag',
+ tagPlural: 'Tags',
+ taggedPhotos: 'Tagged Photos',
+ taggedPhrase: (tag: string) => `Photos tagged '${tag}'`,
+ taggedFavs: 'Favorite Photos',
+ recipe: 'Recipe',
+ recipePlural: 'Recipes',
+ recipeShare: (recipe: string) => `${recipe} recipe photos`,
+ film: 'Film',
+ filmPlural: 'Films',
+ filmShare: (film: string) => `Photos shot on ${film}`,
+ focalLength: 'Focal Length',
+ focalLengthPlural: 'Focal Lengths',
+ focalLengthTitle: (focal: string) => `Focal Length ${focal}`,
+ focalLengthShare: (focal: string) => `Photos shot at ${focal}`,
+ },
+ nav: {
+ home: 'Home',
+ feed: 'Feed',
+ grid: 'Grid',
+ admin: 'Admin',
+ search: 'Search',
+ prev: 'Previous',
+ prevShort: 'Prev',
+ next: 'Next',
+ nextShort: 'Next',
+ },
+ cmdk: {
+ placeholder: 'Search photos, views, settings ...',
+ },
+ tooltip: {
+ '35mm': '35mm Equivalent',
+ zoom: 'Zoom In',
+ sharePhoto: 'Share Photo',
+ recipeInfo: 'Recipe Info',
+ recipeCopy: 'Copy Recipe Text',
+ download: 'Download Original File',
+ },
+ theme: {
+ theme: 'Theme',
+ system: 'System',
+ light: 'Light Mode',
+ dark: 'Dark Mode',
+ },
+ auth: {
+ signIn: 'Sign In',
+ signOut: 'Sign Out',
+ email: 'Admin Email',
+ password: 'Admin Password',
+ invalidEmailPassword: 'Invalid email/password',
+ },
+ admin: {
+ uploadPhotos: 'Upload Photos',
+ upload: 'Upload',
+ uploadPlural: 'Uploads',
+ uploading: 'Uploading',
+ updates: 'Updates',
+ managePhotos: 'Manage Photos',
+ manageCameras: 'Manage Cameras',
+ manageLenses: 'Manage Lenses',
+ manageTags: 'Manage Tags',
+ manageRecipes: 'Manage Recipes',
+ batchEdit: 'Batch Edit Photos ...',
+ batchEditShort: 'Batch Edit ...',
+ batchExitEdit: 'Exit Batch Edit',
+ appInsights: 'App Insights',
+ appConfig: 'App Configuration',
+ edit: 'Edit',
+ favorite: 'Favorite',
+ unfavorite: 'Unfavorite',
+ download: 'Download',
+ sync: 'Sync',
+ delete: 'Delete',
+ deleteConfirm: (photoTitle: string) =>
+ `Are you sure you want to delete "${photoTitle}?"`,
+ },
+ onboarding: {
+ setupComplete: 'Setup Complete!',
+ setupIncomplete: 'Finish Setup',
+ setupSignIn: 'Sign in to upload photos',
+ setupFirstPhoto: 'Add your first photo',
+ // eslint-disable-next-line max-len
+ setupConfig: 'Change the site name and other configuration by editing environment variables referenced in',
+ },
+ misc: {
+ loading: 'Loading ...',
+ finishing: 'Finishing ...',
+ uploading: 'Uploading',
+ repo: 'Made with',
+ copyPhrase: (label: string) => `${label} copied`,
+ },
+ utility: {
+ paginate: (
+ index: number,
+ count: number,
+ action?: string,
+ ) => action
+ ? `${action} ${index} of ${count}`
+ : `${index} of ${count}`,
+ },
+ dateLocale: enUS,
+};
+
+export default TEXT;
diff --git a/src/lens/LensOGTile.tsx b/src/lens/LensOGTile.tsx
index cf6e7263..ba286f4a 100644
--- a/src/lens/LensOGTile.tsx
+++ b/src/lens/LensOGTile.tsx
@@ -1,11 +1,9 @@
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForLensImage, pathForLens } from '@/app/paths';
-import OGTile from '@/components/OGTile';
+import OGTile, { OGLoadingState } from '@/components/OGTile';
import { Lens } from '.';
import { titleForLens, descriptionForLensPhotos } from './meta';
-export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
-
export default function LensOGTile({
lens,
photos,
diff --git a/src/lens/meta.ts b/src/lens/meta.ts
index 6b7036ac..f7af2167 100644
--- a/src/lens/meta.ts
+++ b/src/lens/meta.ts
@@ -9,6 +9,7 @@ import {
absolutePathForLens,
absolutePathForLensImage,
} from '@/app/paths';
+import { APP_TEXT } from '@/app/config';
// Meta functions moved to separate file to avoid
// dependencies (camelcase-keys) found in photo/index.ts
@@ -19,7 +20,7 @@ export const titleForLens = (
photos: Photo[],
explicitCount?: number,
) => [
- 'Lens:',
+ `${APP_TEXT.category.lens}:`,
formatLensText(lensFromPhoto(photos[0], lens)),
photoQuantityText(explicitCount ?? photos.length),
].join(' ');
@@ -29,7 +30,7 @@ export const shareTextForLens = (
photos: Photo[],
) =>
[
- 'Lens:',
+ `${APP_TEXT.category.lens}:`,
formatLensText(lensFromPhoto(photos[0], lens)),
].join(' ');
diff --git a/src/photo/PhotoDate.tsx b/src/photo/PhotoDate.tsx
index d79bf16b..62219264 100644
--- a/src/photo/PhotoDate.tsx
+++ b/src/photo/PhotoDate.tsx
@@ -2,6 +2,7 @@ import ResponsiveDate from '@/components/ResponsiveDate';
import { Photo } from '.';
import { useMemo } from 'react';
import { Timezone } from '@/utility/timezone';
+import { APP_TEXT } from '@/app/config';
export default function PhotoDate({
photo,
@@ -33,11 +34,11 @@ export default function PhotoDate({
const getTitleLabel = () => {
switch (dateType) {
case 'takenAt':
- return 'TAKEN';
+ return APP_TEXT.photo.taken;
case 'createdAt':
- return 'CREATED';
+ return APP_TEXT.photo.created;
case 'updatedAt':
- return 'UPDATED';
+ return APP_TEXT.photo.updated;
}
};
@@ -45,7 +46,7 @@ export default function PhotoDate({
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..9f639099 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.photo.photo.toLocaleUpperCase(),
entityDescription,
indexNumber,
count,
@@ -50,9 +51,8 @@ export default function PhotoHeader({
? photos.findIndex(photo => photo.id === selectedPhoto.id)
: undefined;
- const paginationLabel =
- (indexNumber || (selectedPhotoIndex ?? 0 + 1)) + ' of ' +
- (count ?? photos.length);
+ const paginationIndex = indexNumber || (selectedPhotoIndex ?? 0 + 1);
+ const paginationCount = count ?? photos.length;
const headerType = selectedPhotoIndex === undefined
? 'photo-set'
@@ -154,8 +154,17 @@ export default function PhotoHeader({
dim: true,
}} />}
>
- :
- {entityVerb} {paginationLabel}
+ :
+ {APP_TEXT.utility.paginate(
+ paginationIndex,
+ paginationCount,
+ entityVerb)}
}
>}
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({
<>
{' '}