Browse Source

add translation of user content with google

fix meal context in state, so that tabs get highlighted correctly when viewing a meal

edit pages: enlarge buttons and undo color inversion (now edit pages are primary color)
add pages: enlarge buttons

while working on this commit I learned a lot about how to (more) correctly use state and effect in react.
Therefore, I have added useSet and useMap hooks to properly deal with state in those structures.
I have also started to remove unnecessary useEffect hooks in Meals.jsx. I will continue with the other components in future commits

I have also update react-scripts to the new version. It throws a dependency error with react-scripts ultimately depending on vulnerable versions of nth-check, which wants to install the old react-scripts to force fix. But, according to the Internet, this is not an actual vulnerability and can be ignored from my side.
Ramona Plogmann 2 years ago
parent
commit
b0542d9d21
39 changed files with 5903 additions and 580 deletions
  1. 1 0
      .gitignore
  2. 765 321
      client/package-lock.json
  3. 5 4
      client/package.json
  4. 4 1
      client/src/App.jsx
  5. 113 0
      client/src/components/Buttons/TranslatePageFAB.jsx
  6. 2 2
      client/src/components/Meals/AddMeal.jsx
  7. 9 7
      client/src/components/Meals/EditMeal.jsx
  8. 41 26
      client/src/components/Meals/MealDetailView.jsx
  9. 180 160
      client/src/components/Meals/Meals.jsx
  10. 30 4
      client/src/components/Meals/SelectMealTags.jsx
  11. 2 2
      client/src/components/NavTabs.jsx
  12. 1 1
      client/src/components/Plans/AddPlanItem.jsx
  13. 6 6
      client/src/components/Plans/EditPlanItem.jsx
  14. 35 4
      client/src/components/Plans/Plans.jsx
  15. 9 11
      client/src/components/Plans/ShoppingList.jsx
  16. 4 4
      client/src/components/Settings/EditProfile.jsx
  17. 89 13
      client/src/components/Social/ContactsContent.jsx
  18. 4 3
      client/src/components/util/Snackbars.jsx
  19. 3 0
      client/src/components/util/translation/TranslationContext.jsx
  20. 14 0
      client/src/components/util/translation/TranslationProvider.jsx
  21. 22 0
      client/src/components/util/translation/translate.util.jsx
  22. 38 0
      client/src/components/util/translation/useLanguageDetection.jsx
  23. 52 0
      client/src/components/util/translation/useTranslationMap.jsx
  24. 15 0
      client/src/components/util/useMap.jsx
  25. 20 0
      client/src/components/util/useSet.jsx
  26. 23 2
      client/src/i18n.js
  27. 2 0
      client/src/translations/_example.translation.json
  28. 3 1
      client/src/translations/de.translation.json
  29. 3 1
      client/src/translations/en-GB.translation.json
  30. 3 1
      client/src/translations/en-US.translation.json
  31. 3 1
      client/src/translations/es.translation.json
  32. 3 1
      client/src/translations/fr-FR.translation.json
  33. 4 2
      client/src/translations/it.translation.json
  34. 3 1
      client/src/translations/jp.translation.json
  35. 23 0
      server/controllers/translation.controller.js
  36. 2 0
      server/index.js
  37. 4358 1
      server/package-lock.json
  38. 1 0
      server/package.json
  39. 8 0
      server/routes/translation.routes.js

+ 1 - 0
.gitignore

@@ -11,3 +11,4 @@ myaha.conf
 client/emilia.service
 client/googleTranslate.json
 emilia-server.log
+server/googleKey.json

File diff suppressed because it is too large
+ 765 - 321
client/package-lock.json


+ 5 - 4
client/package.json

@@ -21,20 +21,21 @@
     "@testing-library/user-event": "^12.3.0",
     "axios": "^0.21.1",
     "dateformat": "^4.4.1",
+    "franc-min": "^6.1.0",
     "i18next": "^19.8.4",
     "i18next-browser-languagedetector": "^6.0.1",
-    "jsdoc": "^3.6.6",
+    "jsdoc": "^4.0.2",
+    "languagedetect": "^2.0.0",
     "material-ui-chip-input": "^2.0.0-beta.2",
-    "pwa-asset-generator": "^4.1.1",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
     "react-dropzone": "^11.2.4",
     "react-i18next": "^11.8.5",
     "react-material-ui-carousel": "^2.1.2",
     "react-router-dom": "^6.10.0",
-    "react-scripts": "^2.1.3",
+    "react-scripts": "^5.0.1",
     "react-select": "^4.3.0",
-    "react-swipeable-views": "^0.13.9",
+    "react-swipeable-views": "^0.14.0",
     "web-vitals": "^0.2.4"
   },
   "scripts": {

+ 4 - 1
client/src/App.jsx

@@ -7,6 +7,7 @@ import { useAuth0 } from "@auth0/auth0-react";
 import { getSettingsOfUser, updateUserSettingsForCategory } from "./components/Settings/settings.util";
 import { useLocation, useNavigate, useParams } from "react-router-dom";
 import i18n, { allLanguages } from "./i18n";
+import { TranslationProvider } from "./components/util/translation/TranslationProvider";
 
 /**
  * This is the main App component. It sets the MUI theme as well as the dark mode (if desired). It also deals with Frontend-Routing with the help of React Router.
@@ -99,7 +100,9 @@ const App = () => {
     <>
       <ThemeProvider theme={theme}>
         <CssBaseline />
-        <ContentWrapper setDarkMode={setPrefersDarkMode} />
+        <TranslationProvider>
+          <ContentWrapper setDarkMode={setPrefersDarkMode} />
+        </TranslationProvider>
       </ThemeProvider>
     </>
   );

+ 113 - 0
client/src/components/Buttons/TranslatePageFAB.jsx

@@ -0,0 +1,113 @@
+import React, { useState } from 'react';
+import { bool, func, number, object } from "prop-types";
+import { makeStyles } from '@material-ui/styles';
+import { Box, CircularProgress, Fab } from "@material-ui/core";
+import { KeyboardArrowLeft, KeyboardArrowRight, Translate } from "@material-ui/icons";
+import { useTranslationsMap } from "../util/translation/useTranslationMap";
+import { useTranslation } from "react-i18next";
+import { useLanguageDetection } from "../util/translation/useLanguageDetection";
+
+const useStyles = makeStyles(theme => ({
+  translateFAB: props => ({
+    paddingLeft: '48px',
+    color: theme.palette.background.default,
+    background: props.isTranslated ? `linear-gradient(to right bottom, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 50%, ${theme.palette.secondary.dark} 100%)`
+      : `linear-gradient(to right bottom, ${theme.palette.secondary.main} 0%, ${theme.palette.secondary.dark} 50%, ${theme.palette.primary.dark} 100%)`,
+    transition: 'all 200ms ease-out',
+    position: "fixed",
+    bottom: `calc(1.2rem + ${process.env.REACT_APP_NAV_BOTTOM_HEIGHT}px)`,
+    right: 0,
+    borderTopRightRadius: 0,
+    borderBottomRightRadius: 0,
+  }),
+  caret: {
+    position: 'absolute',
+    left: 0,
+    height: '48px',
+    width: '48px',
+
+    '& svg': {
+      height: '100%',
+      width: '100%',
+      padding: '10px'
+    }
+  },
+  loadingCircle: {
+    position: 'absolute', top: '50%', left: '50%', marginTop: '-12px', marginLeft: '-12px',
+  }
+}));
+
+const TranslatePageFAB = (props) => {
+  const classes = useStyles(props);
+  const { t } = useTranslation();
+  const { translateThingsAndAddToMap } = useTranslationsMap();
+  const { allThingsToTranslate, onTranslate, isTranslated, onBackToOriginal } = props;
+
+  const [isMinimized, setIsMinimized] = useState(false);
+  const [isTranslating, setIsTranslating] = useState(false);
+  const isUserLanguage = useLanguageDetection(allThingsToTranslate);
+
+  const translateProvidedContent = () => {
+    setIsTranslating(true); // show loading animation
+    translateThingsAndAddToMap(allThingsToTranslate, () => {
+      setIsTranslating(false);
+      onTranslate();
+      setIsMinimized(true);
+    });
+  };
+
+  const backToOriginal = () => {
+    setIsMinimized(true);
+    onBackToOriginal();
+  }
+
+  const handleFabClick = (event) => {
+    if (event.nativeEvent.detail > 1) {
+      // do nothing. Triggered by double click or while transitioning
+    } else {
+      if (isTranslated) backToOriginal(); else translateProvidedContent();
+    }
+  }
+
+  const handleCaretClick = (event) => {
+    if (event.nativeEvent.detail > 1) {
+      // do nothing. Triggered by double click or while transitioning
+    } else {
+      event.stopPropagation(); // prevent FAB from being clicked (no translation!)
+      toggleIsMinimized();
+    }
+  }
+
+  const toggleIsMinimized = () => {setIsMinimized(prev => !prev)}
+
+  return (isUserLanguage ? '' : <Fab style={{ transform: isMinimized ? 'translateX(calc(100% - 48px))' : 'translateX(0)' }}
+               disabled={isTranslating || isUserLanguage}
+               variant="extended"
+               color="secondary"
+               className={classes.translateFAB}
+               onClick={handleFabClick}>
+    <Box onClick={handleCaretClick} className={classes.caret}>
+      {isMinimized ? <KeyboardArrowLeft /> : <KeyboardArrowRight />}
+    </Box>
+    <Translate style={{ marginRight: '5px' }} />
+    {isTranslated ? t('Original') : t('Translate')}
+    {isTranslating && (<CircularProgress size={24} color="inherit" className={classes.loadingCircle} />)}
+  </Fab>);
+}
+
+TranslatePageFAB.propTypes = {
+  allThingsToTranslate: object.isRequired,
+  /** function to be executed after translating or when clicking translate if everything is already translated -> use to mark things as translated */
+  onTranslate: func,
+  /** function to be executed when clicking original -> use to mark things as not translated */
+  onBackToOriginal: func,
+  isTranslated: bool,
+};
+
+TranslatePageFAB.defaultProps = {
+  onTranslate: () => {},
+  onBackToOriginal: () => {},
+  isTranslated: undefined,
+};
+
+export default TranslatePageFAB;

