diff --git a/ui/babel.config.js b/ui/babel.config.js new file mode 100644 index 00000000..43e0db34 --- /dev/null +++ b/ui/babel.config.js @@ -0,0 +1,32 @@ +module.exports = function (api) { + const isTest = api.env('test') + const isProduction = api.env('NODE_ENV') == 'production' + + let presets = ['@babel/preset-react'] + let plugins = [] + + if (isTest) { + presets.push('@babel/preset-env') + + plugins.push('@babel/plugin-transform-runtime') + plugins.push('@babel/plugin-transform-modules-commonjs') + } else { + plugins.push(['styled-components', { pure: true }]) + plugins.push('graphql-tag') + if (!isProduction) { + plugins.push([ + 'i18next-extract', + { + locales: ['en', 'da'], + discardOldKeys: true, + defaultValue: null, + }, + ]) + } + } + + return { + presets: presets, + plugins: plugins, + } +} diff --git a/ui/babel.config.json b/ui/babel.config.json deleted file mode 100644 index 91cd094a..00000000 --- a/ui/babel.config.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "presets": ["@babel/preset-react"], - "plugins": [ - "styled-components", - "graphql-tag", - [ - "i18next-extract", - { - "locales": ["en", "da"], - "discardOldKeys": true, - "defaultValue": null - } - ] - ] -} diff --git a/ui/build.mjs b/ui/build.mjs index fc31da15..f6605add 100644 --- a/ui/build.mjs +++ b/ui/build.mjs @@ -72,11 +72,19 @@ if (watchMode) { bs.reload(args) }) } else { - esbuild.build(esbuildOptions).then(() => console.log('esbuild done')) + const esbuildPromise = esbuild + .build(esbuildOptions) + .then(() => console.log('esbuild done')) - workboxBuild.generateSW({ - globDirectory: 'dist/', - globPatterns: ['**/*.{png,svg,woff2,ttf,eot,woff,js,ico,html,json,css}'], - swDest: 'dist/service-worker.js', - }) + const workboxPromise = workboxBuild + .generateSW({ + globDirectory: 'dist/', + globPatterns: ['**/*.{png,svg,woff2,ttf,eot,woff,js,ico,html,json,css}'], + swDest: 'dist/service-worker.js', + }) + .then(() => console.log('workbox done')) + + Promise.all([esbuildPromise, workboxPromise]).then(() => + console.log('build complete') + ) } diff --git a/ui/extractedTranslations/da/translation.json b/ui/extractedTranslations/da/translation.json index b3d0d402..e731f4e6 100644 --- a/ui/extractedTranslations/da/translation.json +++ b/ui/extractedTranslations/da/translation.json @@ -21,8 +21,16 @@ "save": "Gem" }, "loading": { + "album": "Loader album", "default": "Loader...", - "shares": null + "media": "Loader medier", + "page": "Loader side", + "paginate": { + "faces": "Loader flere personer", + "media": "Loader flere medier" + }, + "shares": "Loader delinger...", + "timeline": "Loader tidslinje" } }, "header": { @@ -36,11 +44,6 @@ } } }, - "loading": { - "paginate": { - "media": "Loader flere medier" - } - }, "login_page": { "field": { "password": "Adgangskode", @@ -59,6 +62,16 @@ }, "welcome": "Velkommen til Photoview" }, + "people_page": { + "face_group": { + "label_placeholder": "Navn", + "unlabeled": "Ikke navngivet" + }, + "recognize_unlabeled_faces_button": "Genkend ikke navngivede ansigter" + }, + "routes": { + "page_not_found": "Side ikke fundet" + }, "settings": { "concurrent_workers": { "description": "Det maksimale antal medier som må skannes samtidig", @@ -122,6 +135,15 @@ "title": "Brugere" } }, + "share_page": { + "protected_share": { + "description": "Denne deling er låst med en adgangskode.", + "title": "Beskyttet deling" + }, + "share_not_found": "Deling blev ikke fundet", + "share_not_found_description": "Måske er delingen udløbet eller blevet slettet.", + "wrong_password": "Forkert adgangskode, prøv venligst igen." + }, "sidebar": { "album": { "title": "Album indstillinger" @@ -202,6 +224,7 @@ }, "title": { "loading_album": "Loader album", + "people": "Personer", "settings": "Indstillinger" } } diff --git a/ui/extractedTranslations/en/translation.json b/ui/extractedTranslations/en/translation.json index 4edcd119..8ce673a7 100644 --- a/ui/extractedTranslations/en/translation.json +++ b/ui/extractedTranslations/en/translation.json @@ -21,8 +21,16 @@ "save": "Save" }, "loading": { + "album": "Loading album", "default": "Loading...", - "shares": "Loading shares..." + "media": "Loading media", + "page": "Loading page", + "paginate": { + "faces": "Loading more people", + "media": "Loading more media" + }, + "shares": "Loading shares...", + "timeline": "Loading timeline" } }, "header": { @@ -36,11 +44,6 @@ } } }, - "loading": { - "paginate": { - "media": "Loading more media" - } - }, "login_page": { "field": { "password": "Password", @@ -59,6 +62,16 @@ }, "welcome": "Welcome to Photoview" }, + "people_page": { + "face_group": { + "label_placeholder": "Label", + "unlabeled": "Unlabeled" + }, + "recognize_unlabeled_faces_button": "Recognize unlabeled faces" + }, + "routes": { + "page_not_found": "Page not found" + }, "settings": { "concurrent_workers": { "description": "The maximum amount of scanner jobs that is allowed to run at once", @@ -89,7 +102,7 @@ "submit": "Add user" }, "confirm_delete_user": { - "action": "Delete {user}", + "action": "Delete {{user}}", "description": "<0>Are you sure, you want to delete <1>?

This action cannot be undone

", "title": "Delete user" }, @@ -122,6 +135,15 @@ "title": "Users" } }, + "share_page": { + "protected_share": { + "description": "This share is protected with a password.", + "title": "Protected share" + }, + "share_not_found": "Share not found", + "share_not_found_description": "Maybe the share has expired or has been deleted.", + "wrong_password": "Wrong password, please try again." + }, "sidebar": { "album": { "title": "Album options" @@ -202,6 +224,7 @@ }, "title": { "loading_album": "Loading album", + "people": "People", "settings": "Settings" } } diff --git a/ui/package.json b/ui/package.json index 68d1e70d..5fcf0fdb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -53,8 +53,8 @@ "workbox-build": "^6.1.2" }, "scripts": { - "start": "node build.mjs watch", - "build": "NODE_ENV=production node build.mjs", + "start": "node --experimental-modules build.mjs watch", + "build": "NODE_ENV=production node --experimental-modules build.mjs", "test": "npm run lint && npm run jest", "lint": "eslint ./src --max-warnings 0 --cache", "jest": "jest", diff --git a/ui/src/Layout.test.js b/ui/src/Layout.test.js index a7db6009..b40a3aab 100644 --- a/ui/src/Layout.test.js +++ b/ui/src/Layout.test.js @@ -9,6 +9,8 @@ import { MemoryRouter } from 'react-router-dom' import * as authentication from './helpers/authentication' +require('./localization').default() + jest.mock('./helpers/authentication.js') test('Layout component', async () => { diff --git a/ui/src/Pages/AlbumPage/AlbumPage.js b/ui/src/Pages/AlbumPage/AlbumPage.js index f9b51807..01527b26 100644 --- a/ui/src/Pages/AlbumPage/AlbumPage.js +++ b/ui/src/Pages/AlbumPage/AlbumPage.js @@ -161,7 +161,7 @@ function AlbumPage({ match }) { /> ) diff --git a/ui/src/Pages/PeoplePage/PeoplePage.js b/ui/src/Pages/PeoplePage/PeoplePage.js index 7f82c635..60fac489 100644 --- a/ui/src/Pages/PeoplePage/PeoplePage.js +++ b/ui/src/Pages/PeoplePage/PeoplePage.js @@ -9,6 +9,7 @@ import { Button, Icon, Input } from 'semantic-ui-react' import FaceCircleImage from './FaceCircleImage' import useScrollPagination from '../../hooks/useScrollPagination' import PaginateLoader from '../../components/PaginateLoader' +import { useTranslation } from 'react-i18next' export const MY_FACES_QUERY = gql` query myFaces($limit: Int, $offset: Int) { @@ -73,6 +74,7 @@ const FaceDetailsButton = styled.button` const FaceLabel = styled.span`` const FaceDetails = ({ group }) => { + const { t } = useTranslation() const [editLabel, setEditLabel] = useState(false) const [inputValue, setInputValue] = useState(group.label ?? '') const inputRef = createRef() @@ -124,7 +126,9 @@ const FaceDetails = ({ group }) => { onClick={() => setEditLabel(true)} > {group.imageFaceCount} - {group.label ?? 'Unlabeled'} + + {group.label ?? t('people_page.face_group.unlabeled', 'Unlabeled')} + ) @@ -135,7 +139,7 @@ const FaceDetails = ({ group }) => { loading={loading} ref={inputRef} size="mini" - placeholder="Label" + placeholder={t('people_page.face_group.label_placeholder', 'Label')} icon="arrow right" value={inputValue} onKeyUp={onKeyUp} @@ -199,6 +203,7 @@ const FaceGroupsWrapper = styled.div` ` const PeopleGallery = () => { + const { t } = useTranslation() const { data, error, loading, fetchMore } = useQuery(MY_FACES_QUERY, { variables: { limit: 50, @@ -230,7 +235,7 @@ const PeopleGallery = () => { } return ( - + {faces} ) diff --git a/ui/src/Pages/SharePage/AlbumSharePage.js b/ui/src/Pages/SharePage/AlbumSharePage.js index 5f9f255a..80045b7f 100644 --- a/ui/src/Pages/SharePage/AlbumSharePage.js +++ b/ui/src/Pages/SharePage/AlbumSharePage.js @@ -4,6 +4,7 @@ import Layout from '../../Layout' import AlbumGallery from '../../components/albumGallery/AlbumGallery' import styled from 'styled-components' import { gql, useQuery } from '@apollo/client' +import { useTranslation } from 'react-i18next' export const SHARE_ALBUM_QUERY = gql` query shareAlbumQuery( @@ -73,6 +74,7 @@ const AlbumSharePageWrapper = styled.div` ` const AlbumSharePage = ({ albumID, token, password }) => { + const { t } = useTranslation() const { data, loading, error } = useQuery(SHARE_ALBUM_QUERY, { variables: { id: albumID, @@ -88,14 +90,18 @@ const AlbumSharePage = ({ albumID, token, password }) => { } if (loading) { - return 'Loading...' + return t('general.loading.default', 'Loading...') } const album = data.album return ( - + `/share/${token}/${albumId}`} diff --git a/ui/src/Pages/SharePage/SharePage.js b/ui/src/Pages/SharePage/SharePage.js index fdc83bf5..1188e2ef 100644 --- a/ui/src/Pages/SharePage/SharePage.js +++ b/ui/src/Pages/SharePage/SharePage.js @@ -11,6 +11,7 @@ import { } from '../../helpers/authentication' import AlbumSharePage from './AlbumSharePage' import MediaSharePage from './MediaSharePage' +import { useTranslation } from 'react-i18next' export const SHARE_TOKEN_QUERY = gql` query SharePageToken($token: String!, $password: String) { @@ -71,6 +72,8 @@ export const VALIDATE_TOKEN_PASSWORD_QUERY = gql` ` const AuthorizedTokenRoute = ({ match }) => { + const { t } = useTranslation() + const token = match.params.token const password = getSharePassword(token) @@ -122,7 +125,7 @@ const AuthorizedTokenRoute = ({ match }) => { return } - return

Share not found

+ return

{t('share_page.share_not_found', 'Share not found')}

} AuthorizedTokenRoute.propTypes = { @@ -138,6 +141,8 @@ const ProtectedTokenEnterPassword = ({ refetchWithPassword, loading = false, }) => { + const { t } = useTranslation() + const [passwordValue, setPasswordValue] = useState('') const [invalidPassword, setInvalidPassword] = useState(false) @@ -150,7 +155,9 @@ const ProtectedTokenEnterPassword = ({ if (invalidPassword && !loading) { errorMessage = ( - Wrong password, please try again. + + {t('share_page.wrong_password', 'Wrong password, please try again.')} + ) } @@ -158,18 +165,23 @@ const ProtectedTokenEnterPassword = ({ return (
- Protected share + {t('share_page.protected_share.title', 'Protected share')}
-

This share is protected with a password.

+

+ {t( + 'share_page.protected_share.description', + 'This share is protected with a password.' + )} +

- + event.key == 'Enter' && onSubmit()} onChange={e => setPasswordValue(e.target.value)} - placeholder="Password" + placeholder={t('login_page.field.password', 'Password')} type="password" icon={} /> @@ -186,6 +198,8 @@ ProtectedTokenEnterPassword.propTypes = { } const TokenRoute = ({ match }) => { + const { t } = useTranslation() + const token = match.params.token const { loading, error, data, refetch } = useQuery( @@ -203,8 +217,13 @@ const TokenRoute = ({ match }) => { if (error.message == 'GraphQL error: share not found') { return ( -

Share not found

-

Maybe the share has expired or has been deleted.

+

{t('share_page.share_not_found', 'Share not found')}

+

+ {t( + 'share_page.share_not_found_description', + 'Maybe the share has expired or has been deleted.' + )} +

) } @@ -225,7 +244,7 @@ const TokenRoute = ({ match }) => { ) } - if (loading) return 'Loading...' + if (loading) return t('general.loading.default', 'Loading...') return } @@ -234,16 +253,20 @@ TokenRoute.propTypes = { match: PropTypes.object.isRequired, } -const SharePage = ({ match }) => ( - - - {({ match }) => { - return - }} - - Route not found - -) +const SharePage = ({ match }) => { + const { t } = useTranslation() + + return ( + + + {({ match }) => { + return + }} + + {t('routes.page_not_found', 'Page not found')} + + ) +} SharePage.propTypes = { ...RouterProps, diff --git a/ui/src/Pages/SharePage/SharePage.test.js b/ui/src/Pages/SharePage/SharePage.test.js index c894c16b..fac0019d 100644 --- a/ui/src/Pages/SharePage/SharePage.test.js +++ b/ui/src/Pages/SharePage/SharePage.test.js @@ -18,6 +18,8 @@ import SharePage, { import { SIDEBAR_DOWNLOAD_QUERY } from '../../components/sidebar/SidebarDownload' import { SHARE_ALBUM_QUERY } from './AlbumSharePage' +require('../../localization').default() + describe('load correct share page, based on graphql query', () => { const token = 'TOKEN123' diff --git a/ui/src/components/photoGallery/PhotoGallery.js b/ui/src/components/photoGallery/PhotoGallery.js index a672100d..f3a0c66a 100644 --- a/ui/src/components/photoGallery/PhotoGallery.js +++ b/ui/src/components/photoGallery/PhotoGallery.js @@ -6,6 +6,7 @@ import PresentView from './presentView/PresentView' import PropTypes from 'prop-types' import { SidebarContext } from '../sidebar/Sidebar' import MediaSidebar from '../sidebar/MediaSidebar' +import { useTranslation } from 'react-i18next' const Gallery = styled.div` display: flex; @@ -41,6 +42,7 @@ const PhotoGallery = ({ previousImage, onFavorite, }) => { + const { t } = useTranslation() const { updateSidebar } = useContext(SidebarContext) const activeImage = media && activeIndex != -1 && media[activeIndex] @@ -80,7 +82,9 @@ const PhotoGallery = ({ return ( - Loading images + + {t('general.loading.media', 'Loading media')} + {getPhotoElements(updateSidebar)} diff --git a/ui/src/components/routes/Routes.js b/ui/src/components/routes/Routes.js index 58593cb9..f6b990b0 100644 --- a/ui/src/components/routes/Routes.js +++ b/ui/src/components/routes/Routes.js @@ -4,6 +4,7 @@ import { Route, Switch, Redirect } from 'react-router-dom' import { Loader } from 'semantic-ui-react' import Layout from '../../Layout' import { clearTokenCookie } from '../../helpers/authentication' +import { useTranslation } from 'react-i18next' const AuthorizedRoute = React.lazy(() => import('./AuthorizedRoute')) @@ -26,11 +27,13 @@ const SettingsPage = React.lazy(() => ) const Routes = () => { + const { t } = useTranslation() + return ( - Loading page + {t('general.loading.page', 'Loading page')} } > @@ -51,7 +54,11 @@ const Routes = () => { } /> -
Page not found
} /> + ( +
{t('routes.page_not_found', 'Page not found')}
+ )} + />
) diff --git a/ui/src/components/routes/Routes.test.js b/ui/src/components/routes/Routes.test.js index 97dc92a6..d3dfc1a4 100644 --- a/ui/src/components/routes/Routes.test.js +++ b/ui/src/components/routes/Routes.test.js @@ -10,6 +10,8 @@ import { } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' +require('../../localization').default() + describe('routes', () => { test('unauthorized root path should navigate to login page', async () => { jest.mock('../../Pages/LoginPage/LoginPage.js', () => () => ( diff --git a/ui/src/components/sidebar/MediaSidebar.js b/ui/src/components/sidebar/MediaSidebar.js index 29543631..e1d2bdfa 100644 --- a/ui/src/components/sidebar/MediaSidebar.js +++ b/ui/src/components/sidebar/MediaSidebar.js @@ -169,6 +169,7 @@ export const MetadataInfo = ({ media }) => { exif.focalLength = `${exif.focalLength}mm` } + const flash = flashLookup(t) if (!isNil(exif.flash) && flash[exif.flash]) { exif.flash = flash[exif.flash] } @@ -249,7 +250,7 @@ const exposureProgramsLookup = t => ({ }) // From https://exiftool.org/TagNames/EXIF.html#Flash -const flash = t => { +const flashLookup = t => { const values = { no_flash: t('sidebar.media.exif.flash.no_flash', 'No Flash'), fired: t('sidebar.media.exif.flash.fired', 'Fired'), diff --git a/ui/src/components/sidebar/MediaSidebar.test.js b/ui/src/components/sidebar/MediaSidebar.test.js index e94faf50..67ff5cb1 100644 --- a/ui/src/components/sidebar/MediaSidebar.test.js +++ b/ui/src/components/sidebar/MediaSidebar.test.js @@ -4,6 +4,8 @@ import React from 'react' import { render, screen } from '@testing-library/react' import { MetadataInfo } from './MediaSidebar' +require('../../localization').default() + describe('MetadataInfo', () => { test('without EXIF information', async () => { const media = { @@ -33,11 +35,11 @@ describe('MetadataInfo', () => { expect(screen.queryByText('Maker')).not.toBeInTheDocument() expect(screen.queryByText('Lens')).not.toBeInTheDocument() expect(screen.queryByText('Program')).not.toBeInTheDocument() - expect(screen.queryByText('Date Shot')).not.toBeInTheDocument() + expect(screen.queryByText('Date shot')).not.toBeInTheDocument() expect(screen.queryByText('Exposure')).not.toBeInTheDocument() expect(screen.queryByText('Aperture')).not.toBeInTheDocument() expect(screen.queryByText('ISO')).not.toBeInTheDocument() - expect(screen.queryByText('Focal Length')).not.toBeInTheDocument() + expect(screen.queryByText('Focal length')).not.toBeInTheDocument() expect(screen.queryByText('Flash')).not.toBeInTheDocument() }) @@ -77,7 +79,7 @@ describe('MetadataInfo', () => { expect(screen.getByText('Program')).toBeInTheDocument() expect(screen.getByText('Canon EOS R')).toBeInTheDocument() - expect(screen.getByText('Date Shot')).toBeInTheDocument() + expect(screen.getByText('Date shot')).toBeInTheDocument() expect(screen.getByText('Exposure')).toBeInTheDocument() expect(screen.getByText('1/60')).toBeInTheDocument() @@ -91,7 +93,7 @@ describe('MetadataInfo', () => { expect(screen.getByText('ISO')).toBeInTheDocument() expect(screen.getByText('100')).toBeInTheDocument() - expect(screen.getByText('Focal Length')).toBeInTheDocument() + expect(screen.getByText('Focal length')).toBeInTheDocument() expect(screen.getByText('24mm')).toBeInTheDocument() expect(screen.getByText('Flash')).toBeInTheDocument() diff --git a/ui/src/components/timelineGallery/TimelineGallery.js b/ui/src/components/timelineGallery/TimelineGallery.js index 1dffd49d..06a101ed 100644 --- a/ui/src/components/timelineGallery/TimelineGallery.js +++ b/ui/src/components/timelineGallery/TimelineGallery.js @@ -10,6 +10,7 @@ import { FavoritesCheckbox } from '../AlbumFilter' import useScrollPagination from '../../hooks/useScrollPagination' import PaginateLoader from '../PaginateLoader' import LazyLoad from '../../helpers/LazyLoad' +import { useTranslation } from 'react-i18next' const MY_TIMELINE_QUERY = gql` query myTimeline($onlyFavorites: Boolean, $limit: Int, $offset: Int) { @@ -54,6 +55,7 @@ const GalleryWrapper = styled.div` ` const TimelineGallery = () => { + const { t } = useTranslation() const [activeIndex, setActiveIndex] = useState({ dateGroup: -1, albumGroup: -1, @@ -212,7 +214,9 @@ const TimelineGallery = () => { return ( <> - Loading timeline + + {t('general.loading.timeline', 'Loading timeline')} + { {timelineGroups} {presenting && ( { i18n.addResourceBundle('da', 'translation', danish) diff --git a/ui/src/localization.js b/ui/src/localization.js new file mode 100644 index 00000000..ca05f38a --- /dev/null +++ b/ui/src/localization.js @@ -0,0 +1,21 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +export default function setupLocalization() { + i18n.use(initReactI18next).init({ + resources: { + en: { + translation: { + 'Welcome to React': 'Welcome to React and react-i18next', + }, + }, + }, + lng: 'en', + fallbackLng: 'en', + returnNull: false, + + interpolation: { + escapeValue: false, + }, + }) +}