Merge pull request #248 from sambecker/i18n

Initial I18N support
This commit is contained in:
Sam Becker 2025-05-10 15:23:20 -07:00 committed by GitHub
commit f7ed792f57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 525 additions and 161 deletions

View File

@ -254,6 +254,17 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider
1. Ensure connection string is set to "Transaction Mode" via port `6543`
2. Disable SSL by setting `DISABLE_POSTGRES_SSL = 1`
💬   I18N
-
Partial internationalization (non-admin, user-facing text) provided for a handful of languages. Set your language by setting the environment variable `NEXT_PUBLIC_LOCALE`. If you'd like to add support for a new language, open a PR [using `US_EN`](https://github.com/sambecker/exif-photo-blog/main/src/i18n/locales/us-en.ts) for reference.
### Supported Languages
- `US_EN`
- `ES_ES` (coming soon)
- `PT_BR` (coming soon)
- `PT_PT` (coming soon)
📖  FAQ
-
#### How do I receive template updates?

View File

@ -5,6 +5,7 @@ import { clsx } from 'clsx/lite';
import { redirect } from 'next/navigation';
import LinkWithStatus from '@/components/LinkWithStatus';
import { IoArrowBack } from 'react-icons/io5';
import { APP_TEXT } from '@/app/config';
export default async function SignInPage() {
const session = await auth();
@ -27,7 +28,7 @@ export default async function SignInPage() {
)}
>
<IoArrowBack className="translate-y-[1px]" />
Home
{APP_TEXT.nav.home}
</LinkWithStatus>
</div>
);

View File