+ 2 - 2
client/src/components/Meals/AddMeal.jsx

@@ -104,10 +104,10 @@ const AddMeal = () => {
         {meal.title && isLoading && loadingImagesTakesLong &&
           <>
             <Typography className={classes.waitForPictures} color="error">{t('LOADING_IMAGES_TAKES_LONG')}</Typography>
-            <SavingButton isSaving={isSaving} type="submit" disabled={!meal.title} className={classes.submitWithoutImagesButton} variant='outlined'>{t('Add without images')}</SavingButton>
+            <SavingButton isSaving={isSaving} type="submit" size="large" disabled={!meal.title} className={classes.submitWithoutImagesButton} variant='outlined'>{t('Add without images')}</SavingButton>
           </>
         }
-        <SavingButton isSaving={isSaving} type="submit" disabled={!meal.title || isLoading} className={classes.submitButton} variant='contained' color='primary'>{t('Add')}</SavingButton>
+        <SavingButton isSaving={isSaving} type="submit" size="large" disabled={!meal.title || isLoading} className={classes.submitButton} variant='contained' color='primary'>{t('Add')}</SavingButton>
       </form>
 
     </>

+ 9 - 7
client/src/components/Meals/EditMeal.jsx

@@ -42,11 +42,11 @@ const useStyles = makeStyles((theme) => ({
     }
   },
   actionButtonWrapper: {
-    margin: '1.5em 0 0',
+    margin: '1.5em 0 1em',
   },
 }));
 
-const inverseColors = true;
+const inverseColors = false;
 const serverURL = process.env.REACT_APP_SERVER_URL;
 
 /** Dialog page that allows user to edit meal */
