Merge branch 'mealie-next' into fix-register-flow

This commit is contained in:
Kuchenpirat 2025-06-23 10:56:55 +02:00 committed by GitHub
commit 3062b7bad1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 392 additions and 503 deletions

View file

@ -356,8 +356,9 @@
</section> </section>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus"; import { VueDraggable } from "vue-draggable-plus";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue"; import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue";
import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe"; import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
import { parseIngredientText } from "~/composables/recipes"; import { parseIngredientText } from "~/composables/recipes";
@ -376,121 +377,92 @@ interface MergerHistory {
sourceText: string; sourceText: string;
} }
export default defineNuxtComponent({ const instructionList = defineModel<RecipeStep[]>("modelValue", { required: true, default: () => [] });
components: { const assets = defineModel<RecipeAsset[]>("assets", { required: true, default: () => [] });
VueDraggable,
RecipeIngredientHtml, const props = defineProps({
DropZone,
RecipeIngredients,
},
props: {
modelValue: {
type: Array as () => RecipeStep[],
required: false,
default: () => [],
},
recipe: { recipe: {
type: Object as () => NoUndefinedField<Recipe>, type: Object as () => NoUndefinedField<Recipe>,
required: true, required: true,
}, },
assets: {
type: Array as () => RecipeAsset[],
required: true,
},
scale: { scale: {
type: Number, type: Number,
default: 1, default: 1,
}, },
}, });
emits: ["update:modelValue", "click-instruction-field", "update:assets"],
setup(props, context) { const emit = defineEmits(["click-instruction-field", "update:assets"]);
const i18n = useI18n();
const BASE_URL = useRequestURL().origin;
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug); const BASE_URL = useRequestURL().origin;
const state = reactive({ const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
dialog: false,
disabledSteps: [] as number[],
unusedIngredients: [] as RecipeIngredient[],
usedIngredients: [] as RecipeIngredient[],
});
const showTitleEditor = ref<{ [key: string]: boolean }>({}); const dialog = ref(false);
const disabledSteps = ref<number[]>([]);
const unusedIngredients = ref<RecipeIngredient[]>([]);
const usedIngredients = ref<RecipeIngredient[]>([]);
const actionEvents = [ const showTitleEditor = ref<{ [key: string]: boolean }>({});
{
text: i18n.t("recipe.toggle-section") as string,
event: "toggle-section",
},
{
text: i18n.t("recipe.link-ingredients") as string,
event: "link-ingredients",
},
{
text: i18n.t("recipe.merge-above") as string,
event: "merge-above",
},
];
// =============================================================== // ===============================================================
// UI State Helpers // UI State Helpers
function hasSectionTitle(title: string | undefined) { function hasSectionTitle(title: string | undefined) {
return !(title === null || title === "" || title === undefined); return !(title === null || title === "" || title === undefined);
} }
watch(props.modelValue, (v) => { watch(instructionList, (v) => {
state.disabledSteps = []; disabledSteps.value = [];
v.forEach((element: RecipeStep) => { v.forEach((element: RecipeStep) => {
if (element.id !== undefined) { if (element.id !== undefined) {
showTitleEditor.value[element.id!] = hasSectionTitle(element.title!); showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
} }
}); });
}); }, { deep: true });
const showCookMode = ref(false); const showCookMode = ref(false);
// Eliminate state with an eager call to watcher? onMounted(() => {
onMounted(() => { instructionList.value.forEach((element: RecipeStep) => {
props.modelValue.forEach((element: RecipeStep) => {
if (element.id !== undefined) { if (element.id !== undefined) {
showTitleEditor.value[element.id!] = hasSectionTitle(element.title!); showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
} }
// showCookMode.value = false;
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) { if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
showCookMode.value = true; showCookMode.value = true;
} }
showTitleEditor.value = { ...showTitleEditor.value }; showTitleEditor.value = { ...showTitleEditor.value };
}); });
});
function toggleDisabled(stepIndex: number) { if (assets.value === undefined) {
emit("update:assets", []);
}
});
function toggleDisabled(stepIndex: number) {
if (isEditForm.value) { if (isEditForm.value) {
return; return;
} }
if (state.disabledSteps.includes(stepIndex)) { if (disabledSteps.value.includes(stepIndex)) {
const index = state.disabledSteps.indexOf(stepIndex); const index = disabledSteps.value.indexOf(stepIndex);
if (index !== -1) { if (index !== -1) {
state.disabledSteps.splice(index, 1); disabledSteps.value.splice(index, 1);
} }
} }
else { else {
state.disabledSteps.push(stepIndex); disabledSteps.value.push(stepIndex);
}
} }
}
function isChecked(stepIndex: number) { function isChecked(stepIndex: number) {
if (state.disabledSteps.includes(stepIndex) && !isEditForm.value) { if (disabledSteps.value.includes(stepIndex) && !isEditForm.value) {
return "disabled-card"; return "disabled-card";
} }
} }
function toggleShowTitle(id?: string) { function toggleShowTitle(id?: string) {
if (!id) { if (!id) {
return; return;
} }
@ -499,46 +471,35 @@ export default defineNuxtComponent({
const temp = { ...showTitleEditor.value }; const temp = { ...showTitleEditor.value };
showTitleEditor.value = temp; showTitleEditor.value = temp;
} }
const instructionList = ref<RecipeStep[]>([...props.modelValue]); function onDragEnd() {
watch(
() => props.modelValue,
(newVal) => {
instructionList.value = [...newVal];
},
{ deep: true },
);
function onDragEnd() {
context.emit("update:modelValue", [...instructionList.value]);
drag.value = false; drag.value = false;
} }
// =============================================================== // ===============================================================
// Ingredient Linker // Ingredient Linker
const activeRefs = ref<string[]>([]); const activeRefs = ref<string[]>([]);
const activeIndex = ref(0); const activeIndex = ref(0);
const activeText = ref(""); const activeText = ref("");
function openDialog(idx: number, text: string, refs?: IngredientReferences[]) { function openDialog(idx: number, text: string, refs?: IngredientReferences[]) {
if (!refs) { if (!refs) {
instructionList.value[idx].ingredientReferences = []; instructionList.value[idx].ingredientReferences = [];
refs = props.modelValue[idx].ingredientReferences as IngredientReferences[]; refs = instructionList.value[idx].ingredientReferences as IngredientReferences[];
} }
setUsedIngredients(); setUsedIngredients();
activeText.value = text; activeText.value = text;
activeIndex.value = idx; activeIndex.value = idx;
state.dialog = true; dialog.value = true;
activeRefs.value = refs.map(ref => ref.referenceId ?? ""); activeRefs.value = refs.map(ref => ref.referenceId ?? "");
} }
const availableNextStep = computed(() => activeIndex.value < props.modelValue.length - 1); const availableNextStep = computed(() => activeIndex.value < instructionList.value.length - 1);
function setIngredientIds() { function setIngredientIds() {
const instruction = props.modelValue[activeIndex.value]; const instruction = instructionList.value[activeIndex.value];
instruction.ingredientReferences = activeRefs.value.map((ref) => { instruction.ingredientReferences = activeRefs.value.map((ref) => {
return { return {
referenceId: ref, referenceId: ref,
@ -547,15 +508,15 @@ export default defineNuxtComponent({
// Update the visibility of the cook mode button // Update the visibility of the cook mode button
showCookMode.value = false; showCookMode.value = false;
props.modelValue.forEach((element) => { instructionList.value.forEach((element) => {
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) { if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
showCookMode.value = true; showCookMode.value = true;
} }
}); });
state.dialog = false; dialog.value = false;
} }
function saveAndOpenNextLinkIngredients() { function saveAndOpenNextLinkIngredients() {
const currentStepIndex = activeIndex.value; const currentStepIndex = activeIndex.value;
if (!availableNextStep.value) { if (!availableNextStep.value) {
@ -563,15 +524,15 @@ export default defineNuxtComponent({
} }
setIngredientIds(); setIngredientIds();
const nextStep = props.modelValue[currentStepIndex + 1]; const nextStep = instructionList.value[currentStepIndex + 1];
// close dialog before opening to reset the scroll position // close dialog before opening to reset the scroll position
nextTick(() => openDialog(currentStepIndex + 1, nextStep.text, nextStep.ingredientReferences)); nextTick(() => openDialog(currentStepIndex + 1, nextStep.text, nextStep.ingredientReferences));
} }
function setUsedIngredients() { function setUsedIngredients() {
const usedRefs: { [key: string]: boolean } = {}; const usedRefs: { [key: string]: boolean } = {};
props.modelValue.forEach((element) => { instructionList.value.forEach((element) => {
element.ingredientReferences?.forEach((ref) => { element.ingredientReferences?.forEach((ref) => {
if (ref.referenceId !== undefined) { if (ref.referenceId !== undefined) {
usedRefs[ref.referenceId!] = true; usedRefs[ref.referenceId!] = true;
@ -579,25 +540,25 @@ export default defineNuxtComponent({
}); });
}); });
state.usedIngredients = props.recipe.recipeIngredient.filter((ing) => { usedIngredients.value = props.recipe.recipeIngredient.filter((ing) => {
return ing.referenceId !== undefined && ing.referenceId in usedRefs; return ing.referenceId !== undefined && ing.referenceId in usedRefs;
}); });
state.unusedIngredients = props.recipe.recipeIngredient.filter((ing) => { unusedIngredients.value = props.recipe.recipeIngredient.filter((ing) => {
return !(ing.referenceId !== undefined && ing.referenceId in usedRefs); return !(ing.referenceId !== undefined && ing.referenceId in usedRefs);
}); });
} }
function autoSetReferences() { function autoSetReferences() {
useExtractIngredientReferences( useExtractIngredientReferences(
props.recipe.recipeIngredient, props.recipe.recipeIngredient,
activeRefs.value, activeRefs.value,
activeText.value, activeText.value,
props.recipe.settings.disableAmount, props.recipe.settings.disableAmount,
).forEach((ingredient: string) => activeRefs.value.push(ingredient)); ).forEach((ingredient: string) => activeRefs.value.push(ingredient));
} }
const ingredientLookup = computed(() => { const ingredientLookup = computed(() => {
const results: { [key: string]: RecipeIngredient } = {}; const results: { [key: string]: RecipeIngredient } = {};
return props.recipe.recipeIngredient.reduce((prev, ing) => { return props.recipe.recipeIngredient.reduce((prev, ing) => {
if (ing.referenceId === undefined) { if (ing.referenceId === undefined) {
@ -606,9 +567,9 @@ export default defineNuxtComponent({
prev[ing.referenceId] = ing; prev[ing.referenceId] = ing;
return prev; return prev;
}, results); }, results);
}); });
function getIngredientByRefId(refId: string | undefined) { function getIngredientByRefId(refId: string | undefined) {
if (refId === undefined) { if (refId === undefined) {
return ""; return "";
} }
@ -616,13 +577,13 @@ export default defineNuxtComponent({
const ing = ingredientLookup.value[refId]; const ing = ingredientLookup.value[refId];
if (!ing) return ""; if (!ing) return "";
return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale); return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale);
} }
// =============================================================== // ===============================================================
// Instruction Merger // Instruction Merger
const mergeHistory = ref<MergerHistory[]>([]); const mergeHistory = ref<MergerHistory[]>([]);
function mergeAbove(target: number, source: number) { function mergeAbove(target: number, source: number) {
if (target < 0) { if (target < 0) {
return; return;
} }
@ -630,15 +591,15 @@ export default defineNuxtComponent({
mergeHistory.value.push({ mergeHistory.value.push({
target, target,
source, source,
targetText: props.modelValue[target].text, targetText: instructionList.value[target].text,
sourceText: props.modelValue[source].text, sourceText: instructionList.value[source].text,
}); });
instructionList.value[target].text += " " + props.modelValue[source].text; instructionList.value[target].text += " " + instructionList.value[source].text;
instructionList.value.splice(source, 1); instructionList.value.splice(source, 1);
} }
function undoMerge(event: KeyboardEvent) { function undoMerge(event: KeyboardEvent) {
if (event.ctrlKey && event.code === "KeyZ") { if (event.ctrlKey && event.code === "KeyZ") {
if (!(mergeHistory.value?.length > 0)) { if (!(mergeHistory.value?.length > 0)) {
return; return;
@ -657,30 +618,30 @@ export default defineNuxtComponent({
ingredientReferences: [], ingredientReferences: [],
}); });
} }
} }
function moveTo(dest: string, source: number) { function moveTo(dest: string, source: number) {
if (dest === "top") { if (dest === "top") {
instructionList.value.unshift(instructionList.value.splice(source, 1)[0]); instructionList.value.unshift(instructionList.value.splice(source, 1)[0]);
} }
else { else {
instructionList.value.push(instructionList.value.splice(source, 1)[0]); instructionList.value.push(instructionList.value.splice(source, 1)[0]);
} }
} }
function insert(dest: number) { function insert(dest: number) {
instructionList.value.splice(dest, 0, { id: uuid4(), text: "", title: "", ingredientReferences: [] }); instructionList.value.splice(dest, 0, { id: uuid4(), text: "", title: "", ingredientReferences: [] });
} }
const previewStates = ref<boolean[]>([]); const previewStates = ref<boolean[]>([]);
function togglePreviewState(index: number) { function togglePreviewState(index: number) {
const temp = [...previewStates.value]; const temp = [...previewStates.value];
temp[index] = !temp[index]; temp[index] = !temp[index];
previewStates.value = temp; previewStates.value = temp;
} }
function toggleCollapseSection(index: number) { function toggleCollapseSection(index: number) {
const sectionSteps: number[] = []; const sectionSteps: number[] = [];
for (let i = index; i < instructionList.value.length; i++) { for (let i = index; i < instructionList.value.length; i++) {
@ -692,39 +653,26 @@ export default defineNuxtComponent({
} }
} }
const allCollapsed = sectionSteps.every(idx => state.disabledSteps.includes(idx)); const allCollapsed = sectionSteps.every(idx => disabledSteps.value.includes(idx));
if (allCollapsed) { if (allCollapsed) {
state.disabledSteps = state.disabledSteps.filter(idx => !sectionSteps.includes(idx)); disabledSteps.value = disabledSteps.value.filter(idx => !sectionSteps.includes(idx));
} }
else { else {
state.disabledSteps = [...state.disabledSteps, ...sectionSteps]; disabledSteps.value = [...disabledSteps.value, ...sectionSteps];
}
} }
}
const drag = ref(false); const drag = ref(false);
// =============================================================== // ===============================================================
// Image Uploader // Image Uploader
const api = useUserApi(); const api = useUserApi();
const { recipeAssetPath } = useStaticRoutes(); const { recipeAssetPath } = useStaticRoutes();
const imageUploadMode = ref(false); const loadingStates = ref<{ [key: number]: boolean }>({});
function toggleDragMode() { async function handleImageDrop(index: number, files: File[]) {
console.log("Toggling Drag Mode");
imageUploadMode.value = !imageUploadMode.value;
}
onMounted(() => {
if (props.assets === undefined) {
context.emit("update:assets", []);
}
});
const loadingStates = ref<{ [key: number]: boolean }>({});
async function handleImageDrop(index: number, files: File[]) {
if (!files) { if (!files) {
return; return;
} }
@ -750,13 +698,13 @@ export default defineNuxtComponent({
return; // TODO: Handle error return; // TODO: Handle error
} }
context.emit("update:assets", [...props.assets, data]); emit("update:assets", [...assets.value, data]);
const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string); const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string);
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`; const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
instructionList.value[index].text += text; instructionList.value[index].text += text;
} }
function openImageUpload(index: number) { function openImageUpload(index: number) {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = "image/*";
@ -767,52 +715,7 @@ export default defineNuxtComponent({
} }
}; };
input.click(); input.click();
} }
const breakpoint = useDisplay();
return {
// Image Uploader
toggleDragMode,
handleImageDrop,
imageUploadMode,
openImageUpload,
loadingStates,
// Rest
onDragEnd,
drag,
togglePreviewState,
toggleCollapseSection,
previewStates,
...toRefs(state),
actionEvents,
activeRefs,
activeText,
getIngredientByRefId,
showTitleEditor,
mergeAbove,
moveTo,
openDialog,
setIngredientIds,
availableNextStep,
saveAndOpenNextLinkIngredients,
undoMerge,
toggleDisabled,
isChecked,
toggleShowTitle,
instructionList,
autoSetReferences,
parseIngredientText,
toggleCookMode,
showCookMode,
isCookMode,
isEditForm,
insert,
breakpoint,
};
},
});
</script> </script>
<style lang="css" scoped> <style lang="css" scoped>

View file

@ -4,14 +4,14 @@
<v-col v-for="(day, index) in plan" :key="index" cols="12" sm="12" md="4" lg="4" xl="2" <v-col v-for="(day, index) in plan" :key="index" cols="12" sm="12" md="4" lg="4" xl="2"
class="col-borders my-1 d-flex flex-column"> class="col-borders my-1 d-flex flex-column">
<v-card class="mb-2 border-left-primary rounded-sm px-2"> <v-card class="mb-2 border-left-primary rounded-sm px-2">
<v-container class="px-0"> <v-container class="px-0 d-flex align-center" height="56px">
<v-row no-gutters style="width: 100%;"> <v-row no-gutters style="width: 100%;">
<v-col cols="10"> <v-col cols="10" class="d-flex align-center">
<p class="pl-2 my-1"> <p class="pl-2 my-1">
{{ $d(day.date, "short") }} {{ $d(day.date, "short") }}
</p> </p>
</v-col> </v-col>
<v-col class="d-flex justify-top" cols="2"> <v-col class="d-flex align-center" cols="2">
<GroupMealPlanDayContextMenu v-if="day.recipes.length" :recipes="day.recipes" /> <GroupMealPlanDayContextMenu v-if="day.recipes.length" :recipes="day.recipes" />
</v-col> </v-col>
</v-row> </v-row>
@ -38,39 +38,31 @@
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import type { MealsByDate } from "./types"; import type { MealsByDate } from "./types";
import type { ReadPlanEntry } from "~/lib/api/types/meal-plan"; import type { ReadPlanEntry } from "~/lib/api/types/meal-plan";
import GroupMealPlanDayContextMenu from "~/components/Domain/Household/GroupMealPlanDayContextMenu.vue"; import GroupMealPlanDayContextMenu from "~/components/Domain/Household/GroupMealPlanDayContextMenu.vue";
import RecipeCardMobile from "~/components/Domain/Recipe/RecipeCardMobile.vue"; import RecipeCardMobile from "~/components/Domain/Recipe/RecipeCardMobile.vue";
import type { RecipeSummary } from "~/lib/api/types/recipe"; import type { RecipeSummary } from "~/lib/api/types/recipe";
export default defineNuxtComponent({ const props = defineProps<{
components: { mealplans: MealsByDate[];
GroupMealPlanDayContextMenu, }>();
RecipeCardMobile,
}, type DaySection = {
props: {
mealplans: {
type: Array as () => MealsByDate[],
required: true,
},
},
setup(props) {
type DaySection = {
title: string; title: string;
meals: ReadPlanEntry[]; meals: ReadPlanEntry[];
}; };
type Days = { type Days = {
date: Date; date: Date;
sections: DaySection[]; sections: DaySection[];
recipes: RecipeSummary[]; recipes: RecipeSummary[];
}; };
const i18n = useI18n(); const i18n = useI18n();
const plan = computed<Days[]>(() => { const plan = computed<Days[]>(() => {
return props.mealplans.reduce((acc, day) => { return props.mealplans.reduce((acc, day) => {
const out: Days = { const out: Days = {
date: day.date, date: day.date,
@ -109,11 +101,5 @@ export default defineNuxtComponent({
return acc; return acc;
}, [] as Days[]); }, [] as Days[]);
});
return {
plan,
};
},
}); });
</script> </script>