commit
f7ed792f57
11
README.md
11
README.md
@ -254,6 +254,17 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider
|
||||
1. Ensure connection string is set to "Transaction Mode" via port `6543`
|
||||
2. Disable SSL by setting `DISABLE_POSTGRES_SSL = 1`
|
||||
|
||||
💬 I18N
|
||||
-
|
||||
|
||||
Partial internationalization (non-admin, user-facing text) provided for a handful of languages. Set your language by setting the environment variable `NEXT_PUBLIC_LOCALE`. If you'd like to add support for a new language, open a PR [using `US_EN`](https://github.com/sambecker/exif-photo-blog/main/src/i18n/locales/us-en.ts) for reference.
|
||||
|
||||
### Supported Languages
|
||||
- `US_EN`
|
||||
- `ES_ES` (coming soon)
|
||||
- `PT_BR` (coming soon)
|
||||
- `PT_PT` (coming soon)
|
||||
|
||||
📖 FAQ
|
||||
-
|
||||
#### How do I receive template updates?
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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,
|
||||
}); }
|
||||
|
||||
@ -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]"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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],
|
||||
}}}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[],
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
29
src/i18n/index.ts
Normal 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
123
src/i18n/locales/pt-br.ts
Normal 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
122
src/i18n/locales/us-en.ts
Normal 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;
|
||||
@ -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,
|
||||
|
||||
@ -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(' ');
|
||||
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
|
||||
@ -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 }) =>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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</>}
|
||||
|
||||
@ -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's name and other configuration
|
||||
by editing environment variables referenced in
|
||||
{APP_TEXT.onboarding.setupConfig}
|
||||
{' '}
|
||||
<Link
|
||||
href={PATH_ADMIN_CONFIGURATION}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[] = [],
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
}));
|
||||
|
||||
@ -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) =>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user