mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -04:00 
			
		
		
		
	* added new icons * added timeline badge and dialog to action menu * more icons * implemented timeline dialog using temporary API * added route for fetching all timeline events * formalized API call and added mobile-friendly view * cleaned tags * improved last made UI for mobile * added event context menu with placeholder methods * adjusted default made this date set time to 1 minute before midnight adjusted display to properly interpret UTC * fixed local date display * implemented update/delete routes * fixed formating for long subjects * added api error handling * made everything localizable * fixed weird formatting * removed unnecessary async * combined mobile/desktop views w/ conditional attrs
		
			
				
	
	
		
			246 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			246 lines
		
	
	
		
			7.2 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | ||
|   <BaseDialog
 | ||
|     v-model="dialog"
 | ||
|     :title="attrs.title"
 | ||
|     :icon="$globals.icons.timelineText"
 | ||
|     width="70%"
 | ||
|   >
 | ||
|     <v-card
 | ||
|       v-if="timelineEvents && timelineEvents.length"
 | ||
|       height="fit-content"
 | ||
|       max-height="70vh"
 | ||
|       width="100%"
 | ||
|       style="overflow-y: auto;"
 | ||
|     >
 | ||
|       <v-timeline :dense="attrs.timeline.dense">
 | ||
|         <v-timeline-item
 | ||
|           v-for="(event, index) in timelineEvents"
 | ||
|           :key="event.id"
 | ||
|           :class="attrs.timeline.item.class"
 | ||
|           fill-dot
 | ||
|           :small="attrs.timeline.item.small"
 | ||
|           :icon="chooseEventIcon(event)"
 | ||
|         >
 | ||
|           <template v-if="!useMobileFormat" #opposite>
 | ||
|             <v-chip v-if="event.timestamp" label large>
 | ||
|               <v-icon class="mr-1"> {{ $globals.icons.calendar }} </v-icon>
 | ||