@@ -71,7 +71,7 @@ const EditMeal = () => {
     if (!meal) {
       if (state && state.meal) setMeal(state.meal);
     }
-  }, [state]);
+  }, [state]); // eslint-disable-line
 
   const updateMeal = (key, value) => {
     setMeal(prevState => ({
@@ -125,15 +125,16 @@ const EditMeal = () => {
         <form noValidate onSubmit={editAndClose} className={classes.form}>
           <EditMealCore updateMeal={updateMeal} meal={meal} isSecondary={inverseColors} setImagesLoading={setImagesLoading} setLoadingImagesTakesLong={setLoadingImagesTakesLong} />
           <Grid container spacing={0} justifyContent="space-between" alignItems="center" wrap="nowrap" className={classes.actionButtonWrapper}>
-            <Grid item xs className={classes.cancelButton}>
-              <Button type="button" color={inverseColors ? "secondary" : "primary"} variant="outlined" onClick={closeEdit}>{t('Cancel')}</Button>
+            <Grid item className={classes.cancelButton}>
+              <Button type="button" color={inverseColors ? "secondary" : "primary"} size="large" variant="outlined" onClick={closeEdit}>{t('Cancel')}</Button>
             </Grid>
-            <Grid item xs className={classes.deleteButton}>
+            <Grid item className={classes.deleteButton}>
               <DeleteButton onClick={deleteMeal} />
             </Grid>
-            <Grid item xs className={classes.saveButton}>
+            <Grid item className={classes.saveButton}>
               <SavingButton isSaving={isSaving}
                             type="submit"
+                            size="large"
                             disabled={!meal.title || imagesLoading}
                             color={inverseColors ? "secondary" : "primary"}
                             variant="contained">{t('Save')}</SavingButton>
@@ -142,6 +143,7 @@ const EditMeal = () => {
           {meal.title && imagesLoading && loadingImagesTakesLong &&
             <SavingButton isSaving={isSaving}
                           type="submit"
+                          size="large"
                           className={classes.submitWithoutImagesButton}
                           disabled={!meal.title}
                           variant="outlined">{t('Save without new images')}</SavingButton>}

+ 41 - 26
client/src/components/Meals/MealDetailView.jsx

@@ -13,6 +13,9 @@ import MealImportButton from "./MealImportButton";
 import { useLocation, useNavigate, useParams } from "react-router-dom";
 import PlanMealButton from "../util/PlanMealButton";
 import { getUserById } from "../Settings/settings.util";
+import TranslatePageFAB from "../Buttons/TranslatePageFAB";
+import { useTranslationsMap } from "../util/translation/useTranslationMap";
+import { useSet } from "../util/useSet";
 
 const useStyles = makeStyles((theme) => ({
   content: {
@@ -43,49 +46,57 @@ const MealDetailView = () => {
   const [meal, setMeal] = useState();
   const [nameOfOtherUser, setNameOfOtherUser] = useState(null);
   const [comingFromPlans, setComingFromPlans] = useState(false);
+  const [thingsToTranslate, addThingsToTranslate] = useSet();
+  const [isTranslated, setIsTranslated] = useState(false);
+  const { print } = useTranslationsMap(!own && isTranslated);
 
-  /*  useEffect(() => {
-      if (meal) {
-        getUserById(meal.userId, setMealUser);
-      }
-    }, [meal]);*/
+  if (meal) {
+    if (meal.title && !thingsToTranslate.has(meal.title)) addThingsToTranslate(meal.title);
+    if (meal.comment && !thingsToTranslate.has(meal.comment)) addThingsToTranslate(meal.comment);
+  }
 
   useEffect(() => {
     if (user && meal) {
-      setOwn(user.sub === meal.userId);
+      const own = user.sub === meal.userId;
+      setOwn(own);
+      if (!own && meal.userId) { // meal is loaded but it is other user's meal -> load other user's name for NavBar title
+        getUserById(meal.userId, (user) => {
+          setNameOfOtherUser(user && user.user_metadata && user.user_metadata.username ? user.user_metadata.username : user.given_name);
+        });
+      }
+      // todo set state variable "mealContext" instead of contactMeal to also care about coming from plans
+      if (!own && (state && !state.mealContext)) { // make sure that the social tab is focused even though the URL is /meals/*
+        setMealContext('social');
+      }
     } else {
       setOwn(false);
     }
-    if (!own && meal && meal.userId) {
-      getUserById(meal.userId, (user) => {
-        console.log(user);
-        setNameOfOtherUser(user && user.user_metadata && user.user_metadata.username ? user.user_metadata.username : user.given_name);
-      });
-    }
-  }, [user, meal]);
+  }, [user, meal]); // eslint-disable-line
+
+  const setMealContext = (context) => {
+    const newState = state;
+    state.mealContext = context;
+    navigate(window.location, { replace: true, state: newState });
+  };
 
   // set the meal that is given in state as a temporary option while the one from the server is loaded
   useEffect(() => {
+    console.log('state changed', state);
     if (state) {
       if (!meal) {
         if (state.meal) setMeal(state.meal);
       }
-      if (state.prevRoute && state.prevRoute === '/plans') setComingFromPlans(true);
+      if (state.prevRoute && state.prevRoute === '/plans') {
+        setComingFromPlans(true);
+        setMealContext('plans');
+      }
     }
-  }, [state]);
+  }, [state]); // eslint-disable-line
 
   useEffect(() => {
     fetchAndUpdateMeal(mealId, setMeal);
   }, [mealId]);
 
-  useEffect(() => { // make sure that the social tab is focused even though the URL is /meals/*
-    if (!own && (state && !state.contactMeal)) {
-      const newState = state;
-      state.contactMeal = true;
-      navigate(window.location, { replace: true, state: newState });
-    }
-  }, [own, state]);
-
   const openEditItemDialog = () => {
     if (own) {
       navigate('../edit/' + meal._id, { state: { meal } });
@@ -118,18 +129,22 @@ const MealDetailView = () => {
           <Box className={classes.content}>
             <Grid container spacing={0} justifyContent="space-between" alignItems="flex-start" wrap="nowrap">
               <Grid item xs className={classes.mealTitle}>
-                <Typography variant="h4">{meal.title}</Typography>
+                <Typography variant="h4">{print(meal.title)}</Typography>
               </Grid>
               <Grid item xs>
                 <ShareButton link={window.location.origin + '/meals/detail/' + meal._id} title={meal.title} text={t('Check out the following meal: {{mealTitle}}', meal.title)} />
               </Grid>
             </Grid>
             {meal.recipeLink ? <Typography><Link href={meal.recipeLink} target="_blank">{meal.recipeLink}</Link></Typography> : ''}
-            {meal.comment ? <Typography className={classes.comment}>{meal.comment}</Typography> : ''}
+            {meal.comment ? <Typography className={classes.comment}>{print(meal.comment)}</Typography> : ''}
             {meal.images && meal.images.length > 0 ? <ImageGrid images={meal.images} allowChoosingMain={false} /> : ''}
           </Box>
 
-          {(own && !comingFromPlans) ? <PlanMealButton meal={meal} /> : ''}
+          {(own && !comingFromPlans) && <PlanMealButton meal={meal} />}
+          {!own && <TranslatePageFAB allThingsToTranslate={thingsToTranslate}
+                                     isTranslated={isTranslated}
+                                     onTranslate={() => {setIsTranslated(true)}}
+                                     onBackToOriginal={() => {setIsTranslated(false)}} />}
         </>
         : ''}
     </>

+ 180 - 160
client/src/components/Meals/Meals.jsx

@@ -1,15 +1,17 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
 import { makeStyles } from "@material-ui/styles";
 import { alpha, Box, Button, Collapse, Divider, List, ListItem, ListItemAvatar, ListItemIcon, ListItemText, Typography } from "@material-ui/core";
 import { ExpandLess, ExpandMore, UnfoldLess, UnfoldMore } from "@material-ui/icons";
 import { useTranslation } from "react-i18next";
 import { fetchAndUpdateMealsFromUser } from "./meals.util";
 import useCategoryIcons from "./useCategoryIcons";
-import { bool, string } from "prop-types";
+import { bool, func, instanceOf, string } from "prop-types";
 import MealAvatar from "./MealAvatar";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 import SelectMealTags from "./SelectMealTags";
 import { useLocation, useNavigate } from "react-router-dom";
+import { useTranslationsMap } from "../util/translation/useTranslationMap";
+import { LoadingBody } from "../Loading";
 
 const useStyles = makeStyles(theme => ({
   infoText: {
@@ -53,73 +55,50 @@ const useStyles = makeStyles(theme => ({
 
 /** Content of page that displays all meals of a given use and opens their detail views on click.
  * If meals belong to logged in user, editing will be allowed.
- * todo: keep filters if meal is visualized
- * todo: add search in addition to filters
- * todo: restructure to have logic in a wrapper component, so that refresh can be triggered
- * todo: Snackbar visualization */
+ * todo: add search in addition to filters */
 const Meals = (props) => {
   const classes = useStyles(props);
+  const { own, userId, addThingsToTranslate, thingsToTranslate, isTranslated } = props;
+
   const { t } = useTranslation();
   let navigate = useNavigate();
+  const { print } = useTranslationsMap(isTranslated);
 
   const { pathname, state } = useLocation();
-  const { own, userId } = props;
 
-  const [isFilterOpen, setIsFilterOpen] = useState(false);
-  const [filterTags, setFilterTags] = useState([]);
-  const [meals, setMeals] = useState([]);
-  const [filteredMeals, setFilteredMeals] = useState([]);
-  const [mealsByCategory,] = useState(new Map());
+  const [isFilterOpen, setIsFilterOpen] = useState(state?.activeMealFilter?.isFilterOpen ?? false);
+  const [filterTags, setFilterTags] = useState(state?.activeMealFilter?.filterTags ?? []);
+  const [meals, setMeals] = useState(state?.activeMealFilter?.filteredMeals ?? []); // take state-cached meals while loading takes place in background -> no apparent loading for user
   const [isCategoryOpen, setIsCategoryOpen] = useState({});
-  const [allCategoriesClosed, setAllCategoriesClosed] = useState(false);
   const [categoryIcons, fireIconReload] = useCategoryIcons(userId);
-
   const [emptyListFound, setEmptyListFound] = useState(false);
 
-  const updateMealsCallback = (mealsFound) => {
-    setMeals(mealsFound);
-    if (mealsFound.length === 0) {
-      setEmptyListFound(true);
-    }
-  }
+  // get all categories that are in use (uniquely, without null values)
+  const usedCategories = useMemo(() => Array.from(new Set(meals.map(meal => meal.category).filter(cat => cat))), [meals]);
 
-  const sortMealsIntoCategories = () => {
-    mealsByCategory.clear();
-    const mealsWithoutCategory = [];
-    const categoriesInitiallyExpanded = true;
-    filteredMeals.sort(function (a, b) {
-      if (!b.category) return 1;
-      if (!a.category) return -1;
-      return a.category > b.category ? 1 : -1;
-    });
-    filteredMeals.forEach(meal => {
-      if (meal.category) {
-        const key = meal.category;
-        let mappedMeals = mealsByCategory.get(key);
-        if (!mappedMeals) {
-          mealsByCategory.set(key, [meal]);
-          updateIsCategoryOpen(key, categoriesInitiallyExpanded);
-        } else {
-          mappedMeals.push(meal);
-        }
-      } else {
-        mealsWithoutCategory.push(meal);
-      }
-    });
-    if (mealsWithoutCategory.length > 0) {
-      const key = t('Meals without category');
-      mealsByCategory.set(key, mealsWithoutCategory);
-      updateIsCategoryOpen(key, categoriesInitiallyExpanded);
+  // add elements to translate if this has not been done already
+  // it is in useEffect because it updates the state of the parent component which cannot be done while rendering
+  useEffect(() => {
+    if (addThingsToTranslate && thingsToTranslate && meals.length > 0) {
+      console.log('checking what to add');
+      const titles = meals.map(meal => meal.title);
+      const thingsToAdd = [...titles, ...usedCategories];
+      const filtered = thingsToAdd.filter(str => !thingsToTranslate.has(str));
+      if (filtered.length > 0) addThingsToTranslate(filtered);
     }
-    //forceUpdate(); // I don't remember why this was necessary but now I don't dare to remove it
-  }
+  }, [addThingsToTranslate, thingsToTranslate, meals, usedCategories]);
 
   const updateIsCategoryOpen = (key, value) => {
     setIsCategoryOpen(prevState => {return { ...prevState, [key]: value }});
   }
 
   const fetchAndUpdateMeals = () => {
-    fetchAndUpdateMealsFromUser(userId, updateMealsCallback);
+    fetchAndUpdateMealsFromUser(userId, (mealsFound) => {
+      setMeals(mealsFound);
+      if (mealsFound.length === 0) {
+        setEmptyListFound(true);
+      }
+    });
     fireIconReload();
   }
 
@@ -128,39 +107,64 @@ const Meals = (props) => {
     // eslint-disable-next-line
   }, [userId, pathname]);
 
-  useEffect(() => {
-    if (state) {
-      if (state.refresh) { // force refresh of meals
-        fetchAndUpdateMeals();
-      }
-      if (state.activeMealFilter) { // restore last filter selection
-        const { filteredMeals, filterTags, isFilterOpen } = state.activeMealFilter;
-        setFilteredMeals(filteredMeals);
-        setFilterTags(filterTags);
-        setIsFilterOpen(isFilterOpen);
-      }
+  if (state?.refresh === true) { // force reload meals after undoing deletion and then remove refresh from state
+    fetchAndUpdateMeals();
+    delete state.refresh;
+    navigate(window.location, { replace: true, state: { ...state } });
+  }
+
+  // filter meals according to set filters
+  let filteredMeals = useMemo(() => {
+    if (filterTags.length > 0) {
+      return meals.filter(meal => {
+        if (!meal.tags || meal.tags.length === 0) return false; // do not include meals without tags -> they cannot correspond with any set filter
+        return filterTags.every(tag => meal.tags.includes(tag));
+      });
+    } else {
+      return meals;
     }
-    // eslint-disable-next-line
-  }, [state]);
+  }, [meals, filterTags]);
 
-  useEffect(() => {
-    sortMealsIntoCategories();
-    // eslint-disable-next-line
-  }, [filteredMeals]);
+  const sortedCategories = useMemo(() => {
+    // sort categories alphabetically
+    const sorted = [...usedCategories.sort(function (a, b) { return a > b ? 1 : -1; })]; // deep copy to avoid adding meals without category to original usedCategories
 
-  useEffect(() => {
-      if (filterTags.length > 0) {
-        const newFilteredMeals = meals.filter(meal => {
-          if (!meal.tags || meal.tags.length === 0) return false;
-          return filterTags.every(tag => meal.tags.includes(tag));
-        });
-        setFilteredMeals(newFilteredMeals);
-      } else {
-        setFilteredMeals(meals);
-      }
-      // eslint-disable-next-line
-    }, [filterTags, meals]
-  );
+    // add a category for meals without category, if there are any
+    const mealsWithoutCategory = filteredMeals.filter(meal => !meal.category);
+    if (mealsWithoutCategory.length > 0) {
+      sorted.push(t('Meals without category'));
+    }
+
+    return sorted;
+  }, [filteredMeals, usedCategories, t]);
+
+  // sort meals into categories
+  const mealsByCategory = useMemo(() => {
+    const mealsByCat = new Map();
+
+    // for each category in use, get all corresponding meals and add them to the map
+    sortedCategories.forEach(category => {
+      const mealsInCategory = filteredMeals.filter(meal => {
+        if (!meal.category) {
+          return category === t('Meals without category');
+        }
+        return meal.category === category;
+      });
+      mealsByCat.set(category, mealsInCategory);
+    });
+    return mealsByCat;
+  }, [filteredMeals, sortedCategories, t]);
+
+  // set all categories to be initially open
+  if (meals.length > 0 && Object.keys(isCategoryOpen).length === 0) { // only do this after meals have been loaded, otherwise it results in an infinite loop
+    const categoriesInitiallyExpanded = true;
+
+    const categoriesOpenObject = sortedCategories.reduce((accumulator, catName) => {
+      return { ...accumulator, [catName]: categoriesInitiallyExpanded };
+    }, {});
+
+    setIsCategoryOpen(categoriesOpenObject);
+  }
 
   const openMealDetailView = (meal) => {
     const activeMealFilter = {
@@ -169,7 +173,6 @@ const Meals = (props) => {
       isFilterOpen,
     };
     // set the active meal filter in the state of the current location before navigating away, because it will be restored when navigating back from the detail view with navigate(-1)
-    console.log('activeMealFilter', activeMealFilter);
     navigate(window.location, { replace: true, state: { activeMealFilter } });
     if (own) {
       navigate('detail/' + meal._id, { state: { meal } });
@@ -178,42 +181,6 @@ const Meals = (props) => {
     }
   };
 
-  const getListItems = () => {
-    const listItems = [];
-    mealsByCategory.forEach((meals, categoryName) => {
-      const listItemsForCategory = [];
-      meals.forEach(meal => {
-        listItemsForCategory.push(
-          <ListItem key={meal._id} className={classes.nestedListItem} button onClick={() => {openMealDetailView(meal); }}>
-            <ListItemAvatar>
-              <MealAvatar meal={meal} />
-            </ListItemAvatar>
-            <ListItemText primary={meal.title} />
-          </ListItem>,
-          <Divider key={'Divider' + meal._id} />
-        );
-      });
-      const open = isCategoryOpen[categoryName];
-      const icon = categoryIcons[categoryName];
-      listItems.push(
-        <ListItem button key={categoryName} onClick={() => {
-          updateIsCategoryOpen(categoryName, !isCategoryOpen[categoryName]);
-        }} className={classes.category}>
-          {icon && <ListItemIcon className={classes.listItemIcon}>
-            <FontAwesomeIcon icon={icon} />
-          </ListItemIcon>}
-          <ListItemText primary={categoryName} />
-          {open ? <ExpandLess /> : <ExpandMore />}
-        </ListItem>,
-        <Collapse key={categoryName + 'MealList'} in={open} timeout="auto" unmountOnExit>
-          <List component="div" disablePadding>
-            {listItemsForCategory}
-          </List>
-        </Collapse>);
-    });
-    return listItems;
-  }
-
   const updateFilterTags = (newTags) => {
     setFilterTags(newTags);
   }
@@ -225,53 +192,94 @@ const Meals = (props) => {
     });
   }
 
-  useEffect(() => {
-    setAllCategoriesClosed(Object.values(isCategoryOpen).every(isOpen => !isOpen));
-  }, [isCategoryOpen]);
+  const areAllCategoriesClosed = useMemo(() => Object.values(isCategoryOpen).every(isOpen => isOpen === false), [isCategoryOpen]);
+
+  const getListItems = () => {
+    const listItems = [];
+    mealsByCategory.forEach((meals, categoryName) => {
+      if (meals.length > 0) { // only show categories with meals inside (when filtering otherwise empty categories will be displayed
+        const listItemsForCategory = [];
+        meals.forEach(meal => {
+          listItemsForCategory.push(
+            <ListItem key={meal._id} className={classes.nestedListItem} button onClick={() => {openMealDetailView(meal); }}>
+              <ListItemAvatar>
+                <MealAvatar meal={meal} />
+              </ListItemAvatar>
+              <ListItemText primary={print(meal.title)} />
+            </ListItem>,
+            <Divider key={'Divider' + meal._id} />
+          );
+        });
+        const open = isCategoryOpen[categoryName];
+        const icon = categoryIcons[categoryName];
+        listItems.push(
+          <ListItem button key={categoryName} onClick={() => {
+            updateIsCategoryOpen(categoryName, !isCategoryOpen[categoryName]);
+          }} className={classes.category}>
+            {icon && <ListItemIcon className={classes.listItemIcon}>
+              <FontAwesomeIcon icon={icon} />
+            </ListItemIcon>}
+            <ListItemText>{print(categoryName)}</ListItemText>
+            {open ? <ExpandLess /> : <ExpandMore />}
+          </ListItem>,
+          <Collapse key={categoryName + 'MealList'} in={open} timeout="auto" unmountOnExit>
+            <List component="div" disablePadding>
+              {listItemsForCategory}
+            </List>
+          </Collapse>);
+      }
+    });
+    return listItems;
+  }
 
   let infoText = t("Looks like there are no meals here yet.");
   if (own) infoText += ' ' + t('Add one by clicking in the top right corner.');
 
-  return (
-    <>
-      {meals.length === 0 ?
-        <Typography className={classes.infoText}>{emptyListFound ? infoText : t('Loading') + '...'} </Typography> :
-        <>
-          <Box className={classes.controlBox} style={{ display: 'flex', justifyContent: own ? 'space-between' : 'end' }}>
-            <Button variant="text"
-                    className={classes.optionRowButton}
-                    color="secondary"
-                    onClick={() => {setIsFilterOpen(prevState => !prevState)}}
-                    endIcon={isFilterOpen ? <ExpandLess /> : <ExpandMore />}>
-              {t('Filter')}
-            </Button>
-            <Button variant="text"
-                    className={classes.optionRowButton}
-                    style={{ marginLeft: 'auto' }}
-                    color="primary"
-                    endIcon={allCategoriesClosed ? <UnfoldMore /> : <UnfoldLess />}
-                    onClick={toggleAllCategories}>
-              {allCategoriesClosed ? t('expand all') : t('collapse all')}
-            </Button>
-          </Box>
-          {isFilterOpen && <Box className={classes.filterBox}>
-            <SelectMealTags currentTags={filterTags}
-                            own={own}
-                            otherUserId={userId}
-                            updateTags={updateFilterTags}
-                            placeholderText={t('Filter by Tags')}
-                            className={classes.filterTags}
-                            customControlStyles={{ borderRadius: 0 }} />
-          </Box>}
-          {filteredMeals.length === 0 ?
-            <Typography className={classes.infoText}>{t('No meals found for filter selection')}</Typography> :
-            <List component="nav" className={classes.root} aria-label="meal list" disablePadding>
-              {getListItems()}
-            </List>}
-        </>
-      }
-    </>
-  );
+  if (meals.length === 0) {
+    return (
+      emptyListFound ? <Typography className={classes.infoText}>{infoText}</Typography> : <LoadingBody />
+    );
+  } else {
+    return (
+      <>
+        <Box className={classes.controlBox} style={{ display: 'flex', justifyContent: own ? 'space-between' : 'end' }}>
+          <Button variant="text"
+                  className={classes.optionRowButton}
+                  color="secondary"
+                  onClick={() => {setIsFilterOpen(prevState => !prevState)}}
+                  endIcon={isFilterOpen ? <ExpandLess /> : <ExpandMore />}>
+            {t('Filter')}
+          </Button>
+          <Button variant="text"
+                  className={classes.optionRowButton}
+                  style={{ marginLeft: 'auto' }}
+                  color="primary"
+                  endIcon={areAllCategoriesClosed ? <UnfoldMore /> : <UnfoldLess />}
+                  onClick={toggleAllCategories}>
+            {areAllCategoriesClosed ? t('expand all') : t('collapse all')}
+          </Button>
+        </Box>
+        {isFilterOpen && <Box className={classes.filterBox}>
+          <SelectMealTags currentTags={filterTags}
+                          own={own}
+                          otherUserId={userId}
+                          updateTags={updateFilterTags}
+                          placeholderText={t('Filter by Tags')}
+                          className={classes.filterTags}
+                          customControlStyles={{ borderRadius: 0 }}
+                          shouldBeTranslated={isTranslated} />
+        </Box>
+        }
+        {filteredMeals.length === 0
+          ?
+          <Typography className={classes.infoText}>{t('No meals found for filter selection')}</Typography>
+          :
+          <List component="nav" className={classes.root} aria-label="meal list" disablePadding>
+            {getListItems()}
+          </List>}
+      </>
+    );
+  }
 }
 
 Meals.propTypes = {
@@ -279,6 +287,18 @@ Meals.propTypes = {
   userId: string.isRequired,
   /** are these the user's own meals or is another user watching foreign meals? In the latter case editing will be prohibited. */
   own: bool.isRequired,
+  /** function provided by ContactsContent to determine the strings to translate */
+  addThingsToTranslate: func,
+  /** Set provided by ContactsContent to avoid adding things more than necessary */
+  thingsToTranslate: instanceOf(Set),
+  /** whether to use translated values or not */
+  isTranslated: bool,
+}
+
+Meals.defaultProps = {
+  addThingsToTranslate: null,
+  thingsToTranslate: null,
+  isTranslated: false,
 }
 
 export default Meals;

+ 30 - 4
client/src/components/Meals/SelectMealTags.jsx

@@ -1,5 +1,5 @@
 import React, { useEffect, useState } from 'react';
-import { alpha, ListItemText, useTheme } from '@material-ui/core';
+import { alpha, ListItem, ListItemIcon, ListItemText, useTheme } from '@material-ui/core';
 import CreatableSelect from 'react-select/creatable';
 import ReactSelect from 'react-select';
 import { arrayOf, bool, func, object, string } from "prop-types";
@@ -8,6 +8,8 @@ import { useAuth0, withAuthenticationRequired } from "@auth0/auth0-react";
 import { LoadingBody } from "../Loading";
 import { getSettingsOfUser, updateUserSettingsForCategory } from "../Settings/settings.util";
 import { reactSelectTheme } from "./meals.util";
+import { useTranslationsMap } from "../util/translation/useTranslationMap";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 
 /** Select input that lets the user choose meal tags.
  * If allowCreate is true, user can also create new tags. */
@@ -15,6 +17,7 @@ const SelectMealTags = (props) => {
   const { t } = useTranslation();
   const muiTheme = useTheme();
   const { typography, palette } = muiTheme;
+  const { translationsMap, translateThingsAndAddToMap } = useTranslationsMap();
 
   const {
     updateTags,
@@ -25,11 +28,22 @@ const SelectMealTags = (props) => {
     customControlStyles,
     otherUserId,
     own,
+    shouldBeTranslated,
   } = props;
 
   const { user } = useAuth0();
 
-  const [allTags, setAllTags] = useState([]);
+  const [allTags, setAllTags] = useState(undefined);
+
+  if (shouldBeTranslated) {
+    const tagsNotTranslated = allTags.filter(tag => !translationsMap.has(tag));
+    console.log('allTags', allTags);
+    console.log('translation missing for ' + tagsNotTranslated.length, tagsNotTranslated);
+    if (tagsNotTranslated.length > 0) {
+      // translate tags
+      translateThingsAndAddToMap(tagsNotTranslated);
+    }
+  }
 
   useEffect(() => {
     if (own) {
@@ -44,7 +58,7 @@ const SelectMealTags = (props) => {
         setAllTags(settings.mealTags);
       });
     }
-  }, [user]);
+  }, [own, user]);
 
   const addTag = (tagToAdd) => {
     allTags.push(tagToAdd);
@@ -54,6 +68,7 @@ const SelectMealTags = (props) => {
       });
     }
   }
+
   const handleTagChange = (newTags, actionMeta) => {
     updateTags(newTags);
     if (allowCreate && actionMeta.action === 'create-option') {
@@ -61,6 +76,14 @@ const SelectMealTags = (props) => {
     }
   };
 
+  const getOptionLabel = option => {
+    if (shouldBeTranslated) {
+      return option + ' (' + (translationsMap.get(option) ?? '?') + ')';
+    } else {
+      return option;
+    }
+  }
+
   const customStyles = {
     control: (provided, state) => ({
       ...provided,
@@ -117,7 +140,7 @@ const SelectMealTags = (props) => {
     // getNewOptionData is necessary because otherwise the new option will be an object with identical label and value attributes
     return <CreatableSelect {...commonProps} getNewOptionData={(value) => value} noOptionsMessage={() => t('Type to add Tags')} />;
   } else {
-    return <ReactSelect {...commonProps} noOptionsMessage={() => t('No results')} />;
+    return <ReactSelect {...commonProps} getOptionLabel={getOptionLabel} noOptionsMessage={() => {return (allTags !== undefined) ? t('No results') : (t('Loading') + '...')}} />;
   }
 }
 
@@ -138,6 +161,8 @@ SelectMealTags.propTypes = {
   own: bool,
   /** userId of the user whose tags should be displayed (if own is false) */
   otherUserId: string,
+  /** determine whether meals page is translated and therefore also tags should be shown as translated */
+  shouldBeTranslated: bool,
 }
 
 SelectMealTags.defaultProps = {
@@ -146,6 +171,7 @@ SelectMealTags.defaultProps = {
   placeholderText: null,
   own: true,
   otherUserId: null,
+  shouldBeTranslated: false,
 }
 
 export default withAuthenticationRequired(SelectMealTags, {

+ 2 - 2
client/src/components/NavTabs.jsx

@@ -48,8 +48,8 @@ const NavTabs = (props) => {
 
   let activeTab = null;
   if (useMatch('/meals/*')) {
-    if (state && state.contactMeal) {
-      activeTab = 'social';
+    if (state && (state.mealContext === 'social' || state.mealContext === 'plans')) {
+      activeTab = state.mealContext;
     } else {
       activeTab = 'meals';
     }

+ 1 - 1
client/src/components/Plans/AddPlanItem.jsx

@@ -82,7 +82,7 @@ const AddPlanItem = (props) => {
               rightSideComponent={planItem.title ? <DoneButton label={t('Done')} onClick={addNewPlan} /> : null} />
       <form noValidate onSubmit={addNewPlan} className={classes.form}>
         <EditPlanItemCore updatePlanItem={updatePlanItem} planItem={planItem} autoFocusFirstInput={autoFocusTitle} />
-        <SavingButton isSaving={isSaving} type="submit" disabled={!planItem.title} className={classes.submitButton} variant='contained' color='primary'>{t('Add Plan')}</SavingButton>
+        <SavingButton isSaving={isSaving} type="submit" disabled={!planItem.title} className={classes.submitButton} variant='contained' size="large" color='primary'>{t('Add Plan')}</SavingButton>
       </form>
     </>
   );

+ 6 - 6
client/src/components/Plans/EditPlanItem.jsx

@@ -36,7 +36,7 @@ const useStyles = makeStyles((theme) => ({
   },
 }));
 
-const inverseColors = true;
+const inverseColors = false;
 const serverURL = process.env.REACT_APP_SERVER_URL;
 
 /** page that allows editing a plan */
@@ -120,14 +120,14 @@ const EditPlanItem = () => {
         <form noValidate onSubmit={editAndClose} className={classes.form}>
           <EditPlanItemCore planItem={planItem} updatePlanItem={updatePlanItem} isSecondary={inverseColors} />
           <Grid container spacing={0} justifyContent="space-between" alignItems="center" wrap="nowrap" className={classes.actionButtonWrapper}>
-            <Grid item xs className={classes.cancelButton}>
-              <Button type="button" color={inverseColors ? "secondary" : "primary"} variant="outlined" onClick={goBackToPlans}>{t('Cancel')}</Button>
+            <Grid item className={classes.cancelButton}>
+              <Button type="button" color={inverseColors ? "secondary" : "primary"} size="large" variant="outlined" onClick={goBackToPlans}>{t('Cancel')}</Button>
             </Grid>
-            <Grid item xs className={classes.deleteButton}>
+            <Grid item className={classes.deleteButton}>
               <DeleteButton onClick={deletePlan} />
             </Grid>
-            <Grid item xs className={classes.saveButton}>
-              <SavingButton isSaving={isSaving} type="submit" disabled={!planItem.title} color={inverseColors ? "secondary" : "primary"} variant="contained">{t('Save')}</SavingButton>
+            <Grid item className={classes.saveButton}>
+              <SavingButton isSaving={isSaving} type="submit" size="large" disabled={!planItem.title} color={inverseColors ? "secondary" : "primary"} variant="contained">{t('Save')}</SavingButton>
             </Grid>
           </Grid>
         </form>

+ 35 - 4
client/src/components/Plans/Plans.jsx

@@ -5,12 +5,13 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { faCheck, faShoppingBasket, faTimes } from '@fortawesome/free-solid-svg-icons';
 import { makeStyles } from "@material-ui/styles";
 import { useTranslation } from "react-i18next";
-import { bool, string } from "prop-types";
+import { bool, func, string } from "prop-types";
 import { dateStringOptions, withLoginRequired } from "../util";
 import MissingIngredients from "./MissingIngredients";
 import { getPlansOfUser, getSinglePlan } from "./plans.util";
 import MealAvatar from "../Meals/MealAvatar";
 import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { useTranslationsMap } from "../util/translation/useTranslationMap";
 
 const useStyles = makeStyles((theme) => ({
   plansTable: {
@@ -57,8 +58,9 @@ const Plans = (props) => {
   const { t } = useTranslation();
   const { pathname, state } = useLocation();
 
-  const { own, userId } = props;
+  const { own, userId, addThingsToTranslate, areNewPlansTranslated, areOldPlansTranslated, clearThingsToTranslate, setIsTranslated } = props;
 
+  const { print } = useTranslationsMap(areNewPlansTranslated);
   const [missingIngredientsDialogOpen, setMissingIngredientsDialogOpen] = useState(false);
   const [plans, setPlans] = useState([]);
   const [itemBeingEdited, setItemBeingEdited] = useState(null);
@@ -69,6 +71,11 @@ const Plans = (props) => {
   const fetchAndUpdatePlans = () => {
     getPlansOfUser(userId, plansFound => {
       setPlans(plansFound);
+      console.log('plansfound', plansFound);
+      if (addThingsToTranslate) {
+        addThingsToTranslate(plansFound.filter(plan => !(plan.hasDate && new Date(plan.date).setHours(0, 0, 0, 0) < new Date().setHours(0, 0, 0, 0))) // plan is not in past
+                                       .map(plan => plan.title)); // add each plan's title to thingsToTranslate
+      }
       if (plansFound.length === 0) setEmptyListFound(true);
       if (itemBeingEdited) {
         setItemBeingEdited(plansFound.find(p => p._id === itemBeingEdited._id)); // update itemBeingEdited
@@ -123,8 +130,14 @@ const Plans = (props) => {
     setPastPlansOpen(prevState => {
       // this scrolling mechanism is probably not the best possible behaviour to show the history of plans
       if (prevState === false) { // open history
+        if (addThingsToTranslate && !areOldPlansTranslated) {// add past plans to be translated
+          addThingsToTranslate(plans.filter(plan => (plan.hasDate && new Date(plan.date).setHours(0, 0, 0, 0) < new Date().setHours(0, 0, 0, 0))) // plan is in past
+                                    .map(plan => plan.title)); // add each plan's title to thingsToTranslate
+          setIsTranslated(areOldPlansTranslated);
+        }
         setTimeout(() => {event.target.scrollIntoView({ behavior: "smooth", block: "center" });}, 300);
       } else { // close history
+        clearThingsToTranslate();
         setTimeout(() => {event.target.scrollIntoView({ behavior: "smooth", block: "end" });}, 300);
       }
       return !prevState;
@@ -145,10 +158,10 @@ const Plans = (props) => {
           <TableCell className={classes.tableCell}>
             {plan.connectedMeal ?
               <Grid container spacing={1} justifyContent="space-between" alignItems="center">
-                <Grid item xs={9} onClick={() => {goToEdit(plan);}}>{plan.title}</Grid>
+                <Grid item xs={9} onClick={() => {goToEdit(plan);}}>{print(plan.title)}</Grid>
                 <Grid item xs={3} onClick={() => {openMealDetailView(plan.connectedMeal);}}><MealAvatar meal={plan.connectedMeal} /></Grid>
               </Grid>
-              : <Box onClick={() => {goToEdit(plan);}}>{plan.title}</Box>
+              : <Box onClick={() => {goToEdit(plan);}}>{print(plan.title)}</Box>
             }
           </TableCell>
           <TableCell onClick={() => {goToEdit(plan);}} align="center" className={classes.tableCell + ' ' + classes.narrowCell}>
@@ -224,6 +237,24 @@ Plans.propTypes = {
   own: bool.isRequired,
   /** userId of user whose plans are to be displayed */
   userId: string.isRequired,
+  /** function provided by ContactsContent to determine the strings to translate */
+  addThingsToTranslate: func,
+  /** function provided by ContactsContent to clear strings to translate */
+  clearThingsToTranslate: func,
+  /** whether to use translated values or not */
+  areNewPlansTranslated: bool,
+  /**  */
+  areOldPlansTranslated: bool,
+  /** function provided by ContactsContent to let plans say "you need to translate again" if history is opened
+   * in order to not translate all plans in the past if they don't even get looked at. */
+  setIsTranslated: func,
+}
+
+Plans.defaultProps = {
+  addThingsToTranslate: null,
+  clearThingsToTranslate: null,
+  areNewPlansTranslated: false,
+  areOldPlansTranslated: false,
 }
 
 export default withLoginRequired(Plans);

+ 9 - 11
client/src/components/Plans/ShoppingList.jsx

@@ -8,6 +8,7 @@ import { dateStringOptions } from "../util";
 import { Navigate, useLocation, useNavigate, useParams } from "react-router-dom";
 import Navbar from "../Navbar";
 import AddButton from "../Buttons/AddButton";
+import { useMap } from "../util/useMap";
 
 const useStyles = makeStyles((theme) => ({
   listHeading: {
@@ -48,7 +49,7 @@ const useStyles = makeStyles((theme) => ({
   }
 }));
 
-// todo: Should crossed off ingredients be eliminated from the list?
+// todo: Should crossed off ingredients be eliminated from the list? (they do after date is expired, right?)
 //  If yes, when/what action will trigger this? If yes, may need to add a field to missingIngredients database model
 /** Displays a list of all ingredients of future plans (ingredients of past plans will not be displayed)
  * + checked ingredients will be displayed with a line through the text
@@ -60,10 +61,6 @@ const ShoppingList = () => {
   const { state } = useLocation();
   const navigate = useNavigate();
   const params = useParams();
-
-  // if there is no way to get the shopping list (either from state or from userId, redirect to plans
-  if (!(state && state.plans) && !(params && params.userId)) return <Navigate to="/plans/" replace />;
-
   const { userId } = params;
 
   // set the plans that are given in state as a temporary option while the ones from the server is loaded
@@ -74,10 +71,8 @@ const ShoppingList = () => {
     }
   }, [state]);
 
-  const [, updateState] = React.useState();
-  const forceUpdate = React.useCallback(() => updateState({}), []);
   const [plans, setPlans] = useState();
-  const [dateIngredientMap,] = useState(new Map());
+  const [dateIngredientMap, addToDateIngredientMap, clearDateIngredientMap] = useMap();
 
   const fetchAndUpdatePlans = () => {
     getPlansOfUser(userId, setPlans);
@@ -85,20 +80,19 @@ const ShoppingList = () => {
 
   useEffect(() => {
     if (plans) {
-      dateIngredientMap.clear();
+      clearDateIngredientMap();
       plans.forEach((plan) => {
         const planIsInPast = plan.hasDate && new Date(plan.date).setHours(0, 0, 0, 0) < new Date().setHours(0, 0, 0, 0);
         if (!planIsInPast && plan.missingIngredients && plan.missingIngredients.length > 0) {
           const key = plan.hasDate && plan.date ? new Date(plan.date).setHours(0, 0, 0, 0) : 'noDate';
           let mappedPlans = dateIngredientMap.get(key);
           if (!mappedPlans) {
-            dateIngredientMap.set(key, [plan]);
+            addToDateIngredientMap(key, [plan]);
           } else {
             mappedPlans.push(plan);
           }
         }
       });
-      forceUpdate();
     }
     // eslint-disable-next-line
   }, [plans]);
@@ -149,6 +143,10 @@ const ShoppingList = () => {
   const goToAddMeal = () => {navigate('add');};
   const goBack = () => {navigate(-1);};
 
+
+  // if there is no way to get the shopping list (either from state or from userId, redirect to plans
+  if (!(state && state.plans) && !(params && params.userId)) return <Navigate to="/plans/" replace />;
+
   return (
     <>
       <Navbar pageTitle={t('Plans')} rightSideComponent={<AddButton onClick={goToAddMeal} />} />

+ 4 - 4
client/src/components/Settings/EditProfile.jsx

@@ -65,10 +65,6 @@ const EditProfile = () => {
   let { state } = useLocation();
   const navigate = useNavigate();
 
-  if (!state || !state.userData) {
-    return <Navigate replace to="/settings" />;
-  }
-
   const colorA = IS_SECONDARY ? "primary" : "secondary";
   // const colorB = IS_SECONDARY ? "secondary" : "primary";
 
@@ -143,6 +139,10 @@ const EditProfile = () => {
     navigate('../', { state: { newUsername: username } });
   }
 
+  if (!state || !state.userData) {
+    return <Navigate replace to="/settings" />;
+  }
+
   return (
     <>
       <Navbar pageTitle={t('Edit Profile')} leftSideComponent={<BackButton onClick={goToSettings} />} secondary={IS_SECONDARY} />

+ 89 - 13
client/src/components/Social/ContactsContent.jsx

@@ -1,17 +1,20 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useState } from 'react';
 import SwipeableViews from 'react-swipeable-views';
 import { makeStyles, useTheme } from '@material-ui/core/styles';
 import { Box, Dialog, Tab, Tabs } from '@material-ui/core';
 import Meals from "../Meals/Meals";
 import Navbar from "../Navbar";
 import { useAuth0 } from "@auth0/auth0-react";
-import { useNavigate, useParams } from "react-router-dom";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
 import { getUserById } from "../Settings/settings.util";
 import BackButton from "../Buttons/BackButton";
 import { useTranslation } from "react-i18next";
 import { LoadingBody } from "../Loading";
 import Plans from "../Plans/Plans";
 import { ContactAvatar, getContactName, getContactPicture } from "./social.util";
+import TranslatePageFAB from "../Buttons/TranslatePageFAB";
+import { useSet } from "../util/useSet";
+import { useTranslationsMap } from "../util/translation/useTranslationMap";
 
 const useStyles = makeStyles((theme) => ({
   contactsContent: {
@@ -28,12 +31,15 @@ const useStyles = makeStyles((theme) => ({
     maxHeight: '100%',
     maxWidth: '100%',
   },
-  tabs: {
-  },
-  tab: {
-  }
 }));
 
+const allTabsNotTranslated = { // when using this, use only the keys, not the object! it is saved here to be able to add keys easily if necessary
+  meals: false,
+  newPlans: false,
+  oldPlans: false,
+  plans: false
+};
+
 /**
  * #### Page component that is displayed when other users are accessing a profile that is not their own.
  * + gets the userId from the URL params (of React Router)
@@ -47,11 +53,25 @@ const ContactsContent = () => {
   const { t } = useTranslation();
   const { user, isAuthenticated, isLoading } = useAuth0();
   let { userId, tab } = useParams();
+  let { state } = useLocation();
   let navigate = useNavigate();
 
   const [otherUser, setOtherUser] = React.useState(null);
   const [currentTab, setCurrentTab] = React.useState(0);
   const [isContactProfileOpen, setIsContactProfileOpen] = React.useState(false);
+  const [mealsToTranslate, addMealsToTranslate] = useSet();
+  const [plansToTranslate, addPlansToTranslate, clearPlansToTranslate] = useSet();
+  const [isTabTranslated, setIsTabTranslated] = useState(state && state.areTabsTranslated ? state.areTabsTranslated : allTabsNotTranslated);
+
+  const { translationsMap } = useTranslationsMap();
+
+  // on page reload it might happen that the translations are gone, but the navigation state gets saved -> need to reset navigation state to reflect not translated
+  if (Object.values(isTabTranslated).includes(true) && translationsMap.size === 0) {
+    setIsTabTranslated(() => {
+      navigate(window.location, { replace: true, state: { ...state, areTabsTranslated: { ...allTabsNotTranslated } } });
+      return { ...allTabsNotTranslated };
+    });
+  }
 
   useEffect(() => {
     getUserById(userId, setOtherUser);
@@ -75,9 +95,9 @@ const ContactsContent = () => {
   }
 
   const getTabs = () =>
-    <Tabs value={currentTab} className={classes.tabs} onChange={(event, newValue) => {switchTab(newValue);}} indicatorColor="secondary" textColor="secondary" variant="fullWidth">
-      <Tab label={t('Meals')} className={classes.tab} />
-      <Tab label={t('Plans')} className={classes.tab} />
+    <Tabs value={currentTab} onChange={(event, newValue) => {switchTab(newValue);}} indicatorColor="secondary" textColor="secondary" variant="fullWidth">
+      <Tab label={t('Meals')} />
+      <Tab label={t('Plans')} />
     </Tabs>
   ;
 
@@ -85,28 +105,84 @@ const ContactsContent = () => {
     setIsContactProfileOpen(true);
   }
 
+  const updateIsTabTranslated = (key, value) => {
+    setIsTabTranslated(prevState => {
+      const newState = { ...prevState, [key]: value };
+      navigate(window.location, { replace: true, state: { ...state, areTabsTranslated: newState } });
+      return newState;
+    });
+  }
+
+  const updateIsTabTranslatedDouble = (key1, key2, value) => {
+    setIsTabTranslated(prevState => {
+      const newState = { ...prevState, [key1]: value, [key2]: value };
+      navigate(window.location, { replace: true, state: { ...state, areTabsTranslated: newState } });
+      return newState;
+    });
+  }
+
+  const markTabAsTranslated = () => {
+    if (currentTab === 0) {
+      updateIsTabTranslated('meals', true);
+    } else if (currentTab === 1 && isTabTranslated.newPlans !== true) {
+      updateIsTabTranslatedDouble('newPlans', 'plans', true);
+    } else if (currentTab === 1) {
+      updateIsTabTranslatedDouble('oldPlans', 'plans', true);
+    }
+  }
+
+  const markTabAsUntranslated = () => {
+    if (currentTab === 0) {
+      updateIsTabTranslated('meals', false);
+    } else if (currentTab === 1) {
+      updateIsTabTranslatedDouble('newPlans', 'plans', false);
+    }
+  }
+
+  const setIsPlansTranslated = (value) => {
+    updateIsTabTranslated('plans', value);
+  }
+
+  const thingsToTranslate = currentTab === 1 ? plansToTranslate : mealsToTranslate;
+
   return (
     <Box className={classes.contactsContent}>
       {otherUser ?
         <>
-          <Navbar pageTitle={getContactName(otherUser)} secondary titleOnClick={openContactProfilePicture} leftSideComponent={leftSideComponent()} rightSideComponent={getContactPicture(otherUser) ? <ContactAvatar src={getContactPicture(otherUser)} alt={getContactName(otherUser)} onClick={openContactProfilePicture} /> : null } />
+          <Navbar pageTitle={getContactName(otherUser)}
+                  secondary
+                  titleOnClick={openContactProfilePicture}
+                  leftSideComponent={leftSideComponent()}
+                  rightSideComponent={getContactPicture(otherUser) ?
+                    <ContactAvatar src={getContactPicture(otherUser)} alt={getContactName(otherUser)} onClick={openContactProfilePicture} /> : null} />
 
           <Dialog open={isContactProfileOpen} onClose={() => setIsContactProfileOpen(false)}>
             <img src={getContactPicture(otherUser)} alt={getContactName(otherUser)} className={classes.dialogPicture} />
           </Dialog>
 
           {getTabs()}
-          <SwipeableViews style={{
+          <SwipeableViews containerStyle={{ minHeight: '100%' }} style={{
             height: `calc(100% - 2 * ${process.env.REACT_APP_NAV_TOP_HEIGHT}px)`,
             overflowY: 'auto',
           }} axis={theme.direction === 'rtl' ? 'x-reverse' : 'x'} index={currentTab} onChangeIndex={switchTab}>
             <Box role="tabpanel" hidden={currentTab !== 0} dir={theme.direction}>
-              <Meals own={false} userId={otherUser.user_id} />
+              <Meals own={false} userId={otherUser.user_id} addThingsToTranslate={addMealsToTranslate} thingsToTranslate={mealsToTranslate} isTranslated={isTabTranslated.meals} />
             </Box>
             <Box role="tabpanel" hidden={currentTab !== 1} dir={theme.direction}>
-              <Plans own={false} userId={otherUser.user_id} />
+              <Plans own={false}
+                     userId={otherUser.user_id}
+                     addThingsToTranslate={addPlansToTranslate}
+                     clearThingsToTranslate={clearPlansToTranslate}
+                     areNewPlansTranslated={isTabTranslated.newPlans}
+                     areOldPlansTranslated={isTabTranslated.oldPlans}
+                     setIsTranslated={setIsPlansTranslated} />
             </Box>
           </SwipeableViews>
+
+          <TranslatePageFAB allThingsToTranslate={thingsToTranslate}
+                            isTranslated={(currentTab === 0 && isTabTranslated.meals === true) || (currentTab === 1 && isTabTranslated.plans === true)}
+                            onTranslate={markTabAsTranslated}
+                            onBackToOriginal={markTabAsUntranslated} />
         </> : <>
           <Navbar pageTitle={t('Contacts')} leftSideComponent={leftSideComponent()} />
           {getTabs()}

+ 4 - 3
client/src/components/util/Snackbars.jsx

@@ -7,7 +7,7 @@ import CircularProgressWithLabel from "./CircularProgressWithLabel";
 import { useTranslation } from "react-i18next";
 import { deleteAllImagesFromMeal } from "../Meals/meals.util";
 import axios from "axios";
-import { useNavigate } from "react-router-dom";
+import { useLocation, useNavigate } from "react-router-dom";
 import { addPlan } from "../Plans/plans.util";
 
 const useStyles = makeStyles((theme) => ({
@@ -32,6 +32,7 @@ const Snackbars = (props) => {
   const { category, deletedItem } = props;
   const { t } = useTranslation();
   let navigate = useNavigate();
+  const { state } = useLocation();
 
   const [deleteMessageVisible, setDeleteMessageVisible] = useState(false);
   const [readdedMessageVisible, setReaddedMessageVisible] = useState(false);
@@ -64,13 +65,13 @@ const Snackbars = (props) => {
         console.log('re-add request sent', result.data);
         showReaddedItemMessage();
         // refresh meal list
-        navigate(window.location, { replace: true, state: { refresh: true } });
+        navigate(window.location, { replace: true, state: { ...state, refresh: true } });
       });
     } else if (category === 'Plan') {
       addPlan(deletedItem, () => {
         showReaddedItemMessage();
         // refresh plans
-        navigate(window.location, { replace: true, state: { refresh: true } });
+        navigate(window.location, { replace: true, state: { ...state, refresh: true } });
       });
     }
   }

+ 3 - 0
client/src/components/util/translation/TranslationContext.jsx

@@ -0,0 +1,3 @@
+import { createContext } from "react";
+
+export const TranslationContext = createContext();

+ 14 - 0
client/src/components/util/translation/TranslationProvider.jsx

@@ -0,0 +1,14 @@
+import { useMap } from "../useMap";
+import { TranslationContext } from "./TranslationContext";
+
+const { Provider } = TranslationContext;
+
+export const TranslationProvider = ({ children }) => {
+  const [translationsMap, addToTranslationsMap, clearTranslationsMap] = useMap();
+
+  return (
+    <Provider value={{ translationsMap, addToTranslationsMap, clearTranslationsMap }}>
+      {children}
+    </Provider>
+  )
+}

+ 22 - 0
client/src/components/util/translation/translate.util.jsx

@@ -0,0 +1,22 @@
+import { languagesISO2 } from "../../../i18n";
+import axios from "axios";
+
+const serverURL = process.env.REACT_APP_SERVER_URL;
+
+/**
+ * translate text with google translate
+ * @param {string | Array} text text or array of strings to be translated
+ * @param {string} target target language as provided by i18n.language, gets converted to ISO2 for Google translate in this function
+ * @param {function} callback function to be executed after translations are returned, receives array of translations
+ */
+export const googleTranslate = (text, target, callback) => {
+  const toLng = languagesISO2[target];
+  console.log('trying to translate the following to:', target, toLng, text);
+  // test for free callback(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L']);
+
+  axios.post(serverURL + '/translate/' + toLng, { text })
+       .then(res => {
+         console.log('result of translation', res);
+         if (callback) callback(res.data.translations);
+       }).catch(err => {console.log(err)});
+}

+ 38 - 0
client/src/components/util/translation/useLanguageDetection.jsx

@@ -0,0 +1,38 @@
+import { useTranslation } from "react-i18next";
+import { franc, francAll } from "franc-min";
+import LanguageDetect from "languagedetect";
+import { languagesISO3 } from "../../../i18n";
+
+const languageDetector = new LanguageDetect();
+languageDetector.setLanguageType('iso3');
+
+const languagesToDetect = Array.from(new Set(Object.values(languagesISO3)));
+const minLength = process.env.REACT_APP_MINIMUM_STRING_LENGTH_FOR_LANGUAGE_DETECTION || 30;
+
+/**
+ * Check if a given sample is the user's language or not
+ *
+ * @param sample {string | Array} string(s) to detect the languages for
+ * @returns {boolean} whether or not the submitted strings are likely to be of the same language as the interface language
+ */
+export function useLanguageDetection(sample) {
+  const { i18n } = useTranslation();
+
+  const detectedLanguages = Array.from(sample).map(textSample => franc(textSample, { minLength: minLength, only: languagesToDetect }));
+
+  if(detectedLanguages.every(lng => lng === 'und')) { // could not detect language because all samples were too short
+    // try to join all strings together in a list "a, b, c" and check with two packages. If both get the same language we check if that is the right language
+    const allStringsTogether = Array.from(sample).join(', ');
+    const detectedFromJoined = franc(allStringsTogether, { minLength: 10, only: languagesToDetect });
+    const doubleCheck = languageDetector.detect(allStringsTogether, 1)?.[0]?.[0];
+
+    if(detectedFromJoined === doubleCheck) { // both packages detected the same language
+      return detectedFromJoined === languagesISO3[i18n.language]; // return true if
+    }
+  }
+
+  // if a language was detected from the samples, return if it is the right language
+  /* note: this only checks if ONE of the submitted strings over minLength characters is of the same language as the interface.
+  *  if several longer texts of different languages are expected to occur in the same sample, this would not be a good solution */
+  return detectedLanguages.includes(languagesISO3[i18n.language]);
+}

+ 52 - 0
client/src/components/util/translation/useTranslationMap.jsx

@@ -0,0 +1,52 @@
+import { useContext } from "react";
+import { TranslationContext } from "./TranslationContext";
+import { useTranslation } from "react-i18next";
+import { googleTranslate } from "./translate.util";
+
+/**
+ * Hook to access and add to the translations of user content. Available everywhere in the app due to TranslationProvider
+ * @param isInUse {boolean} toggle whether print uses original or translated values
+ * @returns {{print: ((function(string): string)|*), clearTranslationsMap, translateThingsAndAddToMap: translateThingsAndAddToMap, translationsMap, addToTranslationsMap}}
+ */
+export function useTranslationsMap(isInUse = true) {
+  const { translationsMap, addToTranslationsMap, clearTranslationsMap } = useContext(TranslationContext);
+  const { i18n } = useTranslation();
+
+  /**
+   * Takes strings and sends them to the server, where they get send to google translate. The received translations will be stored in translationsMap as original -> translation
+   * @param allThingsToTranslate {Set | string | Array} string(s) to send to the server to translate with google and then add to the translationsMap
+   * @param callback function to be executed after translation or if no translation necessary, receives no parameters
+   */
+  const translateThingsAndAddToMap = (allThingsToTranslate, callback) => {
+    const thingsToTranslate = Array.from(allThingsToTranslate).filter(text => !translationsMap.has(text)); // make sure only new strings get translated -> save characters = save money on google
+    console.log('asking to translate', allThingsToTranslate);
+    console.log('actually translating', thingsToTranslate);
+    if (thingsToTranslate.length > 0) { // avoid API call if there is nothing to translate
+      googleTranslate(thingsToTranslate, i18n.language, (translations) => {
+        thingsToTranslate.forEach((value, index) => {
+          console.log(index, value, translations[index]);
+          addToTranslationsMap(value, translations[index]);
+        });
+        if (callback) callback();
+        console.log('Map created from', thingsToTranslate, translations);
+      });
+    } else {
+      if (callback) callback();
+    }
+  }
+
+  /**
+   * function to wrap all the user content that should be translated. shows the translation if present and activated, otherwise the original
+   * @param content {string}
+   * @returns {string}
+   */
+  const print = (content) => {
+    if (!isInUse) return content;
+    if (translationsMap.has(content)) {
+      return translationsMap.get(content);
+    }
+    return content;
+  };
+
+  return { translationsMap, print, addToTranslationsMap, clearTranslationsMap, translateThingsAndAddToMap };
+}

+ 15 - 0
client/src/components/util/useMap.jsx

@@ -0,0 +1,15 @@
+import { useState } from "react";
+
+export function useMap() {
+  const [map, setMap] = useState(new Map());
+
+  const updateMap = (k, v) => {
+    setMap(new Map(map.set(k, v)));
+  }
+
+  const clearMap = () => {
+    setMap(new Map());
+  }
+
+  return [map, updateMap, clearMap];
+}

+ 20 - 0
client/src/components/util/useSet.jsx

@@ -0,0 +1,20 @@
+import { useState } from "react";
+
+export function useSet() {
+  const [set, setSet] = useState(new Set());
+
+  /**
+   * Add element(s) to the set
+   * @param {string | Array} valueToAdd can be either string or Array of strings to add
+   */
+  const updateSet = (valueToAdd) => {
+    const valuesToAdd = Array.isArray(valueToAdd) ? valueToAdd : [valueToAdd];
+    setSet(prevState => new Set([...prevState, ...valuesToAdd]));
+  }
+
+  const clearSet = () => {
+    setSet(new Set());
+  }
+
+  return [set, updateSet, clearSet];
+}

+ 23 - 2
client/src/i18n.js

@@ -43,7 +43,29 @@ const resources = {
   },
 };
 
-export const languageShorthandForAuth0 = {
+export const languageShorthandForAuth0 = { // for Auth0, see comment above.
+  de: 'de',
+  it: 'it',
+  jp: 'ja',
+  fr_FR: 'fr_FR fr', // preference list
+  en_GB: 'en',
+  en_US: 'en',
+  es: 'es',
+}
+
+
+export const languagesISO3 = { // for franc translation package https://www.npmjs.com/package/franc-min?activeTab=readme
+  de: 'deu',
+  it: 'ita',
+  jp: 'jap',
+  fr_FR: 'fra',
+  en_GB: 'eng',
+  en_US: 'eng',
+  es: 'spa',
+}
+
+// ISO-639-Code 2-digit code for google Translate API https://cloud.google.com/translate/docs/languages
+export const languagesISO2 = {
   de: 'de',
   it: 'it',
   jp: 'ja',
@@ -65,7 +87,6 @@ i18n
   .use(initReactI18next) // passes i18n down to react-i18next
   .init({
     resources,
-    // lng: "en",
     fallbackLng: "en",
 
     keySeparator: false, // we do not use keys in form messages.welcome

+ 2 - 0
client/src/translations/_example.translation.json

@@ -112,5 +112,7 @@
   "Add without images": "",
   "Save without new images": "",
   "Meal of {{name}}": "",
+  "Translate": "",
+  "Original": "",
   "": ""
 }

+ 3 - 1
client/src/translations/de.translation.json

@@ -113,5 +113,7 @@
   "LOADING_IMAGES_TAKES_LONG": "Es scheint etwas zu dauern, die Bilder zu laden. Sie können warten oder das Gericht ohne diese Bilder hinzufügen.",
   "Add without images": "Ohne Bilder hinzufügen",
   "Save without new images": "Ohne neue Bilder speichern",
-  "Meal of {{name}}": "Gericht von {{name}}"
+  "Meal of {{name}}": "Gericht von {{name}}",
+  "Translate": "Übersetzen",
+  "Original": "Original"
 }

+ 3 - 1
client/src/translations/en-GB.translation.json

@@ -112,5 +112,7 @@
   "LOADING_IMAGES_TAKES_LONG": "It seems to take a while to load the images. You can wait or add the dish without these images.",
   "Add without images": "Add without images",
   "Save without new images": "Save without new images",
-  "Meal of {{name}}": "{{name}}'s Meal"
+  "Meal of {{name}}": "{{name}}'s Meal",
+  "Translate": "Translate",
+  "Original": "Original"
 }

+ 3 - 1
client/src/translations/en-US.translation.json

@@ -112,5 +112,7 @@
   "LOADING_IMAGES_TAKES_LONG": "It seems to take a while to load the images. You can wait or add the dish without these images.",
   "Add without images": "Add without images",
   "Save without new images": "Save without new images",
-  "Meal of {{name}}": "{{name}}'s Meal"
+  "Meal of {{name}}": "{{name}}'s Meal",
+  "Translate": "Translate",
+  "Original": "Original"
 }

+ 3 - 1
client/src/translations/es.translation.json

@@ -112,5 +112,7 @@
   "LOADING_IMAGES_TAKES_LONG": "Parece que tarda un poco en cargar las fotos. Puedes esperar o añadir la comida sin estas fotos.",
   "Add without images": "Añadir sin imágenes",
   "Save without new images": "Guardar sin imágenes nuevas",
-  "Meal of {{name}}": "Comida de {{name}}"
+  "Meal of {{name}}": "Comida de {{name}}",
+  "Translate": "Traducir",
+  "Original": "Original"
 }

+ 3 - 1
client/src/translations/fr-FR.translation.json

@@ -112,5 +112,7 @@
   "LOADING_IMAGES_TAKES_LONG": "Il semble que le chargement des images prenne un peu de temps. Vous pouvez attendre ou ajouter le plat sans ces images.",
   "Add without images": "Ajouter sans images",
   "Save without new images": "Sauvegarder sans nouvelles images",
-  "Meal of {{name}}": "Plat de {{name}}"
+  "Meal of {{name}}": "Plat de {{name}}",
+  "Translate": "Traduisez",
+  "Original": "Original"
 }

+ 4 - 2
client/src/translations/it.translation.json

@@ -42,7 +42,7 @@
   "APP_SUBTITLE": "Qui è possibile creare una raccolta di piatti e pianificare quando cucinare cosa.",
   "Looks like there are no meals here yet.": "Sembra che non ci siano ancora piatti qui.",
   "Add one by clicking in the top right corner.": "Clicca in alto a destra per aggiungerne uno.",
-  "Currently nothing planned": "Attualmente non abbiamo pianificato nulla",
+  "Currently nothing planned": "Attualmente non è pianificato niente",
   "Loading": "Caricamento",
   "No results": "Nessun risultato",
   "No contacts yet": "Non ci sono ancora contatti.",
@@ -112,5 +112,7 @@
   "LOADING_IMAGES_TAKES_LONG": "Sembra che ci voglia un po' di tempo per caricare le immagini. Puoi aspettare o aggiungere il piatto senza queste immagini.",
   "Add without images": "Aggiungi senza immagini",
   "Save without new images": "Salva senza nuove immagini",
-  "Meal of {{name}}": "Piatto di {{name}}"
+  "Meal of {{name}}": "Piatto di {{name}}",
+  "Translate": "Traduci",
+  "Original": "Original"
 }

+ 3 - 1
client/src/translations/jp.translation.json

@@ -112,5 +112,7 @@
   "LOADING_IMAGES_TAKES_LONG": "写真を読み込むのに時間がかかるようです。お待ちいただくか、写真なしでお料理を追加してください。",
   "Add without images": "写真なしで追加",
   "Save without new images": "新規画像なしで保存する",
-  "Meal of {{name}}": "{{name}}のレシピ"
+  "Meal of {{name}}": "{{name}}のレシピ",
+  "Translate": "訳す",
+  "Original": "原典"
 }

+ 23 - 0
server/controllers/translation.controller.js

@@ -0,0 +1,23 @@
+// logic for translation routes
+import { Translate } from "@google-cloud/translate/build/src/v2/index.js";
+
+const translate = new Translate();
+
+export const translateWithGoogle = async (req, res) => {
+  const target = req.params.targetLanguage;
+  //const text = ["This is an example text. I really want this to work. It's less basic than expected.", "questo potrebbe essere un titolo", "y este es una bendicion"];
+  const  {text} = req.body;
+  // todo: google basic translation only allows 128 elements in an array. Either use Advanced API or split into chunks
+  try {
+    let [translations] = await translate.translate(text, target);
+    translations = Array.isArray(translations) ? translations : [translations];
+    translations.forEach((translation, i) => {
+      console.log(`${text[i]} => (${target}) ${translation}`);
+    });
+
+    res.status(200).json({ message: 'translation in console', translations: translations});
+  } catch (error) {
+    res.status(404).json({ message: error.message });
+  }
+}
+

+ 2 - 0
server/index.js

@@ -9,6 +9,7 @@ import planRoutes from './routes/plans.routes.js';
 import imageRoutes from './routes/images.routes.js';
 import mealRoutes from './routes/meals.routes.js';
 import settingsRoutes from './routes/settings.routes.js';
+import translationRoutes from './routes/translation.routes.js';
 import usersRoutes from './routes/users.routes.js';
 
 const app = express();
@@ -21,6 +22,7 @@ app.use('/plans', planRoutes);
 app.use('/images', imageRoutes);
 app.use('/meals', mealRoutes);
 app.use('/settings', settingsRoutes);
+app.use('/translate', translationRoutes);
 app.use('/users', usersRoutes);
 
 const ATLAS_URI = process.env.MONGO_URI;

File diff suppressed because it is too large
+ 4358 - 1
server/package-lock.json


+ 1 - 0
server/package.json

@@ -13,6 +13,7 @@
   "author": "",
   "license": "ISC",
   "dependencies": {
+    "@google-cloud/translate": "^7.2.1",
     "auth0": "^2.33.0",
     "body-parser": "^1.19.0",
     "cloudinary": "^1.25.1",

+ 8 - 0
server/routes/translation.routes.js

@@ -0,0 +1,8 @@
+import express from 'express';
+import { translateWithGoogle } from "../controllers/translation.controller.js";
+
+const router = express.Router();
+
+router.post('/:targetLanguage', translateWithGoogle);
+
+export default router;

Some files were not shown because too many files changed in this diff