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:
Michael Genson 2022-12-11 15:16:55 -06:00 committed by GitHub
parent f5d401a6a6
commit 4e8e2d7510
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 692 additions and 134 deletions

View file

@ -22,6 +22,7 @@
<v-spacer></v-spacer>
<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 />
<RecipeTimelineBadge button-style :slug="recipe.slug" :recipe-name="recipe.name" />
<v-tooltip v-if="!locked" bottom color="info">
<template #activator="{ on, attrs }">
<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"
:fab="$vuetify.breakpoint.xs"
:small="$vuetify.breakpoint.xs"
class="mx-1"
:color="btn.color"
@click="emitHandler(btn.event)"
>
@ -85,6 +85,7 @@
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
import { Recipe } from "~/lib/api/types/recipe";
const SAVE_EVENT = "save";
@ -94,7 +95,7 @@ const JSON_EVENT = "json";
const OCR_EVENT = "ocr";
export default defineComponent({
components: { RecipeContextMenu, RecipeFavoriteBadge },
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
props: {
recipe: {
required: true,

View 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>

View file

@ -1,143 +1,158 @@
<template>
<div>
<div>
<div>
<BaseDialog
v-model="madeThisDialog"
:icon="$globals.icons.chefHat"
title="I Made This"
:submit-text="$tc('general.save')"
@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%;"
<BaseDialog
v-model="madeThisDialog"
:icon="$globals.icons.chefHat"
:title="$tc('recipe.made-this')"
:submit-text="$tc('general.save')"
@submit="createTimelineEvent"
>
<v-icon left>
{{ $globals.icons.calendar }}
</v-icon>
Last Made {{ value ? new Date(value).toLocaleDateString($i18n.locale) : "Never" }}
</v-chip>
<BaseButton @click="madeThisDialog = true">
<v-card-text>
<v-form ref="domMadeThisForm">
<v-textarea
v-model="newTimelineEvent.eventMessage"
autofocus
: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>
I Made This
</BaseButton>
</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>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive, ref, toRefs, useContext, } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core";
import { VForm } from "~/types/vuetify";
import { useUserApi } from "~/composables/api";
import { RecipeTimelineEventIn } from "~/lib/api/types/recipe";
<script lang="ts">
import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
import { whenever } from "@vueuse/core";
import { VForm } from "~/types/vuetify";
import { useUserApi } from "~/composables/api";
import { RecipeTimelineEventIn } from "~/lib/api/types/recipe";
export default defineComponent({
props: {
value: {
type: String,
default: null,
},
recipeSlug: {
type: String,
required: true,
},
export default defineComponent({
props: {
value: {
type: String,
default: null,
},
setup(props, context) {
const madeThisDialog = ref(false);
const userApi = useUserApi();
const { $auth } = useContext();
const domMadeThisForm = ref<VForm>();
const newTimelineEvent = ref<RecipeTimelineEventIn>({
// @ts-expect-error - TS doesn't like the $auth global user attribute
// eslint-disable-next-line
subject: `${$auth.user.fullName} made this`,
eventType: "comment",
eventMessage: "",
timestamp: "",
});
recipeSlug: {
type: String,
required: true,
},
},
setup(props, context) {
const madeThisDialog = ref(false);
const userApi = useUserApi();
const { $auth, i18n } = useContext();
const domMadeThisForm = ref<VForm>();
const newTimelineEvent = ref<RecipeTimelineEventIn>({
// @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(
() => madeThisDialog.value,
() => {
// Set timestamp to now
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();
const state = reactive({datePickerMenu: false});
async function createTimelineEvent() {
if (!newTimelineEvent.value.timestamp) {
return;
}
return {
...toRefs(state),
domMadeThisForm,
madeThisDialog,
newTimelineEvent,
createTimelineEvent,
};
},
});
</script>
const actions: Promise<any>[] = [];
// the user only selects the date, so we set the time to end of day local time
// we choose the end of day so it always comes after "new recipe" events
newTimelineEvent.value.timestamp = new Date(newTimelineEvent.value.timestamp + "T23:59:59").toISOString();
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
// 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>

View 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>

View 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>

View file

@ -52,6 +52,9 @@
"apprise-url": "Apprise URL",
"database": "Database",
"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-version": "New version available!",
"notification": "Notification",
@ -97,9 +100,11 @@
"keyword": "Keyword",
"link-copied": "Link Copied",
"loading-recipes": "Loading Recipes",
"message": "Message",
"monday": "Monday",
"name": "Name",
"new": "New",
"never": "Never",
"no": "No",
"no-recipe-found": "No Recipe Found",
"ok": "OK",
@ -120,6 +125,7 @@
"sort": "Sort",
"sort-alphabetically": "Alphabetical",
"status": "Status",
"subject": "Subject",
"submit": "Submit",
"success-count": "Success: {count}",
"sunday": "Sunday",
@ -277,6 +283,7 @@
"carbohydrate-content": "Carbohydrate",
"categories": "Categories",
"comment-action": "Comment",
"comment": "Comment",
"comments": "Comments",
"delete-confirmation": "Are you sure you want to delete this recipe?",
"delete-recipe": "Delete Recipe",
@ -360,7 +367,14 @@
"decrease-scale-label": "Decrease Scale by 1",
"increase-scale-label": "Increase Scale by 1",
"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": {
"advanced-search": "Advanced Search",

View file

@ -11,8 +11,10 @@ import {
UpdateImageResponse,
RecipeZipTokenResponse,
RecipeTimelineEventIn,
RecipeTimelineEventOut,
RecipeTimelineEventUpdate,
} 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";
@ -47,7 +49,7 @@ const routes = {
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
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> {
@ -132,6 +134,20 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
}
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 },
});
}
}

View file

@ -37,6 +37,7 @@ import {
mdiHeartOutline,
mdiDotsHorizontal,
mdiCheckboxBlankOutline,
mdiCommentTextMultiple,
mdiCommentTextMultipleOutline,
mdiDownload,
mdiFile,
@ -60,6 +61,7 @@ import {
mdiAlert,
mdiCheckboxMarkedCircle,
mdiInformation,
mdiInformationVariant,
mdiBellAlert,
mdiRefreshCircle,
mdiMenu,
@ -122,6 +124,8 @@ import {
mdiCursorMove,
mdiText,
mdiTextBoxOutline,
mdiTimelineText,
mdiMessageText,
mdiChefHat,
mdiContentDuplicate,
} from "@mdi/js";
@ -165,6 +169,7 @@ export const icons = {
codeBraces: mdiCodeJson,
codeJson: mdiCodeJson,
cog: mdiCog,
commentTextMultiple: mdiCommentTextMultiple,
commentTextMultipleOutline: mdiCommentTextMultipleOutline,
contentCopy: mdiContentCopy,
database: mdiDatabase,
@ -194,10 +199,12 @@ export const icons = {
home: mdiHome,
import: mdiImport,
information: mdiInformation,
informationVariant: mdiInformationVariant,
link: mdiLink,
lock: mdiLock,
logout: mdiLogout,
menu: mdiMenu,
messageText: mdiMessageText,
newBox: mdiNewBox,
notificationClearAll: mdiNotificationClearAll,
openInNew: mdiOpenInNew,
@ -220,6 +227,7 @@ export const icons = {
sortClockDescending: mdiSortClockDescending,
star: mdiStar,
testTube: mdiTestTube,
timelineText: mdiTimelineText,
tools: mdiTools,
potSteam: mdiPotSteam,
translate: mdiTranslate,