mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-02 12:05:21 +02:00
Feat/recipe timeline event UI (#1831)
* 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
This commit is contained in:
parent
f5d401a6a6
commit
4e8e2d7510
8 changed files with 692 additions and 134 deletions
|
@ -22,6 +22,7 @@
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<div v-if="!open" class="custom-btn-group ma-1">
|
<div v-if="!open" class="custom-btn-group ma-1">
|
||||||
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="recipe.slug" show-always />
|
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="recipe.slug" show-always />
|
||||||
|
<RecipeTimelineBadge button-style :slug="recipe.slug" :recipe-name="recipe.name" />
|
||||||
<v-tooltip v-if="!locked" bottom color="info">
|
<v-tooltip v-if="!locked" bottom color="info">
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ on, attrs }">
|
||||||
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
|
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
|
||||||
|
@ -70,7 +71,6 @@
|
||||||
:key="index"
|
:key="index"
|
||||||
:fab="$vuetify.breakpoint.xs"
|
:fab="$vuetify.breakpoint.xs"
|
||||||
:small="$vuetify.breakpoint.xs"
|
:small="$vuetify.breakpoint.xs"
|
||||||
class="mx-1"
|
|
||||||
:color="btn.color"
|
:color="btn.color"
|
||||||
@click="emitHandler(btn.event)"
|
@click="emitHandler(btn.event)"
|
||||||
>
|
>
|
||||||
|
@ -85,6 +85,7 @@
|
||||||
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||||
|
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
|
||||||
import { Recipe } from "~/lib/api/types/recipe";
|
import { Recipe } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
const SAVE_EVENT = "save";
|
const SAVE_EVENT = "save";
|
||||||
|
@ -94,7 +95,7 @@ const JSON_EVENT = "json";
|
||||||
const OCR_EVENT = "ocr";
|
const OCR_EVENT = "ocr";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: { RecipeContextMenu, RecipeFavoriteBadge },
|
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
|
||||||
props: {
|
props: {
|
||||||
recipe: {
|
recipe: {
|
||||||
required: true,
|
required: true,
|
||||||
|
|
245
frontend/components/Domain/Recipe/RecipeDialogTimeline.vue
Normal file
245
frontend/components/Domain/Recipe/RecipeDialogTimeline.vue
Normal file
|
@ -0,0 +1,245 @@
|
||||||
|
<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>
|
|
@ -1,143 +1,158 @@
|
||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<BaseDialog
|
||||||
<BaseDialog
|
v-model="madeThisDialog"
|
||||||
v-model="madeThisDialog"
|
:icon="$globals.icons.chefHat"
|
||||||
:icon="$globals.icons.chefHat"
|
:title="$tc('recipe.made-this')"
|
||||||
title="I Made This"
|
:submit-text="$tc('general.save')"
|
||||||
:submit-text="$tc('general.save')"
|
@submit="createTimelineEvent"
|
||||||
@submit="createTimelineEvent"
|
|
||||||
>
|
|
||||||
<v-card-text>
|
|
||||||
<v-form ref="domMadeThisForm">
|
|
||||||
<v-textarea
|
|
||||||
v-model="newTimelineEvent.eventMessage"
|
|
||||||
autofocus
|
|
||||||
label="Comment"
|
|
||||||
hint="How did it turn out?"
|
|
||||||
persistent-hint
|
|
||||||
rows="4"
|
|
||||||
></v-textarea>
|
|
||||||
<v-menu
|
|
||||||
v-model="datePickerMenu"
|
|
||||||
:close-on-content-click="false"
|
|
||||||
transition="scale-transition"
|
|
||||||
offset-y
|
|
||||||
max-width="290px"
|
|
||||||
min-width="auto"
|
|
||||||
>
|
|
||||||
<template #activator="{ on, attrs }">
|
|
||||||
<v-text-field
|
|
||||||
v-model="newTimelineEvent.timestamp"
|
|
||||||
:prepend-icon="$globals.icons.calendar"
|
|
||||||
v-bind="attrs"
|
|
||||||
readonly
|
|
||||||
v-on="on"
|
|
||||||
></v-text-field>
|
|
||||||
</template>
|
|
||||||
<v-date-picker v-model="newTimelineEvent.timestamp" no-title @input="datePickerMenu = false"></v-date-picker>
|
|
||||||
</v-menu>
|
|
||||||
</v-form>
|
|
||||||
</v-card-text>
|
|
||||||
</BaseDialog>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<v-chip
|
|
||||||
label
|
|
||||||
color="accent custom-transparent"
|
|
||||||
class="ma-1"
|
|
||||||
style="height:100%;"
|
|
||||||
>
|
>
|
||||||
<v-icon left>
|
<v-card-text>
|
||||||
{{ $globals.icons.calendar }}
|
<v-form ref="domMadeThisForm">
|
||||||
</v-icon>
|
<v-textarea
|
||||||
Last Made {{ value ? new Date(value).toLocaleDateString($i18n.locale) : "Never" }}
|
v-model="newTimelineEvent.eventMessage"
|
||||||
</v-chip>
|
autofocus
|
||||||
<BaseButton @click="madeThisDialog = true">
|
:label="$tc('recipe.comment')"
|
||||||
|
:hint="$tc('recipe.how-did-it-turn-out')"
|
||||||
|
persistent-hint
|
||||||
|
rows="4"
|
||||||
|
></v-textarea>
|
||||||
|
<v-menu
|
||||||
|
v-model="datePickerMenu"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
transition="scale-transition"
|
||||||
|
offset-y
|
||||||
|
max-width="290px"
|
||||||
|
min-width="auto"
|
||||||
|
>
|
||||||
|
<template #activator="{ on, attrs }">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newTimelineEvent.timestamp"
|
||||||
|
:prepend-icon="$globals.icons.calendar"
|
||||||
|
v-bind="attrs"
|
||||||
|
readonly
|
||||||
|
v-on="on"
|
||||||
|
></v-text-field>
|
||||||
|
</template>
|
||||||
|
<v-date-picker
|
||||||
|
v-model="newTimelineEvent.timestamp"
|
||||||
|
no-title
|
||||||
|
:local="$i18n.locale"
|
||||||
|
@input="datePickerMenu = false"
|
||||||
|
/>
|
||||||
|
</v-menu>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
</BaseDialog>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex justify-center flex-wrap">
|
||||||
|
<BaseButton :small="$vuetify.breakpoint.smAndDown" @click="madeThisDialog = true">
|
||||||
<template #icon> {{ $globals.icons.chefHat }} </template>
|
<template #icon> {{ $globals.icons.chefHat }} </template>
|
||||||
I Made This
|
I Made This
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex justify-center flex-wrap">
|
||||||
|
<v-chip
|
||||||
|
label
|
||||||
|
:small="$vuetify.breakpoint.smAndDown"
|
||||||
|
color="accent custom-transparent"
|
||||||
|
class="ma-1 pa-3"
|
||||||
|
>
|
||||||
|
<v-icon left>
|
||||||
|
{{ $globals.icons.calendar }}
|
||||||
|
</v-icon>
|
||||||
|
Last Made {{ value ? new Date(value+"Z").toLocaleDateString($i18n.locale) : $t("general.never") }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, reactive, ref, toRefs, useContext, } from "@nuxtjs/composition-api";
|
import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||||
import { whenever } from "@vueuse/core";
|
import { whenever } from "@vueuse/core";
|
||||||
import { VForm } from "~/types/vuetify";
|
import { VForm } from "~/types/vuetify";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { RecipeTimelineEventIn } from "~/lib/api/types/recipe";
|
import { RecipeTimelineEventIn } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
value: {
|
value: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
|
||||||
recipeSlug: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
recipeSlug: {
|
||||||
const madeThisDialog = ref(false);
|
type: String,
|
||||||
const userApi = useUserApi();
|
required: true,
|
||||||
const { $auth } = useContext();
|
},
|
||||||
const domMadeThisForm = ref<VForm>();
|
},
|
||||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
setup(props, context) {
|
||||||
// @ts-expect-error - TS doesn't like the $auth global user attribute
|
const madeThisDialog = ref(false);
|
||||||
// eslint-disable-next-line
|
const userApi = useUserApi();
|
||||||
subject: `${$auth.user.fullName} made this`,
|
const { $auth, i18n } = useContext();
|
||||||
eventType: "comment",
|
const domMadeThisForm = ref<VForm>();
|
||||||
eventMessage: "",
|
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||||
timestamp: "",
|
// @ts-expect-error - TS doesn't like the $auth global user attribute
|
||||||
});
|
// eslint-disable-next-line
|
||||||
|
subject: i18n.t("recipe.user-made-this", { user: $auth.user.fullName } as string),
|
||||||
|
eventType: "comment",
|
||||||
|
eventMessage: "",
|
||||||
|
timestamp: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const state = reactive({datePickerMenu: false});
|
whenever(
|
||||||
|
() => madeThisDialog.value,
|
||||||
|
() => {
|
||||||
|
// Set timestamp to now
|
||||||
|
newTimelineEvent.value.timestamp = (
|
||||||
|
new Date(Date.now() - (new Date()).getTimezoneOffset() * 60000)
|
||||||
|
).toISOString().substring(0, 10);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
whenever(
|
const state = reactive({datePickerMenu: false});
|
||||||
() => madeThisDialog.value,
|
async function createTimelineEvent() {
|
||||||
() => {
|
if (!newTimelineEvent.value.timestamp) {
|
||||||
// Set timestamp to now
|
return;
|
||||||
newTimelineEvent.value.timestamp = new Date().toISOString().substring(0, 10);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
async function createTimelineEvent() {
|
|
||||||
if (!newTimelineEvent.value.timestamp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions: Promise<any>[] = []
|
|
||||||
|
|
||||||
// the user only selects the date, so we set the time to noon
|
|
||||||
newTimelineEvent.value.timestamp += "T12:00:00";
|
|
||||||
actions.push(userApi.recipes.createTimelineEvent(props.recipeSlug, newTimelineEvent.value));
|
|
||||||
|
|
||||||
// we also update the recipe's last made value
|
|
||||||
if (!props.value || newTimelineEvent.value.timestamp > props.value) {
|
|
||||||
const payload = {lastMade: newTimelineEvent.value.timestamp};
|
|
||||||
actions.push(userApi.recipes.patchOne(props.recipeSlug, payload));
|
|
||||||
|
|
||||||
// update recipe in parent so the user can see it
|
|
||||||
context.emit("input", newTimelineEvent.value.timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.allSettled(actions)
|
|
||||||
|
|
||||||
// reset form
|
|
||||||
newTimelineEvent.value.eventMessage = "";
|
|
||||||
madeThisDialog.value = false;
|
|
||||||
domMadeThisForm.value?.reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const actions: Promise<any>[] = [];
|
||||||
...toRefs(state),
|
|
||||||
domMadeThisForm,
|
// the user only selects the date, so we set the time to end of day local time
|
||||||
madeThisDialog,
|
// we choose the end of day so it always comes after "new recipe" events
|
||||||
newTimelineEvent,
|
newTimelineEvent.value.timestamp = new Date(newTimelineEvent.value.timestamp + "T23:59:59").toISOString();
|
||||||
createTimelineEvent,
|
actions.push(userApi.recipes.createTimelineEvent(props.recipeSlug, newTimelineEvent.value));
|
||||||
};
|
|
||||||
},
|
// we also update the recipe's last made value
|
||||||
});
|
if (!props.value || newTimelineEvent.value.timestamp > props.value) {
|
||||||
</script>
|
const payload = {lastMade: newTimelineEvent.value.timestamp};
|
||||||
|
actions.push(userApi.recipes.patchOne(props.recipeSlug, payload));
|
||||||
|
|
||||||
|
// update recipe in parent so the user can see it
|
||||||
|
// we remove the trailing "Z" since this is how the API returns it
|
||||||
|
context.emit(
|
||||||
|
"input", newTimelineEvent.value.timestamp
|
||||||
|
.substring(0, newTimelineEvent.value.timestamp.length - 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(actions);
|
||||||
|
|
||||||
|
// reset form
|
||||||
|
newTimelineEvent.value.eventMessage = "";
|
||||||
|
madeThisDialog.value = false;
|
||||||
|
domMadeThisForm.value?.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...toRefs(state),
|
||||||
|
domMadeThisForm,
|
||||||
|
madeThisDialog,
|
||||||
|
newTimelineEvent,
|
||||||
|
createTimelineEvent,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
53
frontend/components/Domain/Recipe/RecipeTimelineBadge.vue
Normal file
53
frontend/components/Domain/Recipe/RecipeTimelineBadge.vue
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
<template>
|
||||||
|
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'info' : 'secondary'">
|
||||||
|
<template #activator="{ on, attrs }">
|
||||||
|
<v-btn
|
||||||
|
small
|
||||||
|
:color="buttonStyle ? 'info' : 'secondary'"
|
||||||
|
:fab="buttonStyle"
|
||||||
|
class="ml-1"
|
||||||
|
v-bind="attrs"
|
||||||
|
v-on="on"
|
||||||
|
@click.prevent="toggleTimeline"
|
||||||
|
>
|
||||||
|
<v-icon :small="!buttonStyle" :color="buttonStyle ? 'white' : 'secondary'">
|
||||||
|
{{ $globals.icons.timelineText }}
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<RecipeDialogTimeline v-model="showTimeline" :slug="slug" :recipe-name="recipeName" />
|
||||||
|
</template>
|
||||||
|
<span>{{ $t('recipe.open-timeline') }}</span>
|
||||||
|
</v-tooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
||||||
|
import RecipeDialogTimeline from "./RecipeDialogTimeline.vue";
|
||||||
|
export default defineComponent({
|
||||||
|
components: { RecipeDialogTimeline },
|
||||||
|
|
||||||
|
props: {
|
||||||
|
buttonStyle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
recipeName: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
const showTimeline = ref(false);
|
||||||
|
function toggleTimeline() {
|
||||||
|
showTimeline.value = !showTimeline.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { showTimeline, toggleTimeline };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
206
frontend/components/Domain/Recipe/RecipeTimelineContextMenu.vue
Normal file
206
frontend/components/Domain/Recipe/RecipeTimelineContextMenu.vue
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
<template>
|
||||||
|
<div class="text-center">
|
||||||
|
<BaseDialog
|
||||||
|
v-model="recipeEventEditDialog"
|
||||||
|
:title="$tc('recipe.edit-timeline-event')"
|
||||||
|
:icon="$globals.icons.edit"
|
||||||
|
:submit-text="$tc('general.save')"
|
||||||
|
@submit="$emit('update')"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
<v-form ref="domMadeThisForm">
|
||||||
|
<v-text-field
|
||||||
|
v-model="event.subject"
|
||||||
|
:label="$tc('general.subject')"
|
||||||
|
/>
|
||||||
|
<v-textarea
|
||||||
|
v-model="event.eventMessage"
|
||||||
|
:label="$tc('general.message')"
|
||||||
|
rows="4"
|
||||||
|
/>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
</BaseDialog>
|
||||||
|
<BaseDialog
|
||||||
|
v-model="recipeEventDeleteDialog"
|
||||||
|
:title="$tc('events.delete-event')"
|
||||||
|
color="error"
|
||||||
|
:icon="$globals.icons.alertCircle"
|
||||||
|
@confirm="$emit('delete')"
|
||||||
|
>
|
||||||
|
<v-card-text>
|
||||||
|
{{ $t("events.event-delete-confirmation") }}
|
||||||
|
</v-card-text>
|
||||||
|
</BaseDialog>
|
||||||
|
<v-menu
|
||||||
|
offset-y
|
||||||
|
left
|
||||||
|
:bottom="!menuTop"
|
||||||
|
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||||
|
:top="menuTop"
|
||||||
|
:nudge-top="menuTop ? '5' : '0'"
|
||||||
|
allow-overflow
|
||||||
|
close-delay="125"
|
||||||
|
open-on-hover
|
||||||
|
content-class="d-print-none"
|
||||||
|
>
|
||||||
|
<template #activator="{ on, attrs }">
|
||||||
|
<v-btn :fab="fab" :small="fab" :elevation="elevation" :color="color" :icon="!fab" v-bind="attrs" v-on="on" @click.prevent>
|
||||||
|
<v-icon>{{ icon }}</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list dense>
|
||||||
|
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
||||||
|
<v-list-item-icon>
|
||||||
|
<v-icon :color="item.color"> {{ item.icon }} </v-icon>
|
||||||
|
</v-list-item-icon>
|
||||||
|
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import { VForm } from "~/types/vuetify";
|
||||||
|
import { RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
|
export interface TimelineContextMenuIncludes {
|
||||||
|
edit: boolean;
|
||||||
|
delete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
color: string | undefined;
|
||||||
|
event: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
props: {
|
||||||
|
useItems: {
|
||||||
|
type: Object as () => TimelineContextMenuIncludes,
|
||||||
|
default: () => ({
|
||||||
|
edit: true,
|
||||||
|
delete: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Append items are added at the end of the useItems list
|
||||||
|
appendItems: {
|
||||||
|
type: Array as () => ContextMenuItem[],
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
// Append items are added at the beginning of the useItems list
|
||||||
|
leadingItems: {
|
||||||
|
type: Array as () => ContextMenuItem[],
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
menuTop: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
fab: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
elevation: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: "primary",
|
||||||
|
},
|
||||||
|
slug: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
type: Object as () => RecipeTimelineEventOut,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
menuIcon: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, context) {
|
||||||
|
const domEditEventForm = ref<VForm>();
|
||||||
|
const state = reactive({
|
||||||
|
recipeEventEditDialog: false,
|
||||||
|
recipeEventDeleteDialog: false,
|
||||||
|
loading: false,
|
||||||
|
menuItems: [] as ContextMenuItem[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { i18n, $globals } = useContext();
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Context Menu Setup
|
||||||
|
|
||||||
|
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||||
|
edit: {
|
||||||
|
title: i18n.tc("general.edit"),
|
||||||
|
icon: $globals.icons.edit,
|
||||||
|
color: undefined,
|
||||||
|
event: "edit",
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
title: i18n.tc("general.delete"),
|
||||||
|
icon: $globals.icons.delete,
|
||||||
|
color: "error",
|
||||||
|
event: "delete",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get Default Menu Items Specified in Props
|
||||||
|
for (const [key, value] of Object.entries(props.useItems)) {
|
||||||
|
if (value) {
|
||||||
|
const item = defaultItems[key];
|
||||||
|
if (item) {
|
||||||
|
state.menuItems.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Leading and Appending Items
|
||||||
|
state.menuItems = [...state.menuItems, ...props.leadingItems, ...props.appendItems];
|
||||||
|
|
||||||
|
const icon = props.menuIcon || $globals.icons.dotsVertical;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Context Menu Event Handler
|
||||||
|
|
||||||
|
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||||
|
edit: () => {
|
||||||
|
state.recipeEventEditDialog = true;
|
||||||
|
},
|
||||||
|
delete: () => {
|
||||||
|
state.recipeEventDeleteDialog = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function contextMenuEventHandler(eventKey: string) {
|
||||||
|
const handler = eventHandlers[eventKey];
|
||||||
|
|
||||||
|
if (handler && typeof handler === "function") {
|
||||||
|
handler();
|
||||||
|
state.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.emit(eventKey);
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...toRefs(state),
|
||||||
|
contextMenuEventHandler,
|
||||||
|
domEditEventForm,
|
||||||
|
icon,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
|
@ -52,6 +52,9 @@
|
||||||
"apprise-url": "Apprise URL",
|
"apprise-url": "Apprise URL",
|
||||||
"database": "Database",
|
"database": "Database",
|
||||||
"delete-event": "Delete Event",
|
"delete-event": "Delete Event",
|
||||||
|
"event-delete-confirmation": "Are you sure you want to delete this event?",
|
||||||
|
"event-deleted": "Event Deleted",
|
||||||
|
"event-updated": "Event Updated",
|
||||||
"new-notification-form-description": "Mealie uses the Apprise library to generate notifications. They offer many options for services to use for notifications. Refer to their wiki for a comprehensive guide on how to create the URL for your service. If available, selecting the type of your notification may include extra features.",
|
"new-notification-form-description": "Mealie uses the Apprise library to generate notifications. They offer many options for services to use for notifications. Refer to their wiki for a comprehensive guide on how to create the URL for your service. If available, selecting the type of your notification may include extra features.",
|
||||||
"new-version": "New version available!",
|
"new-version": "New version available!",
|
||||||
"notification": "Notification",
|
"notification": "Notification",
|
||||||
|
@ -97,9 +100,11 @@
|
||||||
"keyword": "Keyword",
|
"keyword": "Keyword",
|
||||||
"link-copied": "Link Copied",
|
"link-copied": "Link Copied",
|
||||||
"loading-recipes": "Loading Recipes",
|
"loading-recipes": "Loading Recipes",
|
||||||
|
"message": "Message",
|
||||||
"monday": "Monday",
|
"monday": "Monday",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"new": "New",
|
"new": "New",
|
||||||
|
"never": "Never",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
"no-recipe-found": "No Recipe Found",
|
"no-recipe-found": "No Recipe Found",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
|
@ -120,6 +125,7 @@
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
"sort-alphabetically": "Alphabetical",
|
"sort-alphabetically": "Alphabetical",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
|
"subject": "Subject",
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
"success-count": "Success: {count}",
|
"success-count": "Success: {count}",
|
||||||
"sunday": "Sunday",
|
"sunday": "Sunday",
|
||||||
|
@ -277,6 +283,7 @@
|
||||||
"carbohydrate-content": "Carbohydrate",
|
"carbohydrate-content": "Carbohydrate",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
"comment-action": "Comment",
|
"comment-action": "Comment",
|
||||||
|
"comment": "Comment",
|
||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
"delete-confirmation": "Are you sure you want to delete this recipe?",
|
"delete-confirmation": "Are you sure you want to delete this recipe?",
|
||||||
"delete-recipe": "Delete Recipe",
|
"delete-recipe": "Delete Recipe",
|
||||||
|
@ -360,7 +367,14 @@
|
||||||
"decrease-scale-label": "Decrease Scale by 1",
|
"decrease-scale-label": "Decrease Scale by 1",
|
||||||
"increase-scale-label": "Increase Scale by 1",
|
"increase-scale-label": "Increase Scale by 1",
|
||||||
"locked": "Locked",
|
"locked": "Locked",
|
||||||
"public-link": "Public Link"
|
"public-link": "Public Link",
|
||||||
|
"edit-timeline-event": "Edit Timeline Event",
|
||||||
|
"timeline": "Timeline",
|
||||||
|
"timeline-is-empty": "Nothing on the timeline yet. Try making this recipe!",
|
||||||
|
"open-timeline": "Open Timeline",
|
||||||
|
"made-this": "I Made This",
|
||||||
|
"how-did-it-turn-out": "How did it turn out?",
|
||||||
|
"user-made-this": "{user} made this"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"advanced-search": "Advanced Search",
|
"advanced-search": "Advanced Search",
|
||||||
|
|
|
@ -11,8 +11,10 @@ import {
|
||||||
UpdateImageResponse,
|
UpdateImageResponse,
|
||||||
RecipeZipTokenResponse,
|
RecipeZipTokenResponse,
|
||||||
RecipeTimelineEventIn,
|
RecipeTimelineEventIn,
|
||||||
|
RecipeTimelineEventOut,
|
||||||
|
RecipeTimelineEventUpdate,
|
||||||
} from "~/lib/api/types/recipe";
|
} from "~/lib/api/types/recipe";
|
||||||
import { ApiRequestInstance } from "~/lib/api/types/non-generated";
|
import { ApiRequestInstance, PaginationData } from "~/lib/api/types/non-generated";
|
||||||
|
|
||||||
export type Parser = "nlp" | "brute";
|
export type Parser = "nlp" | "brute";
|
||||||
|
|
||||||
|
@ -47,7 +49,7 @@ const routes = {
|
||||||
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
|
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
|
||||||
|
|
||||||
recipesSlugTimelineEvent: (slug: string) => `${prefix}/recipes/${slug}/timeline/events`,
|
recipesSlugTimelineEvent: (slug: string) => `${prefix}/recipes/${slug}/timeline/events`,
|
||||||
recipesSlugTimelineEventId: (slug: string, id: number) => `${prefix}/recipes/${slug}/timeline/events/${id}`,
|
recipesSlugTimelineEventId: (slug: string, id: string) => `${prefix}/recipes/${slug}/timeline/events/${id}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
|
export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
|
||||||
|
@ -132,6 +134,20 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTimelineEvent(recipeSlug: string, payload: RecipeTimelineEventIn) {
|
async createTimelineEvent(recipeSlug: string, payload: RecipeTimelineEventIn) {
|
||||||
return await this.requests.post(routes.recipesSlugTimelineEvent(recipeSlug), payload);
|
return await this.requests.post<RecipeTimelineEventOut>(routes.recipesSlugTimelineEvent(recipeSlug), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTimelineEvent(recipeSlug: string, eventId: string, payload: RecipeTimelineEventUpdate) {
|
||||||
|
return await this.requests.put<RecipeTimelineEventOut, RecipeTimelineEventUpdate>(routes.recipesSlugTimelineEventId(recipeSlug, eventId), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTimelineEvent(recipeSlug: string, eventId: string) {
|
||||||
|
return await this.requests.delete<RecipeTimelineEventOut>(routes.recipesSlugTimelineEventId(recipeSlug, eventId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllTimelineEvents(recipeSlug: string, page = 1, perPage = -1, params = {} as any) {
|
||||||
|
return await this.requests.get<PaginationData<RecipeTimelineEventOut>>(routes.recipesSlugTimelineEvent(recipeSlug), {
|
||||||
|
params: { page, perPage, ...params },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,6 +37,7 @@ import {
|
||||||
mdiHeartOutline,
|
mdiHeartOutline,
|
||||||
mdiDotsHorizontal,
|
mdiDotsHorizontal,
|
||||||
mdiCheckboxBlankOutline,
|
mdiCheckboxBlankOutline,
|
||||||
|
mdiCommentTextMultiple,
|
||||||
mdiCommentTextMultipleOutline,
|
mdiCommentTextMultipleOutline,
|
||||||
mdiDownload,
|
mdiDownload,
|
||||||
mdiFile,
|
mdiFile,
|
||||||
|
@ -60,6 +61,7 @@ import {
|
||||||
mdiAlert,
|
mdiAlert,
|
||||||
mdiCheckboxMarkedCircle,
|
mdiCheckboxMarkedCircle,
|
||||||
mdiInformation,
|
mdiInformation,
|
||||||
|
mdiInformationVariant,
|
||||||
mdiBellAlert,
|
mdiBellAlert,
|
||||||
mdiRefreshCircle,
|
mdiRefreshCircle,
|
||||||
mdiMenu,
|
mdiMenu,
|
||||||
|
@ -122,6 +124,8 @@ import {
|
||||||
mdiCursorMove,
|
mdiCursorMove,
|
||||||
mdiText,
|
mdiText,
|
||||||
mdiTextBoxOutline,
|
mdiTextBoxOutline,
|
||||||
|
mdiTimelineText,
|
||||||
|
mdiMessageText,
|
||||||
mdiChefHat,
|
mdiChefHat,
|
||||||
mdiContentDuplicate,
|
mdiContentDuplicate,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
|
@ -165,6 +169,7 @@ export const icons = {
|
||||||
codeBraces: mdiCodeJson,
|
codeBraces: mdiCodeJson,
|
||||||
codeJson: mdiCodeJson,
|
codeJson: mdiCodeJson,
|
||||||
cog: mdiCog,
|
cog: mdiCog,
|
||||||
|
commentTextMultiple: mdiCommentTextMultiple,
|
||||||
commentTextMultipleOutline: mdiCommentTextMultipleOutline,
|
commentTextMultipleOutline: mdiCommentTextMultipleOutline,
|
||||||
contentCopy: mdiContentCopy,
|
contentCopy: mdiContentCopy,
|
||||||
database: mdiDatabase,
|
database: mdiDatabase,
|
||||||
|
@ -194,10 +199,12 @@ export const icons = {
|
||||||
home: mdiHome,
|
home: mdiHome,
|
||||||
import: mdiImport,
|
import: mdiImport,
|
||||||
information: mdiInformation,
|
information: mdiInformation,
|
||||||
|
informationVariant: mdiInformationVariant,
|
||||||
link: mdiLink,
|
link: mdiLink,
|
||||||
lock: mdiLock,
|
lock: mdiLock,
|
||||||
logout: mdiLogout,
|
logout: mdiLogout,
|
||||||
menu: mdiMenu,
|
menu: mdiMenu,
|
||||||
|
messageText: mdiMessageText,
|
||||||
newBox: mdiNewBox,
|
newBox: mdiNewBox,
|
||||||
notificationClearAll: mdiNotificationClearAll,
|
notificationClearAll: mdiNotificationClearAll,
|
||||||
openInNew: mdiOpenInNew,
|
openInNew: mdiOpenInNew,
|
||||||
|
@ -220,6 +227,7 @@ export const icons = {
|
||||||
sortClockDescending: mdiSortClockDescending,
|
sortClockDescending: mdiSortClockDescending,
|
||||||
star: mdiStar,
|
star: mdiStar,
|
||||||
testTube: mdiTestTube,
|
testTube: mdiTestTube,
|
||||||
|
timelineText: mdiTimelineText,
|
||||||
tools: mdiTools,
|
tools: mdiTools,
|
||||||
potSteam: mdiPotSteam,
|
potSteam: mdiPotSteam,
|
||||||
translate: mdiTranslate,
|
translate: mdiTranslate,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue