diff --git a/README.md b/README.md index 17bca479..27a39ab6 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,17 @@ 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. Set your language by setting the environment variable `NEXT_PUBLIC_LOCALE`. 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/locales/us-en.ts) for reference. + +### Supported Languages +- `US_EN` +- `ES_ES` (coming soon) +- `PT_BR` (coming soon) +- `PT_PT` (coming soon) + 📖  FAQ - #### How do I receive template updates? diff --git a/app/sign-in/page.tsx b/app/sign-in/page.tsx index 13ecc9d3..023c9274 100644 --- a/app/sign-in/page.tsx +++ b/app/sign-in/page.tsx @@ -5,6 +5,7 @@ import { clsx } from 'clsx/lite'; import { redirect } from 'next/navigation'; import LinkWithStatus from '@/components/LinkWithStatus'; import { IoArrowBack } from 'react-icons/io5'; +import { APP_TEXT } from '@/app/config'; export default async function SignInPage() { const session = await auth(); @@ -27,7 +28,7 @@ export default async function SignInPage() { )} > - Home + {APP_TEXT.nav.home} ); diff --git a/src/admin/AdminAppConfigurationClient.tsx b/src/admin/AdminAppConfigurationClient.tsx index 7094efe8..7edf8817 100644 --- a/src/admin/AdminAppConfigurationClient.tsx +++ b/src/admin/AdminAppConfigurationClient.tsx @@ -47,6 +47,8 @@ export default function AdminAppConfigurationClient({ hasAuthSecret, hasAdminUser, // Content + locale, + hasLocale, hasDomain, hasNavTitle, hasNavCaption, @@ -289,6 +291,22 @@ export default function AdminAppConfigurationClient({ title="Content" icon={} > + + Store in environment variable + (check README for + {' '} + + supported locales + + ): + {renderEnvVars(['NEXT_PUBLIC_LOCALE'])} + [] = useMemo(() => ([{ - label: 'Upload Photos', + label: APP_TEXT.admin.uploadPhotos, icon: {photosCountNeedSync} @@ -110,7 +111,7 @@ export default function AdminAppMenu({ } if (photosCountTotal) { items.push({ - label: 'Manage Photos', + label: APP_TEXT.admin.managePhotos, ...photosCountTotal && { annotation: `${photosCountTotal}`, }, @@ -123,7 +124,7 @@ export default function AdminAppMenu({ } if (tagsCount) { items.push({ - label: 'Manage Tags', + label: APP_TEXT.admin.manageTags, annotation: `${tagsCount}`, icon: [] = useMemo(() => ([{ - label: 'Sign Out', + label: APP_TEXT.auth.signOut, icon: , action: () => signOutAction().then(clearAuthStateAndRedirectIfNecessary), }]), [clearAuthStateAndRedirectIfNecessary]); diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 671b1fd6..9fbe9260 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -12,6 +12,7 @@ import { PATH_ADMIN_UPLOADS, } from '@/app/paths'; import AdminNavClient from './AdminNavClient'; +import { APP_TEXT } from '@/app/config'; export default async function AdminNav() { const [ @@ -41,28 +42,28 @@ export default async function AdminNav() { // Photos const items = [{ - label: 'Photos', + label: APP_TEXT.photo.photoPlural, href: PATH_ADMIN_PHOTOS, count: countPhotos, }]; // Uploads if (countUploads > 0) { items.push({ - label: 'Uploads', + label: APP_TEXT.admin.uploadPlural, href: PATH_ADMIN_UPLOADS, count: countUploads, }); } // Tags if (countTags > 0) { items.push({ - label: 'Tags', + label: APP_TEXT.category.tagPlural, href: PATH_ADMIN_TAGS, count: countTags, }); } // Recipes if (countRecipes > 0) { items.push({ - label: 'Recipes', + label: APP_TEXT.category.recipePlural, href: PATH_ADMIN_RECIPES, count: countRecipes, }); } diff --git a/src/admin/AdminPhotoMenu.tsx b/src/admin/AdminPhotoMenu.tsx index 64fefc73..f9104cc6 100644 --- a/src/admin/AdminPhotoMenu.tsx +++ b/src/admin/AdminPhotoMenu.tsx @@ -26,6 +26,7 @@ import IconFavs from '@/components/icons/IconFavs'; import IconEdit from '@/components/icons/IconEdit'; import { photoNeedsToBeSynced } from '@/photo/sync'; import { KEY_COMMANDS } from '@/photo/key-commands'; +import { APP_TEXT } from '@/app/config'; export default function AdminPhotoMenu({ photo, @@ -48,7 +49,7 @@ export default function AdminPhotoMenu({ const sectionMain = useMemo(() => { const items: ComponentProps[] = [{ - label: 'Edit', + label: APP_TEXT.admin.edit, icon: - Sync + {APP_TEXT.admin.sync} {photoNeedsToBeSynced(photo) && [] = useMemo(() => [{ - label: 'Delete', + label: APP_TEXT.admin.delete, icon:
{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() {
signOutAction() .then(clearAuthStateAndRedirectIfNecessary)}> - Sign out + {APP_TEXT.auth.signOut}
@@ -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} }
{response === KEY_CREDENTIALS_SIGN_IN_ERROR && - Invalid email/password + {APP_TEXT.auth.invalidEmailPassword} }
}
- 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({ <> {' '} @@ -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/photo/PhotoUploadWithStatus.tsx b/src/photo/PhotoUploadWithStatus.tsx index 9f6cdc69..405e116d 100644 --- a/src/photo/PhotoUploadWithStatus.tsx +++ b/src/photo/PhotoUploadWithStatus.tsx @@ -11,6 +11,7 @@ import { useRef } from 'react'; import { useEffect } from 'react'; import Spinner from '@/components/Spinner'; import ResponsiveText from '@/components/primitives/ResponsiveText'; +import { APP_TEXT } from '@/app/config'; export default function PhotoUploadWithStatus({ inputRef, @@ -72,10 +73,11 @@ export default function PhotoUploadWithStatus({ } }; }, [resetUploadState]); + const isFinishing = isPending && shouldResetUploadStateAfterPending.current; const uploadStatusText = filesLength > 1 - ? `${fileUploadIndex + 1} of ${filesLength}` + ? APP_TEXT.utility.paginate(fileUploadIndex + 1, filesLength) : undefined; return ( @@ -158,19 +160,19 @@ export default function PhotoUploadWithStatus({ {isUploading ? isFinishing ? <> - Finishing ... + {APP_TEXT.misc.finishing} : <> {!showButton && uploadStatusText ? <> - Uploading {uploadStatusText} + {APP_TEXT.misc.uploading} {uploadStatusText} {': '} {fileUploadName} : - Uploading {fileUploadName} + {APP_TEXT.misc.uploading} {fileUploadName} } : !showButton && <>Initializing} diff --git a/src/photo/PhotosEmptyState.tsx b/src/photo/PhotosEmptyState.tsx index 69a931df..3365b776 100644 --- a/src/photo/PhotosEmptyState.tsx +++ b/src/photo/PhotosEmptyState.tsx @@ -1,6 +1,10 @@ import Container from '@/components/Container'; import AppGrid from '@/components/AppGrid'; -import { IS_SITE_READY, PRESERVE_ORIGINAL_UPLOADS } from '@/app/config'; +import { + APP_TEXT, + IS_SITE_READY, + PRESERVE_ORIGINAL_UPLOADS, +} from '@/app/config'; import AdminAppConfiguration from '@/admin/AdminAppConfiguration'; import { clsx } from 'clsx/lite'; import { HiOutlinePhotograph } from 'react-icons/hi'; @@ -29,7 +33,9 @@ export default function PhotosEmptyState() { 'font-bold text-2xl', 'text-gray-700 dark:text-gray-200', )}> - {!IS_SITE_READY ? 'Finish Setup' : 'Setup Complete!'} + {!IS_SITE_READY + ? APP_TEXT.onboarding.setupIncomplete + : APP_TEXT.onboarding.setupComplete} {!IS_SITE_READY ? @@ -43,8 +49,7 @@ export default function PhotosEmptyState() { }} />
- Change this site's name and other configuration - by editing environment variables referenced in + {APP_TEXT.onboarding.setupConfig} {' '} [ ].join(' '); export const descriptionForPhoto = (photo: Photo) => - photo.takenAtNaiveFormatted?.toUpperCase(); + formatDate({ date: photo.takenAt }).toLocaleUpperCase(); export const getPreviousPhoto = (photo: Photo, photos: Photo[]) => { const index = photos.findIndex(p => p.id === photo.id); @@ -231,10 +232,14 @@ export const titleForPhoto = ( export const altTextForPhoto = (photo: Photo) => photo.semanticDescription || titleForPhoto(photo); -export const photoLabelForCount = (count: number, capitalize = true) => - capitalize - ? count === 1 ? 'Photo' : 'Photos' - : count === 1 ? 'photo' : 'photos'; +export const photoLabelForCount = (count: number, _capitalize = true) => { + const label = count === 1 + ? APP_TEXT.photo.photo + : APP_TEXT.photo.photoPlural; + return _capitalize + ? capitalize(label) + : label; +}; export const photoQuantityText = ( count: number, @@ -246,7 +251,7 @@ export const photoQuantityText = ( : `${count} ${photoLabelForCount(count, capitalize)}`; export const deleteConfirmationTextForPhoto = (photo: Photo) => - `Are you sure you want to delete "${titleForPhoto(photo)}?"`; + APP_TEXT.admin.deleteConfirm(titleForPhoto(photo)); export type PhotoDateRange = { start: string, end: string }; @@ -260,9 +265,10 @@ export const descriptionForPhotoSet = ( dateBased ? dateRangeForPhotos(photos, explicitDateRange).description.toUpperCase() : [ - explicitCount ?? photos.length, - descriptor, - photoLabelForCount(explicitCount ?? photos.length, false), + explicitCount ?? photos.length, ( + descriptor || + photoLabelForCount(explicitCount ?? photos.length, false) + ), ].join(' '); const sortPhotosByDateNonDestructively = ( diff --git a/src/recipe/PhotoRecipeOverlay.tsx b/src/recipe/PhotoRecipeOverlay.tsx index 4030d506..a26b44b8 100644 --- a/src/recipe/PhotoRecipeOverlay.tsx +++ b/src/recipe/PhotoRecipeOverlay.tsx @@ -19,6 +19,7 @@ import { TbChecklist } from 'react-icons/tb'; import CopyButton from '@/components/CopyButton'; import { labelForFilm } from '@/film'; import PhotoRecipe from './PhotoRecipe'; +import { APP_TEXT } from '@/app/config'; export default function PhotoRecipeOverlay({ ref, @@ -138,7 +139,7 @@ export default function PhotoRecipeOverlay({ 'text-black/40 active:text-black/75', 'hover:text-black/40', )} - tooltip="Copy recipe text" + tooltip={APP_TEXT.tooltip.recipeCopy} tooltipColor="frosted" /> 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 ( - +