mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat: Support User-Level Default Activities (#5125)
This commit is contained in:
		
							
								
								
									
										63
									
								
								frontend/composables/use-default-activity.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								frontend/composables/use-default-activity.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| import type { Activity, I18n, TranslationResult } from "~/lib/api/types/activity"; | ||||
| import { ActivityKey } from "~/lib/api/types/activity"; | ||||
|  | ||||
| export const DEFAULT_ACTIVITY = "/g/home" as const; | ||||
|  | ||||
| type ActivityRegistry = { | ||||
|   recipes: Activity; | ||||
|   mealplanner: Activity; | ||||
|   shopping_list: Activity; | ||||
| }; | ||||
|  | ||||
| const selectableActivities: ActivityRegistry = { | ||||
|   recipes: { | ||||
|     key: ActivityKey.RECIPES, | ||||
|     route: groupSlug => groupSlug ? `/g/${groupSlug}` : DEFAULT_ACTIVITY, | ||||
|     label: i18n => i18n.t("general.recipes"), | ||||
|   }, | ||||
|   mealplanner: { | ||||
|     key: ActivityKey.MEALPLANNER, | ||||
|     route: () => "/household/mealplan/planner/view", | ||||
|     label: i18n => i18n.t("meal-plan.meal-planner"), | ||||
|   }, | ||||
|   shopping_list: { | ||||
|     key: ActivityKey.SHOPPING_LIST, | ||||
|     route: () => "/shopping-lists", | ||||
|     label: i18n => i18n.t("shopping-list.shopping-lists"), | ||||
|   }, | ||||
| }; | ||||
|  | ||||
| function getDefaultActivityRoute(activityKey?: ActivityKey, groupSlug?: string): string { | ||||
|   if (!activityKey) { | ||||
|     return DEFAULT_ACTIVITY; | ||||
|   } | ||||
|   const route = selectableActivities[activityKey]?.route ?? (() => DEFAULT_ACTIVITY); | ||||
|   return route(groupSlug); | ||||
| } | ||||
|  | ||||
| function getDefaultActivityLabels(i18n: I18n): TranslationResult[] { | ||||
|   return Object.values(selectableActivities).map( | ||||
|     ({ label }) => label(i18n), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function getActivityKey(i18n: I18n, target: TranslationResult = ""): ActivityKey | undefined { | ||||
|   return Object.values(selectableActivities) | ||||
|     .find(({ label }) => label(i18n) === target)?.key; | ||||
| } | ||||
|  | ||||
| function getActivityLabel(i18n: I18n, target?: ActivityKey): TranslationResult { | ||||
|   return Object.values(selectableActivities) | ||||
|     .find(({ key }) => key === target) | ||||
|     ?.label(i18n) ?? ""; | ||||
| } | ||||
|  | ||||
| export default function useDefaultActivity() { | ||||
|   return { | ||||
|     selectableActivities, | ||||
|     getDefaultActivityRoute, | ||||
|     getDefaultActivityLabels, | ||||
|     getActivityKey, | ||||
|     getActivityLabel, | ||||
|   }; | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| import { useLocalStorage, useSessionStorage } from "@vueuse/core"; | ||||
| import { ActivityKey } from "~/lib/api/types/activity"; | ||||
| import type { RegisteredParser, TimelineEventType } from "~/lib/api/types/recipe"; | ||||
| import type { QueryFilterJSON } from "~/lib/api/types/response"; | ||||
|  | ||||
| @@ -65,6 +66,10 @@ export interface UserRecipeCreatePreferences { | ||||
|   parseRecipe: boolean; | ||||
| } | ||||
|  | ||||
| export interface UserActivityPreferences { | ||||
|   defaultActivity: ActivityKey; | ||||
| } | ||||
|  | ||||
| export function useUserMealPlanPreferences(): Ref<UserMealPlanPreferences> { | ||||
|   const fromStorage = useLocalStorage( | ||||
|     "meal-planner-preferences", | ||||
| @@ -115,6 +120,20 @@ export function useUserSortPreferences(): Ref<UserRecipePreferences> { | ||||
|   return fromStorage; | ||||
| } | ||||
|  | ||||
| export function useUserActivityPreferences(): Ref<UserActivityPreferences> { | ||||
|   const fromStorage = useLocalStorage( | ||||
|     "activity-preferences", | ||||
|     { | ||||
|       defaultActivity: ActivityKey.RECIPES, | ||||
|     }, | ||||
|     { mergeDefaults: true }, | ||||
|     // we cast to a Ref because by default it will return an optional type ref | ||||
|     // but since we pass defaults we know all properties are set. | ||||
|   ) as Ref<UserActivityPreferences>; | ||||
|  | ||||
|   return fromStorage; | ||||
| } | ||||
|  | ||||
| export function useUserSearchQuerySession(): Ref<UserSearchQuery> { | ||||
|   const fromStorage = useSessionStorage( | ||||
|     "search-query", | ||||
|   | ||||
| @@ -1063,7 +1063,9 @@ | ||||
|     "dont-want-to-see-this-anymore-be-sure-to-change-your-email": "Don't want to see this anymore? Be sure to change your email in your user settings!", | ||||
|     "forgot-password": "Forgot Password", | ||||
|     "forgot-password-text": "Please enter your email address and we will send you a link to reset your password.", | ||||
|     "changes-reflected-immediately": "Changes to this user will be reflected immediately." | ||||
|     "changes-reflected-immediately": "Changes to this user will be reflected immediately.", | ||||
|     "default-activity": "Default Activity", | ||||
|     "default-activity-hint": "Select which page you'd like to navigate to upon logging in from this device" | ||||
|   }, | ||||
|   "language-dialog": { | ||||
|     "translated": "translated", | ||||
|   | ||||
							
								
								
									
										18
									
								
								frontend/lib/api/types/activity.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								frontend/lib/api/types/activity.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| export type I18n = ReturnType<typeof useI18n>; | ||||
| export type TRoute = string; | ||||
| export type TranslationResult = string; | ||||
|  | ||||
| export type ActivityRoute = (groupSlug?: string) => TRoute; | ||||
| export type ActivityLabel = (i18n: I18n) => TranslationResult; | ||||
|  | ||||
| export type Activity = { | ||||
|   key: ActivityKey; | ||||
|   route: ActivityRoute; | ||||
|   label: ActivityLabel; | ||||
| }; | ||||
|  | ||||
| export const enum ActivityKey { | ||||
|   RECIPES = "recipes", | ||||
|   MEALPLANNER = "mealplanner", | ||||
|   SHOPPING_LIST = "shopping_list", | ||||
| } | ||||
| @@ -3,6 +3,8 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import useDefaultActivity from "~/composables/use-default-activity"; | ||||
| import { useUserActivityPreferences } from "~/composables/use-users/preferences"; | ||||
| import { useAsyncKey } from "~/composables/use-utils"; | ||||
| import type { AppInfo, AppStartupInfo } from "~/lib/api/types/admin"; | ||||
|  | ||||
| @@ -15,6 +17,8 @@ export default defineNuxtComponent({ | ||||
|     const $auth = useMealieAuth(); | ||||
|     const { $axios } = useNuxtApp(); | ||||
|     const router = useRouter(); | ||||
|     const activityPreferences = useUserActivityPreferences(); | ||||
|     const { getDefaultActivityRoute } = useDefaultActivity(); | ||||
|     const groupSlug = computed(() => $auth.user.value?.groupSlug); | ||||
|  | ||||
|     async function redirectPublicUserToDefaultGroup() { | ||||
| @@ -32,9 +36,16 @@ export default defineNuxtComponent({ | ||||
|         const data = await $axios.get<AppStartupInfo>("/api/app/about/startup-info"); | ||||
|         const isDemo = data.data.isDemo; | ||||
|         const isFirstLogin = data.data.isFirstLogin; | ||||
|         const defaultActivityRoute = getDefaultActivityRoute( | ||||
|           activityPreferences.value.defaultActivity, | ||||
|           groupSlug.value, | ||||
|         ); | ||||
|         if (!isDemo && isFirstLogin && $auth.user.value?.admin) { | ||||
|           router.push("/admin/setup"); | ||||
|         } | ||||
|         else if (defaultActivityRoute) { | ||||
|           router.push(defaultActivityRoute); | ||||
|         } | ||||
|         else { | ||||
|           router.push(`/g/${groupSlug.value}`); | ||||
|         } | ||||
|   | ||||
| @@ -217,6 +217,7 @@ import { usePasswordField } from "~/composables/use-passwords"; | ||||
| import { alert } from "~/composables/use-toast"; | ||||
| import { useAsyncKey } from "~/composables/use-utils"; | ||||
| import type { AppStartupInfo } from "~/lib/api/types/admin"; | ||||
| import { useUserActivityPreferences } from "~/composables/use-users/preferences"; | ||||
|  | ||||
| export default defineNuxtComponent({ | ||||
|   setup() { | ||||
| @@ -233,6 +234,8 @@ export default defineNuxtComponent({ | ||||
|     const groupSlug = computed(() => $auth.user.value?.groupSlug); | ||||
|     const isDemo = ref(false); | ||||
|     const isFirstLogin = ref(false); | ||||
|     const activityPreferences = useUserActivityPreferences(); | ||||
|     const { getDefaultActivityRoute } = useDefaultActivity(); | ||||
|  | ||||
|     useSeoMeta({ | ||||
|       title: i18n.t("user.login"), | ||||
| @@ -253,9 +256,16 @@ export default defineNuxtComponent({ | ||||
|     whenever( | ||||
|       () => loggedIn.value && groupSlug.value, | ||||
|       () => { | ||||
|         const defaultActivityRoute = getDefaultActivityRoute( | ||||
|           activityPreferences.value.defaultActivity, | ||||
|           groupSlug.value, | ||||
|         ); | ||||
|         if (!isDemo.value && isFirstLogin.value && $auth.user.value?.admin) { | ||||
|           router.push("/admin/setup"); | ||||
|         } | ||||
|         else if (defaultActivityRoute) { | ||||
|           router.push(defaultActivityRoute); | ||||
|         } | ||||
|         else { | ||||
|           router.push(`/g/${groupSlug.value || ""}`); | ||||
|         } | ||||
|   | ||||
| @@ -171,13 +171,26 @@ | ||||
|         class="mt-10" | ||||
|         :title="$t('profile.preferences')" | ||||
|       /> | ||||
|       <v-checkbox | ||||
|         v-model="userCopy.advanced" | ||||
|         class="mt-n4" | ||||
|         :label="$t('profile.show-advanced-description')" | ||||
|         color="primary" | ||||
|         @change="updateUser" | ||||
|       /> | ||||
|       <v-card variant="outlined" style="border-color: lightgrey;"> | ||||
|         <v-card-text> | ||||
|           <v-combobox | ||||
|             v-model="selectedDefaultActivity" | ||||
|             :label="$t('user.default-activity')" | ||||
|             :items="activityOptions" | ||||
|             :hint="$t('user.default-activity-hint')" | ||||
|             density="comfortable" | ||||
|             variant="underlined" | ||||
|             validate-on="blur" | ||||
|             persistent-hint | ||||
|           /> | ||||
|           <v-checkbox | ||||
|             v-model="userCopy.advanced" | ||||
|             :label="$t('profile.show-advanced-description')" | ||||
|             color="primary" | ||||
|             @change="updateUser" | ||||
|           /> | ||||
|         </v-card-text> | ||||
|       </v-card> | ||||
|       <nuxt-link | ||||
|         class="mt-5 d-flex flex-column justify-center text-center" | ||||
|         :to="`/group`" | ||||
| @@ -207,6 +220,9 @@ import UserAvatar from "~/components/Domain/User/UserAvatar.vue"; | ||||
| import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue"; | ||||
| import { validators } from "~/composables/use-validators"; | ||||
| import type { VForm } from "~/types/auto-forms"; | ||||
| import { useUserActivityPreferences } from "~/composables/use-users/preferences"; | ||||
| import useDefaultActivity from "~/composables/use-default-activity"; | ||||
| import { ActivityKey } from "~/lib/api/types/activity"; | ||||
|  | ||||
| export default defineNuxtComponent({ | ||||
|   components: { | ||||
| @@ -216,12 +232,20 @@ export default defineNuxtComponent({ | ||||
|   setup() { | ||||
|     const i18n = useI18n(); | ||||
|     const $auth = useMealieAuth(); | ||||
|     const { getDefaultActivityLabels, getActivityLabel, getActivityKey } = useDefaultActivity(); | ||||
|     const user = computed(() => $auth.user.value); | ||||
|  | ||||
|     useSeoMeta({ | ||||
|       title: i18n.t("settings.profile"), | ||||
|     }); | ||||
|  | ||||
|     const activityPreferences = useUserActivityPreferences(); | ||||
|     const activityOptions = getDefaultActivityLabels(i18n); | ||||
|     const selectedDefaultActivity = ref(getActivityLabel(i18n, activityPreferences.value.defaultActivity)); | ||||
|     watch(selectedDefaultActivity, () => { | ||||
|       activityPreferences.value.defaultActivity = getActivityKey(i18n, selectedDefaultActivity.value) ?? ActivityKey.RECIPES; | ||||
|     }); | ||||
|  | ||||
|     watch(user, () => { | ||||
|       userCopy.value = { ...user.value }; | ||||
|     }); | ||||
| @@ -273,6 +297,8 @@ export default defineNuxtComponent({ | ||||
|       updateUser, | ||||
|       updatePassword, | ||||
|       userCopy, | ||||
|       selectedDefaultActivity, | ||||
|       activityOptions, | ||||
|       password, | ||||
|       domUpdatePassword, | ||||
|       passwordsMatch, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user