mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-11-03 18:53:17 -05: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>
 |