|               {{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
 | ||
|             </v-chip>
 | ||
|           </template>
 | ||
|           <v-card>
 | ||
|             <v-sheet>
 | ||
|               <v-card-title>
 | ||
|                 <v-row>
 | ||
|                   <v-col align-self="center" :cols="useMobileFormat ? 'auto' : '2'">
 | ||
|                     <UserAvatar :user-id="event.userId" />
 | ||
|                   </v-col>
 | ||
|                   <v-col v-if="useMobileFormat" align-self="center" class="ml-3">
 | ||
|                     <v-chip label>
 | ||
|                       <v-icon> {{ $globals.icons.calendar }} </v-icon>
 | ||
|                       {{ new Date(event.timestamp+"Z").toLocaleDateString($i18n.locale) }}
 | ||
|                     </v-chip>
 | ||
|                   </v-col>
 | ||
|                   <v-col v-else cols="9">
 | ||
|                     {{ event.subject }}
 | ||
|                   </v-col>
 | ||
|                   <v-spacer />
 | ||
|                   <v-col :cols="useMobileFormat ? 'auto' : '1'" :class="useMobileFormat ? '' : 'pa-0'">
 | ||
|                     <RecipeTimelineContextMenu
 | ||
|                       v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'"
 | ||
|                       :menu-top="false"
 | ||
|                       :slug="slug"
 | ||
|                       :event="event"
 | ||
|                       :menu-icon="$globals.icons.dotsVertical"
 | ||
|                       fab
 | ||
|                       color="transparent"
 | ||
|                       :elevation="0"
 | ||
|                       :card-menu="false"
 | ||
|                       :use-items="{
 | ||
|                         edit: true,
 | ||
|                         delete: true,
 | ||
|                       }"
 | ||
|                       @update="updateTimelineEvent(index)"
 | ||
|                       @delete="deleteTimelineEvent(index)"
 | ||
|                     />
 | ||
|                   </v-col>
 | ||
|                 </v-row>
 | ||
|               </v-card-title>
 | ||
|               <v-card-text>
 | ||
|                 <v-row>
 | ||
|                   <v-col>
 | ||
|                     <strong v-if="useMobileFormat">{{ event.subject }}</strong>
 | ||
|                     <div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
 | ||
|                       {{ event.eventMessage }}
 | ||
|                     </div>
 | ||
|                   </v-col>
 | ||
|                 </v-row>
 | ||
|               </v-card-text>
 | ||
|             </v-sheet>
 | ||
|           </v-card>
 | ||
|         </v-timeline-item>
 | ||
|       </v-timeline>
 | ||
|     </v-card>
 | ||
|     <v-card v-else>
 | ||
|       <v-card-title class="justify-center pa-9">
 | ||
|         {{ $t("recipe.timeline-is-empty") }}
 | ||
|       </v-card-title>
 | ||
|     </v-card>
 | ||
|   </BaseDialog>
 | ||
| </template>
 | ||
| 
 | ||
| <script lang="ts">
 | ||
| import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
 | ||
| import { whenever } from "@vueuse/core";
 | ||
| import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
 | ||
| import { alert } from "~/composables/use-toast";
 | ||
| import { useUserApi } from "~/composables/api";
 | ||
| import { RecipeTimelineEventOut, RecipeTimelineEventUpdate } from "~/lib/api/types/recipe"
 | ||
| import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
 | ||
| 
 | ||
| export default defineComponent({
 | ||
|   components: { RecipeTimelineContextMenu, UserAvatar },
 | ||
| 
 | ||
|   props: {
 | ||
|     value: {
 | ||
|       type: Boolean,
 | ||
|       default: false,
 | ||
|     },
 | ||
|     slug: {
 | ||
|       type: String,
 | ||
|       default: "",
 | ||
|     },
 | ||
|     recipeName: {
 | ||
|       type: String,
 | ||
|       default: "",
 | ||
|     },
 | ||
|   },
 | ||
| 
 | ||
|   setup(props, context) {
 | ||
|     const api = useUserApi();
 | ||
|     const { $globals, $vuetify, i18n } = useContext();
 | ||
|     const timelineEvents = ref([{}] as RecipeTimelineEventOut[])
 | ||
| 
 | ||
|     const useMobileFormat = computed(() => {
 | ||
|       return $vuetify.breakpoint.smAndDown;
 | ||
|     });
 | ||
| 
 | ||
|     const attrs = computed(() => {
 | ||
|       if (useMobileFormat.value) {
 | ||
|         return {
 | ||
|           title: i18n.tc("recipe.timeline"),
 | ||
|           timeline: {
 | ||
|             dense: true,
 | ||
|             item: {
 | ||
|               class: "pr-3",
 | ||
|               small: true
 | ||
|             }
 | ||
|           }
 | ||
|         }
 | ||
|       }
 | ||
|       else {
 | ||
|         return {
 | ||
|           title: `${i18n.tc("recipe.timeline")} – ${props.recipeName}`,
 | ||
|           timeline: {
 | ||
|             dense: false,
 | ||
|             item: {
 | ||
|               class: "px-3",
 | ||
|               small: false
 | ||
|             }
 | ||
|           }
 | ||
|         }
 | ||
|       }
 | ||
|     })
 | ||
| 
 | ||
|     // V-Model Support
 | ||
|     const dialog = computed({
 | ||
|       get: () => {
 | ||
|         return props.value;
 | ||
|       },
 | ||
|       set: (val) => {
 | ||
|         context.emit("input", val);
 | ||
|       },
 | ||
|     });
 | ||
| 
 | ||
|     whenever(
 | ||
|       () => props.value,
 | ||
|       () => {
 | ||
|         refreshTimelineEvents();
 | ||
|       }
 | ||
|     );
 | ||
| 
 | ||
|     function chooseEventIcon(event: RecipeTimelineEventOut) {
 | ||
|       switch (event.eventType) {
 | ||
|         case "comment":
 | ||
|           return $globals.icons.commentTextMultiple;
 | ||
| 
 | ||
|         case "info":
 | ||
|           return $globals.icons.informationVariant;
 | ||
| 
 | ||
|         case "system":
 | ||
|           return $globals.icons.cog;
 | ||
| 
 | ||
|         default:
 | ||
|           return $globals.icons.informationVariant;
 | ||
|       };
 | ||
|     };
 | ||
| 
 | ||
|     async function updateTimelineEvent(index: number) {
 | ||
|       const event = timelineEvents.value[index]
 | ||
|       const payload: RecipeTimelineEventUpdate = {
 | ||
|         subject: event.subject,
 | ||
|         eventMessage: event.eventMessage,
 | ||
|         image: event.image,
 | ||
|       };
 | ||
| 
 | ||
|         const { response } = await api.recipes.updateTimelineEvent(props.slug, event.id, payload);
 | ||
|         if (response?.status !== 200) {
 | ||
|           alert.error(i18n.t("events.something-went-wrong") as string);
 | ||
|           return;
 | ||
|         }
 | ||
| 
 | ||
|         alert.success(i18n.t("events.event-updated") as string);
 | ||
|       };
 | ||
| 
 | ||
|     async function deleteTimelineEvent(index: number) {
 | ||
|       const { response } = await api.recipes.deleteTimelineEvent(props.slug, timelineEvents.value[index].id);
 | ||
|       if (response?.status !== 200) {
 | ||
|         alert.error(i18n.t("events.something-went-wrong") as string);
 | ||
|         return;
 | ||
|       }
 | ||
| 
 | ||
|       timelineEvents.value.splice(index, 1);
 | ||
|       alert.success(i18n.t("events.event-deleted") as string);
 | ||
|     };
 | ||
| 
 | ||
|     async function refreshTimelineEvents() {
 | ||
|       // TODO: implement infinite scroll and paginate instead of loading all events at once
 | ||
|       const page = 1;
 | ||
|       const perPage = -1;
 | ||
|       const orderBy = "timestamp";
 | ||
|       const orderDirection = "asc";
 | ||
| 
 | ||
|       const response = await api.recipes.getAllTimelineEvents(props.slug, page, perPage, { orderBy, orderDirection });
 | ||
|       if (!response?.data) {
 | ||
|         return;
 | ||
|       }
 | ||
| 
 | ||
|       timelineEvents.value = response.data.items;
 | ||
|     };
 | ||
| 
 | ||
|     // preload events
 | ||
|     refreshTimelineEvents();
 | ||
| 
 | ||
|     return {
 | ||
|       attrs,
 | ||
|       chooseEventIcon,
 | ||
|       deleteTimelineEvent,
 | ||
|       dialog,
 | ||
|       refreshTimelineEvents,
 | ||
|       timelineEvents,
 | ||
|       updateTimelineEvent,
 | ||
|       useMobileFormat,
 | ||
|     };
 | ||
|   },
 | ||
| });
 | ||
| </script>
 |