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>1>?0>
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,
+ },
+ })
+}