@ -47,6 +47,8 @@ export default function AdminAppConfigurationClient({
hasAuthSecret,
hasAdminUser,
// Content
locale,
hasLocale,
hasDomain,
hasNavTitle,
hasNavCaption,
@ -289,6 +291,22 @@ export default function AdminAppConfigurationClient({
title="Content"
icon={<BiPencil size={16} />}
>
<ChecklistRow
title={`Configure language: ${locale.toLocaleUpperCase()}`}
status={hasLocale}
>
Store in environment variable
(check README for
{' '}
<AdminLink
// eslint-disable-next-line max-len
href="https://github.com/sambecker/exif-photo-blog?tab=readme-ov-file#supported-locales"
>
supported locales
</AdminLink>
):
{renderEnvVars(['NEXT_PUBLIC_LOCALE'])}
</ChecklistRow>
<ChecklistRow
title="Configure domain"
status={hasDomain}

View File

@ -29,6 +29,7 @@ import IconBroom from '@/components/icons/IconBroom';
import InsightsIndicatorDot from './insights/InsightsIndicatorDot';
import MoreMenuItem from '@/components/more/MoreMenuItem';
import Spinner from '@/components/Spinner';
import { APP_TEXT } from '@/app/config';
export default function AdminAppMenu({
active,
@ -65,7 +66,7 @@ export default function AdminAppMenu({
const sectionUpload: ComponentProps<typeof MoreMenuItem>[] =
useMemo(() => ([{
label: 'Upload Photos',
label: APP_TEXT.admin.uploadPhotos,
icon: <IconUpload
size={15}
className="translate-x-[0.5px] translate-y-[0.5px]"
@ -80,7 +81,7 @@ export default function AdminAppMenu({
if (uploadsCount) {
items.push({
label: 'Uploads',
label: APP_TEXT.admin.uploadPlural,
annotation: `${uploadsCount}`,
icon: <IconFolder
size={16}
@ -91,7 +92,7 @@ export default function AdminAppMenu({
}
if (photosCountNeedSync) {
items.push({
label: 'Updates',
label: APP_TEXT.admin.updates,
annotation: <>
<span className="mr-3">
{photosCountNeedSync}
@ -110,7 +111,7 @@ export default function AdminAppMenu({
}
if (photosCountTotal) {
items.push({
label: 'Manage Photos',
label: APP_TEXT.admin.managePhotos,
...photosCountTotal && {
annotation: `${photosCountTotal}`,
},
@ -123,7 +124,7 @@ export default function AdminAppMenu({
}
if (tagsCount) {
items.push({
label: 'Manage Tags',
label: APP_TEXT.admin.manageTags,
annotation: `${tagsCount}`,
icon: <IconTag
size={15}
@ -134,7 +135,7 @@ export default function AdminAppMenu({
}
if (recipesCount) {
items.push({
label: 'Manage Recipes',
label: APP_TEXT.admin.manageRecipes,
annotation: `${recipesCount}`,
icon: <IconRecipe
size={17}
@ -146,8 +147,8 @@ export default function AdminAppMenu({
if (photosCountTotal) {
items.push({
label: isSelecting
? 'Exit Batch Edit'
: 'Batch Edit ...',
? APP_TEXT.admin.batchExitEdit
: APP_TEXT.admin.batchEditShort,
icon: isSelecting
? <IoCloseSharp
size={18}
@ -173,8 +174,8 @@ export default function AdminAppMenu({
}
items.push({
label: showAppInsightsLink
? 'App Insights'
: 'App Configuration',
? APP_TEXT.admin.appInsights
: APP_TEXT.admin.appConfig,
icon: <AdminAppInfoIcon
size="small"
className="translate-x-[-0.5px] translate-y-[0.5px]"
@ -198,7 +199,7 @@ export default function AdminAppMenu({
const sectionSignOut: ComponentProps<typeof MoreMenuItem>[] =
useMemo(() => ([{
label: 'Sign Out',
label: APP_TEXT.auth.signOut,
icon: <IconSignOut size={15} />,
action: () => signOutAction().then(clearAuthStateAndRedirectIfNecessary),
}]), [clearAuthStateAndRedirectIfNecessary]);

View File

@ -12,6 +12,7 @@ import {
PATH_ADMIN_UPLOADS,
} from '@/app/paths';
import AdminNavClient from './AdminNavClient';
import { APP_TEXT } from '@/app/config';
export default async function AdminNav() {
const [
@ -41,28 +42,28 @@ export default async function AdminNav() {
// Photos
const items = [{
label: 'Photos',
label: APP_TEXT.photo.photoPlural,
href: PATH_ADMIN_PHOTOS,
count: countPhotos,
}];
// Uploads
if (countUploads > 0) { items.push({
label: 'Uploads',
label: APP_TEXT.admin.uploadPlural,
href: PATH_ADMIN_UPLOADS,
count: countUploads,
}); }
// Tags
if (countTags > 0) { items.push({
label: 'Tags',
label: APP_TEXT.category.tagPlural,
href: PATH_ADMIN_TAGS,
count: countTags,
}); }
// Recipes
if (countRecipes > 0) { items.push({
label: 'Recipes',
label: APP_TEXT.category.recipePlural,
href: PATH_ADMIN_RECIPES,
count: countRecipes,
}); }

View File

@ -26,6 +26,7 @@ import IconFavs from '@/components/icons/IconFavs';
import IconEdit from '@/components/icons/IconEdit';
import { photoNeedsToBeSynced } from '@/photo/sync';
import { KEY_COMMANDS } from '@/photo/key-commands';
import { APP_TEXT } from '@/app/config';
export default function AdminPhotoMenu({
photo,
@ -48,7 +49,7 @@ export default function AdminPhotoMenu({
const sectionMain = useMemo(() => {
const items: ComponentProps<typeof MoreMenuItem>[] = [{
label: 'Edit',
label: APP_TEXT.admin.edit,
icon: <IconEdit
size={15}
className="translate-x-[0.5px]"
@ -58,7 +59,7 @@ export default function AdminPhotoMenu({
}];
if (includeFavorite) {
items.push({
label: isFav ? 'Unfavorite' : 'Favorite',
label: isFav ? APP_TEXT.admin.unfavorite : APP_TEXT.admin.favorite,
icon: <IconFavs
size={14}
className="translate-x-[-1px] translate-y-[0.5px]"
@ -76,7 +77,7 @@ export default function AdminPhotoMenu({
});
}
items.push({
label: 'Download',
label: APP_TEXT.admin.download,
icon: <MdOutlineFileDownload
size={17}
className="translate-x-[-1px]"
@ -86,9 +87,9 @@ export default function AdminPhotoMenu({
...showKeyCommands && { keyCommand: KEY_COMMANDS.download },
});
items.push({
label: 'Sync',
label: APP_TEXT.admin.sync,
labelComplex: <span className="inline-flex items-center gap-2">
<span>Sync</span>
<span>{APP_TEXT.admin.sync}</span>
{photoNeedsToBeSynced(photo) &&
<InsightsIndicatorDot
colorOverride="blue"
@ -115,7 +116,7 @@ export default function AdminPhotoMenu({
]);
const sectionDelete: ComponentProps<typeof MoreMenuItem>[] = useMemo(() => [{
label: 'Delete',
label: APP_TEXT.admin.delete,
icon: <BiTrash
size={15}
className="translate-x-[-1px]"

View File

@ -4,6 +4,7 @@ import { useAppState } from '@/state/AppState';
import SignInForm from '@/auth/SignInForm';
import clsx from 'clsx/lite';
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
import { APP_TEXT } from '@/app/config';
export default function SignInOrUploadClient({
shouldResize,
@ -21,10 +22,10 @@ export default function SignInOrUploadClient({
)}>
<div>
{isCheckingAuth
? 'Loading ...'
? APP_TEXT.misc.loading
: isUserSignedIn
? 'Add your first photo'
: 'Sign in to upload photos'}
? APP_TEXT.onboarding.setupFirstPhoto
: APP_TEXT.onboarding.setupSignIn}
</div>
{!isCheckingAuth && isUserSignedIn === false &&
<div className="flex justify-center my-2 sm:my-4">

View File

@ -9,6 +9,7 @@ import {
import IconSearch from '../components/icons/IconSearch';
import { useAppState } from '@/state/AppState';
import {
APP_TEXT,
GRID_HOMEPAGE_ENABLED,
SHOW_KEYBOARD_SHORTCUT_TOOLTIPS,
} from './config';
@ -66,7 +67,7 @@ export default function AppViewSwitcher({
hrefRef={refHrefFeed}
active={currentSelection === 'feed'}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: 'Feed',
content: APP_TEXT.nav.feed,
keyCommand: KEY_COMMANDS.feed,
}}}
noPadding
@ -79,7 +80,7 @@ export default function AppViewSwitcher({
hrefRef={refHrefGrid}
active={currentSelection === 'grid'}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: 'Grid',
content: APP_TEXT.nav.grid,
keyCommand: KEY_COMMANDS.grid,
}}}
noPadding
@ -103,7 +104,7 @@ export default function AppViewSwitcher({
noPadding
tooltip={{
...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: 'Admin Menu',
content: APP_TEXT.nav.admin,
keyCommand: KEY_COMMANDS.admin,
},
}}
@ -116,7 +117,7 @@ export default function AppViewSwitcher({
/>}
tooltip={{
...!isAdminMenuOpen && SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: 'Admin Menu',
content: APP_TEXT.nav.admin,
keyCommand: KEY_COMMANDS.admin,
},
}}
@ -128,7 +129,7 @@ export default function AppViewSwitcher({
icon={<IconSearch includeTitle={false} />}
onClick={() => setIsCommandKOpen?.(true)}
tooltip={{...SHOW_KEYBOARD_SHORTCUT_TOOLTIPS && {
content: 'Search',
content: APP_TEXT.nav.search,
keyCommandModifier: KEY_COMMANDS.search[0],
keyCommand: KEY_COMMANDS.search[1],
}}}

View File

@ -4,7 +4,7 @@ import { clsx } from 'clsx/lite';
import AppGrid from '../components/AppGrid';
import ThemeSwitcher from '@/app/ThemeSwitcher';
import Link from 'next/link';
import { SHOW_REPO_LINK } from '@/app/config';
import { APP_TEXT, SHOW_REPO_LINK } from '@/app/config';
import RepoLink from '../components/RepoLink';
import { usePathname } from 'next/navigation';
import { PATH_ADMIN_PHOTOS, isPathAdmin, isPathSignIn } from './paths';
@ -51,7 +51,7 @@ export default function Footer() {
<form action={() => signOutAction()
.then(clearAuthStateAndRedirectIfNecessary)}>
<SubmitButtonWithStatus styleAs="link">
Sign out
{APP_TEXT.auth.signOut}
</SubmitButtonWithStatus>
</form>
</>
@ -60,7 +60,7 @@ export default function Footer() {
: SHOW_REPO_LINK
? <RepoLink />
: <Link href={PATH_ADMIN_PHOTOS}>
Admin
{APP_TEXT.nav.admin}
</Link>}
</div>
<div className="flex items-center h-10">

View File

@ -5,6 +5,7 @@ import { useTheme } from 'next-themes';
import Switcher from '@/components/Switcher';
import SwitcherItem from '@/components/SwitcherItem';
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
import { APP_TEXT } from './config';
export default function ThemeSwitcher () {
const [mounted, setMounted] = useState(false);
@ -25,19 +26,19 @@ export default function ThemeSwitcher () {
icon={<BiDesktop size={16} />}
onClick={() => setTheme('system')}
active={theme === 'system'}
tooltip={{ content: 'System' }}
tooltip={{ content: APP_TEXT.theme.system }}
/>
<SwitcherItem
icon={<BiSun size={18} />}
onClick={() => setTheme('light')}
active={theme === 'light'}
tooltip={{ content: 'Light Mode' }}
tooltip={{ content: APP_TEXT.theme.light }}
/>
<SwitcherItem
icon={<BiMoon size={16} />}
onClick={() => setTheme('dark')}
active={theme === 'dark'}
tooltip={{ content: 'Dark Mode' }}
tooltip={{ content: APP_TEXT.theme.dark }}
/>
</Switcher>
);

View File

@ -5,6 +5,7 @@ import {
import { getOrderedCategoriesFromString } from '@/category';
import type { StorageType } from '@/platforms/storage';
import { makeUrlAbsolute, shortenUrl } from '@/utility/url';
import { getTextForLocale } from '@/i18n';
// HARD-CODED GLOBAL CONFIGURATION
@ -98,6 +99,10 @@ const SITE_DOMAIN_SHORT = shortenUrl(SITE_DOMAIN);
// SITE META
export const APP_TEXT = getTextForLocale(
process.env.NEXT_PUBLIC_LOCALE,
);
export const NAV_TITLE =
process.env.NEXT_PUBLIC_NAV_TITLE;
@ -338,6 +343,8 @@ export const APP_CONFIGURATION = {
Boolean(process.env.ADMIN_PASSWORD)
),
// Domain
locale: process.env.NEXT_PUBLIC_LOCALE ?? 'US-EN',
hasLocale: Boolean(process.env.NEXT_PUBLIC_LOCALE),
hasDomain: Boolean(
process.env.NEXT_PUBLIC_DOMAIN ||
// Legacy environment variable

View File

@ -21,6 +21,7 @@ import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite';
import { PATH_ADMIN_PHOTOS } from '@/app/paths';
import IconLock from '@/components/icons/IconLock';
import { APP_TEXT } from '@/app/config';
export default function SignInForm({
includeTitle = true,
@ -79,27 +80,27 @@ export default function SignInForm({
)}>
<IconLock className="text-main translate-y-[0.5px]" />
<span className="text-main">
Sign in
{APP_TEXT.auth.signIn}
</span>
</h1>}
<form action={action} className="w-full">
<div className="space-y-5 w-full -translate-y-0.5">
{response === KEY_CREDENTIALS_SIGN_IN_ERROR &&
<ErrorNote>
Invalid email/password
{APP_TEXT.auth.invalidEmailPassword}
</ErrorNote>}
<div className="space-y-4 w-full">
<FieldSetWithStatus
id="email"
inputRef={emailRef}
label="Admin Email"
label={APP_TEXT.auth.email}
type="email"
value={email}
onChange={setEmail}
/>
<FieldSetWithStatus
id="password"
label="Admin Password"
label={APP_TEXT.auth.password}
type="password"
value={password}
onChange={setPassword}
@ -112,7 +113,7 @@ export default function SignInForm({
/>}
</div>
<SubmitButtonWithStatus disabled={!isFormValid}>
Sign in
{APP_TEXT.auth.signIn}
</SubmitButtonWithStatus>
</div>
</form>

View File

@ -1,11 +1,9 @@
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForCameraImage, pathForCamera } from '@/app/paths';
import OGTile from '@/components/OGTile';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import { Camera } from '.';
import { descriptionForCameraPhotos, titleForCamera } from './meta';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function CameraOGTile({
camera,
photos,

View File

@ -9,6 +9,7 @@ import {
absolutePathForCamera,
absolutePathForCameraImage,
} from '@/app/paths';
import { APP_TEXT } from '@/app/config';
// Meta functions moved to separate file to avoid
// dependencies (camelcase-keys) found in photo/index.ts
@ -19,8 +20,9 @@ export const titleForCamera = (
photos: Photo[],
explicitCount?: number,
) => [
'Shot on',
formatCameraText(cameraFromPhoto(photos[0], camera)),
APP_TEXT.category.cameraTitle(
formatCameraText(cameraFromPhoto(photos[0], camera)),
),
photoQuantityText(explicitCount ?? photos.length),
].join(' ');
@ -28,10 +30,9 @@ export const shareTextForCamera = (
camera: Camera,
photos: Photo[],
) =>
[
'Photos shot on',
APP_TEXT.category.cameraShare(
formatCameraText(cameraFromPhoto(photos[0], camera)),
].join(' ');
);
export const descriptionForCameraPhotos = (
photos: Photo[],

View File

@ -52,7 +52,11 @@ import { FaCheck } from 'react-icons/fa6';
import { addHiddenToTags, formatTag, isTagFavs, isTagHidden } from '@/tag';
import { formatCount, formatCountDescriptive } from '@/utility/string';
import CommandKItem from './CommandKItem';
import { CATEGORY_VISIBILITY, GRID_HOMEPAGE_ENABLED } from '@/app/config';
import {
APP_TEXT,
CATEGORY_VISIBILITY,
GRID_HOMEPAGE_ENABLED,
} from '@/app/config';
import { DialogDescription, DialogTitle } from '@radix-ui/react-dialog';
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
import InsightsIndicatorDot from '@/admin/insights/InsightsIndicatorDot';
@ -285,7 +289,7 @@ export default function CommandKClient({
.map(category => {
switch (category) {
case 'cameras': return {
heading: 'Cameras',
heading: APP_TEXT.category.cameraPlural,
accessory: <IconCamera size={14} />,
items: cameras.map(({ camera, count }) => ({
label: formatCameraText(camera),
@ -295,7 +299,7 @@ export default function CommandKClient({
})),
};
case 'lenses': return {
heading: 'Lenses',
heading: APP_TEXT.category.lensPlural,
accessory: <IconLens size={14} className="translate-y-[0.5px]" />,
items: lenses.map(({ lens, count }) => ({
label: formatLensText(lens, 'medium'),
@ -306,7 +310,7 @@ export default function CommandKClient({
})),
};
case 'tags': return {
heading: 'Tags',
heading: APP_TEXT.category.tagPlural,
accessory: <IconTag
size={13}
className="translate-x-[1px] translate-y-[0.75px]"
@ -333,7 +337,7 @@ export default function CommandKClient({
})),
};
case 'recipes': return {
heading: 'Recipes',
heading: APP_TEXT.category.recipePlural,
accessory: <IconRecipe
size={15}
className="translate-x-[-1px]"
@ -346,7 +350,7 @@ export default function CommandKClient({
})),
};
case 'films': return {
heading: 'Films',
heading: APP_TEXT.category.filmPlural,
accessory: <IconFilm size={14} />,
items: films.map(({ film, count }) => ({
label: labelForFilm(film).medium,
@ -356,7 +360,7 @@ export default function CommandKClient({
})),
};
case 'focal-lengths': return {
heading: 'Focal Lengths',
heading: APP_TEXT.category.focalLengthPlural,
accessory: <IconFocalLength className="text-[14px]" />,
items: focalLengths.map(({ focal, count }) => ({
label: formatFocalLength(focal)!,
@ -371,21 +375,21 @@ export default function CommandKClient({
, [tagsIncludingHidden, cameras, lenses, recipes, films, focalLengths]);
const clientSections: CommandKSection[] = [{
heading: 'Theme',
heading: APP_TEXT.theme.theme,
accessory: <IoInvertModeSharp
size={14}
className="translate-y-[0.5px] translate-x-[-1px]"
/>,
items: [{
label: 'Use System',
label: APP_TEXT.theme.system,
annotation: <BiDesktop />,
action: () => setTheme('system'),
}, {
label: 'Light Mode',
label: APP_TEXT.theme.light,
annotation: <BiSun size={16} className="translate-x-[1.25px]" />,
action: () => setTheme('light'),
}, {
label: 'Dark Mode',
label: APP_TEXT.theme.dark,
annotation: <BiMoon className="translate-x-[1px]" />,
action: () => setTheme('dark'),
}],
@ -436,12 +440,16 @@ export default function CommandKClient({
}
const pageFeed: CommandKItem = {
label: GRID_HOMEPAGE_ENABLED ? 'Feed' : 'Feed (Home)',
label: GRID_HOMEPAGE_ENABLED
? APP_TEXT.nav.feed
: `${APP_TEXT.nav.feed} (${APP_TEXT.nav.home})`,
path: PATH_FEED_INFERRED,
};
const pageGrid: CommandKItem = {
label: GRID_HOMEPAGE_ENABLED ? 'Grid (Home)' : 'Grid',
label: GRID_HOMEPAGE_ENABLED
? `${APP_TEXT.nav.grid} (${APP_TEXT.nav.home})`
: APP_TEXT.nav.grid,
path: PATH_GRID_INFERRED,
};
@ -463,40 +471,40 @@ export default function CommandKClient({
if (isUserSignedIn) {
adminSection.items.push({
label: 'Upload Photos',
label: APP_TEXT.admin.uploadPhotos,
annotation: <IconLock narrow />,
action: startUpload,
});
if (uploadsCount) {
adminSection.items.push({
label: `Uploads (${uploadsCount})`,
label: `${APP_TEXT.admin.uploadPlural} (${uploadsCount})`,
annotation: <IconLock narrow />,
path: PATH_ADMIN_UPLOADS,
});
}
adminSection.items.push({
label: `Manage Photos (${photosCountTotal})`,
label: `${APP_TEXT.admin.managePhotos} (${photosCountTotal})`,
annotation: <IconLock narrow />,
path: PATH_ADMIN_PHOTOS,
});
if (tagsCount) {
adminSection.items.push({
label: `Manage Tags (${tagsCount})`,
label: `${APP_TEXT.admin.manageTags} (${tagsCount})`,
annotation: <IconLock narrow />,
path: PATH_ADMIN_TAGS,
});
}
if (recipesCount) {
adminSection.items.push({
label: `Manage Recipes (${recipesCount})`,
label: `${APP_TEXT.admin.manageRecipes} (${recipesCount})`,
annotation: <IconLock narrow />,
path: PATH_ADMIN_RECIPES,
});
}
adminSection.items.push({
label: selectedPhotoIds === undefined
? 'Batch Edit Photos ...'
: 'Exit Batch Edit',
? APP_TEXT.admin.batchEdit
: APP_TEXT.admin.batchExitEdit,
annotation: <IconLock narrow />,
path: selectedPhotoIds === undefined
? PATH_GRID_INFERRED
@ -506,7 +514,7 @@ export default function CommandKClient({
: () => setSelectedPhotoIds?.(undefined),
}, {
label: <span className="flex items-center gap-3">
App Insights
{APP_TEXT.admin.appInsights}
{insightsIndicatorStatus &&
<InsightsIndicatorDot />}
</span>,
@ -514,7 +522,7 @@ export default function CommandKClient({
annotation: <IconLock narrow />,
path: PATH_ADMIN_INSIGHTS,
}, {
label: 'App Config',
label: APP_TEXT.admin.appConfig,
annotation: <IconLock narrow />,
path: PATH_ADMIN_CONFIGURATION,
});
@ -530,14 +538,14 @@ export default function CommandKClient({
});
}
adminSection.items.push({
label: 'Sign Out',
label: APP_TEXT.auth.signOut,
action: () => signOutAction()
.then(clearAuthStateAndRedirectIfNecessary)
.then(() => setIsOpen?.(false)),
});
} else {
adminSection.items.push({
label: 'Sign In',
label: APP_TEXT.auth.signIn,
path: PATH_SIGN_IN,
});
}
@ -588,7 +596,7 @@ export default function CommandKClient({
'focus:outline-hidden',
isPending && 'opacity-20',
)}
placeholder="Search photos, views, settings ..."
placeholder={APP_TEXT.cmdk.placeholder}
disabled={isPending}
/>
{isLoading && !isPending &&

View File

@ -3,6 +3,7 @@ import LoaderButton from './primitives/LoaderButton';
import clsx from 'clsx/lite';
import { toastSuccess } from '@/toast';
import { ComponentProps } from 'react';
import { APP_TEXT } from '@/app/config';
export default function CopyButton({
label,
@ -29,7 +30,7 @@ export default function CopyButton({
onClick={text
? () => {
navigator.clipboard.writeText(text);
toastSuccess(`${label} copied to clipboard`);
toastSuccess(APP_TEXT.misc.copyPhrase(label));
}
: undefined}
styleAs="link"

View File

@ -4,6 +4,7 @@ import { downloadFileNameForPhoto, Photo } from '@/photo';
import LoaderButton from './primitives/LoaderButton';
import { useState } from 'react';
import { downloadFileFromBrowser } from '@/utility/url';
import { APP_TEXT } from '@/app/config';
export default function DownloadButton({
photo,
@ -16,7 +17,7 @@ export default function DownloadButton({
return (
<LoaderButton
tooltip="Download Original File"
tooltip={APP_TEXT.tooltip.download}
className={clsx(
className,
'text-medium',

View File

@ -9,6 +9,7 @@ import { FiUploadCloud } from 'react-icons/fi';
import { MAX_IMAGE_SIZE } from '@/platforms/next-image';
import ProgressButton from './primitives/ProgressButton';
import { useAppState } from '@/state/AppState';
import { APP_TEXT } from '@/app/config';
export default function ImageInput({
ref: inputRefExternal,
@ -84,9 +85,13 @@ export default function ImageInput({
>
{isUploading
? filesLength > 1
? `Uploading ${fileUploadIndex + 1} of ${filesLength}`
: 'Uploading'
: 'Upload Photos'}
? APP_TEXT.utility.paginate(
fileUploadIndex + 1,
filesLength,
APP_TEXT.admin.uploading,
)
: APP_TEXT.admin.uploading
: APP_TEXT.admin.uploadPhotos}
</ProgressButton>}
<input
ref={inputRef}

View File

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

View File

@ -1,3 +1,4 @@
import { APP_TEXT } from '@/app/config';
import { toastWaiting } from '@/toast';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useTransition } from 'react';
@ -6,7 +7,7 @@ import { toast } from 'sonner';
export default function useNavigateOrRunActionWithToast({
pathOrAction,
toastMessage = 'Loading...',
toastMessage = APP_TEXT.misc.loading,
dismissDelay = 1500,
}: {
pathOrAction?: string | (() => Promise<any> | undefined)

View File

@ -3,11 +3,9 @@ import {
absolutePathForFilmImage,
pathForFilm,
} from '@/app/paths';
import OGTile from '@/components/OGTile';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import { descriptionForFilmPhotos, titleForFilm } from '.';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function FilmOGTile({
film,
photos,

View File

@ -19,6 +19,7 @@ import {
} from '@/utility/string';
import { AnnotatedTag } from '@/photo/form';
import PhotoFilmIcon from './PhotoFilmIcon';
import { APP_TEXT } from '@/app/config';
export type FilmWithCount = {
film: string
@ -67,7 +68,7 @@ export const titleForFilm = (
export const shareTextForFilm = (
film: string,
) =>
`Photos shot on ${labelForFilm(film).large}`;
APP_TEXT.category.filmShare(labelForFilm(film).large);
export const descriptionForFilmPhotos = (
photos: Photo[],

View File

@ -3,11 +3,9 @@ import {
absolutePathForFocalLengthImage,
pathForFocalLength,
} from '@/app/paths';
import OGTile from '@/components/OGTile';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import { descriptionForFocalLengthPhotos, titleForFocalLength } from '.';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function FocalLengthOGTile({
focal,
photos,

View File

@ -8,6 +8,7 @@ import {
absolutePathForFocalLength,
absolutePathForFocalLengthImage,
} from '@/app/paths';
import { APP_TEXT } from '@/app/config';
export type FocalLengths = {
focal: number
@ -31,12 +32,12 @@ export const titleForFocalLength = (
photos: Photo[],
explicitCount?: number,
) => [
`${formatFocalLength(focal)} Focal Length`,
APP_TEXT.category.focalLengthTitle(formatFocalLengthSafe(focal)),
photoQuantityText(explicitCount ?? photos.length),
].join(' ');
export const shareTextFocalLength = (focal: number) =>
`Photos shot at ${formatFocalLength(focal)}`;
APP_TEXT.category.focalLengthShare(formatFocalLengthSafe(focal));
export const descriptionForFocalLengthPhotos = (
photos: Photo[],

29
src/i18n/index.ts Normal file
View File

@ -0,0 +1,29 @@
import US_EN from './locales/us-en';
import PT_BR from './locales/pt-br';
export type I18N = typeof US_EN;
export type I18NDeepPartial = {
[key in keyof I18N]?: Partial<I18N[key]>;
}
export const LOCALE_TEXT: Record<string, I18NDeepPartial | undefined> = {
'pt-br': PT_BR,
};
export const getTextForLocale = (
locale = '',
): I18N => {
const text = US_EN;
Object.entries(LOCALE_TEXT[locale.toLocaleLowerCase()] ?? {})
.forEach(([key, value]) => {
// Fall back to English for missing keys
text[key as keyof I18N] = {
...text[key as keyof I18N],
...value,
} as any;
});
return text;
};

123
src/i18n/locales/pt-br.ts Normal file
View File

@ -0,0 +1,123 @@
import { I18NDeepPartial } from '..';
import { ptBR } from 'date-fns/locale';
const TEXT: I18NDeepPartial = {
photo: {
photo: 'Foto',
photoPlural: 'Fotos',
taken: 'Capturado',
created: 'Criado',
updated: 'Atualizado',
copied: 'Link para foto copiado',
},
category: {
camera: 'Câmera',
cameraPlural: 'Câmeras',
cameraTitle: (camera: string) => `Tirado com ${camera}`,
cameraShare: (camera: string) => `Fotos tiradas com ${camera}`,
lens: 'Lente',
lensPlural: 'Lentes',
tag: 'Tag',
tagPlural: 'Tags',
taggedPhotos: 'Fotos Marcadas',
taggedPhrase: (tag: string) => `Fotos marcadas com '${tag}'`,
taggedFavs: 'Fotos Favoritas',
recipe: 'Receita',
recipePlural: 'Receitas',
recipeShare: (recipe: string) => `Fotos da receita ${recipe}`,
film: 'Filme',
filmPlural: 'Filmes',
filmShare: (film: string) => `Fotos tiradas com ${film}`,
focalLength: 'Distância Focal',
focalLengthPlural: 'Distâncias Focais',
focalLengthTitle: (focal: string) => `Distância Focal ${focal}`,
focalLengthShare: (focal: string) => `Fotos tiradas em ${focal}`,
},
nav: {
home: 'Início',
feed: 'Feed',
grid: 'Grade',
admin: 'Menu de Admin',
search: 'Pesquisar',
prev: 'Anterior',
prevShort: 'Ant',
next: 'Próximo',
nextShort: 'Prox',
},
cmdk: {
placeholder: 'Pesquisar fotos, visualizações, configurações ...',
},
tooltip: {
'35mm': 'Equivalente 35mm',
zoom: 'Aumentar Zoom',
sharePhoto: 'Compartilhar Foto',
recipeInfo: 'Informações da Receita',
recipeCopy: 'Copiar Texto da Receita',
download: 'Baixar Arquivo Original',
},
theme: {
theme: 'Tema',
system: 'Sistema',
light: 'Modo Claro',
dark: 'Modo Escuro',
},
auth: {
signIn: 'Entrar',
signOut: 'Sair',
email: 'Email do Admin',
password: 'Senha do Admin',
invalidEmailPassword: 'Email/senha inválidos',
},
admin: {
uploadPhotos: 'Enviar Fotos',
upload: 'Enviar',
uploadPlural: 'Envios',
uploading: 'Enviando',
updates: 'Atualizações',
managePhotos: 'Gerenciar Fotos',
manageCameras: 'Gerenciar Câmeras',
manageLenses: 'Gerenciar Lentes',
manageTags: 'Gerenciar Tags',
manageRecipes: 'Gerenciar Receitas',
batchEdit: 'Editar Fotos em Lote ...',
batchEditShort: 'Editar em Lote ...',
batchExitEdit: 'Sair da Edição em Lote',
appInsights: 'Insights do App',
appConfig: 'Configuração do App',
edit: 'Editar',
favorite: 'Favoritar',
unfavorite: 'Remover dos Favoritos',
download: 'Baixar',
sync: 'Sincronizar',
delete: 'Excluir',
deleteConfirm: (photoTitle: string) =>
`Tem certeza que deseja excluir "${photoTitle}"?`,
},
onboarding: {
setupComplete: 'Configuração Concluída!',
setupIncomplete: 'Finalizar Configuração',
setupSignIn: 'Entre para enviar fotos',
setupFirstPhoto: 'Adicione sua primeira foto',
// eslint-disable-next-line max-len
setupConfig: 'Altere o nome do site e outras configurações editando as variáveis de ambiente referenciadas em',
},
misc: {
loading: 'Carregando ...',
finishing: 'Finalizando ...',
uploading: 'Enviando',
repo: 'Feito com',
copyPhrase: (label: string) => `${label} copiado`,
},
utility: {
paginate: (
index: number,
count: number,
action?: string,
) => action
? `${action} ${index} de ${count}`
: `${index} de ${count}`,
},
dateLocale: ptBR,
};
export default TEXT;

122
src/i18n/locales/us-en.ts Normal file
View File

@ -0,0 +1,122 @@
import { enUS } from 'date-fns/locale';
const TEXT = {
photo: {
photo: 'Photo',
photoPlural: 'Photos',
taken: 'Taken',
created: 'Created',
updated: 'Updated',
copied: 'Link to photo copied',
},
category: {
camera: 'Camera',
cameraPlural: 'Cameras',
cameraTitle: (camera: string) => `Shot on ${camera}`,
cameraShare: (camera: string) => `Photos shot on ${camera}`,
lens: 'Lens',
lensPlural: 'Lenses',
tag: 'Tag',
tagPlural: 'Tags',
taggedPhotos: 'Tagged Photos',
taggedPhrase: (tag: string) => `Photos tagged '${tag}'`,
taggedFavs: 'Favorite Photos',
recipe: 'Recipe',
recipePlural: 'Recipes',
recipeShare: (recipe: string) => `${recipe} recipe photos`,
film: 'Film',
filmPlural: 'Films',
filmShare: (film: string) => `Photos shot on ${film}`,
focalLength: 'Focal Length',
focalLengthPlural: 'Focal Lengths',
focalLengthTitle: (focal: string) => `Focal Length ${focal}`,
focalLengthShare: (focal: string) => `Photos shot at ${focal}`,
},
nav: {
home: 'Home',
feed: 'Feed',
grid: 'Grid',
admin: 'Admin',
search: 'Search',
prev: 'Previous',
prevShort: 'Prev',
next: 'Next',
nextShort: 'Next',
},
cmdk: {
placeholder: 'Search photos, views, settings ...',
},
tooltip: {
'35mm': '35mm Equivalent',
zoom: 'Zoom In',
sharePhoto: 'Share Photo',
recipeInfo: 'Recipe Info',
recipeCopy: 'Copy Recipe Text',
download: 'Download Original File',
},
theme: {
theme: 'Theme',
system: 'System',
light: 'Light Mode',
dark: 'Dark Mode',
},
auth: {
signIn: 'Sign In',
signOut: 'Sign Out',
email: 'Admin Email',
password: 'Admin Password',
invalidEmailPassword: 'Invalid email/password',
},
admin: {
uploadPhotos: 'Upload Photos',
upload: 'Upload',
uploadPlural: 'Uploads',
uploading: 'Uploading',
updates: 'Updates',
managePhotos: 'Manage Photos',
manageCameras: 'Manage Cameras',
manageLenses: 'Manage Lenses',
manageTags: 'Manage Tags',
manageRecipes: 'Manage Recipes',
batchEdit: 'Batch Edit Photos ...',
batchEditShort: 'Batch Edit ...',
batchExitEdit: 'Exit Batch Edit',
appInsights: 'App Insights',
appConfig: 'App Configuration',
edit: 'Edit',
favorite: 'Favorite',
unfavorite: 'Unfavorite',
download: 'Download',
sync: 'Sync',
delete: 'Delete',
deleteConfirm: (photoTitle: string) =>
`Are you sure you want to delete "${photoTitle}?"`,
},
onboarding: {
setupComplete: 'Setup Complete!',
setupIncomplete: 'Finish Setup',
setupSignIn: 'Sign in to upload photos',
setupFirstPhoto: 'Add your first photo',
// eslint-disable-next-line max-len
setupConfig: 'Change the site name and other configuration by editing environment variables referenced in',
},
misc: {
loading: 'Loading ...',
finishing: 'Finishing ...',
uploading: 'Uploading',
repo: 'Made with',
copyPhrase: (label: string) => `${label} copied`,
},
utility: {
paginate: (
index: number,
count: number,
action?: string,
) => action
? `${action} ${index} of ${count}`
: `${index} of ${count}`,
},
dateLocale: enUS,
};
export default TEXT;

View File

@ -1,11 +1,9 @@
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForLensImage, pathForLens } from '@/app/paths';
import OGTile from '@/components/OGTile';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import { Lens } from '.';
import { titleForLens, descriptionForLensPhotos } from './meta';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function LensOGTile({
lens,
photos,

View File

@ -9,6 +9,7 @@ import {
absolutePathForLens,
absolutePathForLensImage,
} from '@/app/paths';
import { APP_TEXT } from '@/app/config';
// Meta functions moved to separate file to avoid
// dependencies (camelcase-keys) found in photo/index.ts
@ -19,7 +20,7 @@ export const titleForLens = (
photos: Photo[],
explicitCount?: number,
) => [
'Lens:',
`${APP_TEXT.category.lens}:`,
formatLensText(lensFromPhoto(photos[0], lens)),
photoQuantityText(explicitCount ?? photos.length),
].join(' ');
@ -29,7 +30,7 @@ export const shareTextForLens = (
photos: Photo[],
) =>
[
'Lens:',
`${APP_TEXT.category.lens}:`,
formatLensText(lensFromPhoto(photos[0], lens)),
].join(' ');

View File

@ -2,6 +2,7 @@ import ResponsiveDate from '@/components/ResponsiveDate';
import { Photo } from '.';
import { useMemo } from 'react';
import { Timezone } from '@/utility/timezone';
import { APP_TEXT } from '@/app/config';
export default function PhotoDate({
photo,
@ -33,11 +34,11 @@ export default function PhotoDate({
const getTitleLabel = () => {
switch (dateType) {
case 'takenAt':
return 'TAKEN';
return APP_TEXT.photo.taken;
case 'createdAt':
return 'CREATED';
return APP_TEXT.photo.created;
case 'updatedAt':
return 'UPDATED';
return APP_TEXT.photo.updated;
}
};
@ -45,7 +46,7 @@ export default function PhotoDate({
<ResponsiveDate {...{
date,
className,
titleLabel: getTitleLabel(),
titleLabel: getTitleLabel().toLocaleUpperCase(),
timezone,
hideTime,
}} />

View File

@ -10,7 +10,7 @@ import FavsTag from '../tag/FavsTag';
import { useAppState } from '@/state/AppState';
import { useMemo, useRef } from 'react';
import HiddenTag from '@/tag/HiddenTag';
import { CATEGORY_VISIBILITY } from '@/app/config';
import { APP_TEXT, CATEGORY_VISIBILITY } from '@/app/config';
import { clsx } from 'clsx/lite';
import PhotoRecipe from '@/recipe/PhotoRecipe';
import IconCamera from '@/components/icons/IconCamera';
@ -83,7 +83,7 @@ export default function PhotoGridSidebar({
const camerasContent = cameras.length > 0
? <HeaderList
key="cameras"
title="Cameras"
title={APP_TEXT.category.cameraPlural}
icon={<IconCamera
size={15}
className="translate-x-[0.5px]"
@ -107,7 +107,7 @@ export default function PhotoGridSidebar({
const lensesContent = lenses.length > 0
? <HeaderList
key="lenses"
title="Lenses"
title={APP_TEXT.category.lensPlural}
icon={<IconLens size={15} />}
maxItems={maxItemsPerCategory}
items={lenses
@ -127,7 +127,7 @@ export default function PhotoGridSidebar({
const tagsContent = tags.length > 0
? <HeaderList
key="tags"
title='Tags'
title={APP_TEXT.category.tagPlural}
icon={<IconTag
size={14}
className="translate-x-[1px] translate-y-[1px]"
@ -172,7 +172,7 @@ export default function PhotoGridSidebar({
const recipesContent = recipes.length > 0
? <HeaderList
key="recipes"
title="Recipes"
title={APP_TEXT.category.recipePlural}
icon={<IconRecipe
size={16}
className="translate-x-[-1px]"
@ -195,7 +195,7 @@ export default function PhotoGridSidebar({
const filmsContent = films.length > 0
? <HeaderList
key="films"
title="Films"
title={APP_TEXT.category.filmPlural}
icon={<IconFilm size={15} />}
maxItems={maxItemsPerCategory}
items={films
@ -213,7 +213,7 @@ export default function PhotoGridSidebar({
const focalLengthsContent = focalLengths.length > 0
? <HeaderList
key="focal-lengths"
title="Focal Lengths"
title={APP_TEXT.category.focalLengthPlural}
icon={<IconFocalLength size={13} />}
maxItems={maxItemsPerCategory}
items={focalLengths.map(({ focal, count }) =>

View File

@ -17,12 +17,13 @@ import PhotoLink from './PhotoLink';
import ResponsiveText from '@/components/primitives/ResponsiveText';
import { useAppState } from '@/state/AppState';
import { GRID_GAP_CLASSNAME } from '@/components';
import { APP_TEXT } from '@/app/config';
export default function PhotoHeader({
photos,
selectedPhoto,
entity,
entityVerb = 'PHOTO',
entityVerb = APP_TEXT.photo.photo.toLocaleUpperCase(),
entityDescription,
indexNumber,
count,
@ -50,9 +51,8 @@ export default function PhotoHeader({
? photos.findIndex(photo => photo.id === selectedPhoto.id)
: undefined;
const paginationLabel =
(indexNumber || (selectedPhotoIndex ?? 0 + 1)) + ' of ' +
(count ?? photos.length);
const paginationIndex = indexNumber || (selectedPhotoIndex ?? 0 + 1);
const paginationCount = count ?? photos.length;
const headerType = selectedPhotoIndex === undefined
? 'photo-set'
@ -154,8 +154,17 @@ export default function PhotoHeader({
dim: true,
}} />}
</>
: <ResponsiveText shortText={paginationLabel}>
{entityVerb} {paginationLabel}
: <ResponsiveText
shortText={APP_TEXT.utility.paginate(
paginationIndex,
paginationCount,
entityVerb,
)}
>
{APP_TEXT.utility.paginate(
paginationIndex,
paginationCount,
entityVerb)}
</ResponsiveText>}
</>}
</div>

View File

@ -31,6 +31,7 @@ import {
SHOW_TAKEN_AT_TIME,
MATTE_COLOR,
MATTE_COLOR_DARK,
APP_TEXT,
} from '@/app/config';
import AdminPhotoMenu from '@/admin/AdminPhotoMenu';
import { RevalidatePhoto } from './InfinitePhotoScroll';
@ -378,7 +379,7 @@ export default function PhotoLarge({
<>
{' '}
<Tooltip
content="35mm equivalent"
content={APP_TEXT.tooltip['35mm']}
sideOffset={3}
supportMobile
>
@ -434,7 +435,7 @@ export default function PhotoLarge({
)}>
{showZoomControls &&
<LoaderButton
tooltip="Zoom In"
tooltip={APP_TEXT.tooltip.zoom}
icon={<LuExpand size={15} />}
onClick={() => refZoomControls.current?.open()}
styleAs="link"
@ -443,7 +444,7 @@ export default function PhotoLarge({
/>}
{shouldShare &&
<ShareButton
tooltip="Share Photo"
tooltip={APP_TEXT.tooltip.sharePhoto}
photo={photo}
tag={shouldShareTag
? primaryTag

View File

@ -5,9 +5,7 @@ import {
} from '@/photo';
import { PhotoSetCategory } from '../category';
import { absolutePathForPhotoImage, pathForPhoto } from '@/app/paths';
import OGTile from '@/components/OGTile';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
import OGTile, { OGLoadingState } from '@/components/OGTile';
export default function PhotoOGTile({
photo,

View File

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

View File

@ -11,6 +11,7 @@ import { useRef } from 'react';
import { useEffect } from 'react';
import Spinner from '@/components/Spinner';
import ResponsiveText from '@/components/primitives/ResponsiveText';
import { APP_TEXT } from '@/app/config';
export default function PhotoUploadWithStatus({
inputRef,
@ -72,10 +73,11 @@ export default function PhotoUploadWithStatus({
}
};
}, [resetUploadState]);
const isFinishing = isPending && shouldResetUploadStateAfterPending.current;
const uploadStatusText = filesLength > 1
? `${fileUploadIndex + 1} of ${filesLength}`
? APP_TEXT.utility.paginate(fileUploadIndex + 1, filesLength)
: undefined;
return (
@ -158,19 +160,19 @@ export default function PhotoUploadWithStatus({
{isUploading
? isFinishing
? <>
Finishing ...
{APP_TEXT.misc.finishing}
</>
: <>
{!showButton && uploadStatusText
? <>
<ResponsiveText shortText={uploadStatusText}>
Uploading {uploadStatusText}
{APP_TEXT.misc.uploading} {uploadStatusText}
</ResponsiveText>
{': '}
{fileUploadName}
</>
: <ResponsiveText shortText={fileUploadName}>
Uploading {fileUploadName}
{APP_TEXT.misc.uploading} {fileUploadName}
</ResponsiveText>}
</>
: !showButton && <>Initializing</>}

View File

@ -1,6 +1,10 @@
import Container from '@/components/Container';
import AppGrid from '@/components/AppGrid';
import { IS_SITE_READY, PRESERVE_ORIGINAL_UPLOADS } from '@/app/config';
import {
APP_TEXT,
IS_SITE_READY,
PRESERVE_ORIGINAL_UPLOADS,
} from '@/app/config';
import AdminAppConfiguration from '@/admin/AdminAppConfiguration';
import { clsx } from 'clsx/lite';
import { HiOutlinePhotograph } from 'react-icons/hi';
@ -29,7 +33,9 @@ export default function PhotosEmptyState() {
'font-bold text-2xl',
'text-gray-700 dark:text-gray-200',
)}>
{!IS_SITE_READY ? 'Finish Setup' : 'Setup Complete!'}
{!IS_SITE_READY
? APP_TEXT.onboarding.setupIncomplete
: APP_TEXT.onboarding.setupComplete}
</div>
{!IS_SITE_READY
? <AdminAppConfiguration simplifiedView />
@ -43,8 +49,7 @@ export default function PhotosEmptyState() {
}}
/>
<div>
Change this site&apos;s name and other configuration
by editing environment variables referenced in
{APP_TEXT.onboarding.setupConfig}
{' '}
<Link
href={PATH_ADMIN_CONFIGURATION}

View File

@ -2,7 +2,8 @@
import { useCallback, useEffect, useState } from 'react';
import { Photo } from '@/photo';
import PhotoOGTile, { OGLoadingState } from './PhotoOGTile';
import PhotoOGTile from './PhotoOGTile';
import { OGLoadingState } from '@/components/OGTile';
const DEFAULT_MAX_CONCURRENCY = 3;

View File

@ -2,6 +2,7 @@ import { formatFocalLength } from '@/focal';
import { getNextImageUrlForRequest } from '@/platforms/next-image';
import { photoHasFilmData } from '@/film';
import {
APP_TEXT,
HIGH_DENSITY_GRID,
IS_PREVIEW,
SHOW_EXIF_DATA,
@ -17,7 +18,7 @@ import {
formatExposureCompensation,
formatExposureTime,
} from '@/utility/exif-format';
import { parameterize } from '@/utility/string';
import { capitalize, parameterize } from '@/utility/string';
import camelcaseKeys from 'camelcase-keys';
import { isBefore } from 'date-fns';
import type { Metadata } from 'next';
@ -171,7 +172,7 @@ export const photoStatsAsString = (photo: Photo) => [
].join(' ');
export const descriptionForPhoto = (photo: Photo) =>
photo.takenAtNaiveFormatted?.toUpperCase();
formatDate({ date: photo.takenAt }).toLocaleUpperCase();
export const getPreviousPhoto = (photo: Photo, photos: Photo[]) => {
const index = photos.findIndex(p => p.id === photo.id);
@ -231,10 +232,14 @@ export const titleForPhoto = (
export const altTextForPhoto = (photo: Photo) =>
photo.semanticDescription || titleForPhoto(photo);
export const photoLabelForCount = (count: number, capitalize = true) =>
capitalize
? count === 1 ? 'Photo' : 'Photos'
: count === 1 ? 'photo' : 'photos';
export const photoLabelForCount = (count: number, _capitalize = true) => {
const label = count === 1
? APP_TEXT.photo.photo
: APP_TEXT.photo.photoPlural;
return _capitalize
? capitalize(label)
: label;
};
export const photoQuantityText = (
count: number,
@ -246,7 +251,7 @@ export const photoQuantityText = (
: `${count} ${photoLabelForCount(count, capitalize)}`;
export const deleteConfirmationTextForPhoto = (photo: Photo) =>
`Are you sure you want to delete "${titleForPhoto(photo)}?"`;
APP_TEXT.admin.deleteConfirm(titleForPhoto(photo));
export type PhotoDateRange = { start: string, end: string };
@ -260,9 +265,10 @@ export const descriptionForPhotoSet = (
dateBased
? dateRangeForPhotos(photos, explicitDateRange).description.toUpperCase()
: [
explicitCount ?? photos.length,
descriptor,
photoLabelForCount(explicitCount ?? photos.length, false),
explicitCount ?? photos.length, (
descriptor ||
photoLabelForCount(explicitCount ?? photos.length, false)
),
].join(' ');
const sortPhotosByDateNonDestructively = (

View File

@ -19,6 +19,7 @@ import { TbChecklist } from 'react-icons/tb';
import CopyButton from '@/components/CopyButton';
import { labelForFilm } from '@/film';
import PhotoRecipe from './PhotoRecipe';
import { APP_TEXT } from '@/app/config';
export default function PhotoRecipeOverlay({
ref,
@ -138,7 +139,7 @@ export default function PhotoRecipeOverlay({
'text-black/40 active:text-black/75',
'hover:text-black/40',
)}
tooltip="Copy recipe text"
tooltip={APP_TEXT.tooltip.recipeCopy}
tooltipColor="frosted"
/>
<span>

View File

@ -4,6 +4,7 @@ import clsx from 'clsx/lite';
import { FaPlus } from 'react-icons/fa6';
import Tooltip from '@/components/Tooltip';
import { useRef } from 'react';
import { APP_TEXT } from '@/app/config';
export default function PhotoRecipeOverlayButton({
className,
@ -17,7 +18,7 @@ export default function PhotoRecipeOverlayButton({
const ref = useRef<HTMLButtonElement>(null);
return (
<Tooltip content="Recipe Info">
<Tooltip content={APP_TEXT.tooltip.recipeInfo}>
<button
ref={ref}
onClick={() => {

View File

@ -1,10 +1,8 @@
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForRecipeImage, pathForRecipe } from '@/app/paths';
import OGTile from '@/components/OGTile';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import { descriptionForRecipePhotos, titleForRecipe } from '.';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function RecipeOGTile({
recipe,
photos,

View File

@ -8,6 +8,7 @@ import {
} from '@/utility/string';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { labelForFilm } from '@/film';
import { APP_TEXT } from '@/app/config';
export type RecipeWithCount = {
recipe: string
@ -32,12 +33,12 @@ export const titleForRecipe = (
photos:Photo[] = [],
explicitCount?: number,
) => [
`Recipe: ${formatRecipe(recipe)}`,
`${APP_TEXT.category.recipe}: ${formatRecipe(recipe)}`,
photoQuantityText(explicitCount ?? photos.length),
].join(' ');
export const shareTextForRecipe = (recipe: string) =>
`${formatRecipe(recipe)} recipe photos`;
APP_TEXT.category.recipeShare(formatRecipe(recipe));
export const descriptionForRecipePhotos = (
photos: Photo[] = [],

View File

@ -8,7 +8,7 @@ import { ReactNode, useEffect } from 'react';
import { shortenUrl } from '@/utility/url';
import { toastSuccess } from '@/toast';
import { PiXLogo } from 'react-icons/pi';
import { SHOW_SOCIAL } from '@/app/config';
import { APP_TEXT, SHOW_SOCIAL } from '@/app/config';
import { generateXPostText } from '@/utility/social';
import { useAppState } from '@/state/AppState';
import useOnPathChange from '@/utility/useOnPathChange';
@ -96,7 +96,7 @@ export default function ShareModal({
<BiCopy size={18} />,
() => {
navigator.clipboard.writeText(pathShare);
toastSuccess('Link to photo copied');
toastSuccess(APP_TEXT.photo.copied);
},
true,
)}

View File

@ -3,7 +3,7 @@ import PhotoTag from './PhotoTag';
import { descriptionForTaggedPhotos, isTagFavs } from '.';
import PhotoHeader from '@/photo/PhotoHeader';
import FavsTag from './FavsTag';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { AI_TEXT_GENERATION_ENABLED, APP_TEXT } from '@/app/config';
export default function TagHeader({
tag,
@ -26,7 +26,7 @@ export default function TagHeader({
entity={isTagFavs(tag)
? <FavsTag contrast="high" />
: <PhotoTag tag={tag} contrast="high" />}
entityVerb="Tagged"
entityVerb={APP_TEXT.category.taggedPhotos}
entityDescription={descriptionForTaggedPhotos(photos, undefined, count)}
photos={photos}
selectedPhoto={selectedPhoto}

View File

@ -1,10 +1,8 @@
import { Photo, PhotoDateRange } from '@/photo';
import { absolutePathForTagImage, pathForTag } from '@/app/paths';
import OGTile from '@/components/OGTile';
import OGTile, { OGLoadingState } from '@/components/OGTile';
import { descriptionForTaggedPhotos, titleForTag } from '.';
export type OGLoadingState = 'unloaded' | 'loading' | 'loaded' | 'failed';
export default function TagOGTile({
tag,
photos,

View File

@ -16,6 +16,7 @@ import {
formatCountDescriptive,
} from '@/utility/string';
import { sortCategoryByCount } from '@/category';
import { APP_TEXT } from '@/app/config';
// Reserved tags
export const TAG_FAVS = 'favs';
@ -48,7 +49,9 @@ export const titleForTag = (
].join(' ');
export const shareTextForTag = (tag: string) =>
isTagFavs(tag) ? 'Favorite photos' : `Photos tagged '${formatTag(tag)}'`;
isTagFavs(tag)
? APP_TEXT.category.taggedFavs
: APP_TEXT.category.taggedPhrase(formatTag(tag));
export const sortTagsArray = (
tags: string[],
@ -95,7 +98,7 @@ export const descriptionForTaggedPhotos = (
) =>
descriptionForPhotoSet(
photos,
'tagged',
APP_TEXT.category.taggedPhotos,
dateBased,
explicitCount,
explicitDateRange,
@ -139,5 +142,6 @@ export const convertTagsForForm = (tags: Tags = []) =>
.map(({ tag, count }) => ({
value: tag,
annotation: formatCount(count),
annotationAria: formatCountDescriptive(count, 'tagged'),
annotationAria:
formatCountDescriptive(count, APP_TEXT.category.taggedPhotos),
}));

View File

@ -1,6 +1,7 @@
import { parseISO, parse, format } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import { Timezone } from './timezone';
import { APP_TEXT } from '@/app/config';
const DATE_STRING_FORMAT_TINY = 'dd MMM yy';
const DATE_STRING_FORMAT_TINY_PLACEHOLDER = '00 000 00';
@ -65,8 +66,10 @@ export const formatDate = ({
return showPlaceholder
? placeholderString
: timezone
? formatInTimeZone(date, timezone, formatString)
: format(date, formatString);
? formatInTimeZone(
date, timezone, formatString, { locale: APP_TEXT.dateLocale },
)
: format(date, formatString, { locale: APP_TEXT.dateLocale });
};
export const formatDateFromPostgresString = (date: string, length?: Length) =>