From 526ba1a43bd8fbb80bbe7129adee0fdd55560dba Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 12 May 2025 09:10:28 -0500 Subject: [PATCH] Lazy load language data --- app/film/[film]/page.tsx | 12 +-- app/focal/[focal]/page.tsx | 5 +- app/layout.tsx | 77 ++++++++++--------- app/lens/[make]/[model]/page.tsx | 5 +- app/recipe/[recipe]/page.tsx | 5 +- app/shot-on/[make]/[model]/page.tsx | 5 +- app/sign-in/page.tsx | 6 +- app/tag/[tag]/page.tsx | 5 +- app/tag/hidden/page.tsx | 7 +- src/admin/AdminAppMenu.tsx | 31 ++++---- src/admin/AdminBatchEditPanelClient.tsx | 4 + src/admin/AdminNav.tsx | 12 +-- src/admin/AdminPhotoMenu.tsx | 20 +++-- src/admin/AdminPhotosClient.tsx | 12 +-- src/admin/AdminRecipeBadge.tsx | 7 +- src/admin/AdminRecipeTable.tsx | 6 +- src/admin/AdminTagBadge.tsx | 7 +- src/admin/AdminTagTable.tsx | 7 +- src/admin/DeletePhotoButton.tsx | 4 +- src/admin/DeletePhotosButton.tsx | 5 +- src/admin/PhotoTagFieldset.tsx | 5 +- src/admin/SignInOrUploadClient.tsx | 10 ++- src/app/AppViewSwitcher.tsx | 14 ++-- src/app/Footer.tsx | 9 ++- src/app/ThemeSwitcher.tsx | 10 ++- src/app/config.ts | 10 +-- src/auth/SignInForm.tsx | 14 ++-- src/camera/CameraHeader.tsx | 12 ++- src/camera/CameraOGTile.tsx | 14 +++- src/camera/CameraShareModal.tsx | 6 +- src/camera/meta.ts | 23 ++++-- src/cmdk/CommandK.tsx | 15 ++-- src/cmdk/CommandKClient.tsx | 72 +++++++++-------- src/components/CopyButton.tsx | 5 +- src/components/DownloadButton.tsx | 6 +- src/components/ImageInput.tsx | 12 +-- src/components/RepoLink.tsx | 6 +- .../useNavigateOrRunActionWithToast.tsx | 8 +- src/film/FilmHeader.tsx | 10 ++- src/film/FilmOGTile.tsx | 8 +- src/film/FilmShareModal.tsx | 6 +- src/film/index.tsx | 14 +++- src/focal/FocalLengthHeader.tsx | 5 ++ src/focal/FocalLengthOGTile.tsx | 7 +- src/focal/FocalLengthShareModal.tsx | 6 +- src/focal/index.ts | 20 +++-- src/i18n/date.ts | 12 +++ src/i18n/index.ts | 74 +++--------------- src/i18n/state/AppTextProvider.tsx | 17 ++++ src/i18n/state/AppTextProviderClient.tsx | 20 +++++ src/i18n/state/client.ts | 9 +++ src/i18n/state/index.ts | 48 ++++++++++++ src/i18n/state/server.ts | 6 ++ src/lens/LensHeader.tsx | 12 ++- src/lens/LensOGTile.tsx | 14 +++- src/lens/LensShareModal.tsx | 6 +- src/lens/meta.ts | 23 ++++-- src/photo/PhotoDate.tsx | 10 ++- src/photo/PhotoGridSidebar.tsx | 26 ++++--- src/photo/PhotoHeader.tsx | 12 ++- src/photo/PhotoLarge.tsx | 10 ++- src/photo/PhotoPrevNextActions.tsx | 12 +-- src/photo/PhotoUploadWithStatus.tsx | 12 +-- src/photo/PhotosEmptyState.tsx | 12 +-- src/photo/form/PhotoForm.tsx | 5 +- src/photo/index.ts | 31 +++++--- src/recipe/PhotoRecipeOverlay.tsx | 6 +- src/recipe/PhotoRecipeOverlayButton.tsx | 6 +- src/recipe/RecipeHeader.tsx | 11 ++- src/recipe/RecipeOGTile.tsx | 14 +++- src/recipe/RecipeShareModal.tsx | 7 +- src/recipe/index.ts | 27 +++++-- src/share/ShareModal.tsx | 7 +- src/tag/HiddenHeader.tsx | 6 +- src/tag/TagHeader.tsx | 15 +++- src/tag/TagOGTile.tsx | 14 +++- src/tag/TagShareModal.tsx | 6 +- src/tag/index.ts | 37 ++++++--- src/utility/date.ts | 11 ++- 79 files changed, 732 insertions(+), 375 deletions(-) create mode 100644 src/i18n/date.ts create mode 100644 src/i18n/state/AppTextProvider.tsx create mode 100644 src/i18n/state/AppTextProviderClient.tsx create mode 100644 src/i18n/state/client.ts create mode 100644 src/i18n/state/index.ts create mode 100644 src/i18n/state/server.ts diff --git a/app/film/[film]/page.tsx b/app/film/[film]/page.tsx index c521a4ea..7be7edbe 100644 --- a/app/film/[film]/page.tsx +++ b/app/film/[film]/page.tsx @@ -8,9 +8,9 @@ import { cache } from 'react'; import { PATH_ROOT } from '@/app/paths'; import { redirect } from 'next/navigation'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; +import { getAppText } from '@/i18n/state/server'; -const getPhotosFilmDataCachedCached = - cache(getPhotosFilmDataCached); +const getPhotosFilmDataCachedCached = cache(getPhotosFilmDataCached); export const generateStaticParams = staticallyGenerateCategoryIfConfigured( 'films', @@ -35,15 +35,17 @@ export async function generateMetadata({ film, limit: INFINITE_SCROLL_GRID_INITIAL, }); - + if (photos.length === 0) { return {}; } - + + const appText = await getAppText(); + const { url, title, description, images, - } = generateMetaForFilm(film, photos, count, dateRange); + } = generateMetaForFilm(film, photos, appText, count, dateRange); return { title, diff --git a/app/focal/[focal]/page.tsx b/app/focal/[focal]/page.tsx index 42db01b2..044087b6 100644 --- a/app/focal/[focal]/page.tsx +++ b/app/focal/[focal]/page.tsx @@ -8,6 +8,7 @@ import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; import { cache } from 'react'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; +import { getAppText } from '@/i18n/state/server'; const getPhotosFocalDataCachedCached = cache((focal: number) => getPhotosFocalLengthDataCached({ @@ -41,12 +42,14 @@ export async function generateMetadata({ if (photos.length === 0) { return {}; } + const appText = await getAppText(); + const { url, title, description, images, - } = generateMetaForFocalLength(focal, photos, count, dateRange); + } = generateMetaForFocalLength(focal, photos, appText, count, dateRange); return { title, diff --git a/app/layout.tsx b/app/layout.tsx index be93992a..e12e3b95 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -24,6 +24,7 @@ import AdminUploadPanel from '@/admin/upload/AdminUploadPanel'; import { revalidatePath } from 'next/cache'; import RecipeModal from '@/recipe/RecipeModal'; import ThemeColors from '@/app/ThemeColors'; +import AppTextProvider from '@/i18n/state/AppTextProvider'; import '../tailwind.css'; @@ -80,43 +81,45 @@ export default function RootLayout({ '3xl:flex flex-col items-center', )}> - - - -
-
- -
- - - - -
+ + + + +
+
+ +
+ + + + +
+
diff --git a/app/lens/[make]/[model]/page.tsx b/app/lens/[make]/[model]/page.tsx index 00953ff5..cf8585d9 100644 --- a/app/lens/[make]/[model]/page.tsx +++ b/app/lens/[make]/[model]/page.tsx @@ -13,6 +13,7 @@ import { import { staticallyGenerateCategoryIfConfigured, } from '@/app/static'; +import { getAppText } from '@/i18n/state/server'; const getPhotosLensDataCachedCached = cache(( make: string | undefined, @@ -41,12 +42,14 @@ export async function generateMetadata({ lens, ] = await getPhotosLensDataCachedCached(make, model); + const appText = await getAppText(); + const { url, title, description, images, - } = generateMetaForLens(lens, photos, count, dateRange); + } = generateMetaForLens(lens, photos, appText, count, dateRange); return { title, diff --git a/app/recipe/[recipe]/page.tsx b/app/recipe/[recipe]/page.tsx index 84657919..18c2b08d 100644 --- a/app/recipe/[recipe]/page.tsx +++ b/app/recipe/[recipe]/page.tsx @@ -8,6 +8,7 @@ import { generateMetaForRecipe } from '@/recipe'; import RecipeOverview from '@/recipe/RecipeOverview'; import { getPhotosRecipeDataCached } from '@/recipe/data'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; +import { getAppText } from '@/i18n/state/server'; const getPhotosRecipeDataCachedCached = cache(getPhotosRecipeDataCached); @@ -39,12 +40,14 @@ export async function generateMetadata({ if (photos.length === 0) { return {}; } + const appText = await getAppText(); + const { url, title, description, images, - } = generateMetaForRecipe(recipe, photos, count, dateRange); + } = generateMetaForRecipe(recipe, photos, appText, count, dateRange); return { title, diff --git a/app/shot-on/[make]/[model]/page.tsx b/app/shot-on/[make]/[model]/page.tsx index cb597b0b..a9795a6b 100644 --- a/app/shot-on/[make]/[model]/page.tsx +++ b/app/shot-on/[make]/[model]/page.tsx @@ -7,6 +7,7 @@ import CameraOverview from '@/camera/CameraOverview'; import { cache } from 'react'; import { getUniqueCameras } from '@/photo/db/query'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; +import { getAppText } from '@/i18n/state/server'; const getPhotosCameraDataCachedCached = cache(( make: string, @@ -35,12 +36,14 @@ export async function generateMetadata({ camera, ] = await getPhotosCameraDataCachedCached(make, model); + const appText = await getAppText(); + const { url, title, description, images, - } = generateMetaForCamera(camera, photos, count, dateRange); + } = generateMetaForCamera(camera, photos, appText, count, dateRange); return { title, diff --git a/app/sign-in/page.tsx b/app/sign-in/page.tsx index 023c9274..64320772 100644 --- a/app/sign-in/page.tsx +++ b/app/sign-in/page.tsx @@ -5,7 +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'; +import { getAppText } from '@/i18n/state/server'; export default async function SignInPage() { const session = await auth(); @@ -13,6 +13,8 @@ export default async function SignInPage() { if (session?.user) { redirect(PATH_ADMIN); } + + const appText = await getAppText(); return (
- {APP_TEXT.nav.home} + {appText.nav.home}
); diff --git a/app/tag/[tag]/page.tsx b/app/tag/[tag]/page.tsx index 1058c1f0..58925e7a 100644 --- a/app/tag/[tag]/page.tsx +++ b/app/tag/[tag]/page.tsx @@ -8,6 +8,7 @@ import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; import { cache } from 'react'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; +import { getAppText } from '@/i18n/state/server'; const getPhotosTagDataCachedCached = cache((tag: string) => getPhotosTagDataCached({ tag, limit: INFINITE_SCROLL_GRID_INITIAL})); @@ -37,12 +38,14 @@ export async function generateMetadata({ if (photos.length === 0) { return {}; } + const appText = await getAppText(); + const { url, title, description, images, - } = generateMetaForTag(tag, photos, count, dateRange); + } = generateMetaForTag(tag, photos, appText, count, dateRange); return { title, diff --git a/app/tag/hidden/page.tsx b/app/tag/hidden/page.tsx index 06f111f9..405cbaf8 100644 --- a/app/tag/hidden/page.tsx +++ b/app/tag/hidden/page.tsx @@ -8,6 +8,7 @@ import { TAG_HIDDEN, descriptionForTaggedPhotos, titleForTag } from '@/tag'; import HiddenHeader from '@/tag/HiddenHeader'; import { Metadata } from 'next'; import { cache } from 'react'; +import { getAppText } from '@/i18n/state/server'; const getPhotosHiddenMetaCached = cache(() => getPhotosMetaCached({ hidden: 'only' })); @@ -17,9 +18,13 @@ export async function generateMetadata(): Promise { if (count === 0) { return {}; } - const title = titleForTag(TAG_HIDDEN, undefined, count); + const appText = await getAppText(); + + const title = titleForTag(TAG_HIDDEN, undefined, appText, count); + const description = descriptionForTaggedPhotos( undefined, + appText, undefined, count, dateRange, diff --git a/src/admin/AdminAppMenu.tsx b/src/admin/AdminAppMenu.tsx index 917d4abd..d12e26ac 100644 --- a/src/admin/AdminAppMenu.tsx +++ b/src/admin/AdminAppMenu.tsx @@ -29,7 +29,7 @@ import IconBroom from '@/components/icons/IconBroom'; import InsightsIndicatorDot from './insights/InsightsIndicatorDot'; import MoreMenuItem from '@/components/more/MoreMenuItem'; import Spinner from '@/components/Spinner'; -import { APP_TEXT } from '@/app/config'; +import { useAppText } from '@/i18n/state/client'; export default function AdminAppMenu({ active, @@ -58,6 +58,8 @@ export default function AdminAppMenu({ clearAuthStateAndRedirectIfNecessary, } = useAppState(); + const appText = useAppText(); + const isSelecting = selectedPhotoIds !== undefined; const isAltPressed = useIsKeyBeingPressed('alt'); @@ -66,7 +68,7 @@ export default function AdminAppMenu({ const sectionUpload: ComponentProps[] = useMemo(() => ([{ - label: APP_TEXT.admin.uploadPhotos, + label: appText.admin.uploadPhotos, icon: , action: startUpload, - }]), [isLoadingAdminData, startUpload]); + }]), [appText, isLoadingAdminData, startUpload]); const sectionMain: ComponentProps[] = useMemo(() => { const items: ComponentProps[] = []; if (uploadsCount) { items.push({ - label: APP_TEXT.admin.uploadPlural, + label: appText.admin.uploadPlural, annotation: `${uploadsCount}`, icon: {photosCountNeedSync} @@ -112,7 +114,7 @@ export default function AdminAppMenu({ } if (photosCountTotal) { items.push({ - label: APP_TEXT.admin.managePhotos, + label: appText.admin.managePhotos, ...photosCountTotal && { annotation: `${photosCountTotal}`, }, @@ -125,7 +127,7 @@ export default function AdminAppMenu({ } if (tagsCount) { items.push({ - label: APP_TEXT.admin.manageTags, + label: appText.admin.manageTags, annotation: `${tagsCount}`, icon: [] = useMemo(() => ([{ - label: APP_TEXT.auth.signOut, + label: appText.auth.signOut, icon: , action: () => signOutAction().then(clearAuthStateAndRedirectIfNecessary), - }]), [clearAuthStateAndRedirectIfNecessary]); + }]), [appText.auth.signOut, clearAuthStateAndRedirectIfNecessary]); const sections = useMemo(() => [sectionUpload, sectionMain, sectionSignOut] diff --git a/src/admin/AdminBatchEditPanelClient.tsx b/src/admin/AdminBatchEditPanelClient.tsx index 710ffb3d..d28a7d90 100644 --- a/src/admin/AdminBatchEditPanelClient.tsx +++ b/src/admin/AdminBatchEditPanelClient.tsx @@ -19,6 +19,7 @@ import { FaArrowDown, FaCheck } from 'react-icons/fa6'; import ResponsiveText from '@/components/primitives/ResponsiveText'; import IconFavs from '@/components/icons/IconFavs'; import IconTag from '@/components/icons/IconTag'; +import { useAppText } from '@/i18n/state/client'; export default function AdminBatchEditPanelClient({ uniqueTags, @@ -37,6 +38,8 @@ export default function AdminBatchEditPanelClient({ setIsPerformingSelectEdit, } = useAppState(); + const appText = useAppText(); + const [tags, setTags] = useState(); const [tagErrorMessage, setTagErrorMessage] = useState(''); const isInTagMode = tags !== undefined; @@ -49,6 +52,7 @@ export default function AdminBatchEditPanelClient({ const photosText = photoQuantityText( selectedPhotoIds?.length ?? 0, + appText, false, false, ); diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 9fbe9260..e7268a9c 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -12,7 +12,7 @@ import { PATH_ADMIN_UPLOADS, } from '@/app/paths'; import AdminNavClient from './AdminNavClient'; -import { APP_TEXT } from '@/app/config'; +import { getAppText } from '@/i18n/state/server'; export default async function AdminNav() { const [ @@ -38,32 +38,34 @@ export default async function AdminNav() { getPhotosMostRecentUpdateCached().catch(() => undefined), ]); + const appText = await getAppText(); + const includeInsights = countPhotos > 0; // Photos const items = [{ - label: APP_TEXT.photo.photoPlural, + label: appText.photo.photoPlural, href: PATH_ADMIN_PHOTOS, count: countPhotos, }]; // Uploads if (countUploads > 0) { items.push({ - label: APP_TEXT.admin.uploadPlural, + label: appText.admin.uploadPlural, href: PATH_ADMIN_UPLOADS, count: countUploads, }); } // Tags if (countTags > 0) { items.push({ - label: APP_TEXT.category.tagPlural, + label: appText.category.tagPlural, href: PATH_ADMIN_TAGS, count: countTags, }); } // Recipes if (countRecipes > 0) { items.push({ - label: APP_TEXT.category.recipePlural, + label: appText.category.recipePlural, href: PATH_ADMIN_RECIPES, count: countRecipes, }); } diff --git a/src/admin/AdminPhotoMenu.tsx b/src/admin/AdminPhotoMenu.tsx index f9104cc6..92c4882a 100644 --- a/src/admin/AdminPhotoMenu.tsx +++ b/src/admin/AdminPhotoMenu.tsx @@ -26,7 +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'; +import { useAppText } from '@/i18n/state/client'; export default function AdminPhotoMenu({ photo, @@ -42,6 +42,8 @@ export default function AdminPhotoMenu({ }) { const { isUserSignedIn, registerAdminUpdate } = useAppState(); + const appText = useAppText(); + const isFav = isPhotoFav(photo); const path = usePathname(); const shouldRedirectFav = isPathFavs(path) && isFav; @@ -49,7 +51,7 @@ export default function AdminPhotoMenu({ const sectionMain = useMemo(() => { const items: ComponentProps[] = [{ - label: APP_TEXT.admin.edit, + label: appText.admin.edit, icon: - {APP_TEXT.admin.sync} + {appText.admin.sync} {photoNeedsToBeSynced(photo) && [] = useMemo(() => [{ - label: APP_TEXT.admin.delete, + label: appText.admin.delete, icon: { - if (confirm(deleteConfirmationTextForPhoto(photo))) { + if (confirm(deleteConfirmationTextForPhoto(photo, appText))) { return deletePhotoAction( photo.id, photo.url, @@ -140,6 +143,7 @@ export default function AdminPhotoMenu({ keyCommand: KEY_COMMANDS.delete[1], }, }], [ + appText, photo, showKeyCommands, revalidatePhoto, diff --git a/src/admin/AdminPhotosClient.tsx b/src/admin/AdminPhotosClient.tsx index 0bad023c..39a381b5 100644 --- a/src/admin/AdminPhotosClient.tsx +++ b/src/admin/AdminPhotosClient.tsx @@ -15,7 +15,7 @@ import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus'; import { pluralize } from '@/utility/string'; import IconBroom from '@/components/icons/IconBroom'; import ResponsiveText from '@/components/primitives/ResponsiveText'; -import { APP_TEXT } from '@/app/config'; +import { useAppText } from '@/i18n/state/client'; export default function AdminPhotosClient({ photos, @@ -42,6 +42,8 @@ export default function AdminPhotosClient({ }) { const { uploadState: { isUploading } } = useAppState(); + const appText = useAppText(); + return ( {pluralize( photosCountNeedsSync, - APP_TEXT.admin.update, - APP_TEXT.admin.updatePlural, + appText.admin.update, + appText.admin.updatePlural, )} } diff --git a/src/admin/AdminRecipeBadge.tsx b/src/admin/AdminRecipeBadge.tsx index 2f8a3b4d..80ca2b2d 100644 --- a/src/admin/AdminRecipeBadge.tsx +++ b/src/admin/AdminRecipeBadge.tsx @@ -2,8 +2,9 @@ import { photoLabelForCount } from '@/photo'; import { clsx } from 'clsx/lite'; import Badge from '@/components/Badge'; import PhotoRecipe from '@/recipe/PhotoRecipe'; +import { getAppText } from '@/i18n/state/server'; -export default function AdminRecipeBadge({ +export default async function AdminRecipeBadge({ recipe, count, hideBadge, @@ -12,6 +13,8 @@ export default function AdminRecipeBadge({ count: number, hideBadge?: boolean, }) { + const appText = await getAppText(); + const renderBadgeContent = () =>
{count}   - {photoLabelForCount(count)} + {photoLabelForCount(count, appText)}
; diff --git a/src/admin/AdminRecipeTable.tsx b/src/admin/AdminRecipeTable.tsx index 37f5232b..d0e6de2d 100644 --- a/src/admin/AdminRecipeTable.tsx +++ b/src/admin/AdminRecipeTable.tsx @@ -9,12 +9,14 @@ import { pathForAdminRecipeEdit } from '@/app/paths'; import { clsx } from 'clsx/lite'; import { formatRecipe, Recipes, sortRecipes } from '@/recipe'; import AdminRecipeBadge from './AdminRecipeBadge'; +import { getAppText } from '@/i18n/state/server'; -export default function AdminRecipeTable({ +export default async function AdminRecipeTable({ recipes, }: { recipes: Recipes }) { + const appText = await getAppText(); return ( {sortRecipes(recipes).map(({ recipe, count }) => @@ -31,7 +33,7 @@ export default function AdminRecipeTable({ action={deletePhotoRecipeGloballyAction} confirmText={ // eslint-disable-next-line max-len - `Are you sure you want to remove "${formatRecipe(recipe)}" from ${photoQuantityText(count, false).toLowerCase()}?`} + `Are you sure you want to remove "${formatRecipe(recipe)}" from ${photoQuantityText(count, appText, false, false).toLowerCase()}?`} > diff --git a/src/admin/AdminTagBadge.tsx b/src/admin/AdminTagBadge.tsx index 0696074f..42c699da 100644 --- a/src/admin/AdminTagBadge.tsx +++ b/src/admin/AdminTagBadge.tsx @@ -4,8 +4,9 @@ import { clsx } from 'clsx/lite'; import FavsTag from '@/tag/FavsTag'; import { isTagFavs } from '@/tag'; import Badge from '@/components/Badge'; +import { getAppText } from '@/i18n/state/server'; -export default function AdminTagBadge({ +export default async function AdminTagBadge({ tag, count, hideBadge, @@ -14,6 +15,8 @@ export default function AdminTagBadge({ count: number, hideBadge?: boolean, }) { + const appText = await getAppText(); + const renderBadgeContent = () =>
{count}   - {photoLabelForCount(count)} + {photoLabelForCount(count, appText)}
; diff --git a/src/admin/AdminTagTable.tsx b/src/admin/AdminTagTable.tsx index 759dc495..75b4734f 100644 --- a/src/admin/AdminTagTable.tsx +++ b/src/admin/AdminTagTable.tsx @@ -9,12 +9,15 @@ import EditButton from '@/admin/EditButton'; import { pathForAdminTagEdit } from '@/app/paths'; import { clsx } from 'clsx/lite'; import AdminTagBadge from './AdminTagBadge'; +import { getAppText } from '@/i18n/state/server'; -export default function AdminTagTable({ +export default async function AdminTagTable({ tags, }: { tags: Tags }) { + const appText = await getAppText(); + return ( {sortTags(tags).map(({ tag, count }) => @@ -31,7 +34,7 @@ export default function AdminTagTable({ action={deletePhotoTagGloballyAction} confirmText={ // eslint-disable-next-line max-len - `Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, false).toLowerCase()}?`} + `Are you sure you want to remove "${formatTag(tag)}" from ${photoQuantityText(count, appText, false, false).toLowerCase()}?`} > diff --git a/src/admin/DeletePhotoButton.tsx b/src/admin/DeletePhotoButton.tsx index fb0319c0..430f30ea 100644 --- a/src/admin/DeletePhotoButton.tsx +++ b/src/admin/DeletePhotoButton.tsx @@ -3,6 +3,7 @@ import { deleteConfirmationTextForPhoto, Photo, titleForPhoto } from '@/photo'; import DeletePhotosButton from './DeletePhotosButton'; import { ComponentProps } from 'react'; +import { useAppText } from '@/i18n/state/client'; export default function DeletePhotoButton({ photo, @@ -10,11 +11,12 @@ export default function DeletePhotoButton({ }: { photo: Photo } & ComponentProps) { + const appText = useAppText(); return ( ); diff --git a/src/admin/DeletePhotosButton.tsx b/src/admin/DeletePhotosButton.tsx index e13681c6..b5b82d30 100644 --- a/src/admin/DeletePhotosButton.tsx +++ b/src/admin/DeletePhotosButton.tsx @@ -7,6 +7,7 @@ import { useAppState } from '@/state/AppState'; import { toastSuccess, toastWarning } from '@/toast'; import { ComponentProps, useState } from 'react'; import DeleteButton from './DeleteButton'; +import { useAppText } from '@/i18n/state/client'; export default function DeletePhotosButton({ photoIds = [], @@ -27,7 +28,9 @@ export default function DeletePhotosButton({ } & ComponentProps) { const [isLoading, setIsLoading] = useState(false); - const photosText = photoQuantityText(photoIds.length, false, false); + const appText = useAppText(); + + const photosText = photoQuantityText(photoIds.length, appText, false, false); const { invalidateSwr, registerAdminUpdate } = useAppState(); diff --git a/src/admin/PhotoTagFieldset.tsx b/src/admin/PhotoTagFieldset.tsx index 87f1a892..17ff1464 100644 --- a/src/admin/PhotoTagFieldset.tsx +++ b/src/admin/PhotoTagFieldset.tsx @@ -1,6 +1,7 @@ 'use client'; import FieldSetWithStatus from '@/components/FieldSetWithStatus'; +import { useAppText } from '@/i18n/state/client'; import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag'; import { ComponentProps, useEffect, useRef, useState } from 'react'; @@ -25,6 +26,8 @@ export default function PhotoTagFieldset(props: { const ref = useRef(null); + const appText = useAppText(); + const [errorMessageLocal, setErrorMessageLocal] = useState(''); useEffect(() => { @@ -43,7 +46,7 @@ export default function PhotoTagFieldset(props: { inputRef={ref} label="Tags" value={tags} - tagOptions={convertTagsForForm(tagOptions)} + tagOptions={convertTagsForForm(tagOptions, appText)} onChange={tags => { onChange(tags); const validationMessage = getValidationMessageForTags(tags) ?? ''; diff --git a/src/admin/SignInOrUploadClient.tsx b/src/admin/SignInOrUploadClient.tsx index 16bc16c2..af11c261 100644 --- a/src/admin/SignInOrUploadClient.tsx +++ b/src/admin/SignInOrUploadClient.tsx @@ -4,7 +4,7 @@ import { useAppState } from '@/state/AppState'; import SignInForm from '@/auth/SignInForm'; import clsx from 'clsx/lite'; import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus'; -import { APP_TEXT } from '@/app/config'; +import { useAppText } from '@/i18n/state/client'; export default function SignInOrUploadClient({ shouldResize, @@ -15,6 +15,8 @@ export default function SignInOrUploadClient({ }) { const { isUserSignedIn, isCheckingAuth } = useAppState(); + const appText = useAppText(); + return (
{isCheckingAuth - ? APP_TEXT.misc.loading + ? appText.misc.loading : isUserSignedIn - ? APP_TEXT.onboarding.setupFirstPhoto - : APP_TEXT.onboarding.setupSignIn} + ? appText.onboarding.setupFirstPhoto + : appText.onboarding.setupSignIn}
{!isCheckingAuth && isUserSignedIn === false &&
diff --git a/src/app/AppViewSwitcher.tsx b/src/app/AppViewSwitcher.tsx index cf03b5b8..fd92b37b 100644 --- a/src/app/AppViewSwitcher.tsx +++ b/src/app/AppViewSwitcher.tsx @@ -9,7 +9,6 @@ import { import IconSearch from '../components/icons/IconSearch'; import { useAppState } from '@/state/AppState'; import { - APP_TEXT, GRID_HOMEPAGE_ENABLED, SHOW_KEYBOARD_SHORTCUT_TOOLTIPS, } from './config'; @@ -20,6 +19,7 @@ import { useCallback, useRef, useState } from 'react'; import useKeydownHandler from '@/utility/useKeydownHandler'; import { usePathname } from 'next/navigation'; import { KEY_COMMANDS } from '@/photo/key-commands'; +import { useAppText } from '@/i18n/state/client'; export type SwitcherSelection = 'feed' | 'grid' | 'admin'; @@ -31,6 +31,8 @@ export default function AppViewSwitcher({ className?: string }) { const pathname = usePathname(); + + const appText = useAppText(); const { isUserSignedIn, @@ -67,7 +69,7 @@ export default function AppViewSwitcher({ hrefRef={refHrefFeed} active={currentSelection === 'feed'} tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { - content: APP_TEXT.nav.feed, + content: appText.nav.feed, keyCommand: KEY_COMMANDS.feed, }}} noPadding @@ -80,7 +82,7 @@ export default function AppViewSwitcher({ hrefRef={refHrefGrid} active={currentSelection === 'grid'} tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { - content: APP_TEXT.nav.grid, + content: appText.nav.grid, keyCommand: KEY_COMMANDS.grid, }}} noPadding @@ -104,7 +106,7 @@ export default function AppViewSwitcher({ noPadding tooltip={{ ...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { - content: APP_TEXT.nav.admin, + content: appText.nav.admin, keyCommand: KEY_COMMANDS.admin, }, }} @@ -117,7 +119,7 @@ export default function AppViewSwitcher({ />} tooltip={{ ...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { - content: APP_TEXT.nav.admin, + content: appText.nav.admin, keyCommand: KEY_COMMANDS.admin, }, }} @@ -129,7 +131,7 @@ export default function AppViewSwitcher({ icon={} onClick={() => setIsCommandKOpen?.(true)} tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { - content: APP_TEXT.nav.search, + content: appText.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 fef82514..62b4894f 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 { APP_TEXT, SHOW_REPO_LINK } from '@/app/config'; +import { SHOW_REPO_LINK } from '@/app/config'; import RepoLink from '../components/RepoLink'; import { usePathname } from 'next/navigation'; import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths'; @@ -13,6 +13,7 @@ import { signOutAction } from '@/auth/actions'; import AnimateItems from '@/components/AnimateItems'; import { useAppState } from '@/state/AppState'; import Spinner from '@/components/Spinner'; +import { useAppText } from '@/i18n/state/client'; export default function Footer() { const pathname = usePathname(); @@ -24,6 +25,8 @@ export default function Footer() { clearAuthStateAndRedirectIfNecessary, } = useAppState(); + const appText = useAppText(); + const showFooter = !isPathSignIn(pathname); const shouldAnimate = !isPathAdmin(pathname); @@ -51,7 +54,7 @@ export default function Footer() {
signOutAction() .then(clearAuthStateAndRedirectIfNecessary)}> - {APP_TEXT.auth.signOut} + {appText.auth.signOut}
@@ -60,7 +63,7 @@ export default function Footer() { : SHOW_REPO_LINK ? : - {APP_TEXT.nav.admin} + {appText.nav.admin} }
diff --git a/src/app/ThemeSwitcher.tsx b/src/app/ThemeSwitcher.tsx index 8ab1d881..8de2624b 100644 --- a/src/app/ThemeSwitcher.tsx +++ b/src/app/ThemeSwitcher.tsx @@ -5,9 +5,11 @@ 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'; +import { useAppText } from '@/i18n/state/client'; export default function ThemeSwitcher () { + const appText = useAppText(); + const [mounted, setMounted] = useState(false); const { theme, setTheme } = useTheme(); @@ -26,19 +28,19 @@ export default function ThemeSwitcher () { icon={} onClick={() => setTheme('system')} active={theme === 'system'} - tooltip={{ content: APP_TEXT.theme.system }} + tooltip={{ content: appText.theme.system }} /> } onClick={() => setTheme('light')} active={theme === 'light'} - tooltip={{ content: APP_TEXT.theme.light }} + tooltip={{ content: appText.theme.light }} /> } onClick={() => setTheme('dark')} active={theme === 'dark'} - tooltip={{ content: APP_TEXT.theme.dark }} + tooltip={{ content: appText.theme.dark }} /> ); diff --git a/src/app/config.ts b/src/app/config.ts index 483cac97..6aa8e9d5 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -5,7 +5,6 @@ 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 @@ -99,9 +98,7 @@ const SITE_DOMAIN_SHORT = shortenUrl(SITE_DOMAIN); // SITE META -export const APP_TEXT = getTextForLocale( - process.env.NEXT_PUBLIC_LOCALE, -); +export const APP_LOCALE = process.env.NEXT_PUBLIC_LOCALE || 'US-EN'; export const NAV_TITLE = process.env.NEXT_PUBLIC_NAV_TITLE; @@ -342,15 +339,14 @@ export const APP_CONFIGURATION = { Boolean(process.env.ADMIN_EMAIL) && Boolean(process.env.ADMIN_PASSWORD) ), - // Domain - locale: process.env.NEXT_PUBLIC_LOCALE ?? 'US-EN', + // Content + locale: APP_LOCALE, hasLocale: Boolean(process.env.NEXT_PUBLIC_LOCALE), hasDomain: Boolean( process.env.NEXT_PUBLIC_DOMAIN || // Legacy environment variable process.env.NEXT_PUBLIC_SITE_DOMAIN, ), - // Content hasNavTitle: Boolean(NAV_TITLE), hasNavCaption: Boolean(NAV_CAPTION), isMetaTitleConfigured: IS_META_TITLE_CONFIGURED, diff --git a/src/auth/SignInForm.tsx b/src/auth/SignInForm.tsx index 70ee6d1b..4ebf8fc2 100644 --- a/src/auth/SignInForm.tsx +++ b/src/auth/SignInForm.tsx @@ -21,7 +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'; +import { useAppText } from '@/i18n/state/client'; export default function SignInForm({ includeTitle = true, @@ -36,6 +36,8 @@ export default function SignInForm({ const { setUserEmail } = useAppState(); + const appText = useAppText(); + const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [response, action] = useActionState(signInAction, undefined); @@ -80,27 +82,27 @@ export default function SignInForm({ )}> - {APP_TEXT.auth.signIn} + {appText.auth.signIn} }
{response === KEY_CREDENTIALS_SIGN_IN_ERROR && - {APP_TEXT.auth.invalidEmailPassword} + {appText.auth.invalidEmailPassword} }
}
- {APP_TEXT.auth.signIn} + {appText.auth.signIn}
diff --git a/src/camera/CameraHeader.tsx b/src/camera/CameraHeader.tsx index bfe4e577..ccca7bea 100644 --- a/src/camera/CameraHeader.tsx +++ b/src/camera/CameraHeader.tsx @@ -4,8 +4,9 @@ import { Camera, cameraFromPhoto } from '.'; import PhotoCamera from './PhotoCamera'; import { descriptionForCameraPhotos } from './meta'; import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; +import { getAppText } from '@/i18n/state/server'; -export default function CameraHeader({ +export default async function CameraHeader({ camera: cameraProp, photos, selectedPhoto, @@ -21,12 +22,19 @@ export default function CameraHeader({ dateRange?: PhotoDateRange }) { const camera = cameraFromPhoto(photos[0], cameraProp); + const appText = await getAppText(); return ( } entityDescription={ - descriptionForCameraPhotos(photos, undefined, count, dateRange)} + descriptionForCameraPhotos( + photos, + appText, + undefined, + count, + dateRange, + )} photos={photos} selectedPhoto={selectedPhoto} indexNumber={indexNumber} diff --git a/src/camera/CameraOGTile.tsx b/src/camera/CameraOGTile.tsx index 1c67e989..b5fc63ec 100644 --- a/src/camera/CameraOGTile.tsx +++ b/src/camera/CameraOGTile.tsx @@ -3,8 +3,9 @@ import { absolutePathForCameraImage, pathForCamera } from '@/app/paths'; import OGTile, { OGLoadingState } from '@/components/OGTile'; import { Camera } from '.'; import { descriptionForCameraPhotos, titleForCamera } from './meta'; +import { getAppText } from '@/i18n/state/server'; -export default function CameraOGTile({ +export default async function CameraOGTile({ camera, photos, loadingState: loadingStateExternal, @@ -25,10 +26,17 @@ export default function CameraOGTile({ count?: number dateRange?: PhotoDateRange }) { + const appText = await getAppText(); return ( diff --git a/src/camera/meta.ts b/src/camera/meta.ts index 9d7b3ce8..5f39f992 100644 --- a/src/camera/meta.ts +++ b/src/camera/meta.ts @@ -9,7 +9,7 @@ import { absolutePathForCamera, absolutePathForCameraImage, } from '@/app/paths'; -import { APP_TEXT } from '@/app/config'; +import { I18NState } from '@/i18n/state'; // Meta functions moved to separate file to avoid // dependencies (camelcase-keys) found in photo/index.ts @@ -18,30 +18,34 @@ import { APP_TEXT } from '@/app/config'; export const titleForCamera = ( camera: Camera, photos: Photo[], + appText: I18NState, explicitCount?: number, ) => [ - APP_TEXT.category.cameraTitle( + appText.category.cameraTitle( formatCameraText(cameraFromPhoto(photos[0], camera)), ), - photoQuantityText(explicitCount ?? photos.length), + photoQuantityText(explicitCount ?? photos.length, appText), ].join(' '); export const shareTextForCamera = ( camera: Camera, photos: Photo[], + appText: I18NState, ) => - APP_TEXT.category.cameraShare( + appText.category.cameraShare( formatCameraText(cameraFromPhoto(photos[0], camera)), ); export const descriptionForCameraPhotos = ( photos: Photo[], + appText: I18NState, dateBased?: boolean, explicitCount?: number, explicitDateRange?: PhotoDateRange, ) => descriptionForPhotoSet( photos, + appText, undefined, dateBased, explicitCount, @@ -51,12 +55,19 @@ export const descriptionForCameraPhotos = ( export const generateMetaForCamera = ( camera: Camera, photos: Photo[], + appText: I18NState, explicitCount?: number, explicitDateRange?: PhotoDateRange, ) => ({ url: absolutePathForCamera(camera), - title: titleForCamera(camera, photos, explicitCount), + title: titleForCamera(camera, photos, appText, explicitCount), description: - descriptionForCameraPhotos(photos, true, explicitCount, explicitDateRange), + descriptionForCameraPhotos( + photos, + appText, + true, + explicitCount, + explicitDateRange, + ), images: absolutePathForCameraImage(camera), }); diff --git a/src/cmdk/CommandK.tsx b/src/cmdk/CommandK.tsx index 47dafb46..bb93e721 100644 --- a/src/cmdk/CommandK.tsx +++ b/src/cmdk/CommandK.tsx @@ -3,6 +3,7 @@ import { getPhotosMetaCached } from '@/photo/cache'; import { photoQuantityText } from '@/photo'; import { ADMIN_DEBUG_TOOLS_ENABLED } from '../app/config'; import { getDataForCategoriesCached } from '@/category/cache'; +import { getAppText } from '@/i18n/state/server'; export default async function CommandK() { const [ @@ -15,9 +16,13 @@ export default async function CommandK() { getDataForCategoriesCached(), ]); - return ; + const appText = await getAppText(); + + return ( + + ); } diff --git a/src/cmdk/CommandKClient.tsx b/src/cmdk/CommandKClient.tsx index a040071f..300d9f97 100644 --- a/src/cmdk/CommandKClient.tsx +++ b/src/cmdk/CommandKClient.tsx @@ -53,7 +53,6 @@ import { addHiddenToTags, formatTag, isTagFavs, isTagHidden } from '@/tag'; import { formatCount, formatCountDescriptive } from '@/utility/string'; import CommandKItem from './CommandKItem'; import { - APP_TEXT, CATEGORY_VISIBILITY, GRID_HOMEPAGE_ENABLED, } from '@/app/config'; @@ -78,6 +77,7 @@ import useMaskedScroll from '../components/useMaskedScroll'; import { labelForFilm } from '@/film'; import IconFavs from '@/components/icons/IconFavs'; import IconHidden from '@/components/icons/IconHidden'; +import { useAppText } from '@/i18n/state/client'; const DIALOG_TITLE = 'Global Command-K Menu'; const DIALOG_DESCRIPTION = 'For searching photos, views, and settings'; @@ -157,6 +157,8 @@ export default function CommandKClient({ setShouldDebugRecipeOverlays, } = useAppState(); + const appText = useAppText(); + const isOpenRef = useRef(isOpen); const refInput = useRef(null); @@ -261,7 +263,7 @@ export default function CommandKClient({ setIsLoading(false); }); } - }, [queryDebounced, isPending]); + }, [queryDebounced, isPending, appText]); useEffect(() => { if (queryLive === '') { @@ -289,7 +291,7 @@ export default function CommandKClient({ .map(category => { switch (category) { case 'cameras': return { - heading: APP_TEXT.category.cameraPlural, + heading: appText.category.cameraPlural, accessory: , items: cameras.map(({ camera, count }) => ({ label: formatCameraText(camera), @@ -299,7 +301,7 @@ export default function CommandKClient({ })), }; case 'lenses': return { - heading: APP_TEXT.category.lensPlural, + heading: appText.category.lensPlural, accessory: , items: lenses.map(({ lens, count }) => ({ label: formatLensText(lens, 'medium'), @@ -310,7 +312,7 @@ export default function CommandKClient({ })), }; case 'tags': return { - heading: APP_TEXT.category.tagPlural, + heading: appText.category.tagPlural, accessory: , items: films.map(({ film, count }) => ({ label: labelForFilm(film).medium, @@ -360,7 +362,7 @@ export default function CommandKClient({ })), }; case 'focal-lengths': return { - heading: APP_TEXT.category.focalLengthPlural, + heading: appText.category.focalLengthPlural, accessory: , items: focalLengths.map(({ focal, count }) => ({ label: formatFocalLength(focal)!, @@ -372,24 +374,32 @@ export default function CommandKClient({ } }) .filter(Boolean) as CommandKSection[] - , [tagsIncludingHidden, cameras, lenses, recipes, films, focalLengths]); + , [ + appText, + tagsIncludingHidden, + cameras, + lenses, + recipes, + films, + focalLengths, + ]); const clientSections: CommandKSection[] = [{ - heading: APP_TEXT.theme.theme, + heading: appText.theme.theme, accessory: , items: [{ - label: APP_TEXT.theme.system, + label: appText.theme.system, annotation: , action: () => setTheme('system'), }, { - label: APP_TEXT.theme.light, + label: appText.theme.light, annotation: , action: () => setTheme('light'), }, { - label: APP_TEXT.theme.dark, + label: appText.theme.dark, annotation: , action: () => setTheme('dark'), }], @@ -441,15 +451,15 @@ export default function CommandKClient({ const pageFeed: CommandKItem = { label: GRID_HOMEPAGE_ENABLED - ? APP_TEXT.nav.feed - : `${APP_TEXT.nav.feed} (${APP_TEXT.nav.home})`, + ? appText.nav.feed + : `${appText.nav.feed} (${appText.nav.home})`, path: PATH_FEED_INFERRED, }; const pageGrid: CommandKItem = { label: GRID_HOMEPAGE_ENABLED - ? `${APP_TEXT.nav.grid} (${APP_TEXT.nav.home})` - : APP_TEXT.nav.grid, + ? `${appText.nav.grid} (${appText.nav.home})` + : appText.nav.grid, path: PATH_GRID_INFERRED, }; @@ -471,40 +481,40 @@ export default function CommandKClient({ if (isUserSignedIn) { adminSection.items.push({ - label: APP_TEXT.admin.uploadPhotos, + label: appText.admin.uploadPhotos, annotation: , action: startUpload, }); if (uploadsCount) { adminSection.items.push({ - label: `${APP_TEXT.admin.uploadPlural} (${uploadsCount})`, + label: `${appText.admin.uploadPlural} (${uploadsCount})`, annotation: , path: PATH_ADMIN_UPLOADS, }); } adminSection.items.push({ - label: `${APP_TEXT.admin.managePhotos} (${photosCountTotal})`, + label: `${appText.admin.managePhotos} (${photosCountTotal})`, annotation: , path: PATH_ADMIN_PHOTOS, }); if (tagsCount) { adminSection.items.push({ - label: `${APP_TEXT.admin.manageTags} (${tagsCount})`, + label: `${appText.admin.manageTags} (${tagsCount})`, annotation: , path: PATH_ADMIN_TAGS, }); } if (recipesCount) { adminSection.items.push({ - label: `${APP_TEXT.admin.manageRecipes} (${recipesCount})`, + label: `${appText.admin.manageRecipes} (${recipesCount})`, annotation: , path: PATH_ADMIN_RECIPES, }); } adminSection.items.push({ label: selectedPhotoIds === undefined - ? APP_TEXT.admin.batchEdit - : APP_TEXT.admin.batchExitEdit, + ? appText.admin.batchEdit + : appText.admin.batchExitEdit, annotation: , path: selectedPhotoIds === undefined ? PATH_GRID_INFERRED @@ -514,7 +524,7 @@ export default function CommandKClient({ : () => setSelectedPhotoIds?.(undefined), }, { label: - {APP_TEXT.admin.appInsights} + {appText.admin.appInsights} {insightsIndicatorStatus && } , @@ -522,7 +532,7 @@ export default function CommandKClient({ annotation: , path: PATH_ADMIN_INSIGHTS, }, { - label: APP_TEXT.admin.appConfig, + label: appText.admin.appConfig, annotation: , path: PATH_ADMIN_CONFIGURATION, }); @@ -538,14 +548,14 @@ export default function CommandKClient({ }); } adminSection.items.push({ - label: APP_TEXT.auth.signOut, + label: appText.auth.signOut, action: () => signOutAction() .then(clearAuthStateAndRedirectIfNecessary) .then(() => setIsOpen?.(false)), }); } else { adminSection.items.push({ - label: APP_TEXT.auth.signIn, + label: appText.auth.signIn, path: PATH_SIGN_IN, }); } @@ -596,7 +606,7 @@ export default function CommandKClient({ 'focus:outline-hidden', isPending && 'opacity-20', )} - placeholder={APP_TEXT.cmdk.placeholder} + placeholder={appText.cmdk.placeholder} disabled={isPending} /> {isLoading && !isPending && @@ -617,8 +627,8 @@ export default function CommandKClient({
{isLoading - ? APP_TEXT.cmdk.searching - : APP_TEXT.cmdk.noResults} + ? appText.cmdk.searching + : appText.cmdk.noResults} {queriedSections .concat(categorySections) diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index e9e18f6c..59232923 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -3,7 +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'; +import { useAppText } from '@/i18n/state/client'; export default function CopyButton({ label, @@ -19,6 +19,7 @@ export default function CopyButton({ iconSize?: number className?: string } & ComponentProps) { + const appText = useAppText(); return ( { navigator.clipboard.writeText(text); - toastSuccess(APP_TEXT.misc.copyPhrase(label)); + toastSuccess(appText.misc.copyPhrase(label)); } : undefined} styleAs="link" diff --git a/src/components/DownloadButton.tsx b/src/components/DownloadButton.tsx index 677e695c..06b4d974 100644 --- a/src/components/DownloadButton.tsx +++ b/src/components/DownloadButton.tsx @@ -4,7 +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'; +import { useAppText } from '@/i18n/state/client'; export default function DownloadButton({ photo, @@ -15,9 +15,11 @@ export default function DownloadButton({ }) { const [isLoading, setIsLoading] = useState(false); + const appText = useAppText(); + return ( {isUploading ? filesLength > 1 - ? APP_TEXT.utility.paginateAction( + ? appText.utility.paginateAction( fileUploadIndex + 1, filesLength, - APP_TEXT.admin.uploading, + appText.admin.uploading, ) - : APP_TEXT.admin.uploading - : APP_TEXT.admin.uploadPhotos} + : appText.admin.uploading + : appText.admin.uploadPhotos} } - {APP_TEXT.misc.repo} + {appText.misc.repo} Promise | undefined) @@ -16,6 +16,10 @@ export default function useNavigateOrRunActionWithToast({ }) { const router = useRouter(); + const appText = useAppText(); + + const toastMessage = _toastMessage ?? appText.misc.loading; + const toastId = useRef(undefined); const [isPending, startTransition] = useTransition(); diff --git a/src/film/FilmHeader.tsx b/src/film/FilmHeader.tsx index 56dc1200..6f57c810 100644 --- a/src/film/FilmHeader.tsx +++ b/src/film/FilmHeader.tsx @@ -7,6 +7,7 @@ import PhotoFilm from '@/film/PhotoFilm'; import { getRecipePropsFromPhotos } from '@/recipe'; import { useAppState } from '@/state/AppState'; import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; +import { useAppText } from '@/i18n/state/client'; export default function FilmHeader({ film, @@ -31,6 +32,8 @@ export default function FilmHeader({ ? getRecipePropsFromPhotos(photos, selectedPhoto) : undefined; + const appText = useAppText(); + return ( } entityDescription={descriptionForFilmPhotos( - photos, undefined, count, dateRange)} + photos, + appText, + undefined, + count, + dateRange, + )} photos={photos} selectedPhoto={selectedPhoto} indexNumber={indexNumber} diff --git a/src/film/FilmOGTile.tsx b/src/film/FilmOGTile.tsx index b0db04b8..6be56aae 100644 --- a/src/film/FilmOGTile.tsx +++ b/src/film/FilmOGTile.tsx @@ -5,8 +5,9 @@ import { } from '@/app/paths'; import OGTile, { OGLoadingState } from '@/components/OGTile'; import { descriptionForFilmPhotos, titleForFilm } from '.'; +import { getAppText } from '@/i18n/state/server'; -export default function FilmOGTile({ +export default async function FilmOGTile({ film, photos, loadingState: loadingStateExternal, @@ -27,11 +28,12 @@ export default function FilmOGTile({ count?: number dateRange?: PhotoDateRange }) { + const appText = await getAppText(); return ( diff --git a/src/film/index.tsx b/src/film/index.tsx index 14ead1e1..6306d958 100644 --- a/src/film/index.tsx +++ b/src/film/index.tsx @@ -19,7 +19,7 @@ import { } from '@/utility/string'; import { AnnotatedTag } from '@/photo/form'; import PhotoFilmIcon from './PhotoFilmIcon'; -import { APP_TEXT } from '@/app/config'; +import { I18NState } from '@/i18n/state'; export type FilmWithCount = { film: string @@ -59,25 +59,29 @@ export const sortFilmsWithCount = ( export const titleForFilm = ( film: string, photos: Photo[], + appText: I18NState, explicitCount?: number, ) => [ labelForFilm(film).large, - photoQuantityText(explicitCount ?? photos.length), + photoQuantityText(explicitCount ?? photos.length, appText), ].join(' '); export const shareTextForFilm = ( film: string, + appText: I18NState, ) => - APP_TEXT.category.filmShare(labelForFilm(film).large); + appText.category.filmShare(labelForFilm(film).large); export const descriptionForFilmPhotos = ( photos: Photo[], + appText: I18NState, dateBased?: boolean, explicitCount?: number, explicitDateRange?: PhotoDateRange, ) => descriptionForPhotoSet( photos, + appText, undefined, dateBased, explicitCount, @@ -87,13 +91,15 @@ export const descriptionForFilmPhotos = ( export const generateMetaForFilm = ( film: string, photos: Photo[], + appText: I18NState, explicitCount?: number, explicitDateRange?: PhotoDateRange, ) => ({ url: absolutePathForFilm(film), - title: titleForFilm(film, photos, explicitCount), + title: titleForFilm(film, photos, appText, explicitCount), description: descriptionForFilmPhotos( photos, + appText, true, explicitCount, explicitDateRange, diff --git a/src/focal/FocalLengthHeader.tsx b/src/focal/FocalLengthHeader.tsx index 98b92d3d..d495c1c7 100644 --- a/src/focal/FocalLengthHeader.tsx +++ b/src/focal/FocalLengthHeader.tsx @@ -3,6 +3,8 @@ import { descriptionForFocalLengthPhotos } from '.'; import PhotoHeader from '@/photo/PhotoHeader'; import PhotoFocalLength from './PhotoFocalLength'; import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; +import { useAppText } from '@/i18n/state/client'; + export default function FocalLengthHeader({ focal, photos, @@ -18,14 +20,17 @@ export default function FocalLengthHeader({ count?: number dateRange?: PhotoDateRange }) { + const appText = useAppText(); return ( } entityDescription={descriptionForFocalLengthPhotos( photos, + appText, undefined, count, + dateRange, )} photos={photos} selectedPhoto={selectedPhoto} diff --git a/src/focal/FocalLengthOGTile.tsx b/src/focal/FocalLengthOGTile.tsx index c3ebd695..2de9dd58 100644 --- a/src/focal/FocalLengthOGTile.tsx +++ b/src/focal/FocalLengthOGTile.tsx @@ -5,8 +5,9 @@ import { } from '@/app/paths'; import OGTile, { OGLoadingState } from '@/components/OGTile'; import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.'; +import { getAppText } from '@/i18n/state/server'; -export default function FocalLengthOGTile({ +export default async function FocalLengthOGTile({ focal, photos, loadingState: loadingStateExternal, @@ -27,11 +28,13 @@ export default function FocalLengthOGTile({ count?: number dateRange?: PhotoDateRange }) { + const appText = await getAppText(); return ( diff --git a/src/focal/index.ts b/src/focal/index.ts index ee5569e5..2121fded 100644 --- a/src/focal/index.ts +++ b/src/focal/index.ts @@ -8,7 +8,7 @@ import { absolutePathForFocalLength, absolutePathForFocalLengthImage, } from '@/app/paths'; -import { APP_TEXT } from '@/app/config'; +import { I18NState } from '@/i18n/state'; export type FocalLengths = { focal: number @@ -30,23 +30,29 @@ export const formatFocalLengthSafe = (focal = 0) => export const titleForFocalLength = ( focal: number, photos: Photo[], + appText: I18NState, explicitCount?: number, ) => [ - APP_TEXT.category.focalLengthTitle(formatFocalLengthSafe(focal)), - photoQuantityText(explicitCount ?? photos.length), + appText.category.focalLengthTitle(formatFocalLengthSafe(focal)), + photoQuantityText(explicitCount ?? photos.length, appText), ].join(' '); -export const shareTextFocalLength = (focal: number) => - APP_TEXT.category.focalLengthShare(formatFocalLengthSafe(focal)); +export const shareTextFocalLength = ( + focal: number, + appText: I18NState, +) => + appText.category.focalLengthShare(formatFocalLengthSafe(focal)); export const descriptionForFocalLengthPhotos = ( photos: Photo[], + appText: I18NState, dateBased?: boolean, explicitCount?: number, explicitDateRange?: PhotoDateRange, ) => descriptionForPhotoSet( photos, + appText, undefined, dateBased, explicitCount, @@ -56,13 +62,15 @@ export const descriptionForFocalLengthPhotos = ( export const generateMetaForFocalLength = ( focal: number, photos: Photo[], + appText: I18NState, explicitCount?: number, explicitDateRange?: PhotoDateRange, ) => ({ url: absolutePathForFocalLength(focal), - title: titleForFocalLength(focal, photos, explicitCount), + title: titleForFocalLength(focal, photos, appText, explicitCount), description: descriptionForFocalLengthPhotos( photos, + appText, true, explicitCount, explicitDateRange, diff --git a/src/i18n/date.ts b/src/i18n/date.ts new file mode 100644 index 00000000..568c56f2 --- /dev/null +++ b/src/i18n/date.ts @@ -0,0 +1,12 @@ +import { enUS, ptBR, pt } from 'date-fns/locale'; +import { APP_LOCALE } from '@/app/config'; + +const getDateFnLocale = (locale: string) => { + switch (locale) { + case 'pt-pt': return pt; + case 'pt-br': return ptBR; + default: return enUS; + } +}; + +export const DATE_FN_LOCALE = getDateFnLocale(APP_LOCALE); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 7ef7979b..87f6ce0e 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,7 +1,4 @@ import US_EN from './locales/us-en'; -import PT_BR from './locales/pt-br'; -import PT_PT from './locales/pt-pt'; -import { enUS, ptBR, pt } from 'date-fns/locale'; export type I18N = typeof US_EN; @@ -9,71 +6,20 @@ export type I18NDeepPartial = { [key in keyof I18N]?: Partial; } -const getDateFnLocale = (locale: string) => { - switch (locale) { - case 'pt-pt': return pt; - case 'pt-br': return ptBR; - default: return enUS; - } +export const LOCALE_TEXT: Record< + string, + () => Promise +> = { + 'pt-br': () => import('./locales/pt-br').then((m) => m.default), + 'pt-pt': () => import('./locales/pt-pt').then((m) => m.default), }; -const generateI18NWithFunctions = (i18nText: I18N) => { - return { - ...i18nText, - category: { - ...i18nText.category, - cameraTitle: (camera: string) => - i18nText.category.cameraTitle.replace('{{camera}}', camera), - cameraShare: (camera: string) => - i18nText.category.cameraShare.replace('{{camera}}', camera), - taggedPhrase: (tag: string) => - i18nText.category.taggedPhrase.replace('{{tag}}', tag), - recipeShare: (recipe: string) => - i18nText.category.recipeShare.replace('{{recipe}}', recipe), - filmShare: (film: string) => - i18nText.category.filmShare.replace('{{film}}', film), - focalLengthTitle: (focal: string) => - i18nText.category.focalLengthTitle.replace('{{focal}}', focal), - focalLengthShare: (focal: string) => - i18nText.category.focalLengthShare.replace('{{focal}}', focal), - }, - admin: { - ...i18nText.admin, - deleteConfirm: (photoTitle: string) => - i18nText.admin.deleteConfirm.replace('{{photoTitle}}', photoTitle), - }, - misc: { - ...i18nText.misc, - copyPhrase: (label: string) => - i18nText.misc.copyPhrase.replace('{{label}}', label), - }, - utility: { - ...i18nText.utility, - paginate: (index: number, count: number) => - i18nText.utility.paginate - .replace('{{index}}', index.toString()) - .replace('{{count}}', count.toString()), - paginateAction: (index: number, count: number, action: string) => - i18nText.utility.paginateAction - .replace('{{index}}', index.toString()) - .replace('{{count}}', count.toString()) - .replace('{{action}}', action), - }, - dateLocale: getDateFnLocale(i18nText.locale), - }; -}; - -export const LOCALE_TEXT: Record = { - 'pt-br': PT_BR, - 'pt-pt': PT_PT, -}; - -export const getTextForLocale = ( +export const getTextForLocale = async ( locale = '', -) => { +): Promise => { const text = US_EN; - Object.entries(LOCALE_TEXT[locale.toLocaleLowerCase()] ?? {}) + Object.entries(await LOCALE_TEXT[locale.toLocaleLowerCase()]?.() ?? {}) .forEach(([key, value]) => { // Fall back to English for missing keys text[key as keyof I18N] = { @@ -82,5 +28,5 @@ export const getTextForLocale = ( }; }); - return generateI18NWithFunctions(text); + return text; }; diff --git a/src/i18n/state/AppTextProvider.tsx b/src/i18n/state/AppTextProvider.tsx new file mode 100644 index 00000000..f05bc128 --- /dev/null +++ b/src/i18n/state/AppTextProvider.tsx @@ -0,0 +1,17 @@ +import { ReactNode } from 'react'; +import { getTextForLocale } from '..'; +import { APP_LOCALE } from '@/app/config'; +import AppTextProviderClient from './AppTextProviderClient'; + +export default async function AppTextProvider({ + children, +}: { + children: ReactNode +}) { + const value = await getTextForLocale(APP_LOCALE); + return ( + + {children} + + ); +} diff --git a/src/i18n/state/AppTextProviderClient.tsx b/src/i18n/state/AppTextProviderClient.tsx new file mode 100644 index 00000000..7df28445 --- /dev/null +++ b/src/i18n/state/AppTextProviderClient.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ReactNode } from 'react'; +import { AppTextContext } from './client'; +import { I18N } from '..'; +import { generateI18NState } from '.'; + +export default function AppTextProviderClient({ + children, + value, +}: { + children: ReactNode + value: I18N +}) { + return ( + + {children} + + ); +} diff --git a/src/i18n/state/client.ts b/src/i18n/state/client.ts new file mode 100644 index 00000000..8e2691cc --- /dev/null +++ b/src/i18n/state/client.ts @@ -0,0 +1,9 @@ +'use client'; + +import { createContext, use } from 'react'; +import { generateI18NState } from '.'; +import US_EN from '../locales/us-en'; + +export const AppTextContext = createContext(generateI18NState(US_EN)); + +export const useAppText = () => use(AppTextContext); diff --git a/src/i18n/state/index.ts b/src/i18n/state/index.ts new file mode 100644 index 00000000..abe1f166 --- /dev/null +++ b/src/i18n/state/index.ts @@ -0,0 +1,48 @@ +import { I18N } from '..'; + +export type I18NState = ReturnType; + +export const generateI18NState = (i18nText: I18N) => { + return { + ...i18nText, + category: { + ...i18nText.category, + cameraTitle: (camera: string) => + i18nText.category.cameraTitle.replace('{{camera}}', camera), + cameraShare: (camera: string) => + i18nText.category.cameraShare.replace('{{camera}}', camera), + taggedPhrase: (tag: string) => + i18nText.category.taggedPhrase.replace('{{tag}}', tag), + recipeShare: (recipe: string) => + i18nText.category.recipeShare.replace('{{recipe}}', recipe), + filmShare: (film: string) => + i18nText.category.filmShare.replace('{{film}}', film), + focalLengthTitle: (focal: string) => + i18nText.category.focalLengthTitle.replace('{{focal}}', focal), + focalLengthShare: (focal: string) => + i18nText.category.focalLengthShare.replace('{{focal}}', focal), + }, + admin: { + ...i18nText.admin, + deleteConfirm: (photoTitle: string) => + i18nText.admin.deleteConfirm.replace('{{photoTitle}}', photoTitle), + }, + misc: { + ...i18nText.misc, + copyPhrase: (label: string) => + i18nText.misc.copyPhrase.replace('{{label}}', label), + }, + utility: { + ...i18nText.utility, + paginate: (index: number, count: number) => + i18nText.utility.paginate + .replace('{{index}}', index.toString()) + .replace('{{count}}', count.toString()), + paginateAction: (index: number, count: number, action: string) => + i18nText.utility.paginateAction + .replace('{{index}}', index.toString()) + .replace('{{count}}', count.toString()) + .replace('{{action}}', action), + }, + }; +}; \ No newline at end of file diff --git a/src/i18n/state/server.ts b/src/i18n/state/server.ts new file mode 100644 index 00000000..aab838b6 --- /dev/null +++ b/src/i18n/state/server.ts @@ -0,0 +1,6 @@ +import { APP_LOCALE } from '@/app/config'; +import { getTextForLocale } from '..'; +import { generateI18NState } from '.'; + +export const getAppText = async () => + getTextForLocale(APP_LOCALE).then(generateI18NState); diff --git a/src/lens/LensHeader.tsx b/src/lens/LensHeader.tsx index 4c7f00ca..6c133cd7 100644 --- a/src/lens/LensHeader.tsx +++ b/src/lens/LensHeader.tsx @@ -4,8 +4,9 @@ import { Lens, lensFromPhoto } from '.'; import PhotoLens from './PhotoLens'; import { descriptionForLensPhotos } from './meta'; import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; +import { getAppText } from '@/i18n/state/server'; -export default function LensHeader({ +export default async function LensHeader({ lens: lensProp, photos, selectedPhoto, @@ -21,12 +22,19 @@ export default function LensHeader({ dateRange?: PhotoDateRange }) { const lens = lensFromPhoto(photos[0], lensProp); + const appText = await getAppText(); return ( } entityDescription={ - descriptionForLensPhotos(photos, undefined, count, dateRange)} + descriptionForLensPhotos( + photos, + appText, + undefined, + count, + dateRange, + )} photos={photos} selectedPhoto={selectedPhoto} indexNumber={indexNumber} diff --git a/src/lens/LensOGTile.tsx b/src/lens/LensOGTile.tsx index ba286f4a..c5f173eb 100644 --- a/src/lens/LensOGTile.tsx +++ b/src/lens/LensOGTile.tsx @@ -3,8 +3,9 @@ import { absolutePathForLensImage, pathForLens } from '@/app/paths'; import OGTile, { OGLoadingState } from '@/components/OGTile'; import { Lens } from '.'; import { titleForLens, descriptionForLensPhotos } from './meta'; +import { getAppText } from '@/i18n/state/server'; -export default function LensOGTile({ +export default async function LensOGTile({ lens, photos, loadingState: loadingStateExternal, @@ -25,10 +26,17 @@ export default function LensOGTile({ count?: number dateRange?: PhotoDateRange }) { + const appText = await getAppText(); return ( diff --git a/src/lens/meta.ts b/src/lens/meta.ts index f7af2167..2cb670c8 100644 --- a/src/lens/meta.ts +++ b/src/lens/meta.ts @@ -9,7 +9,7 @@ import { absolutePathForLens, absolutePathForLensImage, } from '@/app/paths'; -import { APP_TEXT } from '@/app/config'; +import { I18NState } from '@/i18n/state'; // Meta functions moved to separate file to avoid // dependencies (camelcase-keys) found in photo/index.ts @@ -18,30 +18,34 @@ import { APP_TEXT } from '@/app/config'; export const titleForLens = ( lens: Lens, photos: Photo[], + appText: I18NState, explicitCount?: number, ) => [ - `${APP_TEXT.category.lens}:`, + `${appText.category.lens}:`, formatLensText(lensFromPhoto(photos[0], lens)), - photoQuantityText(explicitCount ?? photos.length), + photoQuantityText(explicitCount ?? photos.length, appText), ].join(' '); export const shareTextForLens = ( lens: Lens, photos: Photo[], + appText: I18NState, ) => [ - `${APP_TEXT.category.lens}:`, + `${appText.category.lens}:`, formatLensText(lensFromPhoto(photos[0], lens)), ].join(' '); export const descriptionForLensPhotos = ( photos: Photo[], + appText: I18NState, dateBased?: boolean, explicitCount?: number, explicitDateRange?: PhotoDateRange, ) => descriptionForPhotoSet( photos, + appText, undefined, dateBased, explicitCount, @@ -51,12 +55,19 @@ export const descriptionForLensPhotos = ( export const generateMetaForLens = ( lens: Lens, photos: Photo[], + appText: I18NState, explicitCount?: number, explicitDateRange?: PhotoDateRange, ) => ({ url: absolutePathForLens(lens), - title: titleForLens(lens, photos, explicitCount), + title: titleForLens(lens, photos, appText, explicitCount), description: - descriptionForLensPhotos(photos, true, explicitCount, explicitDateRange), + descriptionForLensPhotos( + photos, + appText, + true, + explicitCount, + explicitDateRange, + ), images: absolutePathForLensImage(lens), }); diff --git a/src/photo/PhotoDate.tsx b/src/photo/PhotoDate.tsx index 62219264..e06b7e5e 100644 --- a/src/photo/PhotoDate.tsx +++ b/src/photo/PhotoDate.tsx @@ -2,7 +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'; +import { useAppText } from '@/i18n/state/client'; export default function PhotoDate({ photo, @@ -31,14 +31,16 @@ export default function PhotoDate({ photo.updatedAt, ]); + const appText = useAppText(); + const getTitleLabel = () => { switch (dateType) { case 'takenAt': - return APP_TEXT.photo.taken; + return appText.photo.taken; case 'createdAt': - return APP_TEXT.photo.created; + return appText.photo.created; case 'updatedAt': - return APP_TEXT.photo.updated; + return appText.photo.updated; } }; diff --git a/src/photo/PhotoGridSidebar.tsx b/src/photo/PhotoGridSidebar.tsx index 3273b0d2..c0a15c7d 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 { APP_TEXT, CATEGORY_VISIBILITY } from '@/app/config'; +import { CATEGORY_VISIBILITY } from '@/app/config'; import { clsx } from 'clsx/lite'; import PhotoRecipe from '@/recipe/PhotoRecipe'; import IconCamera from '@/components/icons/IconCamera'; @@ -26,6 +26,7 @@ import { } from '@/category'; import PhotoFocalLength from '@/focal/PhotoFocalLength'; import useElementHeight from '@/utility/useElementHeight'; +import { useAppText } from '@/i18n/state/client'; const APPROXIMATE_ITEM_HEIGHT = 34; const ABOUT_HEIGHT_OFFSET = 80; @@ -58,6 +59,8 @@ export default function PhotoGridSidebar({ categories, ); + const appText = useAppText(); + const aboutRef = useRef(null); const aboutHeight = useElementHeight(aboutRef); const height = containerHeight @@ -72,7 +75,10 @@ export default function PhotoGridSidebar({ ) : undefined; - const { start, end } = dateRangeForPhotos(undefined, photosDateRange); + const { start, end } = dateRangeForPhotos( + undefined, + photosDateRange, + ); const { photosCountHidden } = useAppState(); @@ -83,7 +89,7 @@ export default function PhotoGridSidebar({ const camerasContent = cameras.length > 0 ? 0 ? } maxItems={maxItemsPerCategory} items={lenses @@ -127,7 +133,7 @@ export default function PhotoGridSidebar({ const tagsContent = tags.length > 0 ? 0 ? 0 ? } maxItems={maxItemsPerCategory} items={films @@ -213,7 +219,7 @@ export default function PhotoGridSidebar({ const focalLengthsContent = focalLengths.length > 0 ? } maxItems={maxItemsPerCategory} items={focalLengths.map(({ focal, count }) => @@ -232,14 +238,14 @@ export default function PhotoGridSidebar({ ? start ? : : null; diff --git a/src/photo/PhotoHeader.tsx b/src/photo/PhotoHeader.tsx index 7eec0d93..d27fe4fb 100644 --- a/src/photo/PhotoHeader.tsx +++ b/src/photo/PhotoHeader.tsx @@ -17,13 +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'; +import { useAppText } from '@/i18n/state/client'; export default function PhotoHeader({ photos, selectedPhoto, entity, - entityVerb = APP_TEXT.photo.photo.toLocaleUpperCase(), + entityVerb: _entityVerb, entityDescription, indexNumber, count, @@ -45,6 +45,10 @@ export default function PhotoHeader({ } & PhotoSetCategory) { const { isGridHighDensity } = useAppState(); + const appText = useAppText(); + + const entityVerb = _entityVerb ?? appText.photo.photo.toLocaleUpperCase(); + const { start, end } = dateRangeForPhotos(photos, dateRange); const selectedPhotoIndex = selectedPhoto @@ -155,13 +159,13 @@ export default function PhotoHeader({ }} />} : - {APP_TEXT.utility.paginateAction( + {appText.utility.paginateAction( paginationIndex, paginationCount, entityVerb)} diff --git a/src/photo/PhotoLarge.tsx b/src/photo/PhotoLarge.tsx index adea75da..f48273d4 100644 --- a/src/photo/PhotoLarge.tsx +++ b/src/photo/PhotoLarge.tsx @@ -31,7 +31,6 @@ import { SHOW_TAKEN_AT_TIME, MATTE_COLOR, MATTE_COLOR_DARK, - APP_TEXT, } from '@/app/config'; import AdminPhotoMenu from '@/admin/AdminPhotoMenu'; import { RevalidatePhoto } from './InfinitePhotoScroll'; @@ -51,6 +50,7 @@ import PhotoLens from '@/lens/PhotoLens'; import { lensFromPhoto } from '@/lens'; import MaskedScroll from '@/components/MaskedScroll'; import useCategoryCountsForPhoto from '@/category/useCategoryCountsForPhoto'; +import { useAppText } from '@/i18n/state/client'; export default function PhotoLarge({ photo, @@ -117,6 +117,8 @@ export default function PhotoLarge({ isUserSignedIn, } = useAppState(); + const appText = useAppText(); + const { cameraCount, lensCount, @@ -379,7 +381,7 @@ export default function PhotoLarge({ <> {' '} @@ -435,7 +437,7 @@ export default function PhotoLarge({ )}> {showZoomControls && } onClick={() => refZoomControls.current?.open()} styleAs="link" @@ -444,7 +446,7 @@ export default function PhotoLarge({ />} {shouldShare && - {APP_TEXT.nav.prevShort} + {appText.nav.prevShort} @@ -223,7 +225,7 @@ export default function PhotoPrevNextActions({ / - {APP_TEXT.nav.nextShort} + {appText.nav.nextShort} diff --git a/src/photo/PhotoUploadWithStatus.tsx b/src/photo/PhotoUploadWithStatus.tsx index 405e116d..df19173a 100644 --- a/src/photo/PhotoUploadWithStatus.tsx +++ b/src/photo/PhotoUploadWithStatus.tsx @@ -11,7 +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'; +import { useAppText } from '@/i18n/state/client'; export default function PhotoUploadWithStatus({ inputRef, @@ -45,6 +45,8 @@ export default function PhotoUploadWithStatus({ resetUploadState, } = useAppState(); + const appText = useAppText(); + const router = useRouter(); useEffect(() => { @@ -77,7 +79,7 @@ export default function PhotoUploadWithStatus({ const isFinishing = isPending && shouldResetUploadStateAfterPending.current; const uploadStatusText = filesLength > 1 - ? APP_TEXT.utility.paginate(fileUploadIndex + 1, filesLength) + ? appText.utility.paginate(fileUploadIndex + 1, filesLength) : undefined; return ( @@ -160,19 +162,19 @@ export default function PhotoUploadWithStatus({ {isUploading ? isFinishing ? <> - {APP_TEXT.misc.finishing} + {appText.misc.finishing} : <> {!showButton && uploadStatusText ? <> - {APP_TEXT.misc.uploading} {uploadStatusText} + {appText.misc.uploading} {uploadStatusText} {': '} {fileUploadName} : - {APP_TEXT.misc.uploading} {fileUploadName} + {appText.misc.uploading} {fileUploadName} } : !showButton && <>Initializing} diff --git a/src/photo/PhotosEmptyState.tsx b/src/photo/PhotosEmptyState.tsx index 3365b776..7eea2d4a 100644 --- a/src/photo/PhotosEmptyState.tsx +++ b/src/photo/PhotosEmptyState.tsx @@ -1,7 +1,6 @@ import Container from '@/components/Container'; import AppGrid from '@/components/AppGrid'; import { - APP_TEXT, IS_SITE_READY, PRESERVE_ORIGINAL_UPLOADS, } from '@/app/config'; @@ -13,8 +12,11 @@ import SignInOrUploadClient from '@/admin/SignInOrUploadClient'; import Link from 'next/link'; import { PATH_ADMIN_CONFIGURATION } from '@/app/paths'; import AnimateItems from '@/components/AnimateItems'; +import { getAppText } from '@/i18n/state/server'; + +export default async function PhotosEmptyState() { + const appText = await getAppText(); -export default function PhotosEmptyState() { return ( {!IS_SITE_READY - ? APP_TEXT.onboarding.setupIncomplete - : APP_TEXT.onboarding.setupComplete} + ? appText.onboarding.setupIncomplete + : appText.onboarding.setupComplete}
{!IS_SITE_READY ? @@ -49,7 +51,7 @@ export default function PhotosEmptyState() { }} />
- {APP_TEXT.onboarding.setupConfig} + {appText.onboarding.setupConfig} {' '} getChangedFormFields(initialPhotoForm, formData), [initialPhotoForm, formData]); @@ -328,7 +331,7 @@ export default function PhotoForm({ {/* Fields */}
{FORM_METADATA_ENTRIES( - convertTagsForForm(uniqueTags), + convertTagsForForm(uniqueTags, appText), convertRecipesForForm(uniqueRecipes), convertFilmsForForm(uniqueFilms, isMakeFujifilm(formData.make)), aiContent !== undefined, diff --git a/src/photo/index.ts b/src/photo/index.ts index cce0340f..315aab71 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -2,7 +2,6 @@ import { formatFocalLength } from '@/focal'; import { getNextImageUrlForRequest } from '@/platforms/next-image'; import { photoHasFilmData } from '@/film'; import { - APP_TEXT, HIGH_DENSITY_GRID, IS_PREVIEW, SHOW_EXIF_DATA, @@ -25,6 +24,7 @@ import type { Metadata } from 'next'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FujifilmSimulation } from '@/platforms/fujifilm/simulation'; import { PhotoSyncStatus, generatePhotoSyncStatus } from './sync'; +import { I18NState } from '@/i18n/state'; // INFINITE SCROLL: FEED export const INFINITE_SCROLL_FEED_INITIAL = @@ -232,10 +232,14 @@ export const titleForPhoto = ( export const altTextForPhoto = (photo: Photo) => photo.semanticDescription || titleForPhoto(photo); -export const photoLabelForCount = (count: number, _capitalize = true) => { +export const photoLabelForCount = ( + count: number, + appText: I18NState, + _capitalize = true, +) => { const label = count === 1 - ? APP_TEXT.photo.photo - : APP_TEXT.photo.photoPlural; + ? appText.photo.photo + : appText.photo.photoPlural; return _capitalize ? capitalize(label) : label; @@ -243,31 +247,38 @@ export const photoLabelForCount = (count: number, _capitalize = true) => { export const photoQuantityText = ( count: number, + appText: I18NState, includeParentheses = true, capitalize?: boolean, ) => includeParentheses - ? `(${count} ${photoLabelForCount(count, capitalize)})` - : `${count} ${photoLabelForCount(count, capitalize)}`; + ? `(${count} ${photoLabelForCount(count, appText, capitalize)})` + : `${count} ${photoLabelForCount(count, appText, capitalize)}`; -export const deleteConfirmationTextForPhoto = (photo: Photo) => - APP_TEXT.admin.deleteConfirm(titleForPhoto(photo)); +export const deleteConfirmationTextForPhoto = ( + photo: Photo, + appText: I18NState, +) => + appText.admin.deleteConfirm(titleForPhoto(photo)); export type PhotoDateRange = { start: string, end: string }; export const descriptionForPhotoSet = ( photos:Photo[] = [], + appText: I18NState, descriptor?: string, dateBased?: boolean, explicitCount?: number, explicitDateRange?: PhotoDateRange, ) => dateBased - ? dateRangeForPhotos(photos, explicitDateRange).description.toUpperCase() + ? dateRangeForPhotos(photos, explicitDateRange) + .description + .toLocaleUpperCase() : [ explicitCount ?? photos.length, ( descriptor || - photoLabelForCount(explicitCount ?? photos.length, false) + photoLabelForCount(explicitCount ?? photos.length, appText, false) ), ].join(' '); diff --git a/src/recipe/PhotoRecipeOverlay.tsx b/src/recipe/PhotoRecipeOverlay.tsx index a26b44b8..a48728e9 100644 --- a/src/recipe/PhotoRecipeOverlay.tsx +++ b/src/recipe/PhotoRecipeOverlay.tsx @@ -19,7 +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'; +import { useAppText } from '@/i18n/state/client'; export default function PhotoRecipeOverlay({ ref, @@ -47,6 +47,8 @@ export default function PhotoRecipeOverlay({ bwMagentaGreen, } = data; + const appText = useAppText(); + const whiteBalanceTypeFormatted = formatWhiteBalance(data); const renderDataSquare = ( @@ -139,7 +141,7 @@ export default function PhotoRecipeOverlay({ 'text-black/40 active:text-black/75', 'hover:text-black/40', )} - tooltip={APP_TEXT.tooltip.recipeCopy} + tooltip={appText.tooltip.recipeCopy} tooltipColor="frosted" /> diff --git a/src/recipe/PhotoRecipeOverlayButton.tsx b/src/recipe/PhotoRecipeOverlayButton.tsx index f1d213f2..02792a0c 100644 --- a/src/recipe/PhotoRecipeOverlayButton.tsx +++ b/src/recipe/PhotoRecipeOverlayButton.tsx @@ -4,7 +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'; +import { useAppText } from '@/i18n/state/client'; export default function PhotoRecipeOverlayButton({ className, @@ -17,8 +17,10 @@ export default function PhotoRecipeOverlayButton({ }) { const ref = useRef(null); + const appText = useAppText(); + return ( - +