Lazy load language data

This commit is contained in:
Sam Becker 2025-05-12 09:10:28 -05:00
parent 878edc713d
commit 526ba1a43b
79 changed files with 732 additions and 375 deletions

View File

@ -8,9 +8,9 @@ import { cache } from 'react';
import { PATH_ROOT } from '@/app/paths'; import { PATH_ROOT } from '@/app/paths';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
import { getAppText } from '@/i18n/state/server';
const getPhotosFilmDataCachedCached = const getPhotosFilmDataCachedCached = cache(getPhotosFilmDataCached);
cache(getPhotosFilmDataCached);
export const generateStaticParams = staticallyGenerateCategoryIfConfigured( export const generateStaticParams = staticallyGenerateCategoryIfConfigured(
'films', 'films',
@ -38,12 +38,14 @@ export async function generateMetadata({
if (photos.length === 0) { return {}; } if (photos.length === 0) { return {}; }
const appText = await getAppText();
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForFilm(film, photos, count, dateRange); } = generateMetaForFilm(film, photos, appText, count, dateRange);
return { return {
title, title,

View File

@ -8,6 +8,7 @@ import type { Metadata } from 'next';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { cache } from 'react'; import { cache } from 'react';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
import { getAppText } from '@/i18n/state/server';
const getPhotosFocalDataCachedCached = cache((focal: number) => const getPhotosFocalDataCachedCached = cache((focal: number) =>
getPhotosFocalLengthDataCached({ getPhotosFocalLengthDataCached({
@ -41,12 +42,14 @@ export async function generateMetadata({
if (photos.length === 0) { return {}; } if (photos.length === 0) { return {}; }
const appText = await getAppText();
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForFocalLength(focal, photos, count, dateRange); } = generateMetaForFocalLength(focal, photos, appText, count, dateRange);
return { return {
title, title,

View File

@ -24,6 +24,7 @@ import AdminUploadPanel from '@/admin/upload/AdminUploadPanel';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import RecipeModal from '@/recipe/RecipeModal'; import RecipeModal from '@/recipe/RecipeModal';
import ThemeColors from '@/app/ThemeColors'; import ThemeColors from '@/app/ThemeColors';
import AppTextProvider from '@/i18n/state/AppTextProvider';
import '../tailwind.css'; import '../tailwind.css';
@ -80,6 +81,7 @@ export default function RootLayout({
'3xl:flex flex-col items-center', '3xl:flex flex-col items-center',
)}> )}>
<AppStateProvider> <AppStateProvider>
<AppTextProvider>
<ThemeColors /> <ThemeColors />
<ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}> <ThemeProvider attribute="class" defaultTheme={DEFAULT_THEME}>
<SwrConfigClient> <SwrConfigClient>
@ -117,6 +119,7 @@ export default function RootLayout({
<PhotoEscapeHandler /> <PhotoEscapeHandler />
<ToasterWithThemes /> <ToasterWithThemes />
</ThemeProvider> </ThemeProvider>
</AppTextProvider>
</AppStateProvider> </AppStateProvider>
</body> </body>
</html> </html>

View File

@ -13,6 +13,7 @@ import {
import { import {
staticallyGenerateCategoryIfConfigured, staticallyGenerateCategoryIfConfigured,
} from '@/app/static'; } from '@/app/static';
import { getAppText } from '@/i18n/state/server';
const getPhotosLensDataCachedCached = cache(( const getPhotosLensDataCachedCached = cache((
make: string | undefined, make: string | undefined,
@ -41,12 +42,14 @@ export async function generateMetadata({
lens, lens,
] = await getPhotosLensDataCachedCached(make, model); ] = await getPhotosLensDataCachedCached(make, model);
const appText = await getAppText();
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForLens(lens, photos, count, dateRange); } = generateMetaForLens(lens, photos, appText, count, dateRange);
return { return {
title, title,

View File

@ -8,6 +8,7 @@ import { generateMetaForRecipe } from '@/recipe';
import RecipeOverview from '@/recipe/RecipeOverview'; import RecipeOverview from '@/recipe/RecipeOverview';
import { getPhotosRecipeDataCached } from '@/recipe/data'; import { getPhotosRecipeDataCached } from '@/recipe/data';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
import { getAppText } from '@/i18n/state/server';
const getPhotosRecipeDataCachedCached = cache(getPhotosRecipeDataCached); const getPhotosRecipeDataCachedCached = cache(getPhotosRecipeDataCached);
@ -39,12 +40,14 @@ export async function generateMetadata({
if (photos.length === 0) { return {}; } if (photos.length === 0) { return {}; }
const appText = await getAppText();
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForRecipe(recipe, photos, count, dateRange); } = generateMetaForRecipe(recipe, photos, appText, count, dateRange);
return { return {
title, title,

View File

@ -7,6 +7,7 @@ import CameraOverview from '@/camera/CameraOverview';
import { cache } from 'react'; import { cache } from 'react';
import { getUniqueCameras } from '@/photo/db/query'; import { getUniqueCameras } from '@/photo/db/query';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
import { getAppText } from '@/i18n/state/server';
const getPhotosCameraDataCachedCached = cache(( const getPhotosCameraDataCachedCached = cache((
make: string, make: string,
@ -35,12 +36,14 @@ export async function generateMetadata({
camera, camera,
] = await getPhotosCameraDataCachedCached(make, model); ] = await getPhotosCameraDataCachedCached(make, model);
const appText = await getAppText();
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForCamera(camera, photos, count, dateRange); } = generateMetaForCamera(camera, photos, appText, count, dateRange);
return { return {
title, title,

View File

@ -5,7 +5,7 @@ import { clsx } from 'clsx/lite';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import LinkWithStatus from '@/components/LinkWithStatus'; import LinkWithStatus from '@/components/LinkWithStatus';
import { IoArrowBack } from 'react-icons/io5'; import { IoArrowBack } from 'react-icons/io5';
import { APP_TEXT } from '@/app/config'; import { getAppText } from '@/i18n/state/server';
export default async function SignInPage() { export default async function SignInPage() {
const session = await auth(); const session = await auth();
@ -14,6 +14,8 @@ export default async function SignInPage() {
redirect(PATH_ADMIN); redirect(PATH_ADMIN);
} }
const appText = await getAppText();
return ( return (
<div className={clsx( <div className={clsx(
'fixed top-0 left-0 right-0 bottom-0', 'fixed top-0 left-0 right-0 bottom-0',
@ -28,7 +30,7 @@ export default async function SignInPage() {
)} )}
> >
<IoArrowBack className="translate-y-[1px]" /> <IoArrowBack className="translate-y-[1px]" />
{APP_TEXT.nav.home} {appText.nav.home}
</LinkWithStatus> </LinkWithStatus>
</div> </div>
); );

View File

@ -8,6 +8,7 @@ import type { Metadata } from 'next';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { cache } from 'react'; import { cache } from 'react';
import { staticallyGenerateCategoryIfConfigured } from '@/app/static'; import { staticallyGenerateCategoryIfConfigured } from '@/app/static';
import { getAppText } from '@/i18n/state/server';
const getPhotosTagDataCachedCached = cache((tag: string) => const getPhotosTagDataCachedCached = cache((tag: string) =>
getPhotosTagDataCached({ tag, limit: INFINITE_SCROLL_GRID_INITIAL})); getPhotosTagDataCached({ tag, limit: INFINITE_SCROLL_GRID_INITIAL}));
@ -37,12 +38,14 @@ export async function generateMetadata({
if (photos.length === 0) { return {}; } if (photos.length === 0) { return {}; }
const appText = await getAppText();
const { const {
url, url,
title, title,
description, description,
images, images,
} = generateMetaForTag(tag, photos, count, dateRange); } = generateMetaForTag(tag, photos, appText, count, dateRange);
return { return {
title, title,

View File

@ -8,6 +8,7 @@ import { TAG_HIDDEN, descriptionForTaggedPhotos, titleForTag } from '@/tag';
import HiddenHeader from '@/tag/HiddenHeader'; import HiddenHeader from '@/tag/HiddenHeader';
import { Metadata } from 'next'; import { Metadata } from 'next';
import { cache } from 'react'; import { cache } from 'react';
import { getAppText } from '@/i18n/state/server';
const getPhotosHiddenMetaCached = cache(() => const getPhotosHiddenMetaCached = cache(() =>
getPhotosMetaCached({ hidden: 'only' })); getPhotosMetaCached({ hidden: 'only' }));
@ -17,9 +18,13 @@ export async function generateMetadata(): Promise<Metadata> {
if (count === 0) { return {}; } 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( const description = descriptionForTaggedPhotos(
undefined, undefined,
appText,
undefined, undefined,
count, count,
dateRange, dateRange,

View File

@ -29,7 +29,7 @@ import IconBroom from '@/components/icons/IconBroom';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot'; import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
import MoreMenuItem from '@/components/more/MoreMenuItem'; import MoreMenuItem from '@/components/more/MoreMenuItem';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function AdminAppMenu({ export default function AdminAppMenu({
active, active,
@ -58,6 +58,8 @@ export default function AdminAppMenu({
clearAuthStateAndRedirectIfNecessary, clearAuthStateAndRedirectIfNecessary,
} = useAppState(); } = useAppState();
const appText = useAppText();
const isSelecting = selectedPhotoIds !== undefined; const isSelecting = selectedPhotoIds !== undefined;
const isAltPressed = useIsKeyBeingPressed('alt'); const isAltPressed = useIsKeyBeingPressed('alt');
@ -66,7 +68,7 @@ export default function AdminAppMenu({
const sectionUpload: ComponentProps<typeof MoreMenuItem>[] = const sectionUpload: ComponentProps<typeof MoreMenuItem>[] =
useMemo(() => ([{ useMemo(() => ([{
label: APP_TEXT.admin.uploadPhotos, label: appText.admin.uploadPhotos,
icon: <IconUpload icon: <IconUpload
size={15} size={15}
className="translate-x-[0.5px] translate-y-[0.5px]" className="translate-x-[0.5px] translate-y-[0.5px]"
@ -74,14 +76,14 @@ export default function AdminAppMenu({
annotation: isLoadingAdminData && annotation: isLoadingAdminData &&
<Spinner className="translate-y-[1.5px]" />, <Spinner className="translate-y-[1.5px]" />,
action: startUpload, action: startUpload,
}]), [isLoadingAdminData, startUpload]); }]), [appText, isLoadingAdminData, startUpload]);
const sectionMain: ComponentProps<typeof MoreMenuItem>[] = useMemo(() => { const sectionMain: ComponentProps<typeof MoreMenuItem>[] = useMemo(() => {
const items: ComponentProps<typeof MoreMenuItem>[] = []; const items: ComponentProps<typeof MoreMenuItem>[] = [];
if (uploadsCount) { if (uploadsCount) {
items.push({ items.push({
label: APP_TEXT.admin.uploadPlural, label: appText.admin.uploadPlural,
annotation: `${uploadsCount}`, annotation: `${uploadsCount}`,
icon: <IconFolder icon: <IconFolder
size={16} size={16}
@ -92,7 +94,7 @@ export default function AdminAppMenu({
} }
if (photosCountNeedSync) { if (photosCountNeedSync) {
items.push({ items.push({
label: APP_TEXT.admin.updatePlural, label: appText.admin.updatePlural,
annotation: <> annotation: <>
<span className="mr-3"> <span className="mr-3">
{photosCountNeedSync} {photosCountNeedSync}
@ -112,7 +114,7 @@ export default function AdminAppMenu({
} }
if (photosCountTotal) { if (photosCountTotal) {
items.push({ items.push({
label: APP_TEXT.admin.managePhotos, label: appText.admin.managePhotos,
...photosCountTotal && { ...photosCountTotal && {
annotation: `${photosCountTotal}`, annotation: `${photosCountTotal}`,
}, },
@ -125,7 +127,7 @@ export default function AdminAppMenu({
} }
if (tagsCount) { if (tagsCount) {
items.push({ items.push({
label: APP_TEXT.admin.manageTags, label: appText.admin.manageTags,
annotation: `${tagsCount}`, annotation: `${tagsCount}`,
icon: <IconTag icon: <IconTag
size={15} size={15}
@ -136,7 +138,7 @@ export default function AdminAppMenu({
} }
if (recipesCount) { if (recipesCount) {
items.push({ items.push({
label: APP_TEXT.admin.manageRecipes, label: appText.admin.manageRecipes,
annotation: `${recipesCount}`, annotation: `${recipesCount}`,
icon: <IconRecipe icon: <IconRecipe
size={17} size={17}
@ -148,8 +150,8 @@ export default function AdminAppMenu({
if (photosCountTotal) { if (photosCountTotal) {
items.push({ items.push({
label: isSelecting label: isSelecting
? APP_TEXT.admin.batchExitEdit ? appText.admin.batchExitEdit
: APP_TEXT.admin.batchEditShort, : appText.admin.batchEditShort,
icon: isSelecting icon: isSelecting
? <IoCloseSharp ? <IoCloseSharp
size={18} size={18}
@ -175,8 +177,8 @@ export default function AdminAppMenu({
} }
items.push({ items.push({
label: showAppInsightsLink label: showAppInsightsLink
? APP_TEXT.admin.appInsights ? appText.admin.appInsights
: APP_TEXT.admin.appConfig, : appText.admin.appConfig,
icon: <AdminAppInfoIcon icon: <AdminAppInfoIcon
size="small" size="small"
className="translate-x-[-0.5px] translate-y-[0.5px]" className="translate-x-[-0.5px] translate-y-[0.5px]"
@ -188,6 +190,7 @@ export default function AdminAppMenu({
return items; return items;
}, [ }, [
appText,
isSelecting, isSelecting,
photosCountNeedSync, photosCountNeedSync,
photosCountTotal, photosCountTotal,
@ -200,10 +203,10 @@ export default function AdminAppMenu({
const sectionSignOut: ComponentProps<typeof MoreMenuItem>[] = const sectionSignOut: ComponentProps<typeof MoreMenuItem>[] =
useMemo(() => ([{ useMemo(() => ([{
label: APP_TEXT.auth.signOut, label: appText.auth.signOut,
icon: <IconSignOut size={15} />, icon: <IconSignOut size={15} />,
action: () => signOutAction().then(clearAuthStateAndRedirectIfNecessary), action: () => signOutAction().then(clearAuthStateAndRedirectIfNecessary),
}]), [clearAuthStateAndRedirectIfNecessary]); }]), [appText.auth.signOut, clearAuthStateAndRedirectIfNecessary]);
const sections = useMemo(() => const sections = useMemo(() =>
[sectionUpload, sectionMain, sectionSignOut] [sectionUpload, sectionMain, sectionSignOut]

View File

@ -19,6 +19,7 @@ import { FaArrowDown, FaCheck } from 'react-icons/fa6';
import ResponsiveText from '@/components/primitives/ResponsiveText'; import ResponsiveText from '@/components/primitives/ResponsiveText';
import IconFavs from '@/components/icons/IconFavs'; import IconFavs from '@/components/icons/IconFavs';
import IconTag from '@/components/icons/IconTag'; import IconTag from '@/components/icons/IconTag';
import { useAppText } from '@/i18n/state/client';
export default function AdminBatchEditPanelClient({ export default function AdminBatchEditPanelClient({
uniqueTags, uniqueTags,
@ -37,6 +38,8 @@ export default function AdminBatchEditPanelClient({
setIsPerformingSelectEdit, setIsPerformingSelectEdit,
} = useAppState(); } = useAppState();
const appText = useAppText();
const [tags, setTags] = useState<string>(); const [tags, setTags] = useState<string>();
const [tagErrorMessage, setTagErrorMessage] = useState(''); const [tagErrorMessage, setTagErrorMessage] = useState('');
const isInTagMode = tags !== undefined; const isInTagMode = tags !== undefined;
@ -49,6 +52,7 @@ export default function AdminBatchEditPanelClient({
const photosText = photoQuantityText( const photosText = photoQuantityText(
selectedPhotoIds?.length ?? 0, selectedPhotoIds?.length ?? 0,
appText,
false, false,
false, false,
); );

View File

@ -12,7 +12,7 @@ import {
PATH_ADMIN_UPLOADS, PATH_ADMIN_UPLOADS,
} from '@/app/paths'; } from '@/app/paths';
import AdminNavClient from './AdminNavClient'; import AdminNavClient from './AdminNavClient';
import { APP_TEXT } from '@/app/config'; import { getAppText } from '@/i18n/state/server';
export default async function AdminNav() { export default async function AdminNav() {
const [ const [
@ -38,32 +38,34 @@ export default async function AdminNav() {
getPhotosMostRecentUpdateCached().catch(() => undefined), getPhotosMostRecentUpdateCached().catch(() => undefined),
]); ]);
const appText = await getAppText();
const includeInsights = countPhotos > 0; const includeInsights = countPhotos > 0;
// Photos // Photos
const items = [{ const items = [{
label: APP_TEXT.photo.photoPlural, label: appText.photo.photoPlural,
href: PATH_ADMIN_PHOTOS, href: PATH_ADMIN_PHOTOS,
count: countPhotos, count: countPhotos,
}]; }];
// Uploads // Uploads
if (countUploads > 0) { items.push({ if (countUploads > 0) { items.push({
label: APP_TEXT.admin.uploadPlural, label: appText.admin.uploadPlural,
href: PATH_ADMIN_UPLOADS, href: PATH_ADMIN_UPLOADS,
count: countUploads, count: countUploads,
}); } }); }
// Tags // Tags
if (countTags > 0) { items.push({ if (countTags > 0) { items.push({
label: APP_TEXT.category.tagPlural, label: appText.category.tagPlural,
href: PATH_ADMIN_TAGS, href: PATH_ADMIN_TAGS,
count: countTags, count: countTags,
}); } }); }
// Recipes // Recipes
if (countRecipes > 0) { items.push({ if (countRecipes > 0) { items.push({
label: APP_TEXT.category.recipePlural, label: appText.category.recipePlural,
href: PATH_ADMIN_RECIPES, href: PATH_ADMIN_RECIPES,
count: countRecipes, count: countRecipes,
}); } }); }

View File

@ -26,7 +26,7 @@ import IconFavs from '@/components/icons/IconFavs';
import IconEdit from '@/components/icons/IconEdit'; import IconEdit from '@/components/icons/IconEdit';
import { photoNeedsToBeSynced } from '@/photo/sync'; import { photoNeedsToBeSynced } from '@/photo/sync';
import { KEY_COMMANDS } from '@/photo/key-commands'; import { KEY_COMMANDS } from '@/photo/key-commands';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function AdminPhotoMenu({ export default function AdminPhotoMenu({
photo, photo,
@ -42,6 +42,8 @@ export default function AdminPhotoMenu({
}) { }) {
const { isUserSignedIn, registerAdminUpdate } = useAppState(); const { isUserSignedIn, registerAdminUpdate } = useAppState();
const appText = useAppText();
const isFav = isPhotoFav(photo); const isFav = isPhotoFav(photo);
const path = usePathname(); const path = usePathname();
const shouldRedirectFav = isPathFavs(path) && isFav; const shouldRedirectFav = isPathFavs(path) && isFav;
@ -49,7 +51,7 @@ export default function AdminPhotoMenu({
const sectionMain = useMemo(() => { const sectionMain = useMemo(() => {
const items: ComponentProps<typeof MoreMenuItem>[] = [{ const items: ComponentProps<typeof MoreMenuItem>[] = [{
label: APP_TEXT.admin.edit, label: appText.admin.edit,
icon: <IconEdit icon: <IconEdit
size={15} size={15}
className="translate-x-[0.5px]" className="translate-x-[0.5px]"
@ -59,7 +61,7 @@ export default function AdminPhotoMenu({
}]; }];
if (includeFavorite) { if (includeFavorite) {
items.push({ items.push({
label: isFav ? APP_TEXT.admin.unfavorite : APP_TEXT.admin.favorite, label: isFav ? appText.admin.unfavorite : appText.admin.favorite,
icon: <IconFavs icon: <IconFavs
size={14} size={14}
className="translate-x-[-1px] translate-y-[0.5px]" className="translate-x-[-1px] translate-y-[0.5px]"
@ -77,7 +79,7 @@ export default function AdminPhotoMenu({
}); });
} }
items.push({ items.push({
label: APP_TEXT.admin.download, label: appText.admin.download,
icon: <MdOutlineFileDownload icon: <MdOutlineFileDownload
size={17} size={17}
className="translate-x-[-1px]" className="translate-x-[-1px]"
@ -87,9 +89,9 @@ export default function AdminPhotoMenu({
...showKeyCommands && { keyCommand: KEY_COMMANDS.download }, ...showKeyCommands && { keyCommand: KEY_COMMANDS.download },
}); });
items.push({ items.push({
label: APP_TEXT.admin.sync, label: appText.admin.sync,
labelComplex: <span className="inline-flex items-center gap-2"> labelComplex: <span className="inline-flex items-center gap-2">
<span>{APP_TEXT.admin.sync}</span> <span>{appText.admin.sync}</span>
{photoNeedsToBeSynced(photo) && {photoNeedsToBeSynced(photo) &&
<InsightsIndicatorDot <InsightsIndicatorDot
colorOverride="blue" colorOverride="blue"
@ -107,6 +109,7 @@ export default function AdminPhotoMenu({
return items; return items;
}, [ }, [
appText,
photo, photo,
showKeyCommands, showKeyCommands,
includeFavorite, includeFavorite,
@ -116,7 +119,7 @@ export default function AdminPhotoMenu({
]); ]);
const sectionDelete: ComponentProps<typeof MoreMenuItem>[] = useMemo(() => [{ const sectionDelete: ComponentProps<typeof MoreMenuItem>[] = useMemo(() => [{
label: APP_TEXT.admin.delete, label: appText.admin.delete,
icon: <BiTrash icon: <BiTrash
size={15} size={15}
className="translate-x-[-1px]" className="translate-x-[-1px]"
@ -124,7 +127,7 @@ export default function AdminPhotoMenu({
className: 'text-error *:hover:text-error', className: 'text-error *:hover:text-error',
color: 'red', color: 'red',
action: () => { action: () => {
if (confirm(deleteConfirmationTextForPhoto(photo))) { if (confirm(deleteConfirmationTextForPhoto(photo, appText))) {
return deletePhotoAction( return deletePhotoAction(
photo.id, photo.id,
photo.url, photo.url,
@ -140,6 +143,7 @@ export default function AdminPhotoMenu({
keyCommand: KEY_COMMANDS.delete[1], keyCommand: KEY_COMMANDS.delete[1],
}, },
}], [ }], [
appText,
photo, photo,
showKeyCommands, showKeyCommands,
revalidatePhoto, revalidatePhoto,

View File

@ -15,7 +15,7 @@ import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
import { pluralize } from '@/utility/string'; import { pluralize } from '@/utility/string';
import IconBroom from '@/components/icons/IconBroom'; import IconBroom from '@/components/icons/IconBroom';
import ResponsiveText from '@/components/primitives/ResponsiveText'; import ResponsiveText from '@/components/primitives/ResponsiveText';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function AdminPhotosClient({ export default function AdminPhotosClient({
photos, photos,
@ -42,6 +42,8 @@ export default function AdminPhotosClient({
}) { }) {
const { uploadState: { isUploading } } = useAppState(); const { uploadState: { isUploading } } = useAppState();
const appText = useAppText();
return ( return (
<AppGrid <AppGrid
contentMain={ contentMain={
@ -64,8 +66,8 @@ export default function AdminPhotosClient({
tooltip={( tooltip={(
pluralize( pluralize(
photosCountNeedsSync, photosCountNeedsSync,
APP_TEXT.photo.photo, appText.photo.photo,
APP_TEXT.photo.photoPlural, appText.photo.photoPlural,
) + ) +
' missing data or AI-generated text' ' missing data or AI-generated text'
)} )}
@ -83,8 +85,8 @@ export default function AdminPhotosClient({
<ResponsiveText shortText={photosCountNeedsSync}> <ResponsiveText shortText={photosCountNeedsSync}>
{pluralize( {pluralize(
photosCountNeedsSync, photosCountNeedsSync,
APP_TEXT.admin.update, appText.admin.update,
APP_TEXT.admin.updatePlural, appText.admin.updatePlural,
)} )}
</ResponsiveText> </ResponsiveText>
</PathLoaderButton>} </PathLoaderButton>}

View File

@ -2,8 +2,9 @@ import { photoLabelForCount } from '@/photo';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import PhotoRecipe from '@/recipe/PhotoRecipe'; import PhotoRecipe from '@/recipe/PhotoRecipe';
import { getAppText } from '@/i18n/state/server';
export default function AdminRecipeBadge({ export default async function AdminRecipeBadge({
recipe, recipe,
count, count,
hideBadge, hideBadge,
@ -12,6 +13,8 @@ export default function AdminRecipeBadge({
count: number, count: number,
hideBadge?: boolean, hideBadge?: boolean,
}) { }) {
const appText = await getAppText();
const renderBadgeContent = () => const renderBadgeContent = () =>
<div className={clsx( <div className={clsx(
'inline-flex items-center gap-2', 'inline-flex items-center gap-2',
@ -21,7 +24,7 @@ export default function AdminRecipeBadge({
<span>{count}</span> <span>{count}</span>
<span className="hidden xs:inline-block"> <span className="hidden xs:inline-block">
&nbsp; &nbsp;
{photoLabelForCount(count)} {photoLabelForCount(count, appText)}
</span> </span>
</div> </div>
</div>; </div>;

View File

@ -9,12 +9,14 @@ import { pathForAdminRecipeEdit } from '@/app/paths';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { formatRecipe, Recipes, sortRecipes } from '@/recipe'; import { formatRecipe, Recipes, sortRecipes } from '@/recipe';
import AdminRecipeBadge from './AdminRecipeBadge'; import AdminRecipeBadge from './AdminRecipeBadge';
import { getAppText } from '@/i18n/state/server';
export default function AdminRecipeTable({ export default async function AdminRecipeTable({
recipes, recipes,
}: { }: {
recipes: Recipes recipes: Recipes
}) { }) {
const appText = await getAppText();
return ( return (
<AdminTable> <AdminTable>
{sortRecipes(recipes).map(({ recipe, count }) => {sortRecipes(recipes).map(({ recipe, count }) =>
@ -31,7 +33,7 @@ export default function AdminRecipeTable({
action={deletePhotoRecipeGloballyAction} action={deletePhotoRecipeGloballyAction}
confirmText={ confirmText={
// eslint-disable-next-line max-len // 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()}?`}
> >
<input type="hidden" name="recipe" value={recipe} /> <input type="hidden" name="recipe" value={recipe} />
<DeleteFormButton clearLocalState /> <DeleteFormButton clearLocalState />

View File

@ -4,8 +4,9 @@ import { clsx } from 'clsx/lite';
import FavsTag from '@/tag/FavsTag'; import FavsTag from '@/tag/FavsTag';
import { isTagFavs } from '@/tag'; import { isTagFavs } from '@/tag';
import Badge from '@/components/Badge'; import Badge from '@/components/Badge';
import { getAppText } from '@/i18n/state/server';
export default function AdminTagBadge({ export default async function AdminTagBadge({
tag, tag,
count, count,
hideBadge, hideBadge,
@ -14,6 +15,8 @@ export default function AdminTagBadge({
count: number, count: number,
hideBadge?: boolean, hideBadge?: boolean,
}) { }) {
const appText = await getAppText();
const renderBadgeContent = () => const renderBadgeContent = () =>
<div className={clsx( <div className={clsx(
'inline-flex items-center gap-2', 'inline-flex items-center gap-2',
@ -28,7 +31,7 @@ export default function AdminTagBadge({
<span>{count}</span> <span>{count}</span>
<span className="hidden xs:inline-block"> <span className="hidden xs:inline-block">
&nbsp; &nbsp;
{photoLabelForCount(count)} {photoLabelForCount(count, appText)}
</span> </span>
</div> </div>
</div>; </div>;

View File

@ -9,12 +9,15 @@ import EditButton from '@/admin/EditButton';
import { pathForAdminTagEdit } from '@/app/paths'; import { pathForAdminTagEdit } from '@/app/paths';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import AdminTagBadge from './AdminTagBadge'; import AdminTagBadge from './AdminTagBadge';
import { getAppText } from '@/i18n/state/server';
export default function AdminTagTable({ export default async function AdminTagTable({
tags, tags,
}: { }: {
tags: Tags tags: Tags
}) { }) {
const appText = await getAppText();
return ( return (
<AdminTable> <AdminTable>
{sortTags(tags).map(({ tag, count }) => {sortTags(tags).map(({ tag, count }) =>
@ -31,7 +34,7 @@ export default function AdminTagTable({
action={deletePhotoTagGloballyAction} action={deletePhotoTagGloballyAction}
confirmText={ confirmText={
// eslint-disable-next-line max-len // 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()}?`}
> >
<input type="hidden" name="tag" value={tag} /> <input type="hidden" name="tag" value={tag} />
<DeleteFormButton clearLocalState /> <DeleteFormButton clearLocalState />

View File

@ -3,6 +3,7 @@
import { deleteConfirmationTextForPhoto, Photo, titleForPhoto } from '@/photo'; import { deleteConfirmationTextForPhoto, Photo, titleForPhoto } from '@/photo';
import DeletePhotosButton from './DeletePhotosButton'; import DeletePhotosButton from './DeletePhotosButton';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
import { useAppText } from '@/i18n/state/client';
export default function DeletePhotoButton({ export default function DeletePhotoButton({
photo, photo,
@ -10,11 +11,12 @@ export default function DeletePhotoButton({
}: { }: {
photo: Photo photo: Photo
} & ComponentProps<typeof DeletePhotosButton>) { } & ComponentProps<typeof DeletePhotosButton>) {
const appText = useAppText();
return ( return (
<DeletePhotosButton <DeletePhotosButton
{...rest} {...rest}
photoIds={[photo.id]} photoIds={[photo.id]}
confirmText={deleteConfirmationTextForPhoto(photo)} confirmText={deleteConfirmationTextForPhoto(photo, appText)}
toastText={`"${titleForPhoto(photo)}" deleted`} toastText={`"${titleForPhoto(photo)}" deleted`}
/> />
); );

View File

@ -7,6 +7,7 @@ import { useAppState } from '@/state/AppState';
import { toastSuccess, toastWarning } from '@/toast'; import { toastSuccess, toastWarning } from '@/toast';
import { ComponentProps, useState } from 'react'; import { ComponentProps, useState } from 'react';
import DeleteButton from './DeleteButton'; import DeleteButton from './DeleteButton';
import { useAppText } from '@/i18n/state/client';
export default function DeletePhotosButton({ export default function DeletePhotosButton({
photoIds = [], photoIds = [],
@ -27,7 +28,9 @@ export default function DeletePhotosButton({
} & ComponentProps<typeof LoaderButton>) { } & ComponentProps<typeof LoaderButton>) {
const [isLoading, setIsLoading] = useState(false); 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(); const { invalidateSwr, registerAdminUpdate } = useAppState();

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import { useAppText } from '@/i18n/state/client';
import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag'; import { convertTagsForForm, getValidationMessageForTags, Tags } from '@/tag';
import { ComponentProps, useEffect, useRef, useState } from 'react'; import { ComponentProps, useEffect, useRef, useState } from 'react';
@ -25,6 +26,8 @@ export default function PhotoTagFieldset(props: {
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const appText = useAppText();
const [errorMessageLocal, setErrorMessageLocal] = useState(''); const [errorMessageLocal, setErrorMessageLocal] = useState('');
useEffect(() => { useEffect(() => {
@ -43,7 +46,7 @@ export default function PhotoTagFieldset(props: {
inputRef={ref} inputRef={ref}
label="Tags" label="Tags"
value={tags} value={tags}
tagOptions={convertTagsForForm(tagOptions)} tagOptions={convertTagsForForm(tagOptions, appText)}
onChange={tags => { onChange={tags => {
onChange(tags); onChange(tags);
const validationMessage = getValidationMessageForTags(tags) ?? ''; const validationMessage = getValidationMessageForTags(tags) ?? '';

View File

@ -4,7 +4,7 @@ import { useAppState } from '@/state/AppState';
import SignInForm from '@/auth/SignInForm'; import SignInForm from '@/auth/SignInForm';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus'; import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function SignInOrUploadClient({ export default function SignInOrUploadClient({
shouldResize, shouldResize,
@ -15,6 +15,8 @@ export default function SignInOrUploadClient({
}) { }) {
const { isUserSignedIn, isCheckingAuth } = useAppState(); const { isUserSignedIn, isCheckingAuth } = useAppState();
const appText = useAppText();
return ( return (
<div className={clsx( <div className={clsx(
'flex justify-center items-center flex-col gap-4', 'flex justify-center items-center flex-col gap-4',
@ -22,10 +24,10 @@ export default function SignInOrUploadClient({
)}> )}>
<div> <div>
{isCheckingAuth {isCheckingAuth
? APP_TEXT.misc.loading ? appText.misc.loading
: isUserSignedIn : isUserSignedIn
? APP_TEXT.onboarding.setupFirstPhoto ? appText.onboarding.setupFirstPhoto
: APP_TEXT.onboarding.setupSignIn} : appText.onboarding.setupSignIn}
</div> </div>
{!isCheckingAuth && isUserSignedIn === false && {!isCheckingAuth && isUserSignedIn === false &&
<div className="flex justify-center my-2 sm:my-4"> <div className="flex justify-center my-2 sm:my-4">

View File

@ -9,7 +9,6 @@ import {
import IconSearch from '../components/icons/IconSearch'; import IconSearch from '../components/icons/IconSearch';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { import {
APP_TEXT,
GRID_HOMEPAGE_ENABLED, GRID_HOMEPAGE_ENABLED,
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS, SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
} from './config'; } from './config';
@ -20,6 +19,7 @@ import { useCallback, useRef, useState } from 'react';
import useKeydownHandler from '@/utility/useKeydownHandler'; import useKeydownHandler from '@/utility/useKeydownHandler';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { KEY_COMMANDS } from '@/photo/key-commands'; import { KEY_COMMANDS } from '@/photo/key-commands';
import { useAppText } from '@/i18n/state/client';
export type SwitcherSelection = 'feed' | 'grid' | 'admin'; export type SwitcherSelection = 'feed' | 'grid' | 'admin';
@ -32,6 +32,8 @@ export default function AppViewSwitcher({
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const appText = useAppText();
const { const {
isUserSignedIn, isUserSignedIn,
isUserSignedInEager, isUserSignedInEager,
@ -67,7 +69,7 @@ export default function AppViewSwitcher({
hrefRef={refHrefFeed} hrefRef={refHrefFeed}
active={currentSelection === 'feed'} active={currentSelection === 'feed'}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: APP_TEXT.nav.feed, content: appText.nav.feed,
keyCommand: KEY_COMMANDS.feed, keyCommand: KEY_COMMANDS.feed,
}}} }}}
noPadding noPadding
@ -80,7 +82,7 @@ export default function AppViewSwitcher({
hrefRef={refHrefGrid} hrefRef={refHrefGrid}
active={currentSelection === 'grid'} active={currentSelection === 'grid'}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: APP_TEXT.nav.grid, content: appText.nav.grid,
keyCommand: KEY_COMMANDS.grid, keyCommand: KEY_COMMANDS.grid,
}}} }}}
noPadding noPadding
@ -104,7 +106,7 @@ export default function AppViewSwitcher({
noPadding noPadding
tooltip={{ tooltip={{
...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { ...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: APP_TEXT.nav.admin, content: appText.nav.admin,
keyCommand: KEY_COMMANDS.admin, keyCommand: KEY_COMMANDS.admin,
}, },
}} }}
@ -117,7 +119,7 @@ export default function AppViewSwitcher({
/>} />}
tooltip={{ tooltip={{
...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { ...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: APP_TEXT.nav.admin, content: appText.nav.admin,
keyCommand: KEY_COMMANDS.admin, keyCommand: KEY_COMMANDS.admin,
}, },
}} }}
@ -129,7 +131,7 @@ export default function AppViewSwitcher({
icon={<IconSearch includeTitle={false} />} icon={<IconSearch includeTitle={false} />}
onClick={() => setIsCommandKOpen?.(true)} onClick={() => setIsCommandKOpen?.(true)}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: APP_TEXT.nav.search, content: appText.nav.search,
keyCommandModifier: KEY_COMMANDS.search[0], keyCommandModifier: KEY_COMMANDS.search[0],
keyCommand: KEY_COMMANDS.search[1], keyCommand: KEY_COMMANDS.search[1],
}}} }}}

View File

@ -4,7 +4,7 @@ import { clsx } from 'clsx/lite';
import AppGrid from '../components/AppGrid'; import AppGrid from '../components/AppGrid';
import ThemeSwitcher from '@/app/ThemeSwitcher'; import ThemeSwitcher from '@/app/ThemeSwitcher';
import Link from 'next/link'; 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 RepoLink from '../components/RepoLink';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths'; import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths';
@ -13,6 +13,7 @@ import { signOutAction } from '@/auth/actions';
import AnimateItems from '@/components/AnimateItems'; import AnimateItems from '@/components/AnimateItems';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
import { useAppText } from '@/i18n/state/client';
export default function Footer() { export default function Footer() {
const pathname = usePathname(); const pathname = usePathname();
@ -24,6 +25,8 @@ export default function Footer() {
clearAuthStateAndRedirectIfNecessary, clearAuthStateAndRedirectIfNecessary,
} = useAppState(); } = useAppState();
const appText = useAppText();
const showFooter = !isPathSignIn(pathname); const showFooter = !isPathSignIn(pathname);
const shouldAnimate = !isPathAdmin(pathname); const shouldAnimate = !isPathAdmin(pathname);
@ -51,7 +54,7 @@ export default function Footer() {
<form action={() => signOutAction() <form action={() => signOutAction()
.then(clearAuthStateAndRedirectIfNecessary)}> .then(clearAuthStateAndRedirectIfNecessary)}>
<SubmitButtonWithStatus styleAs="link"> <SubmitButtonWithStatus styleAs="link">
{APP_TEXT.auth.signOut} {appText.auth.signOut}
</SubmitButtonWithStatus> </SubmitButtonWithStatus>
</form> </form>
</> </>
@ -60,7 +63,7 @@ export default function Footer() {
: SHOW_REPO_LINK : SHOW_REPO_LINK
? <RepoLink /> ? <RepoLink />
: <Link href={PATH_ADMIN_PHOTOS}> : <Link href={PATH_ADMIN_PHOTOS}>
{APP_TEXT.nav.admin} {appText.nav.admin}
</Link>} </Link>}
</div> </div>
<div className="flex items-center h-10"> <div className="flex items-center h-10">

View File

@ -5,9 +5,11 @@ import { useTheme } from 'next-themes';
import Switcher from '@/components/Switcher'; import Switcher from '@/components/Switcher';
import SwitcherItem from '@/components/SwitcherItem'; import SwitcherItem from '@/components/SwitcherItem';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi'; import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { APP_TEXT } from './config'; import { useAppText } from '@/i18n/state/client';
export default function ThemeSwitcher () { export default function ThemeSwitcher () {
const appText = useAppText();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@ -26,19 +28,19 @@ export default function ThemeSwitcher () {
icon={<BiDesktop size={16} />} icon={<BiDesktop size={16} />}
onClick={() => setTheme('system')} onClick={() => setTheme('system')}
active={theme === 'system'} active={theme === 'system'}
tooltip={{ content: APP_TEXT.theme.system }} tooltip={{ content: appText.theme.system }}
/> />
<SwitcherItem <SwitcherItem
icon={<BiSun size={18} />} icon={<BiSun size={18} />}
onClick={() => setTheme('light')} onClick={() => setTheme('light')}
active={theme === 'light'} active={theme === 'light'}
tooltip={{ content: APP_TEXT.theme.light }} tooltip={{ content: appText.theme.light }}
/> />
<SwitcherItem <SwitcherItem
icon={<BiMoon size={16} />} icon={<BiMoon size={16} />}
onClick={() => setTheme('dark')} onClick={() => setTheme('dark')}
active={theme === 'dark'} active={theme === 'dark'}
tooltip={{ content: APP_TEXT.theme.dark }} tooltip={{ content: appText.theme.dark }}
/> />
</Switcher> </Switcher>
); );

View File

@ -5,7 +5,6 @@ import {
import { getOrderedCategoriesFromString } from '@/category'; import { getOrderedCategoriesFromString } from '@/category';
import type { StorageType } from '@/platforms/storage'; import type { StorageType } from '@/platforms/storage';
import { makeUrlAbsolute, shortenUrl } from '@/utility/url'; import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
import { getTextForLocale } from '@/i18n';
// HARD-CODED GLOBAL CONFIGURATION // HARD-CODED GLOBAL CONFIGURATION
@ -99,9 +98,7 @@ const SITE_DOMAIN_SHORT = shortenUrl(SITE_DOMAIN);
// SITE META // SITE META
export const APP_TEXT = getTextForLocale( export const APP_LOCALE = process.env.NEXT_PUBLIC_LOCALE || 'US-EN';
process.env.NEXT_PUBLIC_LOCALE,
);
export const NAV_TITLE = export const NAV_TITLE =
process.env.NEXT_PUBLIC_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_EMAIL) &&
Boolean(process.env.ADMIN_PASSWORD) Boolean(process.env.ADMIN_PASSWORD)
), ),
// Domain // Content
locale: process.env.NEXT_PUBLIC_LOCALE ?? 'US-EN', locale: APP_LOCALE,
hasLocale: Boolean(process.env.NEXT_PUBLIC_LOCALE), hasLocale: Boolean(process.env.NEXT_PUBLIC_LOCALE),
hasDomain: Boolean( hasDomain: Boolean(
process.env.NEXT_PUBLIC_DOMAIN || process.env.NEXT_PUBLIC_DOMAIN ||
// Legacy environment variable // Legacy environment variable
process.env.NEXT_PUBLIC_SITE_DOMAIN, process.env.NEXT_PUBLIC_SITE_DOMAIN,
), ),
// Content
hasNavTitle: Boolean(NAV_TITLE), hasNavTitle: Boolean(NAV_TITLE),
hasNavCaption: Boolean(NAV_CAPTION), hasNavCaption: Boolean(NAV_CAPTION),
isMetaTitleConfigured: IS_META_TITLE_CONFIGURED, isMetaTitleConfigured: IS_META_TITLE_CONFIGURED,

View File

@ -21,7 +21,7 @@ import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { PATH_ADMIN_PHOTOS } from '@/app/paths'; import { PATH_ADMIN_PHOTOS } from '@/app/paths';
import IconLock from '@/components/icons/IconLock'; import IconLock from '@/components/icons/IconLock';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function SignInForm({ export default function SignInForm({
includeTitle = true, includeTitle = true,
@ -36,6 +36,8 @@ export default function SignInForm({
const { setUserEmail } = useAppState(); const { setUserEmail } = useAppState();
const appText = useAppText();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [response, action] = useActionState(signInAction, undefined); const [response, action] = useActionState(signInAction, undefined);
@ -80,27 +82,27 @@ export default function SignInForm({
)}> )}>
<IconLock className="text-main translate-y-[0.5px]" /> <IconLock className="text-main translate-y-[0.5px]" />
<span className="text-main"> <span className="text-main">
{APP_TEXT.auth.signIn} {appText.auth.signIn}
</span> </span>
</h1>} </h1>}
<form action={action} className="w-full"> <form action={action} className="w-full">
<div className="space-y-5 w-full -translate-y-0.5"> <div className="space-y-5 w-full -translate-y-0.5">
{response === KEY_CREDENTIALS_SIGN_IN_ERROR && {response === KEY_CREDENTIALS_SIGN_IN_ERROR &&
<ErrorNote> <ErrorNote>
{APP_TEXT.auth.invalidEmailPassword} {appText.auth.invalidEmailPassword}
</ErrorNote>} </ErrorNote>}
<div className="space-y-4 w-full"> <div className="space-y-4 w-full">
<FieldSetWithStatus <FieldSetWithStatus
id="email" id="email"
inputRef={emailRef} inputRef={emailRef}
label={APP_TEXT.auth.email} label={appText.auth.email}
type="email" type="email"
value={email} value={email}
onChange={setEmail} onChange={setEmail}
/> />
<FieldSetWithStatus <FieldSetWithStatus
id="password" id="password"
label={APP_TEXT.auth.password} label={appText.auth.password}
type="password" type="password"
value={password} value={password}
onChange={setPassword} onChange={setPassword}
@ -113,7 +115,7 @@ export default function SignInForm({
/>} />}
</div> </div>
<SubmitButtonWithStatus disabled={!isFormValid}> <SubmitButtonWithStatus disabled={!isFormValid}>
{APP_TEXT.auth.signIn} {appText.auth.signIn}
</SubmitButtonWithStatus> </SubmitButtonWithStatus>
</div> </div>
</form> </form>

View File

@ -4,8 +4,9 @@ import { Camera, cameraFromPhoto } from '.';
import PhotoCamera from './PhotoCamera'; import PhotoCamera from './PhotoCamera';
import { descriptionForCameraPhotos } from './meta'; import { descriptionForCameraPhotos } from './meta';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; 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, camera: cameraProp,
photos, photos,
selectedPhoto, selectedPhoto,
@ -21,12 +22,19 @@ export default function CameraHeader({
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
const camera = cameraFromPhoto(photos[0], cameraProp); const camera = cameraFromPhoto(photos[0], cameraProp);
const appText = await getAppText();
return ( return (
<PhotoHeader <PhotoHeader
camera={camera} camera={camera}
entity={<PhotoCamera {...{ camera }} contrast="high" />} entity={<PhotoCamera {...{ camera }} contrast="high" />}
entityDescription={ entityDescription={
descriptionForCameraPhotos(photos, undefined, count, dateRange)} descriptionForCameraPhotos(
photos,
appText,
undefined,
count,
dateRange,
)}
photos={photos} photos={photos}
selectedPhoto={selectedPhoto} selectedPhoto={selectedPhoto}
indexNumber={indexNumber} indexNumber={indexNumber}

View File

@ -3,8 +3,9 @@ import { absolutePathForCameraImage, pathForCamera } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile'; import OGTile, { OGLoadingState } from '@/components/OGTile';
import { Camera } from '.'; import { Camera } from '.';
import { descriptionForCameraPhotos, titleForCamera } from './meta'; import { descriptionForCameraPhotos, titleForCamera } from './meta';
import { getAppText } from '@/i18n/state/server';
export default function CameraOGTile({ export default async function CameraOGTile({
camera, camera,
photos, photos,
loadingState: loadingStateExternal, loadingState: loadingStateExternal,
@ -25,10 +26,17 @@ export default function CameraOGTile({
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
const appText = await getAppText();
return ( return (
<OGTile {...{ <OGTile {...{
title: titleForCamera(camera, photos, count), title: titleForCamera(camera, photos, appText, count),
description: descriptionForCameraPhotos(photos, true, count, dateRange), description: descriptionForCameraPhotos(
photos,
appText,
true,
count,
dateRange,
),
path: pathForCamera(camera), path: pathForCamera(camera),
pathImageAbsolute: absolutePathForCameraImage(camera), pathImageAbsolute: absolutePathForCameraImage(camera),
loadingState: loadingStateExternal, loadingState: loadingStateExternal,

View File

@ -4,8 +4,9 @@ import ShareModal from '@/share/ShareModal';
import CameraOGTile from './CameraOGTile'; import CameraOGTile from './CameraOGTile';
import { Camera, formatCameraText } from '.'; import { Camera, formatCameraText } from '.';
import { shareTextForCamera } from './meta'; import { shareTextForCamera } from './meta';
import { getAppText } from '@/i18n/state/server';
export default function CameraShareModal({ export default async function CameraShareModal({
camera, camera,
photos, photos,
count, count,
@ -13,11 +14,12 @@ export default function CameraShareModal({
}: { }: {
camera: Camera camera: Camera
} & PhotoSetAttributes) { } & PhotoSetAttributes) {
const appText = await getAppText();
return ( return (
<ShareModal <ShareModal
pathShare={absolutePathForCamera(camera, true)} pathShare={absolutePathForCamera(camera, true)}
navigatorTitle={formatCameraText(camera)} navigatorTitle={formatCameraText(camera)}
socialText={shareTextForCamera(camera, photos)} socialText={shareTextForCamera(camera, photos, appText)}
> >
<CameraOGTile {...{ camera, photos, count, dateRange }} /> <CameraOGTile {...{ camera, photos, count, dateRange }} />
</ShareModal> </ShareModal>

View File

@ -9,7 +9,7 @@ import {
absolutePathForCamera, absolutePathForCamera,
absolutePathForCameraImage, absolutePathForCameraImage,
} from '@/app/paths'; } from '@/app/paths';
import { APP_TEXT } from '@/app/config'; import { I18NState } from '@/i18n/state';
// Meta functions moved to separate file to avoid // Meta functions moved to separate file to avoid
// dependencies (camelcase-keys) found in photo/index.ts // dependencies (camelcase-keys) found in photo/index.ts
@ -18,30 +18,34 @@ import { APP_TEXT } from '@/app/config';
export const titleForCamera = ( export const titleForCamera = (
camera: Camera, camera: Camera,
photos: Photo[], photos: Photo[],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
) => [ ) => [
APP_TEXT.category.cameraTitle( appText.category.cameraTitle(
formatCameraText(cameraFromPhoto(photos[0], camera)), formatCameraText(cameraFromPhoto(photos[0], camera)),
), ),
photoQuantityText(explicitCount ?? photos.length), photoQuantityText(explicitCount ?? photos.length, appText),
].join(' '); ].join(' ');
export const shareTextForCamera = ( export const shareTextForCamera = (
camera: Camera, camera: Camera,
photos: Photo[], photos: Photo[],
appText: I18NState,
) => ) =>
APP_TEXT.category.cameraShare( appText.category.cameraShare(
formatCameraText(cameraFromPhoto(photos[0], camera)), formatCameraText(cameraFromPhoto(photos[0], camera)),
); );
export const descriptionForCameraPhotos = ( export const descriptionForCameraPhotos = (
photos: Photo[], photos: Photo[],
appText: I18NState,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ) =>
descriptionForPhotoSet( descriptionForPhotoSet(
photos, photos,
appText,
undefined, undefined,
dateBased, dateBased,
explicitCount, explicitCount,
@ -51,12 +55,19 @@ export const descriptionForCameraPhotos = (
export const generateMetaForCamera = ( export const generateMetaForCamera = (
camera: Camera, camera: Camera,
photos: Photo[], photos: Photo[],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ({ ) => ({
url: absolutePathForCamera(camera), url: absolutePathForCamera(camera),
title: titleForCamera(camera, photos, explicitCount), title: titleForCamera(camera, photos, appText, explicitCount),
description: description:
descriptionForCameraPhotos(photos, true, explicitCount, explicitDateRange), descriptionForCameraPhotos(
photos,
appText,
true,
explicitCount,
explicitDateRange,
),
images: absolutePathForCameraImage(camera), images: absolutePathForCameraImage(camera),
}); });

View File

@ -3,6 +3,7 @@ import { getPhotosMetaCached } from '@/photo/cache';
import { photoQuantityText } from '@/photo'; import { photoQuantityText } from '@/photo';
import { ADMIN_DEBUG_TOOLS_ENABLED } from '../app/config'; import { ADMIN_DEBUG_TOOLS_ENABLED } from '../app/config';
import { getDataForCategoriesCached } from '@/category/cache'; import { getDataForCategoriesCached } from '@/category/cache';
import { getAppText } from '@/i18n/state/server';
export default async function CommandK() { export default async function CommandK() {
const [ const [
@ -15,9 +16,13 @@ export default async function CommandK() {
getDataForCategoriesCached(), getDataForCategoriesCached(),
]); ]);
return <CommandKClient const appText = await getAppText();
return (
<CommandKClient
{...categories} {...categories}
showDebugTools={ADMIN_DEBUG_TOOLS_ENABLED} showDebugTools={ADMIN_DEBUG_TOOLS_ENABLED}
footer={photoQuantityText(count, false)} footer={photoQuantityText(count, appText, false)}
/>; />
);
} }

View File

@ -53,7 +53,6 @@ import { addHiddenToTags, formatTag, isTagFavs, isTagHidden } from '@/tag';
import { formatCount, formatCountDescriptive } from '@/utility/string'; import { formatCount, formatCountDescriptive } from '@/utility/string';
import CommandKItem from './CommandKItem'; import CommandKItem from './CommandKItem';
import { import {
APP_TEXT,
CATEGORY_VISIBILITY, CATEGORY_VISIBILITY,
GRID_HOMEPAGE_ENABLED, GRID_HOMEPAGE_ENABLED,
} from '@/app/config'; } from '@/app/config';
@ -78,6 +77,7 @@ import useMaskedScroll from '../components/useMaskedScroll';
import { labelForFilm } from '@/film'; import { labelForFilm } from '@/film';
import IconFavs from '@/components/icons/IconFavs'; import IconFavs from '@/components/icons/IconFavs';
import IconHidden from '@/components/icons/IconHidden'; import IconHidden from '@/components/icons/IconHidden';
import { useAppText } from '@/i18n/state/client';
const DIALOG_TITLE = 'Global Command-K Menu'; const DIALOG_TITLE = 'Global Command-K Menu';
const DIALOG_DESCRIPTION = 'For searching photos, views, and settings'; const DIALOG_DESCRIPTION = 'For searching photos, views, and settings';
@ -157,6 +157,8 @@ export default function CommandKClient({
setShouldDebugRecipeOverlays, setShouldDebugRecipeOverlays,
} = useAppState(); } = useAppState();
const appText = useAppText();
const isOpenRef = useRef(isOpen); const isOpenRef = useRef(isOpen);
const refInput = useRef<HTMLInputElement>(null); const refInput = useRef<HTMLInputElement>(null);
@ -261,7 +263,7 @@ export default function CommandKClient({
setIsLoading(false); setIsLoading(false);
}); });
} }
}, [queryDebounced, isPending]); }, [queryDebounced, isPending, appText]);
useEffect(() => { useEffect(() => {
if (queryLive === '') { if (queryLive === '') {
@ -289,7 +291,7 @@ export default function CommandKClient({
.map(category => { .map(category => {
switch (category) { switch (category) {
case 'cameras': return { case 'cameras': return {
heading: APP_TEXT.category.cameraPlural, heading: appText.category.cameraPlural,
accessory: <IconCamera size={14} />, accessory: <IconCamera size={14} />,
items: cameras.map(({ camera, count }) => ({ items: cameras.map(({ camera, count }) => ({
label: formatCameraText(camera), label: formatCameraText(camera),
@ -299,7 +301,7 @@ export default function CommandKClient({
})), })),
}; };
case 'lenses': return { case 'lenses': return {
heading: APP_TEXT.category.lensPlural, heading: appText.category.lensPlural,
accessory: <IconLens size={14} className="translate-y-[0.5px]" />, accessory: <IconLens size={14} className="translate-y-[0.5px]" />,
items: lenses.map(({ lens, count }) => ({ items: lenses.map(({ lens, count }) => ({
label: formatLensText(lens, 'medium'), label: formatLensText(lens, 'medium'),
@ -310,7 +312,7 @@ export default function CommandKClient({
})), })),
}; };
case 'tags': return { case 'tags': return {
heading: APP_TEXT.category.tagPlural, heading: appText.category.tagPlural,
accessory: <IconTag accessory: <IconTag
size={13} size={13}
className="translate-x-[1px] translate-y-[0.75px]" className="translate-x-[1px] translate-y-[0.75px]"
@ -337,7 +339,7 @@ export default function CommandKClient({
})), })),
}; };
case 'recipes': return { case 'recipes': return {
heading: APP_TEXT.category.recipePlural, heading: appText.category.recipePlural,
accessory: <IconRecipe accessory: <IconRecipe
size={15} size={15}
className="translate-x-[-1px]" className="translate-x-[-1px]"
@ -350,7 +352,7 @@ export default function CommandKClient({
})), })),
}; };
case 'films': return { case 'films': return {
heading: APP_TEXT.category.filmPlural, heading: appText.category.filmPlural,
accessory: <IconFilm size={14} />, accessory: <IconFilm size={14} />,
items: films.map(({ film, count }) => ({ items: films.map(({ film, count }) => ({
label: labelForFilm(film).medium, label: labelForFilm(film).medium,
@ -360,7 +362,7 @@ export default function CommandKClient({
})), })),
}; };
case 'focal-lengths': return { case 'focal-lengths': return {
heading: APP_TEXT.category.focalLengthPlural, heading: appText.category.focalLengthPlural,
accessory: <IconFocalLength className="text-[14px]" />, accessory: <IconFocalLength className="text-[14px]" />,
items: focalLengths.map(({ focal, count }) => ({ items: focalLengths.map(({ focal, count }) => ({
label: formatFocalLength(focal)!, label: formatFocalLength(focal)!,
@ -372,24 +374,32 @@ export default function CommandKClient({
} }
}) })
.filter(Boolean) as CommandKSection[] .filter(Boolean) as CommandKSection[]
, [tagsIncludingHidden, cameras, lenses, recipes, films, focalLengths]); , [
appText,
tagsIncludingHidden,
cameras,
lenses,
recipes,
films,
focalLengths,
]);
const clientSections: CommandKSection[] = [{ const clientSections: CommandKSection[] = [{
heading: APP_TEXT.theme.theme, heading: appText.theme.theme,
accessory: <IoInvertModeSharp accessory: <IoInvertModeSharp
size={14} size={14}
className="translate-y-[0.5px] translate-x-[-1px]" className="translate-y-[0.5px] translate-x-[-1px]"
/>, />,
items: [{ items: [{
label: APP_TEXT.theme.system, label: appText.theme.system,
annotation: <BiDesktop />, annotation: <BiDesktop />,
action: () => setTheme('system'), action: () => setTheme('system'),
}, { }, {
label: APP_TEXT.theme.light, label: appText.theme.light,
annotation: <BiSun size={16} className="translate-x-[1.25px]" />, annotation: <BiSun size={16} className="translate-x-[1.25px]" />,
action: () => setTheme('light'), action: () => setTheme('light'),
}, { }, {
label: APP_TEXT.theme.dark, label: appText.theme.dark,
annotation: <BiMoon className="translate-x-[1px]" />, annotation: <BiMoon className="translate-x-[1px]" />,
action: () => setTheme('dark'), action: () => setTheme('dark'),
}], }],
@ -441,15 +451,15 @@ export default function CommandKClient({
const pageFeed: CommandKItem = { const pageFeed: CommandKItem = {
label: GRID_HOMEPAGE_ENABLED label: GRID_HOMEPAGE_ENABLED
? APP_TEXT.nav.feed ? appText.nav.feed
: `${APP_TEXT.nav.feed} (${APP_TEXT.nav.home})`, : `${appText.nav.feed} (${appText.nav.home})`,
path: PATH_FEED_INFERRED, path: PATH_FEED_INFERRED,
}; };
const pageGrid: CommandKItem = { const pageGrid: CommandKItem = {
label: GRID_HOMEPAGE_ENABLED label: GRID_HOMEPAGE_ENABLED
? `${APP_TEXT.nav.grid} (${APP_TEXT.nav.home})` ? `${appText.nav.grid} (${appText.nav.home})`
: APP_TEXT.nav.grid, : appText.nav.grid,
path: PATH_GRID_INFERRED, path: PATH_GRID_INFERRED,
}; };
@ -471,40 +481,40 @@ export default function CommandKClient({
if (isUserSignedIn) { if (isUserSignedIn) {
adminSection.items.push({ adminSection.items.push({
label: APP_TEXT.admin.uploadPhotos, label: appText.admin.uploadPhotos,
annotation: <IconLock narrow />, annotation: <IconLock narrow />,
action: startUpload, action: startUpload,
}); });
if (uploadsCount) { if (uploadsCount) {
adminSection.items.push({ adminSection.items.push({
label: `${APP_TEXT.admin.uploadPlural} (${uploadsCount})`, label: `${appText.admin.uploadPlural} (${uploadsCount})`,
annotation: <IconLock narrow />, annotation: <IconLock narrow />,
path: PATH_ADMIN_UPLOADS, path: PATH_ADMIN_UPLOADS,
}); });
} }
adminSection.items.push({ adminSection.items.push({
label: `${APP_TEXT.admin.managePhotos} (${photosCountTotal})`, label: `${appText.admin.managePhotos} (${photosCountTotal})`,
annotation: <IconLock narrow />, annotation: <IconLock narrow />,
path: PATH_ADMIN_PHOTOS, path: PATH_ADMIN_PHOTOS,
}); });
if (tagsCount) { if (tagsCount) {
adminSection.items.push({ adminSection.items.push({
label: `${APP_TEXT.admin.manageTags} (${tagsCount})`, label: `${appText.admin.manageTags} (${tagsCount})`,
annotation: <IconLock narrow />, annotation: <IconLock narrow />,
path: PATH_ADMIN_TAGS, path: PATH_ADMIN_TAGS,
}); });
} }
if (recipesCount) { if (recipesCount) {
adminSection.items.push({ adminSection.items.push({
label: `${APP_TEXT.admin.manageRecipes} (${recipesCount})`, label: `${appText.admin.manageRecipes} (${recipesCount})`,
annotation: <IconLock narrow />, annotation: <IconLock narrow />,
path: PATH_ADMIN_RECIPES, path: PATH_ADMIN_RECIPES,
}); });
} }
adminSection.items.push({ adminSection.items.push({
label: selectedPhotoIds === undefined label: selectedPhotoIds === undefined
? APP_TEXT.admin.batchEdit ? appText.admin.batchEdit
: APP_TEXT.admin.batchExitEdit, : appText.admin.batchExitEdit,
annotation: <IconLock narrow />, annotation: <IconLock narrow />,
path: selectedPhotoIds === undefined path: selectedPhotoIds === undefined
? PATH_GRID_INFERRED ? PATH_GRID_INFERRED
@ -514,7 +524,7 @@ export default function CommandKClient({
: () => setSelectedPhotoIds?.(undefined), : () => setSelectedPhotoIds?.(undefined),
}, { }, {
label: <span className="flex items-center gap-3"> label: <span className="flex items-center gap-3">
{APP_TEXT.admin.appInsights} {appText.admin.appInsights}
{insightsIndicatorStatus && {insightsIndicatorStatus &&
<InsightsIndicatorDot />} <InsightsIndicatorDot />}
</span>, </span>,
@ -522,7 +532,7 @@ export default function CommandKClient({
annotation: <IconLock narrow />, annotation: <IconLock narrow />,
path: PATH_ADMIN_INSIGHTS, path: PATH_ADMIN_INSIGHTS,
}, { }, {
label: APP_TEXT.admin.appConfig, label: appText.admin.appConfig,
annotation: <IconLock narrow />, annotation: <IconLock narrow />,
path: PATH_ADMIN_CONFIGURATION, path: PATH_ADMIN_CONFIGURATION,
}); });
@ -538,14 +548,14 @@ export default function CommandKClient({
}); });
} }
adminSection.items.push({ adminSection.items.push({
label: APP_TEXT.auth.signOut, label: appText.auth.signOut,
action: () => signOutAction() action: () => signOutAction()
.then(clearAuthStateAndRedirectIfNecessary) .then(clearAuthStateAndRedirectIfNecessary)
.then(() => setIsOpen?.(false)), .then(() => setIsOpen?.(false)),
}); });
} else { } else {
adminSection.items.push({ adminSection.items.push({
label: APP_TEXT.auth.signIn, label: appText.auth.signIn,
path: PATH_SIGN_IN, path: PATH_SIGN_IN,
}); });
} }
@ -596,7 +606,7 @@ export default function CommandKClient({
'focus:outline-hidden', 'focus:outline-hidden',
isPending && 'opacity-20', isPending && 'opacity-20',
)} )}
placeholder={APP_TEXT.cmdk.placeholder} placeholder={appText.cmdk.placeholder}
disabled={isPending} disabled={isPending}
/> />
{isLoading && !isPending && {isLoading && !isPending &&
@ -617,8 +627,8 @@ export default function CommandKClient({
<div className="px-3 pt-2 pb-3.5 space-y-2"> <div className="px-3 pt-2 pb-3.5 space-y-2">
<Command.Empty className="mt-1 pl-3 text-dim text-base pb-0.5"> <Command.Empty className="mt-1 pl-3 text-dim text-base pb-0.5">
{isLoading {isLoading
? APP_TEXT.cmdk.searching ? appText.cmdk.searching
: APP_TEXT.cmdk.noResults} : appText.cmdk.noResults}
</Command.Empty> </Command.Empty>
{queriedSections {queriedSections
.concat(categorySections) .concat(categorySections)

View File

@ -3,7 +3,7 @@ import LoaderButton from './primitives/LoaderButton';
import clsx from 'clsx/lite'; import clsx from 'clsx/lite';
import { toastSuccess } from '@/toast'; import { toastSuccess } from '@/toast';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function CopyButton({ export default function CopyButton({
label, label,
@ -19,6 +19,7 @@ export default function CopyButton({
iconSize?: number iconSize?: number
className?: string className?: string
} & ComponentProps<typeof LoaderButton>) { } & ComponentProps<typeof LoaderButton>) {
const appText = useAppText();
return ( return (
<LoaderButton <LoaderButton
{...props} {...props}
@ -30,7 +31,7 @@ export default function CopyButton({
onClick={text onClick={text
? () => { ? () => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
toastSuccess(APP_TEXT.misc.copyPhrase(label)); toastSuccess(appText.misc.copyPhrase(label));
} }
: undefined} : undefined}
styleAs="link" styleAs="link"

View File

@ -4,7 +4,7 @@ import { downloadFileNameForPhoto, Photo } from '@/photo';
import LoaderButton from './primitives/LoaderButton'; import LoaderButton from './primitives/LoaderButton';
import { useState } from 'react'; import { useState } from 'react';
import { downloadFileFromBrowser } from '@/utility/url'; import { downloadFileFromBrowser } from '@/utility/url';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function DownloadButton({ export default function DownloadButton({
photo, photo,
@ -15,9 +15,11 @@ export default function DownloadButton({
}) { }) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const appText = useAppText();
return ( return (
<LoaderButton <LoaderButton
tooltip={APP_TEXT.tooltip.download} tooltip={appText.tooltip.download}
className={clsx( className={clsx(
className, className,
'text-medium', 'text-medium',

View File

@ -9,7 +9,7 @@ import { FiUploadCloud } from 'react-icons/fi';
import { MAX_IMAGE_SIZE } from '@/platforms/next-image'; import { MAX_IMAGE_SIZE } from '@/platforms/next-image';
import ProgressButton from './primitives/ProgressButton'; import ProgressButton from './primitives/ProgressButton';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function ImageInput({ export default function ImageInput({
ref: inputRefExternal, ref: inputRefExternal,
@ -55,6 +55,8 @@ export default function ImageInput({
resetUploadState, resetUploadState,
} = useAppState(); } = useAppState();
const appText = useAppText();
const disabled = disabledProp || isUploading; const disabled = disabledProp || isUploading;
return ( return (
@ -85,13 +87,13 @@ export default function ImageInput({
> >
{isUploading {isUploading
? filesLength > 1 ? filesLength > 1
? APP_TEXT.utility.paginateAction( ? appText.utility.paginateAction(
fileUploadIndex + 1, fileUploadIndex + 1,
filesLength, filesLength,
APP_TEXT.admin.uploading, appText.admin.uploading,
) )
: APP_TEXT.admin.uploading : appText.admin.uploading
: APP_TEXT.admin.uploadPhotos} : appText.admin.uploadPhotos}
</ProgressButton>} </ProgressButton>}
<input <input
ref={inputRef} ref={inputRef}

View File

@ -1,13 +1,15 @@
import { APP_TEXT, TEMPLATE_REPO_NAME, TEMPLATE_REPO_URL } from '@/app/config'; import { TEMPLATE_REPO_NAME, TEMPLATE_REPO_URL } from '@/app/config';
import { useAppText } from '@/i18n/state/client';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import Link from 'next/link'; import Link from 'next/link';
import { BiLogoGithub } from 'react-icons/bi'; import { BiLogoGithub } from 'react-icons/bi';
export default function RepoLink() { export default function RepoLink() {
const appText = useAppText();
return ( return (
<span className="inline-flex items-center gap-2 whitespace-nowrap"> <span className="inline-flex items-center gap-2 whitespace-nowrap">
<span className="hidden sm:inline-block"> <span className="hidden sm:inline-block">
{APP_TEXT.misc.repo} {appText.misc.repo}
</span> </span>
<Link <Link
href={TEMPLATE_REPO_URL} href={TEMPLATE_REPO_URL}

View File

@ -1,4 +1,4 @@
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
import { toastWaiting } from '@/toast'; import { toastWaiting } from '@/toast';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useTransition } from 'react'; import { useCallback, useEffect, useRef, useTransition } from 'react';
@ -7,7 +7,7 @@ import { toast } from 'sonner';
export default function useNavigateOrRunActionWithToast({ export default function useNavigateOrRunActionWithToast({
pathOrAction, pathOrAction,
toastMessage = APP_TEXT.misc.loading, toastMessage: _toastMessage,
dismissDelay = 1500, dismissDelay = 1500,
}: { }: {
pathOrAction?: string | (() => Promise<any> | undefined) pathOrAction?: string | (() => Promise<any> | undefined)
@ -16,6 +16,10 @@ export default function useNavigateOrRunActionWithToast({
}) { }) {
const router = useRouter(); const router = useRouter();
const appText = useAppText();
const toastMessage = _toastMessage ?? appText.misc.loading;
const toastId = useRef<string | number>(undefined); const toastId = useRef<string | number>(undefined);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();

View File

@ -7,6 +7,7 @@ import PhotoFilm from '@/film/PhotoFilm';
import { getRecipePropsFromPhotos } from '@/recipe'; import { getRecipePropsFromPhotos } from '@/recipe';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { useAppText } from '@/i18n/state/client';
export default function FilmHeader({ export default function FilmHeader({
film, film,
@ -31,6 +32,8 @@ export default function FilmHeader({
? getRecipePropsFromPhotos(photos, selectedPhoto) ? getRecipePropsFromPhotos(photos, selectedPhoto)
: undefined; : undefined;
const appText = useAppText();
return ( return (
<PhotoHeader <PhotoHeader
film={film} film={film}
@ -42,7 +45,12 @@ export default function FilmHeader({
: undefined} : undefined}
/>} />}
entityDescription={descriptionForFilmPhotos( entityDescription={descriptionForFilmPhotos(
photos, undefined, count, dateRange)} photos,
appText,
undefined,
count,
dateRange,
)}
photos={photos} photos={photos}
selectedPhoto={selectedPhoto} selectedPhoto={selectedPhoto}
indexNumber={indexNumber} indexNumber={indexNumber}

View File

@ -5,8 +5,9 @@ import {
} from '@/app/paths'; } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile'; import OGTile, { OGLoadingState } from '@/components/OGTile';
import { descriptionForFilmPhotos, titleForFilm } from '.'; import { descriptionForFilmPhotos, titleForFilm } from '.';
import { getAppText } from '@/i18n/state/server';
export default function FilmOGTile({ export default async function FilmOGTile({
film, film,
photos, photos,
loadingState: loadingStateExternal, loadingState: loadingStateExternal,
@ -27,11 +28,12 @@ export default function FilmOGTile({
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
const appText = await getAppText();
return ( return (
<OGTile {...{ <OGTile {...{
title: titleForFilm(film, photos, count), title: titleForFilm(film, photos, appText, count),
description: description:
descriptionForFilmPhotos(photos, true, count, dateRange), descriptionForFilmPhotos(photos, appText, true, count, dateRange),
path: pathForFilm(film), path: pathForFilm(film),
pathImageAbsolute: absolutePathForFilmImage(film), pathImageAbsolute: absolutePathForFilmImage(film),
loadingState: loadingStateExternal, loadingState: loadingStateExternal,

View File

@ -3,8 +3,9 @@ import { PhotoSetAttributes } from '../category';
import ShareModal from '@/share/ShareModal'; import ShareModal from '@/share/ShareModal';
import FilmOGTile from './FilmOGTile'; import FilmOGTile from './FilmOGTile';
import { labelForFilm, shareTextForFilm } from '.'; import { labelForFilm, shareTextForFilm } from '.';
import { getAppText } from '@/i18n/state/server';
export default function FilmShareModal({ export default async function FilmShareModal({
film, film,
photos, photos,
count, count,
@ -12,11 +13,12 @@ export default function FilmShareModal({
}: { }: {
film: string film: string
} & PhotoSetAttributes) { } & PhotoSetAttributes) {
const appText = await getAppText();
return ( return (
<ShareModal <ShareModal
pathShare={absolutePathForFilm(film, true)} pathShare={absolutePathForFilm(film, true)}
navigatorTitle={labelForFilm(film).large} navigatorTitle={labelForFilm(film).large}
socialText={shareTextForFilm(film)} socialText={shareTextForFilm(film, appText)}
> >
<FilmOGTile {...{ film, photos, count, dateRange }} /> <FilmOGTile {...{ film, photos, count, dateRange }} />
</ShareModal> </ShareModal>

View File

@ -19,7 +19,7 @@ import {
} from '@/utility/string'; } from '@/utility/string';
import { AnnotatedTag } from '@/photo/form'; import { AnnotatedTag } from '@/photo/form';
import PhotoFilmIcon from './PhotoFilmIcon'; import PhotoFilmIcon from './PhotoFilmIcon';
import { APP_TEXT } from '@/app/config'; import { I18NState } from '@/i18n/state';
export type FilmWithCount = { export type FilmWithCount = {
film: string film: string
@ -59,25 +59,29 @@ export const sortFilmsWithCount = (
export const titleForFilm = ( export const titleForFilm = (
film: string, film: string,
photos: Photo[], photos: Photo[],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
) => [ ) => [
labelForFilm(film).large, labelForFilm(film).large,
photoQuantityText(explicitCount ?? photos.length), photoQuantityText(explicitCount ?? photos.length, appText),
].join(' '); ].join(' ');
export const shareTextForFilm = ( export const shareTextForFilm = (
film: string, film: string,
appText: I18NState,
) => ) =>
APP_TEXT.category.filmShare(labelForFilm(film).large); appText.category.filmShare(labelForFilm(film).large);
export const descriptionForFilmPhotos = ( export const descriptionForFilmPhotos = (
photos: Photo[], photos: Photo[],
appText: I18NState,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ) =>
descriptionForPhotoSet( descriptionForPhotoSet(
photos, photos,
appText,
undefined, undefined,
dateBased, dateBased,
explicitCount, explicitCount,
@ -87,13 +91,15 @@ export const descriptionForFilmPhotos = (
export const generateMetaForFilm = ( export const generateMetaForFilm = (
film: string, film: string,
photos: Photo[], photos: Photo[],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ({ ) => ({
url: absolutePathForFilm(film), url: absolutePathForFilm(film),
title: titleForFilm(film, photos, explicitCount), title: titleForFilm(film, photos, appText, explicitCount),
description: descriptionForFilmPhotos( description: descriptionForFilmPhotos(
photos, photos,
appText,
true, true,
explicitCount, explicitCount,
explicitDateRange, explicitDateRange,

View File

@ -3,6 +3,8 @@ import { descriptionForFocalLengthPhotos } from '.';
import PhotoHeader from '@/photo/PhotoHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import PhotoFocalLength from './PhotoFocalLength'; import PhotoFocalLength from './PhotoFocalLength';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { useAppText } from '@/i18n/state/client';
export default function FocalLengthHeader({ export default function FocalLengthHeader({
focal, focal,
photos, photos,
@ -18,14 +20,17 @@ export default function FocalLengthHeader({
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
const appText = useAppText();
return ( return (
<PhotoHeader <PhotoHeader
focal={focal} focal={focal}
entity={<PhotoFocalLength focal={focal} contrast="high" />} entity={<PhotoFocalLength focal={focal} contrast="high" />}
entityDescription={descriptionForFocalLengthPhotos( entityDescription={descriptionForFocalLengthPhotos(
photos, photos,
appText,
undefined, undefined,
count, count,
dateRange,
)} )}
photos={photos} photos={photos}
selectedPhoto={selectedPhoto} selectedPhoto={selectedPhoto}

View File

@ -5,8 +5,9 @@ import {
} from '@/app/paths'; } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile'; import OGTile, { OGLoadingState } from '@/components/OGTile';
import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.'; import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.';
import { getAppText } from '@/i18n/state/server';
export default function FocalLengthOGTile({ export default async function FocalLengthOGTile({
focal, focal,
photos, photos,
loadingState: loadingStateExternal, loadingState: loadingStateExternal,
@ -27,11 +28,13 @@ export default function FocalLengthOGTile({
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
const appText = await getAppText();
return ( return (
<OGTile {...{ <OGTile {...{
title: titleForFocalLength(focal, photos, count), title: titleForFocalLength(focal, photos, appText, count),
description: descriptionForFocalLengthPhotos( description: descriptionForFocalLengthPhotos(
photos, photos,
appText,
true, true,
count, count,
dateRange, dateRange,

View File

@ -3,8 +3,9 @@ import { PhotoSetAttributes } from '../category';
import ShareModal from '@/share/ShareModal'; import ShareModal from '@/share/ShareModal';
import FocalLengthOGTile from './FocalLengthOGTile'; import FocalLengthOGTile from './FocalLengthOGTile';
import { formatFocalLengthSafe, shareTextFocalLength } from '.'; import { formatFocalLengthSafe, shareTextFocalLength } from '.';
import { getAppText } from '@/i18n/state/server';
export default function FocalLengthShareModal({ export default async function FocalLengthShareModal({
focal, focal,
photos, photos,
count, count,
@ -12,11 +13,12 @@ export default function FocalLengthShareModal({
}: { }: {
focal: number focal: number
} & PhotoSetAttributes) { } & PhotoSetAttributes) {
const appText = await getAppText();
return ( return (
<ShareModal <ShareModal
pathShare={absolutePathForFocalLength(focal, true)} pathShare={absolutePathForFocalLength(focal, true)}
navigatorTitle={formatFocalLengthSafe(focal)} navigatorTitle={formatFocalLengthSafe(focal)}
socialText={shareTextFocalLength(focal)} socialText={shareTextFocalLength(focal, appText)}
> >
<FocalLengthOGTile {...{ focal, photos, count, dateRange }} /> <FocalLengthOGTile {...{ focal, photos, count, dateRange }} />
</ShareModal> </ShareModal>

View File

@ -8,7 +8,7 @@ import {
absolutePathForFocalLength, absolutePathForFocalLength,
absolutePathForFocalLengthImage, absolutePathForFocalLengthImage,
} from '@/app/paths'; } from '@/app/paths';
import { APP_TEXT } from '@/app/config'; import { I18NState } from '@/i18n/state';
export type FocalLengths = { export type FocalLengths = {
focal: number focal: number
@ -30,23 +30,29 @@ export const formatFocalLengthSafe = (focal = 0) =>
export const titleForFocalLength = ( export const titleForFocalLength = (
focal: number, focal: number,
photos: Photo[], photos: Photo[],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
) => [ ) => [
APP_TEXT.category.focalLengthTitle(formatFocalLengthSafe(focal)), appText.category.focalLengthTitle(formatFocalLengthSafe(focal)),
photoQuantityText(explicitCount ?? photos.length), photoQuantityText(explicitCount ?? photos.length, appText),
].join(' '); ].join(' ');
export const shareTextFocalLength = (focal: number) => export const shareTextFocalLength = (
APP_TEXT.category.focalLengthShare(formatFocalLengthSafe(focal)); focal: number,
appText: I18NState,
) =>
appText.category.focalLengthShare(formatFocalLengthSafe(focal));
export const descriptionForFocalLengthPhotos = ( export const descriptionForFocalLengthPhotos = (
photos: Photo[], photos: Photo[],
appText: I18NState,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ) =>
descriptionForPhotoSet( descriptionForPhotoSet(
photos, photos,
appText,
undefined, undefined,
dateBased, dateBased,
explicitCount, explicitCount,
@ -56,13 +62,15 @@ export const descriptionForFocalLengthPhotos = (
export const generateMetaForFocalLength = ( export const generateMetaForFocalLength = (
focal: number, focal: number,
photos: Photo[], photos: Photo[],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ({ ) => ({
url: absolutePathForFocalLength(focal), url: absolutePathForFocalLength(focal),
title: titleForFocalLength(focal, photos, explicitCount), title: titleForFocalLength(focal, photos, appText, explicitCount),
description: descriptionForFocalLengthPhotos( description: descriptionForFocalLengthPhotos(
photos, photos,
appText,
true, true,
explicitCount, explicitCount,
explicitDateRange, explicitDateRange,

12
src/i18n/date.ts Normal file
View File

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

View File

@ -1,7 +1,4 @@
import US_EN from './locales/us-en'; 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; export type I18N = typeof US_EN;
@ -9,71 +6,20 @@ export type I18NDeepPartial = {
[key in keyof I18N]?: Partial<I18N[key]>; [key in keyof I18N]?: Partial<I18N[key]>;
} }
const getDateFnLocale = (locale: string) => { export const LOCALE_TEXT: Record<
switch (locale) { string,
case 'pt-pt': return pt; () => Promise<I18NDeepPartial | undefined>
case 'pt-br': return ptBR; > = {
default: return enUS; 'pt-br': () => import('./locales/pt-br').then((m) => m.default),
} 'pt-pt': () => import('./locales/pt-pt').then((m) => m.default),
}; };
const generateI18NWithFunctions = (i18nText: I18N) => { export const getTextForLocale = async (
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<string, I18NDeepPartial | undefined> = {
'pt-br': PT_BR,
'pt-pt': PT_PT,
};
export const getTextForLocale = (
locale = '', locale = '',
) => { ): Promise<I18N> => {
const text = US_EN; const text = US_EN;
Object.entries(LOCALE_TEXT[locale.toLocaleLowerCase()] ?? {}) Object.entries(await LOCALE_TEXT[locale.toLocaleLowerCase()]?.() ?? {})
.forEach(([key, value]) => { .forEach(([key, value]) => {
// Fall back to English for missing keys // Fall back to English for missing keys
text[key as keyof I18N] = { text[key as keyof I18N] = {
@ -82,5 +28,5 @@ export const getTextForLocale = (
}; };
}); });
return generateI18NWithFunctions(text); return text;
}; };

View File

@ -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 (
<AppTextProviderClient {...{ value }}>
{children}
</AppTextProviderClient>
);
}

View File

@ -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 (
<AppTextContext.Provider value={generateI18NState(value)}>
{children}
</AppTextContext.Provider>
);
}

9
src/i18n/state/client.ts Normal file
View File

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

48
src/i18n/state/index.ts Normal file
View File

@ -0,0 +1,48 @@
import { I18N } from '..';
export type I18NState = ReturnType<typeof generateI18NState>;
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),
},
};
};

6
src/i18n/state/server.ts Normal file
View File

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

View File

@ -4,8 +4,9 @@ import { Lens, lensFromPhoto } from '.';
import PhotoLens from './PhotoLens'; import PhotoLens from './PhotoLens';
import { descriptionForLensPhotos } from './meta'; import { descriptionForLensPhotos } from './meta';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; 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, lens: lensProp,
photos, photos,
selectedPhoto, selectedPhoto,
@ -21,12 +22,19 @@ export default function LensHeader({
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
const lens = lensFromPhoto(photos[0], lensProp); const lens = lensFromPhoto(photos[0], lensProp);
const appText = await getAppText();
return ( return (
<PhotoHeader <PhotoHeader
lens={lens} lens={lens}
entity={<PhotoLens {...{ lens }} contrast="high" />} entity={<PhotoLens {...{ lens }} contrast="high" />}
entityDescription={ entityDescription={
descriptionForLensPhotos(photos, undefined, count, dateRange)} descriptionForLensPhotos(
photos,
appText,
undefined,
count,
dateRange,
)}
photos={photos} photos={photos}
selectedPhoto={selectedPhoto} selectedPhoto={selectedPhoto}
indexNumber={indexNumber} indexNumber={indexNumber}

View File

@ -3,8 +3,9 @@ import { absolutePathForLensImage, pathForLens } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile'; import OGTile, { OGLoadingState } from '@/components/OGTile';
import { Lens } from '.'; import { Lens } from '.';
import { titleForLens, descriptionForLensPhotos } from './meta'; import { titleForLens, descriptionForLensPhotos } from './meta';
import { getAppText } from '@/i18n/state/server';
export default function LensOGTile({ export default async function LensOGTile({
lens, lens,
photos, photos,
loadingState: loadingStateExternal, loadingState: loadingStateExternal,
@ -25,10 +26,17 @@ export default function LensOGTile({
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
const appText = await getAppText();
return ( return (
<OGTile {...{ <OGTile {...{
title: titleForLens(lens, photos, count), title: titleForLens(lens, photos, appText, count),
description: descriptionForLensPhotos(photos, true, count, dateRange), description: descriptionForLensPhotos(
photos,
appText,
true,
count,
dateRange,
),
path: pathForLens(lens), path: pathForLens(lens),
pathImageAbsolute: absolutePathForLensImage(lens), pathImageAbsolute: absolutePathForLensImage(lens),
loadingState: loadingStateExternal, loadingState: loadingStateExternal,

View File

@ -4,8 +4,9 @@ import ShareModal from '@/share/ShareModal';
import { formatLensText, Lens } from '.'; import { formatLensText, Lens } from '.';
import { shareTextForLens } from './meta'; import { shareTextForLens } from './meta';
import LensOGTile from './LensOGTile'; import LensOGTile from './LensOGTile';
import { getAppText } from '@/i18n/state/server';
export default function LensShareModal({ export default async function LensShareModal({
lens, lens,
photos, photos,
count, count,
@ -13,11 +14,12 @@ export default function LensShareModal({
}: { }: {
lens: Lens lens: Lens
} & PhotoSetAttributes) { } & PhotoSetAttributes) {
const appText = await getAppText();
return ( return (
<ShareModal <ShareModal
pathShare={absolutePathForLens(lens, true)} pathShare={absolutePathForLens(lens, true)}
navigatorTitle={formatLensText(lens)} navigatorTitle={formatLensText(lens)}
socialText={shareTextForLens(lens, photos)} socialText={shareTextForLens(lens, photos, appText)}
> >
<LensOGTile {...{ lens, photos, count, dateRange }} /> <LensOGTile {...{ lens, photos, count, dateRange }} />
</ShareModal> </ShareModal>

View File

@ -9,7 +9,7 @@ import {
absolutePathForLens, absolutePathForLens,
absolutePathForLensImage, absolutePathForLensImage,
} from '@/app/paths'; } from '@/app/paths';
import { APP_TEXT } from '@/app/config'; import { I18NState } from '@/i18n/state';
// Meta functions moved to separate file to avoid // Meta functions moved to separate file to avoid
// dependencies (camelcase-keys) found in photo/index.ts // dependencies (camelcase-keys) found in photo/index.ts
@ -18,30 +18,34 @@ import { APP_TEXT } from '@/app/config';
export const titleForLens = ( export const titleForLens = (
lens: Lens, lens: Lens,
photos: Photo[], photos: Photo[],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
) => [ ) => [
`${APP_TEXT.category.lens}:`, `${appText.category.lens}:`,
formatLensText(lensFromPhoto(photos[0], lens)), formatLensText(lensFromPhoto(photos[0], lens)),
photoQuantityText(explicitCount ?? photos.length), photoQuantityText(explicitCount ?? photos.length, appText),
].join(' '); ].join(' ');
export const shareTextForLens = ( export const shareTextForLens = (
lens: Lens, lens: Lens,
photos: Photo[], photos: Photo[],
appText: I18NState,
) => ) =>
[ [
`${APP_TEXT.category.lens}:`, `${appText.category.lens}:`,
formatLensText(lensFromPhoto(photos[0], lens)), formatLensText(lensFromPhoto(photos[0], lens)),
].join(' '); ].join(' ');
export const descriptionForLensPhotos = ( export const descriptionForLensPhotos = (
photos: Photo[], photos: Photo[],
appText: I18NState,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ) =>
descriptionForPhotoSet( descriptionForPhotoSet(
photos, photos,
appText,
undefined, undefined,
dateBased, dateBased,
explicitCount, explicitCount,
@ -51,12 +55,19 @@ export const descriptionForLensPhotos = (
export const generateMetaForLens = ( export const generateMetaForLens = (
lens: Lens, lens: Lens,
photos: Photo[], photos: Photo[],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ({ ) => ({
url: absolutePathForLens(lens), url: absolutePathForLens(lens),
title: titleForLens(lens, photos, explicitCount), title: titleForLens(lens, photos, appText, explicitCount),
description: description:
descriptionForLensPhotos(photos, true, explicitCount, explicitDateRange), descriptionForLensPhotos(
photos,
appText,
true,
explicitCount,
explicitDateRange,
),
images: absolutePathForLensImage(lens), images: absolutePathForLensImage(lens),
}); });

View File

@ -2,7 +2,7 @@ import ResponsiveDate from '@/components/ResponsiveDate';
import { Photo } from '.'; import { Photo } from '.';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Timezone } from '@/utility/timezone'; import { Timezone } from '@/utility/timezone';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function PhotoDate({ export default function PhotoDate({
photo, photo,
@ -31,14 +31,16 @@ export default function PhotoDate({
photo.updatedAt, photo.updatedAt,
]); ]);
const appText = useAppText();
const getTitleLabel = () => { const getTitleLabel = () => {
switch (dateType) { switch (dateType) {
case 'takenAt': case 'takenAt':
return APP_TEXT.photo.taken; return appText.photo.taken;
case 'createdAt': case 'createdAt':
return APP_TEXT.photo.created; return appText.photo.created;
case 'updatedAt': case 'updatedAt':
return APP_TEXT.photo.updated; return appText.photo.updated;
} }
}; };

View File

@ -10,7 +10,7 @@ import FavsTag from '../tag/FavsTag';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import HiddenTag from '@/tag/HiddenTag'; 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 { clsx } from 'clsx/lite';
import PhotoRecipe from '@/recipe/PhotoRecipe'; import PhotoRecipe from '@/recipe/PhotoRecipe';
import IconCamera from '@/components/icons/IconCamera'; import IconCamera from '@/components/icons/IconCamera';
@ -26,6 +26,7 @@ import {
} from '@/category'; } from '@/category';
import PhotoFocalLength from '@/focal/PhotoFocalLength'; import PhotoFocalLength from '@/focal/PhotoFocalLength';
import useElementHeight from '@/utility/useElementHeight'; import useElementHeight from '@/utility/useElementHeight';
import { useAppText } from '@/i18n/state/client';
const APPROXIMATE_ITEM_HEIGHT = 34; const APPROXIMATE_ITEM_HEIGHT = 34;
const ABOUT_HEIGHT_OFFSET = 80; const ABOUT_HEIGHT_OFFSET = 80;
@ -58,6 +59,8 @@ export default function PhotoGridSidebar({
categories, categories,
); );
const appText = useAppText();
const aboutRef = useRef<HTMLParagraphElement>(null); const aboutRef = useRef<HTMLParagraphElement>(null);
const aboutHeight = useElementHeight(aboutRef); const aboutHeight = useElementHeight(aboutRef);
const height = containerHeight const height = containerHeight
@ -72,7 +75,10 @@ export default function PhotoGridSidebar({
) )
: undefined; : undefined;
const { start, end } = dateRangeForPhotos(undefined, photosDateRange); const { start, end } = dateRangeForPhotos(
undefined,
photosDateRange,
);
const { photosCountHidden } = useAppState(); const { photosCountHidden } = useAppState();
@ -83,7 +89,7 @@ export default function PhotoGridSidebar({
const camerasContent = cameras.length > 0 const camerasContent = cameras.length > 0
? <HeaderList ? <HeaderList
key="cameras" key="cameras"
title={APP_TEXT.category.cameraPlural} title={appText.category.cameraPlural}
icon={<IconCamera icon={<IconCamera
size={15} size={15}
className="translate-x-[0.5px]" className="translate-x-[0.5px]"
@ -107,7 +113,7 @@ export default function PhotoGridSidebar({
const lensesContent = lenses.length > 0 const lensesContent = lenses.length > 0
? <HeaderList ? <HeaderList
key="lenses" key="lenses"
title={APP_TEXT.category.lensPlural} title={appText.category.lensPlural}
icon={<IconLens size={15} />} icon={<IconLens size={15} />}
maxItems={maxItemsPerCategory} maxItems={maxItemsPerCategory}
items={lenses items={lenses
@ -127,7 +133,7 @@ export default function PhotoGridSidebar({
const tagsContent = tags.length > 0 const tagsContent = tags.length > 0
? <HeaderList ? <HeaderList
key="tags" key="tags"
title={APP_TEXT.category.tagPlural} title={appText.category.tagPlural}
icon={<IconTag icon={<IconTag
size={14} size={14}
className="translate-x-[1px] translate-y-[1px]" className="translate-x-[1px] translate-y-[1px]"
@ -172,7 +178,7 @@ export default function PhotoGridSidebar({
const recipesContent = recipes.length > 0 const recipesContent = recipes.length > 0
? <HeaderList ? <HeaderList
key="recipes" key="recipes"
title={APP_TEXT.category.recipePlural} title={appText.category.recipePlural}
icon={<IconRecipe icon={<IconRecipe
size={16} size={16}
className="translate-x-[-1px]" className="translate-x-[-1px]"
@ -195,7 +201,7 @@ export default function PhotoGridSidebar({
const filmsContent = films.length > 0 const filmsContent = films.length > 0
? <HeaderList ? <HeaderList
key="films" key="films"
title={APP_TEXT.category.filmPlural} title={appText.category.filmPlural}
icon={<IconFilm size={15} />} icon={<IconFilm size={15} />}
maxItems={maxItemsPerCategory} maxItems={maxItemsPerCategory}
items={films items={films
@ -213,7 +219,7 @@ export default function PhotoGridSidebar({
const focalLengthsContent = focalLengths.length > 0 const focalLengthsContent = focalLengths.length > 0
? <HeaderList ? <HeaderList
key="focal-lengths" key="focal-lengths"
title={APP_TEXT.category.focalLengthPlural} title={appText.category.focalLengthPlural}
icon={<IconFocalLength size={13} />} icon={<IconFocalLength size={13} />}
maxItems={maxItemsPerCategory} maxItems={maxItemsPerCategory}
items={focalLengths.map(({ focal, count }) => items={focalLengths.map(({ focal, count }) =>
@ -232,14 +238,14 @@ export default function PhotoGridSidebar({
? start ? start
? <HeaderList ? <HeaderList
key="photo-stats" key="photo-stats"
title={photoQuantityText(photosCount, false)} title={photoQuantityText(photosCount, appText, false)}
items={start === end items={start === end
? [start] ? [start]
: [`${end} `, start]} : [`${end} `, start]}
/> />
: <HeaderList : <HeaderList
key="photo-stats" key="photo-stats"
items={[photoQuantityText(photosCount, false)]} items={[photoQuantityText(photosCount, appText, false)]}
/> />
: null; : null;

View File

@ -17,13 +17,13 @@ import PhotoLink from './PhotoLink';
import ResponsiveText from '@/components/primitives/ResponsiveText'; import ResponsiveText from '@/components/primitives/ResponsiveText';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { GRID_GAP_CLASSNAME } from '@/components'; import { GRID_GAP_CLASSNAME } from '@/components';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function PhotoHeader({ export default function PhotoHeader({
photos, photos,
selectedPhoto, selectedPhoto,
entity, entity,
entityVerb = APP_TEXT.photo.photo.toLocaleUpperCase(), entityVerb: _entityVerb,
entityDescription, entityDescription,
indexNumber, indexNumber,
count, count,
@ -45,6 +45,10 @@ export default function PhotoHeader({
} & PhotoSetCategory) { } & PhotoSetCategory) {
const { isGridHighDensity } = useAppState(); const { isGridHighDensity } = useAppState();
const appText = useAppText();
const entityVerb = _entityVerb ?? appText.photo.photo.toLocaleUpperCase();
const { start, end } = dateRangeForPhotos(photos, dateRange); const { start, end } = dateRangeForPhotos(photos, dateRange);
const selectedPhotoIndex = selectedPhoto const selectedPhotoIndex = selectedPhoto
@ -155,13 +159,13 @@ export default function PhotoHeader({
}} />} }} />}
</> </>
: <ResponsiveText : <ResponsiveText
shortText={APP_TEXT.utility.paginateAction( shortText={appText.utility.paginateAction(
paginationIndex, paginationIndex,
paginationCount, paginationCount,
entityVerb, entityVerb,
)} )}
> >
{APP_TEXT.utility.paginateAction( {appText.utility.paginateAction(
paginationIndex, paginationIndex,
paginationCount, paginationCount,
entityVerb)} entityVerb)}

View File

@ -31,7 +31,6 @@ import {
SHOW_TAKEN_AT_TIME, SHOW_TAKEN_AT_TIME,
MATTE_COLOR, MATTE_COLOR,
MATTE_COLOR_DARK, MATTE_COLOR_DARK,
APP_TEXT,
} from '@/app/config'; } from '@/app/config';
import AdminPhotoMenu from '@/admin/AdminPhotoMenu'; import AdminPhotoMenu from '@/admin/AdminPhotoMenu';
import { RevalidatePhoto } from './InfinitePhotoScroll'; import { RevalidatePhoto } from './InfinitePhotoScroll';
@ -51,6 +50,7 @@ import PhotoLens from '@/lens/PhotoLens';
import { lensFromPhoto } from '@/lens'; import { lensFromPhoto } from '@/lens';
import MaskedScroll from '@/components/MaskedScroll'; import MaskedScroll from '@/components/MaskedScroll';
import useCategoryCountsForPhoto from '@/category/useCategoryCountsForPhoto'; import useCategoryCountsForPhoto from '@/category/useCategoryCountsForPhoto';
import { useAppText } from '@/i18n/state/client';
export default function PhotoLarge({ export default function PhotoLarge({
photo, photo,
@ -117,6 +117,8 @@ export default function PhotoLarge({
isUserSignedIn, isUserSignedIn,
} = useAppState(); } = useAppState();
const appText = useAppText();
const { const {
cameraCount, cameraCount,
lensCount, lensCount,
@ -379,7 +381,7 @@ export default function PhotoLarge({
<> <>
{' '} {' '}
<Tooltip <Tooltip
content={APP_TEXT.tooltip['35mm']} content={appText.tooltip['35mm']}
sideOffset={3} sideOffset={3}
supportMobile supportMobile
> >
@ -435,7 +437,7 @@ export default function PhotoLarge({
)}> )}>
{showZoomControls && {showZoomControls &&
<LoaderButton <LoaderButton
tooltip={APP_TEXT.tooltip.zoom} tooltip={appText.tooltip.zoom}
icon={<LuExpand size={15} />} icon={<LuExpand size={15} />}
onClick={() => refZoomControls.current?.open()} onClick={() => refZoomControls.current?.open()}
styleAs="link" styleAs="link"
@ -444,7 +446,7 @@ export default function PhotoLarge({
/>} />}
{shouldShare && {shouldShare &&
<ShareButton <ShareButton
tooltip={APP_TEXT.tooltip.sharePhoto} tooltip={appText.tooltip.sharePhoto}
photo={photo} photo={photo}
tag={shouldShareTag tag={shouldShareTag
? primaryTag ? primaryTag

View File

@ -25,13 +25,13 @@ import { isPhotoFav } from '@/tag';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import { import {
ALLOW_PUBLIC_DOWNLOADS, ALLOW_PUBLIC_DOWNLOADS,
APP_TEXT,
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS, SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
} from '@/app/config'; } from '@/app/config';
import { downloadFileFromBrowser } from '@/utility/url'; import { downloadFileFromBrowser } from '@/utility/url';
import useKeydownHandler from '@/utility/useKeydownHandler'; import useKeydownHandler from '@/utility/useKeydownHandler';
import { KEY_COMMANDS } from './key-commands'; import { KEY_COMMANDS } from './key-commands';
import { syncPhotoConfirmText } from '@/admin/confirm'; import { syncPhotoConfirmText } from '@/admin/confirm';
import { useAppText } from '@/i18n/state/client';
const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 }; const ANIMATION_LEFT: AnimationConfig = { type: 'left', duration: 0.3 };
const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 }; const ANIMATION_RIGHT: AnimationConfig = { type: 'right', duration: 0.3 };
@ -50,6 +50,8 @@ export default function PhotoPrevNextActions({
} & PhotoSetCategory) { } & PhotoSetCategory) {
const { setNextPhotoAnimation, isUserSignedIn } = useAppState(); const { setNextPhotoAnimation, isUserSignedIn } = useAppState();
const appText = useAppText();
const photoTitle = photo const photoTitle = photo
? photo.title ? photo.title
? `'${photo.title}'` ? `'${photo.title}'`
@ -201,7 +203,7 @@ export default function PhotoPrevNextActions({
'*:select-none', '*:select-none',
)}> )}>
<Tooltip {...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { <Tooltip {...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: APP_TEXT.nav.prev, content: appText.nav.prev,
keyCommand: KEY_COMMANDS.prev[0], keyCommand: KEY_COMMANDS.prev[0],
}}> }}>
<PhotoLink <PhotoLink
@ -215,7 +217,7 @@ export default function PhotoPrevNextActions({
> >
<FiChevronLeft className="sm:hidden text-[1.1rem]" /> <FiChevronLeft className="sm:hidden text-[1.1rem]" />
<span className="hidden sm:inline-block uppercase"> <span className="hidden sm:inline-block uppercase">
{APP_TEXT.nav.prevShort} {appText.nav.prevShort}
</span> </span>
</PhotoLink> </PhotoLink>
</Tooltip> </Tooltip>
@ -223,7 +225,7 @@ export default function PhotoPrevNextActions({
/ /
</span> </span>
<Tooltip {...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && { <Tooltip {...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: APP_TEXT.nav.next, content: appText.nav.next,
keyCommand: KEY_COMMANDS.next[0], keyCommand: KEY_COMMANDS.next[0],
}}> }}>
<PhotoLink <PhotoLink
@ -237,7 +239,7 @@ export default function PhotoPrevNextActions({
> >
<FiChevronRight className="sm:hidden text-[1.1rem]" /> <FiChevronRight className="sm:hidden text-[1.1rem]" />
<span className="hidden sm:inline-block uppercase"> <span className="hidden sm:inline-block uppercase">
{APP_TEXT.nav.nextShort} {appText.nav.nextShort}
</span> </span>
</PhotoLink> </PhotoLink>
</Tooltip> </Tooltip>

View File

@ -11,7 +11,7 @@ import { useRef } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
import ResponsiveText from '@/components/primitives/ResponsiveText'; import ResponsiveText from '@/components/primitives/ResponsiveText';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function PhotoUploadWithStatus({ export default function PhotoUploadWithStatus({
inputRef, inputRef,
@ -45,6 +45,8 @@ export default function PhotoUploadWithStatus({
resetUploadState, resetUploadState,
} = useAppState(); } = useAppState();
const appText = useAppText();
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
@ -77,7 +79,7 @@ export default function PhotoUploadWithStatus({
const isFinishing = isPending && shouldResetUploadStateAfterPending.current; const isFinishing = isPending && shouldResetUploadStateAfterPending.current;
const uploadStatusText = filesLength > 1 const uploadStatusText = filesLength > 1
? APP_TEXT.utility.paginate(fileUploadIndex + 1, filesLength) ? appText.utility.paginate(fileUploadIndex + 1, filesLength)
: undefined; : undefined;
return ( return (
@ -160,19 +162,19 @@ export default function PhotoUploadWithStatus({
{isUploading {isUploading
? isFinishing ? isFinishing
? <> ? <>
{APP_TEXT.misc.finishing} {appText.misc.finishing}
</> </>
: <> : <>
{!showButton && uploadStatusText {!showButton && uploadStatusText
? <> ? <>
<ResponsiveText shortText={uploadStatusText}> <ResponsiveText shortText={uploadStatusText}>
{APP_TEXT.misc.uploading} {uploadStatusText} {appText.misc.uploading} {uploadStatusText}
</ResponsiveText> </ResponsiveText>
{': '} {': '}
{fileUploadName} {fileUploadName}
</> </>
: <ResponsiveText shortText={fileUploadName}> : <ResponsiveText shortText={fileUploadName}>
{APP_TEXT.misc.uploading} {fileUploadName} {appText.misc.uploading} {fileUploadName}
</ResponsiveText>} </ResponsiveText>}
</> </>
: !showButton && <>Initializing</>} : !showButton && <>Initializing</>}

View File

@ -1,7 +1,6 @@
import Container from '@/components/Container'; import Container from '@/components/Container';
import AppGrid from '@/components/AppGrid'; import AppGrid from '@/components/AppGrid';
import { import {
APP_TEXT,
IS_SITE_READY, IS_SITE_READY,
PRESERVE_ORIGINAL_UPLOADS, PRESERVE_ORIGINAL_UPLOADS,
} from '@/app/config'; } from '@/app/config';
@ -13,8 +12,11 @@ import SignInOrUploadClient from '@/admin/SignInOrUploadClient';
import Link from 'next/link'; import Link from 'next/link';
import { PATH_ADMIN_CONFIGURATION } from '@/app/paths'; import { PATH_ADMIN_CONFIGURATION } from '@/app/paths';
import AnimateItems from '@/components/AnimateItems'; 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 ( return (
<AppGrid <AppGrid
contentMain={ contentMain={
@ -34,8 +36,8 @@ export default function PhotosEmptyState() {
'text-gray-700 dark:text-gray-200', 'text-gray-700 dark:text-gray-200',
)}> )}>
{!IS_SITE_READY {!IS_SITE_READY
? APP_TEXT.onboarding.setupIncomplete ? appText.onboarding.setupIncomplete
: APP_TEXT.onboarding.setupComplete} : appText.onboarding.setupComplete}
</div> </div>
{!IS_SITE_READY {!IS_SITE_READY
? <AdminAppConfiguration simplifiedView /> ? <AdminAppConfiguration simplifiedView />
@ -49,7 +51,7 @@ export default function PhotosEmptyState() {
}} }}
/> />
<div> <div>
{APP_TEXT.onboarding.setupConfig} {appText.onboarding.setupConfig}
{' '} {' '}
<Link <Link
href={PATH_ADMIN_CONFIGURATION} href={PATH_ADMIN_CONFIGURATION}

View File

@ -46,6 +46,7 @@ import { isMakeFujifilm } from '@/platforms/fujifilm';
import PhotoFilmIcon from '@/film/PhotoFilmIcon'; import PhotoFilmIcon from '@/film/PhotoFilmIcon';
import FieldsetFavs from './FieldsetFavs'; import FieldsetFavs from './FieldsetFavs';
import FieldsetHidden from './FieldsetHidden'; import FieldsetHidden from './FieldsetHidden';
import { useAppText } from '@/i18n/state/client';
const THUMBNAIL_SIZE = 300; const THUMBNAIL_SIZE = 300;
@ -84,6 +85,8 @@ export default function PhotoForm({
const { invalidateSwr, shouldDebugImageFallbacks } = useAppState(); const { invalidateSwr, shouldDebugImageFallbacks } = useAppState();
const appText = useAppText();
const changedFormKeys = useMemo(() => const changedFormKeys = useMemo(() =>
getChangedFormFields(initialPhotoForm, formData), getChangedFormFields(initialPhotoForm, formData),
[initialPhotoForm, formData]); [initialPhotoForm, formData]);
@ -328,7 +331,7 @@ export default function PhotoForm({
{/* Fields */} {/* Fields */}
<div className="space-y-6"> <div className="space-y-6">
{FORM_METADATA_ENTRIES( {FORM_METADATA_ENTRIES(
convertTagsForForm(uniqueTags), convertTagsForForm(uniqueTags, appText),
convertRecipesForForm(uniqueRecipes), convertRecipesForForm(uniqueRecipes),
convertFilmsForForm(uniqueFilms, isMakeFujifilm(formData.make)), convertFilmsForForm(uniqueFilms, isMakeFujifilm(formData.make)),
aiContent !== undefined, aiContent !== undefined,

View File

@ -2,7 +2,6 @@ import { formatFocalLength } from '@/focal';
import { getNextImageUrlForRequest } from '@/platforms/next-image'; import { getNextImageUrlForRequest } from '@/platforms/next-image';
import { photoHasFilmData } from '@/film'; import { photoHasFilmData } from '@/film';
import { import {
APP_TEXT,
HIGH_DENSITY_GRID, HIGH_DENSITY_GRID,
IS_PREVIEW, IS_PREVIEW,
SHOW_EXIF_DATA, SHOW_EXIF_DATA,
@ -25,6 +24,7 @@ import type { Metadata } from 'next';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation'; import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import { PhotoSyncStatus, generatePhotoSyncStatus } from './sync'; import { PhotoSyncStatus, generatePhotoSyncStatus } from './sync';
import { I18NState } from '@/i18n/state';
// INFINITE SCROLL: FEED // INFINITE SCROLL: FEED
export const INFINITE_SCROLL_FEED_INITIAL = export const INFINITE_SCROLL_FEED_INITIAL =
@ -232,10 +232,14 @@ export const titleForPhoto = (
export const altTextForPhoto = (photo: Photo) => export const altTextForPhoto = (photo: Photo) =>
photo.semanticDescription || titleForPhoto(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 const label = count === 1
? APP_TEXT.photo.photo ? appText.photo.photo
: APP_TEXT.photo.photoPlural; : appText.photo.photoPlural;
return _capitalize return _capitalize
? capitalize(label) ? capitalize(label)
: label; : label;
@ -243,31 +247,38 @@ export const photoLabelForCount = (count: number, _capitalize = true) => {
export const photoQuantityText = ( export const photoQuantityText = (
count: number, count: number,
appText: I18NState,
includeParentheses = true, includeParentheses = true,
capitalize?: boolean, capitalize?: boolean,
) => ) =>
includeParentheses includeParentheses
? `(${count} ${photoLabelForCount(count, capitalize)})` ? `(${count} ${photoLabelForCount(count, appText, capitalize)})`
: `${count} ${photoLabelForCount(count, capitalize)}`; : `${count} ${photoLabelForCount(count, appText, capitalize)}`;
export const deleteConfirmationTextForPhoto = (photo: Photo) => export const deleteConfirmationTextForPhoto = (
APP_TEXT.admin.deleteConfirm(titleForPhoto(photo)); photo: Photo,
appText: I18NState,
) =>
appText.admin.deleteConfirm(titleForPhoto(photo));
export type PhotoDateRange = { start: string, end: string }; export type PhotoDateRange = { start: string, end: string };
export const descriptionForPhotoSet = ( export const descriptionForPhotoSet = (
photos:Photo[] = [], photos:Photo[] = [],
appText: I18NState,
descriptor?: string, descriptor?: string,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ) =>
dateBased dateBased
? dateRangeForPhotos(photos, explicitDateRange).description.toUpperCase() ? dateRangeForPhotos(photos, explicitDateRange)
.description
.toLocaleUpperCase()
: [ : [
explicitCount ?? photos.length, ( explicitCount ?? photos.length, (
descriptor || descriptor ||
photoLabelForCount(explicitCount ?? photos.length, false) photoLabelForCount(explicitCount ?? photos.length, appText, false)
), ),
].join(' '); ].join(' ');

View File

@ -19,7 +19,7 @@ import { TbChecklist } from 'react-icons/tb';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import { labelForFilm } from '@/film'; import { labelForFilm } from '@/film';
import PhotoRecipe from './PhotoRecipe'; import PhotoRecipe from './PhotoRecipe';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function PhotoRecipeOverlay({ export default function PhotoRecipeOverlay({
ref, ref,
@ -47,6 +47,8 @@ export default function PhotoRecipeOverlay({
bwMagentaGreen, bwMagentaGreen,
} = data; } = data;
const appText = useAppText();
const whiteBalanceTypeFormatted = formatWhiteBalance(data); const whiteBalanceTypeFormatted = formatWhiteBalance(data);
const renderDataSquare = ( const renderDataSquare = (
@ -139,7 +141,7 @@ export default function PhotoRecipeOverlay({
'text-black/40 active:text-black/75', 'text-black/40 active:text-black/75',
'hover:text-black/40', 'hover:text-black/40',
)} )}
tooltip={APP_TEXT.tooltip.recipeCopy} tooltip={appText.tooltip.recipeCopy}
tooltipColor="frosted" tooltipColor="frosted"
/> />
<span> <span>

View File

@ -4,7 +4,7 @@ import clsx from 'clsx/lite';
import { FaPlus } from 'react-icons/fa6'; import { FaPlus } from 'react-icons/fa6';
import Tooltip from '@/components/Tooltip'; import Tooltip from '@/components/Tooltip';
import { useRef } from 'react'; import { useRef } from 'react';
import { APP_TEXT } from '@/app/config'; import { useAppText } from '@/i18n/state/client';
export default function PhotoRecipeOverlayButton({ export default function PhotoRecipeOverlayButton({
className, className,
@ -17,8 +17,10 @@ export default function PhotoRecipeOverlayButton({
}) { }) {
const ref = useRef<HTMLButtonElement>(null); const ref = useRef<HTMLButtonElement>(null);
const appText = useAppText();
return ( return (
<Tooltip content={APP_TEXT.tooltip.recipeInfo}> <Tooltip content={appText.tooltip.recipeInfo}>
<button <button
ref={ref} ref={ref}
onClick={() => { onClick={() => {

View File

@ -6,6 +6,7 @@ import PhotoRecipe from './PhotoRecipe';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import { descriptionForRecipePhotos, getRecipePropsFromPhotos } from '.'; import { descriptionForRecipePhotos, getRecipePropsFromPhotos } from '.';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { useAppText } from '@/i18n/state/client';
export default function RecipeHeader({ export default function RecipeHeader({
recipe, recipe,
@ -24,6 +25,8 @@ export default function RecipeHeader({
}) { }) {
const { recipeModalProps, setRecipeModalProps } = useAppState(); const { recipeModalProps, setRecipeModalProps } = useAppState();
const appText = useAppText();
const recipeProps = getRecipePropsFromPhotos(photos, selectedPhoto); const recipeProps = getRecipePropsFromPhotos(photos, selectedPhoto);
return ( return (
@ -37,7 +40,13 @@ export default function RecipeHeader({
? () => setRecipeModalProps?.(recipeProps) ? () => setRecipeModalProps?.(recipeProps)
: undefined} : undefined}
/>} />}
entityDescription={descriptionForRecipePhotos(photos, undefined, count)} entityDescription={descriptionForRecipePhotos(
photos,
appText,
undefined,
count,
dateRange,
)}
photos={photos} photos={photos}
selectedPhoto={selectedPhoto} selectedPhoto={selectedPhoto}
indexNumber={indexNumber} indexNumber={indexNumber}

View File

@ -2,8 +2,9 @@ import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForRecipeImage, pathForRecipe } from '@/app/paths'; import { absolutePathForRecipeImage, pathForRecipe } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile'; import OGTile, { OGLoadingState } from '@/components/OGTile';
import { descriptionForRecipePhotos, titleForRecipe } from '.'; import { descriptionForRecipePhotos, titleForRecipe } from '.';
import { getAppText } from '@/i18n/state/server';
export default function RecipeOGTile({ export default async function RecipeOGTile({
recipe, recipe,
photos, photos,
loadingState: loadingStateExternal, loadingState: loadingStateExternal,
@ -24,10 +25,17 @@ export default function RecipeOGTile({
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
const appText = await getAppText();
return ( return (
<OGTile {...{ <OGTile {...{
title: titleForRecipe(recipe, photos, count), title: titleForRecipe(recipe, photos, appText, count),
description: descriptionForRecipePhotos(photos, true, count, dateRange), description: descriptionForRecipePhotos(
photos,
appText,
true,
count,
dateRange,
),
path: pathForRecipe(recipe), path: pathForRecipe(recipe),
pathImageAbsolute: absolutePathForRecipeImage(recipe), pathImageAbsolute: absolutePathForRecipeImage(recipe),
loadingState: loadingStateExternal, loadingState: loadingStateExternal,

View File

@ -8,8 +8,9 @@ import {
generateRecipeText, generateRecipeText,
} from '.'; } from '.';
import RecipeOGTile from './RecipeOGTile'; import RecipeOGTile from './RecipeOGTile';
import { getAppText } from '@/i18n/state/server';
export default function RecipeShareModal({ export default async function RecipeShareModal({
recipe, recipe,
photos, photos,
count, count,
@ -23,10 +24,12 @@ export default function RecipeShareModal({
? generateRecipeText({ data, film }) ? generateRecipeText({ data, film })
: undefined; : undefined;
const appText = await getAppText();
return ( return (
<ShareModal <ShareModal
pathShare={absolutePathForRecipe(recipe, true)} pathShare={absolutePathForRecipe(recipe, true)}
socialText={shareTextForRecipe(recipe)} socialText={shareTextForRecipe(recipe, appText)}
navigatorTitle={formatRecipe(recipe)} navigatorTitle={formatRecipe(recipe)}
navigatorText={recipeText} navigatorText={recipeText}
> >

View File

@ -8,7 +8,7 @@ import {
} from '@/utility/string'; } from '@/utility/string';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe'; import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { labelForFilm } from '@/film'; import { labelForFilm } from '@/film';
import { APP_TEXT } from '@/app/config'; import { I18NState } from '@/i18n/state';
export type RecipeWithCount = { export type RecipeWithCount = {
recipe: string recipe: string
@ -31,23 +31,29 @@ export const formatRecipe = (recipe?: string) =>
export const titleForRecipe = ( export const titleForRecipe = (
recipe: string, recipe: string,
photos:Photo[] = [], photos:Photo[] = [],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
) => [ ) => [
`${APP_TEXT.category.recipe}: ${formatRecipe(recipe)}`, `${appText.category.recipe}: ${formatRecipe(recipe)}`,
photoQuantityText(explicitCount ?? photos.length), photoQuantityText(explicitCount ?? photos.length, appText),
].join(' '); ].join(' ');
export const shareTextForRecipe = (recipe: string) => export const shareTextForRecipe = (
APP_TEXT.category.recipeShare(formatRecipe(recipe)); recipe: string,
appText: I18NState,
) =>
appText.category.recipeShare(formatRecipe(recipe));
export const descriptionForRecipePhotos = ( export const descriptionForRecipePhotos = (
photos: Photo[] = [], photos: Photo[] = [],
appText: I18NState,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ) =>
descriptionForPhotoSet( descriptionForPhotoSet(
photos, photos,
appText,
undefined, undefined,
dateBased, dateBased,
explicitCount, explicitCount,
@ -139,13 +145,20 @@ export const generateRecipeText = (
export const generateMetaForRecipe = ( export const generateMetaForRecipe = (
recipe: string, recipe: string,
photos: Photo[], photos: Photo[],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ({ ) => ({
url: absolutePathForRecipe(recipe), url: absolutePathForRecipe(recipe),
title: titleForRecipe(recipe, photos, explicitCount), title: titleForRecipe(recipe, photos, appText, explicitCount),
description: description:
descriptionForRecipePhotos(photos, true, explicitCount, explicitDateRange), descriptionForRecipePhotos(
photos,
appText,
true,
explicitCount,
explicitDateRange,
),
images: absolutePathForRecipeImage(recipe), images: absolutePathForRecipeImage(recipe),
}); });

View File

@ -8,12 +8,13 @@ import { ReactNode, useEffect } from 'react';
import { shortenUrl } from '@/utility/url'; import { shortenUrl } from '@/utility/url';
import { toastSuccess } from '@/toast'; import { toastSuccess } from '@/toast';
import { PiXLogo } from 'react-icons/pi'; import { PiXLogo } from 'react-icons/pi';
import { APP_TEXT, SHOW_SOCIAL } from '@/app/config'; import { SHOW_SOCIAL } from '@/app/config';
import { generateXPostText } from '@/utility/social'; import { generateXPostText } from '@/utility/social';
import { useAppState } from '@/state/AppState'; import { useAppState } from '@/state/AppState';
import useOnPathChange from '@/utility/useOnPathChange'; import useOnPathChange from '@/utility/useOnPathChange';
import { IoArrowUp } from 'react-icons/io5'; import { IoArrowUp } from 'react-icons/io5';
import MaskedScroll from '@/components/MaskedScroll'; import MaskedScroll from '@/components/MaskedScroll';
import { useAppText } from '@/i18n/state/client';
export default function ShareModal({ export default function ShareModal({
title, title,
@ -35,6 +36,8 @@ export default function ShareModal({
setShouldRespondToKeyboardCommands, setShouldRespondToKeyboardCommands,
} = useAppState(); } = useAppState();
const appText = useAppText();
useEffect(() => { useEffect(() => {
setShouldRespondToKeyboardCommands?.(false); setShouldRespondToKeyboardCommands?.(false);
return () => setShouldRespondToKeyboardCommands?.(true); return () => setShouldRespondToKeyboardCommands?.(true);
@ -96,7 +99,7 @@ export default function ShareModal({
<BiCopy size={18} />, <BiCopy size={18} />,
() => { () => {
navigator.clipboard.writeText(pathShare); navigator.clipboard.writeText(pathShare);
toastSuccess(APP_TEXT.photo.copied); toastSuccess(appText.photo.copied);
}, },
true, true,
)} )}

View File

@ -2,8 +2,9 @@ import { Photo, photoQuantityText } from '@/photo';
import PhotoHeader from '@/photo/PhotoHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import HiddenTag from './HiddenTag'; import HiddenTag from './HiddenTag';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config'; import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { getAppText } from '@/i18n/state/server';
export default function HiddenHeader({ export default async function HiddenHeader({
photos, photos,
selectedPhoto, selectedPhoto,
indexNumber, indexNumber,
@ -14,11 +15,12 @@ export default function HiddenHeader({
indexNumber?: number indexNumber?: number
count: number count: number
}) { }) {
const appText = await getAppText();
return ( return (
<PhotoHeader <PhotoHeader
key="HiddenHeader" key="HiddenHeader"
entity={<HiddenTag contrast="high" />} entity={<HiddenTag contrast="high" />}
entityDescription={photoQuantityText(count, false)} entityDescription={photoQuantityText(count, appText, false, false)}
photos={photos} photos={photos}
selectedPhoto={selectedPhoto} selectedPhoto={selectedPhoto}
indexNumber={indexNumber} indexNumber={indexNumber}

View File

@ -3,9 +3,10 @@ import PhotoTag from './PhotoTag';
import { descriptionForTaggedPhotos, isTagFavs } from '.'; import { descriptionForTaggedPhotos, isTagFavs } from '.';
import PhotoHeader from '@/photo/PhotoHeader'; import PhotoHeader from '@/photo/PhotoHeader';
import FavsTag from './FavsTag'; import FavsTag from './FavsTag';
import { AI_TEXT_GENERATION_ENABLED, APP_TEXT } from '@/app/config'; import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { getAppText } from '@/i18n/state/server';
export default function TagHeader({ export default async function TagHeader({
tag, tag,
photos, photos,
selectedPhoto, selectedPhoto,
@ -20,14 +21,20 @@ export default function TagHeader({
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
const appText = await getAppText();
return ( return (
<PhotoHeader <PhotoHeader
tag={tag} tag={tag}
entity={isTagFavs(tag) entity={isTagFavs(tag)
? <FavsTag contrast="high" /> ? <FavsTag contrast="high" />
: <PhotoTag tag={tag} contrast="high" />} : <PhotoTag tag={tag} contrast="high" />}
entityVerb={APP_TEXT.category.taggedPhotos} entityVerb={appText.category.taggedPhotos}
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)} entityDescription={descriptionForTaggedPhotos(
photos,
appText,
undefined,
count,
)}
photos={photos} photos={photos}
selectedPhoto={selectedPhoto} selectedPhoto={selectedPhoto}
indexNumber={indexNumber} indexNumber={indexNumber}

View File

@ -2,8 +2,9 @@ import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForTagImage, pathForTag } from '@/app/paths'; import { absolutePathForTagImage, pathForTag } from '@/app/paths';
import OGTile, { OGLoadingState } from '@/components/OGTile'; import OGTile, { OGLoadingState } from '@/components/OGTile';
import { descriptionForTaggedPhotos, titleForTag } from '.'; import { descriptionForTaggedPhotos, titleForTag } from '.';
import { getAppText } from '@/i18n/state/server';
export default function TagOGTile({ export default async function TagOGTile({
tag, tag,
photos, photos,
loadingState: loadingStateExternal, loadingState: loadingStateExternal,
@ -24,10 +25,17 @@ export default function TagOGTile({
count?: number count?: number
dateRange?: PhotoDateRange dateRange?: PhotoDateRange
}) { }) {
const appText = await getAppText();
return ( return (
<OGTile {...{ <OGTile {...{
title: titleForTag(tag, photos, count), title: titleForTag(tag, photos, appText, count),
description: descriptionForTaggedPhotos(photos, true, count, dateRange), description: descriptionForTaggedPhotos(
photos,
appText,
true,
count,
dateRange,
),
path: pathForTag(tag), path: pathForTag(tag),
pathImageAbsolute: absolutePathForTagImage(tag), pathImageAbsolute: absolutePathForTagImage(tag),
loadingState: loadingStateExternal, loadingState: loadingStateExternal,

View File

@ -3,8 +3,9 @@ import { PhotoSetAttributes } from '../category';
import ShareModal from '@/share/ShareModal'; import ShareModal from '@/share/ShareModal';
import TagOGTile from './TagOGTile'; import TagOGTile from './TagOGTile';
import { formatTag, shareTextForTag } from '.'; import { formatTag, shareTextForTag } from '.';
import { getAppText } from '@/i18n/state/server';
export default function TagShareModal({ export default async function TagShareModal({
tag, tag,
photos, photos,
count, count,
@ -12,11 +13,12 @@ export default function TagShareModal({
}: { }: {
tag: string tag: string
} & PhotoSetAttributes) { } & PhotoSetAttributes) {
const appText = await getAppText();
return ( return (
<ShareModal <ShareModal
pathShare={absolutePathForTag(tag, true)} pathShare={absolutePathForTag(tag, true)}
navigatorTitle={formatTag(tag)} navigatorTitle={formatTag(tag)}
socialText={shareTextForTag(tag)} socialText={shareTextForTag(tag, appText)}
> >
<TagOGTile {...{ tag, photos, count, dateRange }} /> <TagOGTile {...{ tag, photos, count, dateRange }} />
</ShareModal> </ShareModal>

View File

@ -16,7 +16,7 @@ import {
formatCountDescriptive, formatCountDescriptive,
} from '@/utility/string'; } from '@/utility/string';
import { sortCategoryByCount } from '@/category'; import { sortCategoryByCount } from '@/category';
import { APP_TEXT } from '@/app/config'; import { I18NState } from '@/i18n/state';
// Reserved tags // Reserved tags
export const TAG_FAVS = 'favs'; export const TAG_FAVS = 'favs';
@ -42,16 +42,20 @@ export const getValidationMessageForTags = (tags?: string) => {
export const titleForTag = ( export const titleForTag = (
tag: string, tag: string,
photos:Photo[] = [], photos:Photo[] = [],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
) => [ ) => [
formatTag(tag), formatTag(tag),
photoQuantityText(explicitCount ?? photos.length), photoQuantityText(explicitCount ?? photos.length, appText),
].join(' '); ].join(' ');
export const shareTextForTag = (tag: string) => export const shareTextForTag = (
tag: string,
appText: I18NState,
) =>
isTagFavs(tag) isTagFavs(tag)
? APP_TEXT.category.taggedFavs ? appText.category.taggedFavs
: APP_TEXT.category.taggedPhrase(formatTag(tag)); : appText.category.taggedPhrase(formatTag(tag));
export const sortTagsArray = ( export const sortTagsArray = (
tags: string[], tags: string[],
@ -92,13 +96,15 @@ export const sortTagsObjectWithoutFavs = (tags: Tags) =>
export const descriptionForTaggedPhotos = ( export const descriptionForTaggedPhotos = (
photos: Photo[] = [], photos: Photo[] = [],
appText: I18NState,
dateBased?: boolean, dateBased?: boolean,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ) =>
descriptionForPhotoSet( descriptionForPhotoSet(
photos, photos,
APP_TEXT.category.taggedPhotos, appText,
appText.category.taggedPhotos,
dateBased, dateBased,
explicitCount, explicitCount,
explicitDateRange, explicitDateRange,
@ -107,13 +113,19 @@ export const descriptionForTaggedPhotos = (
export const generateMetaForTag = ( export const generateMetaForTag = (
tag: string, tag: string,
photos: Photo[], photos: Photo[],
appText: I18NState,
explicitCount?: number, explicitCount?: number,
explicitDateRange?: PhotoDateRange, explicitDateRange?: PhotoDateRange,
) => ({ ) => ({
url: absolutePathForTag(tag), url: absolutePathForTag(tag),
title: titleForTag(tag, photos, explicitCount), title: titleForTag(tag, photos, appText, explicitCount),
description: description: descriptionForTaggedPhotos(
descriptionForTaggedPhotos(photos, true, explicitCount, explicitDateRange), photos,
appText,
true,
explicitCount,
explicitDateRange,
),
images: absolutePathForTagImage(tag), images: absolutePathForTagImage(tag),
}); });
@ -137,11 +149,14 @@ export const addHiddenToTags = (tags: Tags, photosCountHidden = 0) =>
) )
: tags; : tags;
export const convertTagsForForm = (tags: Tags = []) => export const convertTagsForForm = (
tags: Tags = [],
appText: I18NState,
) =>
sortTagsObjectWithoutFavs(tags) sortTagsObjectWithoutFavs(tags)
.map(({ tag, count }) => ({ .map(({ tag, count }) => ({
value: tag, value: tag,
annotation: formatCount(count), annotation: formatCount(count),
annotationAria: annotationAria:
formatCountDescriptive(count, APP_TEXT.category.taggedPhotos), formatCountDescriptive(count, appText.category.taggedPhotos),
})); }));

View File

@ -1,7 +1,7 @@
import { parseISO, parse, format } from 'date-fns'; import { parseISO, parse, format } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz'; import { formatInTimeZone } from 'date-fns-tz';
import { Timezone } from './timezone'; import { Timezone } from './timezone';
import { APP_TEXT } from '@/app/config'; import { DATE_FN_LOCALE } from '@/i18n/date';
const DATE_STRING_FORMAT_TINY = 'dd MMM yy'; const DATE_STRING_FORMAT_TINY = 'dd MMM yy';
const DATE_STRING_FORMAT_TINY_PLACEHOLDER = '00 000 00'; const DATE_STRING_FORMAT_TINY_PLACEHOLDER = '00 000 00';
@ -67,12 +67,15 @@ export const formatDate = ({
? placeholderString ? placeholderString
: timezone : timezone
? formatInTimeZone( ? formatInTimeZone(
date, timezone, formatString, { locale: APP_TEXT.dateLocale }, date, timezone, formatString, { locale: DATE_FN_LOCALE },
) )
: format(date, formatString, { locale: APP_TEXT.dateLocale }); : format(date, formatString, { locale: DATE_FN_LOCALE });
}; };
export const formatDateFromPostgresString = (date: string, length?: Length) => export const formatDateFromPostgresString = (
date: string,
length?: Length,
) =>
formatDate({ formatDate({
date: parse(date, DATE_STRING_FORMAT_POSTGRES, new Date()), date: parse(date, DATE_STRING_FORMAT_POSTGRES, new Date()),
length, length,