mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-05 13:35:23 +02:00
Feature/recipe instructions improvements (#785)
* feat(frontend): ✨ split paragraph by 1. 1) or 1: regex matches * feat(frontend): ✨ Update frontend to support ingredient To step refs * feat(backend): ✨ Update backend to support ingredient to step refs * fix title editor * move about info to site-settings * update change-log Co-authored-by: Hayden K <hay-kot@pm.me>
This commit is contained in:
parent
9f8c61a75a
commit
5cb4a1ade0
22 changed files with 621 additions and 210 deletions
|
@ -6,7 +6,6 @@
|
|||
color="rgb(255, 0, 0, 0.0)"
|
||||
flat
|
||||
style="z-index: 2; position: sticky"
|
||||
:class="{ 'fixed-bar-mobile': $vuetify.breakpoint.xs }"
|
||||
>
|
||||
<BaseDialog
|
||||
ref="deleteRecipieConfirm"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-dialog v-model="dialog" width="600">
|
||||
<v-dialog v-model="dialog" width="800">
|
||||
<template #activator="{ on, attrs }">
|
||||
<BaseButton v-bind="attrs" v-on="on" @click="inputText = ''">
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
|
@ -20,7 +20,7 @@
|
|||
<v-textarea
|
||||
v-model="inputText"
|
||||
outlined
|
||||
rows="10"
|
||||
rows="12"
|
||||
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
|
||||
>
|
||||
</v-textarea>
|
||||
|
@ -41,6 +41,14 @@
|
|||
</template>
|
||||
<span> Trim first character from each line </span>
|
||||
</v-tooltip>
|
||||
<v-tooltip top>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn class="ml-1" outlined color="info" small v-bind="attrs" @click="splitByNumberedLine" v-on="on">
|
||||
Split By Numbered Line
|
||||
</v-btn>
|
||||
</template>
|
||||
<span> Attempts to split a paragraph by matching 1) or 1. patterns </span>
|
||||
</v-tooltip>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
@ -74,6 +82,18 @@ export default defineComponent({
|
|||
.join("\n");
|
||||
}
|
||||
|
||||
const numberedLineRegex = /\d[.):] /gm;
|
||||
|
||||
function splitByNumberedLine() {
|
||||
// Split inputText by numberedLineRegex
|
||||
const matches = state.inputText.match(numberedLineRegex);
|
||||
|
||||
matches?.forEach((match, idx) => {
|
||||
const replaceText = idx === 0 ? "" : "\n";
|
||||
state.inputText = state.inputText.replace(match, replaceText);
|
||||
});
|
||||
}
|
||||
|
||||
function trimAllLines() {
|
||||
const splitLines = splitText();
|
||||
|
||||
|
@ -93,6 +113,7 @@ export default defineComponent({
|
|||
splitText,
|
||||
trimAllLines,
|
||||
removeFirstCharacter,
|
||||
splitByNumberedLine,
|
||||
save,
|
||||
...toRefs(state),
|
||||
};
|
||||
|
|
|
@ -1,6 +1,54 @@
|
|||
<template>
|
||||
<section>
|
||||
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
|
||||
<section @keyup.ctrl.90="undoMerge">
|
||||
<!-- Ingredient Link Editor -->
|
||||
<v-dialog v-model="dialog" width="600">
|
||||
<v-card>
|
||||
<v-app-bar dark color="primary" class="mt-n1 mb-3">
|
||||
<v-icon large left>
|
||||
{{ $globals.icons.link }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline"> Ingredient Linker </v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
</v-app-bar>
|
||||
|
||||
<v-card-text class="pt-4">
|
||||
<p>
|
||||
{{ activeText }}
|
||||
</p>
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
<v-checkbox
|
||||
v-for="ing in ingredients"
|
||||
:key="ing.referenceId"
|
||||
v-model="activeRefs"
|
||||
:label="ing.note"
|
||||
:value="ing.referenceId"
|
||||
class="mb-n2 mt-n2"
|
||||
></v-checkbox>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions>
|
||||
<BaseButton cancel @click="dialog = false"> </BaseButton>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton color="info" @click="autoSetReferences">
|
||||
<template #icon> {{ $globals.icons.robot }}</template>
|
||||
Auto
|
||||
</BaseButton>
|
||||
<BaseButton save @click="setIngredientIds"> </BaseButton>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<div class="d-flex justify-space-between justify-start">
|
||||
<h2 class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
|
||||
<BaseButton minor :to="$router.currentRoute.path + '/cook'" cancel color="primary">
|
||||
<template #icon>
|
||||
{{ $globals.icons.primary }}
|
||||
</template>
|
||||
Cook
|
||||
</BaseButton>
|
||||
</div>
|
||||
<draggable
|
||||
:disabled="!edit"
|
||||
:value="value"
|
||||
|
@ -35,23 +83,24 @@
|
|||
@click="toggleDisabled(index)"
|
||||
>
|
||||
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
||||
<v-btn
|
||||
v-if="edit"
|
||||
fab
|
||||
x-small
|
||||
color="white"
|
||||
class="mr-2"
|
||||
elevation="0"
|
||||
@click="removeByIndex(value, index)"
|
||||
>
|
||||
<v-btn v-if="edit" fab x-small color="white" class="mr-2" elevation="0" @click="value.splice(index, 1)">
|
||||
<v-icon size="24" color="error">{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
|
||||
{{ $t("recipe.step-index", { step: index + 1 }) }}
|
||||
|
||||
<v-btn v-if="edit" text color="primary" class="ml-auto" @click="toggleShowTitle(index)">
|
||||
{{ !showTitleEditor[index] ? $t("recipe.insert-section") : $t("recipe.remove-section") }}
|
||||
</v-btn>
|
||||
<div class="ml-auto">
|
||||
<BaseOverflowButton
|
||||
v-if="edit"
|
||||
small
|
||||
mode="event"
|
||||
:items="actionEvents || []"
|
||||
@merge-above="mergeAbove(index - 1, index)"
|
||||
@toggle-section="toggleShowTitle(index)"
|
||||
@link-ingredients="openDialog(index, step.ingredientReferences, step.text)"
|
||||
>
|
||||
</BaseOverflowButton>
|
||||
</div>
|
||||
<v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||
<v-fade-transition>
|
||||
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
|
||||
|
@ -62,11 +111,17 @@
|
|||
<v-card-text v-if="edit">
|
||||
<v-textarea :key="'instructions' + index" v-model="value[index]['text']" auto-grow dense rows="4">
|
||||
</v-textarea>
|
||||
<div v-for="ing in step.ingredientReferences" :key="ing.referenceId">
|
||||
{{ getIngredientByRefId(ing.referenceId).note }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-expand-transition>
|
||||
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
|
||||
<v-card-text>
|
||||
<VueMarkdown :source="step.text"> </VueMarkdown>
|
||||
<div v-for="ing in step.ingredientReferences" :key="ing.referenceId">
|
||||
{{ getIngredientByRefId(ing.referenceId).note }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
|
@ -77,81 +132,242 @@
|
|||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import draggable from "vuedraggable";
|
||||
// @ts-ignore
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
export default {
|
||||
import { ref, toRefs, reactive, defineComponent, watch, onMounted } from "@nuxtjs/composition-api";
|
||||
import { RecipeStep, IngredientToStepRef, RecipeIngredient } from "~/types/api-types/recipe";
|
||||
|
||||
interface MergerHistory {
|
||||
target: number;
|
||||
source: number;
|
||||
targetText: string;
|
||||
sourceText: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
VueMarkdown,
|
||||
draggable,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
type: Array as () => RecipeStep[],
|
||||
required: true,
|
||||
},
|
||||
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
ingredients: {
|
||||
type: Array as () => RecipeIngredient[],
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
disabledSteps: [],
|
||||
showTitleEditor: [],
|
||||
};
|
||||
},
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
dialog: false,
|
||||
disabledSteps: [] as number[],
|
||||
});
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
handler() {
|
||||
this.disabledSteps = [];
|
||||
this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
|
||||
const showTitleEditor = ref<boolean[]>([]);
|
||||
|
||||
const actionEvents = [
|
||||
{
|
||||
text: "Toggle Section",
|
||||
event: "toggle-section",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Link Ingredients",
|
||||
event: "link-ingredients",
|
||||
},
|
||||
{
|
||||
text: "Merge Above",
|
||||
event: "merge-above",
|
||||
},
|
||||
];
|
||||
|
||||
mounted() {
|
||||
this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
|
||||
},
|
||||
|
||||
methods: {
|
||||
removeByIndex(list, index) {
|
||||
list.splice(index, 1);
|
||||
},
|
||||
validateTitle(title) {
|
||||
// ===============================================================
|
||||
// UI State Helpers
|
||||
function validateTitle(title: string | undefined) {
|
||||
return !(title === null || title === "");
|
||||
},
|
||||
toggleDisabled(stepIndex) {
|
||||
if (this.edit) return;
|
||||
if (this.disabledSteps.includes(stepIndex)) {
|
||||
const index = this.disabledSteps.indexOf(stepIndex);
|
||||
}
|
||||
|
||||
watch(props.value, (v) => {
|
||||
state.disabledSteps = [];
|
||||
showTitleEditor.value = v.map((x) => validateTitle(x.title));
|
||||
});
|
||||
|
||||
// Eliminate state with an eager call to watcher?
|
||||
onMounted(() => {
|
||||
showTitleEditor.value = props.value.map((x) => validateTitle(x.title));
|
||||
});
|
||||
|
||||
function toggleDisabled(stepIndex: number) {
|
||||
if (props.edit) {
|
||||
return;
|
||||
}
|
||||
if (state.disabledSteps.includes(stepIndex)) {
|
||||
const index = state.disabledSteps.indexOf(stepIndex);
|
||||
if (index !== -1) {
|
||||
this.disabledSteps.splice(index, 1);
|
||||
state.disabledSteps.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
this.disabledSteps.push(stepIndex);
|
||||
state.disabledSteps.push(stepIndex);
|
||||
}
|
||||
},
|
||||
isChecked(stepIndex) {
|
||||
if (this.disabledSteps.includes(stepIndex) && !this.edit) {
|
||||
}
|
||||
function isChecked(stepIndex: number) {
|
||||
if (state.disabledSteps.includes(stepIndex) && !props.edit) {
|
||||
return "disabled-card";
|
||||
}
|
||||
},
|
||||
toggleShowTitle(index) {
|
||||
const newVal = !this.showTitleEditor[index];
|
||||
}
|
||||
function toggleShowTitle(index: number) {
|
||||
const newVal = !showTitleEditor.value[index];
|
||||
if (!newVal) {
|
||||
this.value[index].title = "";
|
||||
props.value[index].title = "";
|
||||
}
|
||||
this.$set(this.showTitleEditor, index, newVal);
|
||||
},
|
||||
updateIndex(data) {
|
||||
this.$emit("input", data);
|
||||
},
|
||||
|
||||
// Must create a new temporary list due to vue-composition-api backport limitations (I think...)
|
||||
const tempList = [...showTitleEditor.value];
|
||||
tempList[index] = newVal;
|
||||
showTitleEditor.value = tempList;
|
||||
}
|
||||
function updateIndex(data: RecipeStep) {
|
||||
context.emit("input", data);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// Ingredient Linker
|
||||
const activeRefs = ref<String[]>([]);
|
||||
const activeIndex = ref(0);
|
||||
const activeText = ref("");
|
||||
|
||||
function openDialog(idx: number, refs: IngredientToStepRef[], text: string) {
|
||||
activeText.value = text;
|
||||
activeIndex.value = idx;
|
||||
state.dialog = true;
|
||||
activeRefs.value = refs.map((ref) => ref.referenceId);
|
||||
}
|
||||
|
||||
function setIngredientIds() {
|
||||
const instruction = props.value[activeIndex.value];
|
||||
instruction.ingredientReferences = activeRefs.value.map((ref) => {
|
||||
return {
|
||||
referenceId: ref as string,
|
||||
};
|
||||
});
|
||||
state.dialog = false;
|
||||
}
|
||||
|
||||
function autoSetReferences() {
|
||||
// Ingore matching blacklisted words when auto-linking - This is kind of a cludgey implementation. We're blacklisting common words but
|
||||
// other common phrases trigger false positives and I'm not sure how else to approach this. In the future I maybe look at looking directly
|
||||
// at the food variable and seeing if the food is in the instructions, but I still need to support those who don't want to provide the value
|
||||
// and only use the "notes" feature.
|
||||
const blackListedText = [
|
||||
"and",
|
||||
"or",
|
||||
"the",
|
||||
"a",
|
||||
"an",
|
||||
"of",
|
||||
"in",
|
||||
"on",
|
||||
"to",
|
||||
"for",
|
||||
"by",
|
||||
"with",
|
||||
"without",
|
||||
"",
|
||||
" ",
|
||||
];
|
||||
const blackListedRegexMatch = /\d/gm; // Match Any Number
|
||||
|
||||
// Check if any of the words in the active text match the ingredient text
|
||||
const instructionsByWord = activeText.value.toLowerCase().split(" ");
|
||||
|
||||
instructionsByWord.forEach((word) => {
|
||||
if (blackListedText.includes(word) || word.match(blackListedRegexMatch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.ingredients.forEach((ingredient) => {
|
||||
if (
|
||||
ingredient.note.toLowerCase().includes(" " + word) &&
|
||||
!activeRefs.value.includes(ingredient.referenceId)
|
||||
) {
|
||||
console.info("Word Matched", `'${word}'`, ingredient.note);
|
||||
activeRefs.value.push(ingredient.referenceId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getIngredientByRefId(refId: String) {
|
||||
return props.ingredients.find((ing) => ing.referenceId === refId) || "";
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
// Instruction Merger
|
||||
const mergeHistory = ref<MergerHistory[]>([]);
|
||||
|
||||
function mergeAbove(target: number, source: number) {
|
||||
if (target < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
mergeHistory.value.push({
|
||||
target,
|
||||
source,
|
||||
targetText: props.value[target].text,
|
||||
sourceText: props.value[source].text,
|
||||
});
|
||||
|
||||
props.value[target].text += " " + props.value[source].text;
|
||||
props.value.splice(source, 1);
|
||||
}
|
||||
|
||||
function undoMerge(event: KeyboardEvent) {
|
||||
if (event.ctrlKey && event.code === "KeyZ") {
|
||||
if (!(mergeHistory.value?.length > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastMerge = mergeHistory.value.pop();
|
||||
if (!lastMerge) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.value[lastMerge.target].text = lastMerge.targetText;
|
||||
props.value.splice(lastMerge.source, 0, {
|
||||
title: "",
|
||||
text: lastMerge.sourceText,
|
||||
ingredientReferences: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
actionEvents,
|
||||
activeRefs,
|
||||
activeText,
|
||||
getIngredientByRefId,
|
||||
showTitleEditor,
|
||||
mergeAbove,
|
||||
openDialog,
|
||||
setIngredientIds,
|
||||
undoMerge,
|
||||
toggleDisabled,
|
||||
isChecked,
|
||||
toggleShowTitle,
|
||||
updateIndex,
|
||||
autoSetReferences,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
v-on="$listeners"
|
||||
@click="download ? downloadFile() : undefined"
|
||||
>
|
||||
<v-icon left>
|
||||
<v-icon v-if="!iconRight" left>
|
||||
<slot name="icon">
|
||||
{{ btnAttrs.icon }}
|
||||
</slot>
|
||||
|
@ -20,6 +20,11 @@
|
|||
<slot name="default">
|
||||
{{ btnAttrs.text }}
|
||||
</slot>
|
||||
<v-icon v-if="iconRight" right>
|
||||
<slot name="icon">
|
||||
{{ btnAttrs.icon }}
|
||||
</slot>
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
|
@ -96,6 +101,10 @@ export default {
|
|||
type: String,
|
||||
default: null,
|
||||
},
|
||||
iconRight: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const api = useApiSingleton();
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn color="primary" v-bind="attrs" :class="btnClass" :disabled="disabled" v-on="on">
|
||||
<v-btn color="primary" v-bind="{ ...attrs, ...$attrs }" :class="btnClass" :disabled="disabled" v-on="on">
|
||||
<v-icon v-if="activeObj.icon" left>
|
||||
{{ activeObj.icon }}
|
||||
</v-icon>
|
||||
|
|
|
@ -133,11 +133,6 @@ export default defineComponent({
|
|||
title: i18n.t("about.support"),
|
||||
href: "https://github.com/sponsors/hay-kot",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.information,
|
||||
title: i18n.t("about.about"),
|
||||
to: "/admin/about",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,111 +0,0 @@
|
|||
<template>
|
||||
<v-container fluid>
|
||||
<v-card class="mt-3">
|
||||
<v-card-title class="headline">
|
||||
{{ $t("about.about-mealie") }}
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text>
|
||||
<v-list-item-group color="primary">
|
||||
<v-list-item v-for="property in appInfo" :key="property.name">
|
||||
<v-list-item-icon>
|
||||
<v-icon> {{ property.icon || $globals.icons.user }} </v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title class="pl-4 flex row justify-space-between">
|
||||
<div>{{ property.name }}</div>
|
||||
<div>{{ property.value }}</div>
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list-item-group>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useAsync, useContext } from "@nuxtjs/composition-api";
|
||||
import { useAdminApi } from "~/composables/use-api";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
|
||||
export default defineComponent({
|
||||
layout: "admin",
|
||||
setup() {
|
||||
const adminApi = useAdminApi();
|
||||
// @ts-ignore
|
||||
const { $globals, i18n } = useContext();
|
||||
|
||||
function getAppInfo() {
|
||||
const statistics = useAsync(async () => {
|
||||
const { data } = await adminApi.about.about();
|
||||
|
||||
if (data) {
|
||||
const prettyInfo = [
|
||||
{
|
||||
name: i18n.t("about.version"),
|
||||
icon: $globals.icons.information,
|
||||
value: data.version,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.application-mode"),
|
||||
icon: $globals.icons.devTo,
|
||||
value: data.production ? i18n.t("about.production") : i18n.t("about.development"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.demo-status"),
|
||||
icon: $globals.icons.testTube,
|
||||
value: data.demoStatus ? i18n.t("about.demo") : i18n.t("about.not-demo"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.api-port"),
|
||||
icon: $globals.icons.api,
|
||||
value: data.apiPort,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.api-docs"),
|
||||
icon: $globals.icons.file,
|
||||
value: data.apiDocs ? i18n.t("general.enabled") : i18n.t("general.disabled"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.database-type"),
|
||||
icon: $globals.icons.database,
|
||||
value: data.dbType,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.database-url"),
|
||||
icon: $globals.icons.database,
|
||||
value: data.dbUrl,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.default-group"),
|
||||
icon: $globals.icons.group,
|
||||
value: data.defaultGroup,
|
||||
},
|
||||
];
|
||||
|
||||
return prettyInfo;
|
||||
}
|
||||
|
||||
return data;
|
||||
}, useAsyncKey());
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
const appInfo = getAppInfo();
|
||||
|
||||
return {
|
||||
appInfo,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return {
|
||||
title: this.$t("about.about") as string,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
|
@ -69,14 +69,42 @@
|
|||
</template>
|
||||
</v-card>
|
||||
</section>
|
||||
<section class="mt-4">
|
||||
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="General About"> </BaseCardSectionTitle>
|
||||
<v-card class="mb-4">
|
||||
<v-list-item v-for="property in appInfo" :key="property.name">
|
||||
<v-list-item-icon>
|
||||
<v-icon> {{ property.icon || $globals.icons.user }} </v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
<div>{{ property.name }}</div>
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-end">
|
||||
{{ property.value }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-card>
|
||||
</section>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, reactive, toRefs, ref } from "@nuxtjs/composition-api";
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
reactive,
|
||||
toRefs,
|
||||
ref,
|
||||
defineComponent,
|
||||
useAsync,
|
||||
useContext,
|
||||
} from "@nuxtjs/composition-api";
|
||||
import { CheckAppConfig } from "~/api/admin/admin-about";
|
||||
import { useAdminApi, useApiSingleton } from "~/composables/use-api";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
|
||||
interface SimpleCheck {
|
||||
status: boolean;
|
||||
|
@ -104,9 +132,9 @@ export default defineComponent({
|
|||
|
||||
const api = useApiSingleton();
|
||||
|
||||
const adminAPI = useAdminApi();
|
||||
const adminApi = useAdminApi();
|
||||
onMounted(async () => {
|
||||
const { data } = await adminAPI.about.checkApp();
|
||||
const { data } = await adminApi.about.checkApp();
|
||||
|
||||
if (data) {
|
||||
appConfig.value = data;
|
||||
|
@ -173,6 +201,70 @@ export default defineComponent({
|
|||
return booly ? "success" : "error";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// General About Info
|
||||
// @ts-ignore
|
||||
const { $globals, i18n } = useContext();
|
||||
|
||||
function getAppInfo() {
|
||||
const statistics = useAsync(async () => {
|
||||
const { data } = await adminApi.about.about();
|
||||
|
||||
if (data) {
|
||||
const prettyInfo = [
|
||||
{
|
||||
name: i18n.t("about.version"),
|
||||
icon: $globals.icons.information,
|
||||
value: data.version,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.application-mode"),
|
||||
icon: $globals.icons.devTo,
|
||||
value: data.production ? i18n.t("about.production") : i18n.t("about.development"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.demo-status"),
|
||||
icon: $globals.icons.testTube,
|
||||
value: data.demoStatus ? i18n.t("about.demo") : i18n.t("about.not-demo"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.api-port"),
|
||||
icon: $globals.icons.api,
|
||||
value: data.apiPort,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.api-docs"),
|
||||
icon: $globals.icons.file,
|
||||
value: data.apiDocs ? i18n.t("general.enabled") : i18n.t("general.disabled"),
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.database-type"),
|
||||
icon: $globals.icons.database,
|
||||
value: data.dbType,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.database-url"),
|
||||
icon: $globals.icons.database,
|
||||
value: data.dbUrl,
|
||||
},
|
||||
{
|
||||
name: i18n.t("about.default-group"),
|
||||
icon: $globals.icons.group,
|
||||
value: data.defaultGroup,
|
||||
},
|
||||
];
|
||||
|
||||
return prettyInfo;
|
||||
}
|
||||
|
||||
return data;
|
||||
}, useAsyncKey());
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
const appInfo = getAppInfo();
|
||||
|
||||
return {
|
||||
simpleChecks,
|
||||
getColor,
|
||||
|
@ -182,6 +274,7 @@ export default defineComponent({
|
|||
validators,
|
||||
...toRefs(state),
|
||||
testEmail,
|
||||
appInfo,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
|
|
85
frontend/pages/recipe/_slug/cook.vue
Normal file
85
frontend/pages/recipe/_slug/cook.vue
Normal file
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<v-container
|
||||
v-if="recipe"
|
||||
:class="{
|
||||
'pa-0': $vuetify.breakpoint.smAndDown,
|
||||
}"
|
||||
>
|
||||
<v-card-title>
|
||||
<h1 class="headline">{{ recipe.name }}</h1>
|
||||
</v-card-title>
|
||||
<v-stepper v-model="activeStep" flat>
|
||||
<v-toolbar class="ma-1 elevation-2 rounded">
|
||||
<v-toolbar-title class="headline">
|
||||
Step {{ activeStep }} of {{ recipe.recipeInstructions.length }}</v-toolbar-title
|
||||
>
|
||||
</v-toolbar>
|
||||
<v-stepper-items>
|
||||
<template v-for="(step, index) in recipe.recipeInstructions">
|
||||
<v-stepper-content :key="index + 1 + '-content'" :step="index + 1" class="pa-0 mt-2 elevation-0">
|
||||
<v-card class="ma-2">
|
||||
<v-card-text>
|
||||
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
|
||||
<VueMarkdown :source="step.text"> </VueMarkdown>
|
||||
<v-divider></v-divider>
|
||||
<h2 class="mb-4 mt-4">{{ $t("recipe.ingredients") }}</h2>
|
||||
<div v-for="ing in step.ingredientReferences" :key="ing.referenceId">
|
||||
{{ getIngredientByRefId(ing.referenceId).note }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-card-actions class="justify-center">
|
||||
<BaseButton color="primary" :disabled="index == 0" @click="activeStep = activeStep - 1">
|
||||
<template #icon> {{ $globals.icons.arrowLeftBold }}</template>
|
||||
Back
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
icon-right
|
||||
:disabled="index + 1 == recipe.recipeInstructions.length"
|
||||
color="primary"
|
||||
@click="activeStep = activeStep + 1"
|
||||
>
|
||||
<template #icon> {{ $globals.icons.arrowRightBold }}</template>
|
||||
Next
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
</v-stepper-content>
|
||||
</template>
|
||||
</v-stepper-items>
|
||||
</v-stepper>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useRoute, ref } from "@nuxtjs/composition-api";
|
||||
// @ts-ignore
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
import { useRecipeContext } from "~/composables/use-recipe-context";
|
||||
export default defineComponent({
|
||||
components: { VueMarkdown },
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const slug = route.value.params.slug;
|
||||
const activeStep = ref(1);
|
||||
|
||||
const { getBySlug } = useRecipeContext();
|
||||
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
|
||||
const recipe = getBySlug(slug);
|
||||
|
||||
function getIngredientByRefId(refId: String) {
|
||||
return recipe.value?.recipeIngredient.find((ing) => ing.referenceId === refId) || "";
|
||||
}
|
||||
|
||||
return {
|
||||
getIngredientByRefId,
|
||||
activeStep,
|
||||
slug,
|
||||
recipe,
|
||||
recipeImage,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -112,7 +112,7 @@
|
|||
<draggable v-model="recipe.recipeIngredient" handle=".handle">
|
||||
<RecipeIngredientEditor
|
||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||
:key="ingredient.ref"
|
||||
:key="ingredient.referenceId"
|
||||
v-model="recipe.recipeIngredient[index]"
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
@delete="recipe.recipeIngredient.splice(index, 1)"
|
||||
|
@ -229,7 +229,11 @@
|
|||
<v-divider v-if="$vuetify.breakpoint.mdAndUp" class="my-divider" :vertical="true"></v-divider>
|
||||
|
||||
<v-col cols="12" sm="12" md="8" lg="8">
|
||||
<RecipeInstructions v-model="recipe.recipeInstructions" :edit="form" />
|
||||
<RecipeInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
:ingredients="recipe.recipeIngredient"
|
||||
:edit="form"
|
||||
/>
|
||||
<div v-if="form" class="d-flex">
|
||||
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
||||
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
|
||||
|
@ -431,12 +435,12 @@ export default defineComponent({
|
|||
|
||||
if (steps) {
|
||||
const cleanedSteps = steps.map((step) => {
|
||||
return { text: step, title: "" };
|
||||
return { text: step, title: "", ingredientReferences: [] };
|
||||
});
|
||||
|
||||
recipe.value.recipeInstructions.push(...cleanedSteps);
|
||||
} else {
|
||||
recipe.value.recipeInstructions.push({ text: "", title: "" });
|
||||
recipe.value.recipeInstructions.push({ text: "", title: "", ingredientReferences: [] });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -444,7 +448,7 @@ export default defineComponent({
|
|||
if (ingredients?.length) {
|
||||
const newIngredients = ingredients.map((x) => {
|
||||
return {
|
||||
ref: uuid4(),
|
||||
referenceId: uuid4(),
|
||||
title: "",
|
||||
note: x,
|
||||
unit: null,
|
||||
|
@ -459,7 +463,7 @@ export default defineComponent({
|
|||
}
|
||||
} else {
|
||||
recipe?.value?.recipeIngredient?.push({
|
||||
ref: uuid4(),
|
||||
referenceId: uuid4(),
|
||||
title: "",
|
||||
note: "",
|
||||
unit: null,
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
Select one of the various ways to create a recipe
|
||||
<template #content>
|
||||
<div class="ml-auto">
|
||||
<BaseOverflowButton v-model="tab" rounded outlined :items="tabs"> </BaseOverflowButton>
|
||||
<BaseOverflowButton v-model="tab" rounded :items="tabs"> </BaseOverflowButton>
|
||||
</div>
|
||||
</template>
|
||||
</BasePageTitle>
|
||||
|
|
|
@ -80,7 +80,7 @@ export interface Recipe {
|
|||
comments?: CommentOut[];
|
||||
}
|
||||
export interface RecipeIngredient {
|
||||
ref: string;
|
||||
referenceId: string;
|
||||
title: string;
|
||||
note: string;
|
||||
unit?: RecipeIngredientUnit | null;
|
||||
|
@ -96,9 +96,13 @@ export interface RecipeIngredientFood {
|
|||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
export interface IngredientToStepRef {
|
||||
referenceId: string;
|
||||
}
|
||||
export interface RecipeStep {
|
||||
title?: string;
|
||||
text: string;
|
||||
ingredientReferences: IngredientToStepRef[];
|
||||
}
|
||||
export interface RecipeSettings {
|
||||
public?: boolean;
|
||||
|
|
|
@ -100,6 +100,7 @@ import {
|
|||
mdiArrowRightBoldOutline,
|
||||
mdiTimerSand,
|
||||
mdiRefresh,
|
||||
mdiArrowRightBold,
|
||||
} from "@mdi/js";
|
||||
|
||||
export const icons = {
|
||||
|
@ -113,6 +114,7 @@ export const icons = {
|
|||
alertCircle: mdiAlertCircle,
|
||||
api: mdiApi,
|
||||
arrowLeftBold: mdiArrowLeftBold,
|
||||
arrowRightBold: mdiArrowRightBold,
|
||||
arrowUpDown: mdiDrag,
|
||||
backupRestore: mdiBackupRestore,
|
||||
bellAlert: mdiBellAlert,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue