Merge branch 'mealie-next' into feat/consolidate-admin-apis

This commit is contained in:
Michael Genson 2025-06-28 22:47:38 -05:00 committed by GitHub
commit 7f5880ea64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 855 additions and 2363 deletions

View file

@ -12,7 +12,7 @@ repos:
exclude: ^tests/data/ exclude: ^tests/data/
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version. # Ruff version.
rev: v0.11.13 rev: v0.12.0
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format

View file

@ -156,8 +156,6 @@ Setting the following environmental variables will change the theme of the front
### Docker Secrets ### Docker Secrets
### Docker Secrets
> <super>&dagger;</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger > <super>&dagger;</super> Starting in version `2.4.2`, any environment variable in the preceding lists with a dagger
> symbol next to them support the Docker Compose secrets pattern, below. > symbol next to them support the Docker Compose secrets pattern, below.
[Docker Compose secrets][docker-secrets] can be used to secure sensitive information regarding the Mealie implementation [Docker Compose secrets][docker-secrets] can be used to secure sensitive information regarding the Mealie implementation

View file

@ -24,8 +24,7 @@
<v-container <v-container
v-if="book" v-if="book"
fluid class="my-0"
class="py-0 my-0"
> >
<v-sheet <v-sheet
color="transparent" color="transparent"
@ -33,13 +32,12 @@
elevation="0" elevation="0"
> >
<div class="d-flex align-center w-100 mb-2"> <div class="d-flex align-center w-100 mb-2">
<v-toolbar-title class="headline mb-0">
<v-icon size="large" class="mr-3"> <v-icon size="large" class="mr-3">
{{ $globals.icons.pages }} {{ $globals.icons.pages }}
</v-icon> </v-icon>
<v-toolbar-title class="headline mb-0">
{{ book.name }} {{ book.name }}
</v-toolbar-title> </v-toolbar-title>
<v-spacer />
<BaseButton <BaseButton
v-if="canEdit" v-if="canEdit"
class="mx-1" class="mx-1"
@ -70,7 +68,8 @@
<script lang="ts"> <script lang="ts">
import { useLazyRecipes } from "~/composables/recipes"; import { useLazyRecipes } from "~/composables/recipes";
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue"; import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { useCookbook, useCookbooks } from "~/composables/use-group-cookbooks"; import { useCookbookStore } from "~/composables/store/use-cookbook-store";
import { useCookbook } from "~/composables/use-group-cookbooks";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { RecipeCookBook } from "~/lib/api/types/cookbook"; import type { RecipeCookBook } from "~/lib/api/types/cookbook";
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue"; import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
@ -87,7 +86,7 @@ export default defineNuxtComponent({
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value); const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const slug = route.params.slug as string; const slug = route.params.slug as string;
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value); const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
const { actions } = useCookbooks(); const { actions } = useCookbookStore();
const router = useRouter(); const router = useRouter();
const tab = ref(null); const tab = ref(null);

View file

@ -69,22 +69,22 @@ export default defineNuxtComponent({
const i18n = useI18n(); const i18n = useI18n();
const MEAL_TYPE_OPTIONS = [ const MEAL_TYPE_OPTIONS = [
{ text: i18n.t("meal-plan.breakfast"), value: "breakfast" }, { title: i18n.t("meal-plan.breakfast"), value: "breakfast" },
{ text: i18n.t("meal-plan.lunch"), value: "lunch" }, { title: i18n.t("meal-plan.lunch"), value: "lunch" },
{ text: i18n.t("meal-plan.dinner"), value: "dinner" }, { title: i18n.t("meal-plan.dinner"), value: "dinner" },
{ text: i18n.t("meal-plan.side"), value: "side" }, { title: i18n.t("meal-plan.side"), value: "side" },
{ text: i18n.t("meal-plan.type-any"), value: "unset" }, { title: i18n.t("meal-plan.type-any"), value: "unset" },
]; ];
const MEAL_DAY_OPTIONS = [ const MEAL_DAY_OPTIONS = [
{ text: i18n.t("general.monday"), value: "monday" }, { title: i18n.t("general.monday"), value: "monday" },
{ text: i18n.t("general.tuesday"), value: "tuesday" }, { title: i18n.t("general.tuesday"), value: "tuesday" },
{ text: i18n.t("general.wednesday"), value: "wednesday" }, { title: i18n.t("general.wednesday"), value: "wednesday" },
{ text: i18n.t("general.thursday"), value: "thursday" }, { title: i18n.t("general.thursday"), value: "thursday" },
{ text: i18n.t("general.friday"), value: "friday" }, { title: i18n.t("general.friday"), value: "friday" },
{ text: i18n.t("general.saturday"), value: "saturday" }, { title: i18n.t("general.saturday"), value: "saturday" },
{ text: i18n.t("general.sunday"), value: "sunday" }, { title: i18n.t("general.sunday"), value: "sunday" },
{ text: i18n.t("meal-plan.day-any"), value: "unset" }, { title: i18n.t("meal-plan.day-any"), value: "unset" },
]; ];
const inputDay = computed({ const inputDay = computed({

View file

@ -189,6 +189,7 @@
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)" @update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
@ -198,6 +199,7 @@
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)" @update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
@ -207,6 +209,7 @@
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)" @update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
@ -216,6 +219,7 @@
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)" @update:model-value="setOrganizerValues(field, index, $event)"
/> />
<RecipeOrganizerSelector <RecipeOrganizerSelector
@ -225,6 +229,7 @@
:show-add="false" :show-add="false"
:show-label="false" :show-label="false"
:show-icon="false" :show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)" @update:model-value="setOrganizerValues(field, index, $event)"
/> />
</v-col> </v-col>

View file

@ -74,6 +74,7 @@
:size="$vuetify.display.xs ? 'small' : undefined" :size="$vuetify.display.xs ? 'small' : undefined"
:color="btn.color" :color="btn.color"
variant="elevated" variant="elevated"
:icon="$vuetify.display.xs"
@click="emitHandler(btn.event)" @click="emitHandler(btn.event)"
> >
<v-icon :left="!$vuetify.display.xs"> <v-icon :left="!$vuetify.display.xs">

View file

@ -10,7 +10,7 @@
v-for="category in items.slice(0, limit)" v-for="category in items.slice(0, limit)"
:key="category.name" :key="category.name"
label label
class="ma-1" class="mr-1 mt-1"
color="accent" color="accent"
variant="flat" variant="flat"
:size="small ? 'small' : 'default'" :size="small ? 'small' : 'default'"

View file

@ -17,7 +17,7 @@
id="arrow-search" id="arrow-search"
v-model="search.query.value" v-model="search.query.value"
autofocus autofocus
variant="solo-filled" variant="solo"
flat flat
autocomplete="off" autocomplete="off"
bg-color="primary-lighten-1" bg-color="primary-lighten-1"

View file

@ -1,7 +1,7 @@
<template> <template>
<v-container <v-container
fluid fluid
class="pa-0" class="px-0"
> >
<div class="search-container pb-8"> <div class="search-container pb-8">
<form <form

View file

@ -42,7 +42,7 @@
clearable clearable
:messages="messages" :messages="messages"
> >
<template #append-outer> <template #append>
<v-btn <v-btn
class="ml-2" class="ml-2"
color="primary" color="primary"

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="ma-0 pa-0 text-subtitle-1 dense-markdown ingredient-item"> <div class="text-subtitle-1 dense-markdown ingredient-item">
<SafeMarkdown <SafeMarkdown
v-if="parsedIng.quantity" v-if="parsedIng.quantity"
class="d-inline" class="d-inline"

View file

@ -28,6 +28,7 @@
</template> </template>
<v-list-item <v-list-item
density="compact" density="compact"
class="pa-0"
@click.stop="toggleChecked(index)" @click.stop="toggleChecked(index)"
> >
<template #prepend> <template #prepend>

View file

@ -59,7 +59,7 @@
<v-app-bar <v-app-bar
color="transparent" color="transparent"
flat flat
class="mt-n1 rounded align-center px-4 position-relative w-100 left-0 top-0" class="mt-n1 rounded align-center position-relative w-100 left-0 top-0"
> >
<v-icon <v-icon
size="large" size="large"

View file

@ -9,9 +9,9 @@
closable-chips closable-chips
item-title="name" item-title="name"
multiple multiple
variant="underlined" :variant="variant"
:prepend-inner-icon="icon" :prepend-inner-icon="icon"
:append-icon="$globals.icons.create" :append-icon="showAdd ? $globals.icons.create : undefined"
return-object return-object
auto-select-first auto-select-first
class="pa-0" class="pa-0"
@ -93,6 +93,10 @@ export default defineNuxtComponent({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
variant: {
type: String as () => "filled" | "underlined" | "outlined" | "plain" | "solo" | "solo-inverted" | "solo-filled",
default: "outlined",
},
}, },
emits: ["update:modelValue"], emits: ["update:modelValue"],

View file

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<v-container v-show="!isCookMode" key="recipe-page" class="pt-0" :class="{ 'pa-0': $vuetify.display.smAndDown.value }"> <v-container v-show="!isCookMode" key="recipe-page" class="px-0" :class="{ 'pa-0': $vuetify.display.smAndDown.value }">
<v-card :flat="$vuetify.display.smAndDown.value" class="d-print-none"> <v-card :flat="$vuetify.display.smAndDown.value" class="d-print-none">
<RecipePageHeader <RecipePageHeader
:recipe="recipe" :recipe="recipe"
@ -87,11 +87,10 @@
/> />
<RecipePrintContainer :recipe="recipe" :scale="scale" /> <RecipePrintContainer :recipe="recipe" :scale="scale" />
</v-container> </v-container>
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same timer --> <!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same time -->
<v-sheet <v-sheet
v-show="isCookMode && !hasLinkedIngredients" v-show="isCookMode && !hasLinkedIngredients"
key="cookmode" key="cookmode"
:style="{ height: $vuetify.display.smAndUp ? 'calc(100vh - 48px)' : '' }"
> >
<!-- the calc is to account for the toolbar a more dynamic solution could be needed --> <!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
<v-row style="height: 100%" no-gutters class="overflow-hidden"> <v-row style="height: 100%" no-gutters class="overflow-hidden">
@ -107,7 +106,12 @@
/> />
<v-divider /> <v-divider />
</v-col> </v-col>
<v-col class="overflow-y-auto py-2" style="height: 100%" cols="12" sm="7"> <v-col class="overflow-y-auto"
:class="$vuetify.display.smAndDown.value ? 'py-2': 'py-6'"
style="height: 100%" cols="12" sm="7">
<h2 class="text-h5 px-4 font-weight-medium opacity-80">
{{ $t('recipe.instructions') }}
</h2>
<RecipePageInstructions <RecipePageInstructions
v-model="recipe.recipeInstructions" v-model="recipe.recipeInstructions"
v-model:assets="recipe.assets" v-model:assets="recipe.assets"

View file

@ -1,31 +1,28 @@
<template> <template>
<div class="d-flex justify-start align-top py-2"> <div class="d-flex justify-start align-top flex-wrap">
<RecipeImageUploadBtn <RecipeImageUploadBtn
class="my-1" class="my-2"
:slug="recipe.slug" :slug="recipe.slug"
@upload="uploadImage" @upload="uploadImage"
@refresh="imageKey++" @refresh="imageKey++"
/> />
<RecipeSettingsMenu <RecipeSettingsMenu
v-model="recipe.settings" v-model="recipe.settings"
class="my-1 mx-1" class="my-2 mx-1"
:is-owner="recipe.userId == user.id" :is-owner="recipe.userId == user.id"
@upload="uploadImage" @upload="uploadImage"
/> />
<v-spacer /> <v-spacer />
<v-container
class="py-0"
style="width: 40%;"
>
<v-select <v-select
v-model="recipe.userId" v-model="recipe.userId"
class="my-2"
max-width="300"
:items="allUsers" :items="allUsers"
item-title="fullName" :item-props="itemsProps"
item-value="id"
:label="$t('general.owner')" :label="$t('general.owner')"
hide-details
:disabled="!canEditOwner" :disabled="!canEditOwner"
variant="underlined" variant="outlined"
density="compact"
> >
<template #prepend> <template #prepend>
<UserAvatar <UserAvatar
@ -34,17 +31,7 @@
/> />
</template> </template>
</v-select> </v-select>
<v-card-text </div>
v-if="ownerHousehold"
class="pa-0 d-flex"
style="align-items: flex-end;"
>
<v-spacer />
<v-icon>{{ $globals.icons.household }}</v-icon>
<span class="pl-1">{{ ownerHousehold.name }}</span>
</v-card-text>
</v-container>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -71,13 +58,15 @@ const canEditOwner = computed(() => {
const { store: allUsers } = useUserStore(); const { store: allUsers } = useUserStore();
const { store: households } = useHouseholdStore(); const { store: households } = useHouseholdStore();
const ownerHousehold = computed(() => {
const owner = allUsers.value.find(u => u.id === recipe.value.userId); function itemsProps(item: any) {
if (!owner) { const owner = allUsers.value.find(u => u.id === item.id);
return null; return {
} value: item.id,
return households.value.find(h => h.id === owner.householdId); title: item.fullName,
}); subtitle: owner ? households.value.find(h => h.id === owner.householdId)?.name || "" : "",
};
}
async function uploadImage(fileObject: File) { async function uploadImage(fileObject: File) {
if (!recipe.value || !recipe.value.slug) { if (!recipe.value || !recipe.value.slug) {

View file

@ -11,15 +11,17 @@
class="d-flex flex-column justify-center align-center" class="d-flex flex-column justify-center align-center"
> >
<v-card-text class="w-100"> <v-card-text class="w-100">
<v-card-title class="text-h5 font-weight-regular pa-0 d-flex flex-column align-center justify-center opacity-80"> <div class="d-flex flex-column align-center">
<v-card-title class="text-h5 font-weight-regular pa-0 text-wrap text-center opacity-80">
{{ recipe.name }} {{ recipe.name }}
</v-card-title>
<RecipeRating <RecipeRating
:key="recipe.slug" :key="recipe.slug"
:value="recipe.rating" :value="recipe.rating"
:recipe-id="recipe.id" :recipe-id="recipe.id"
:slug="recipe.slug" :slug="recipe.slug"
/> />
</v-card-title> </div>
<v-divider class="my-2" /> <v-divider class="my-2" />
<SafeMarkdown :source="recipe.description" class="my-3" /> <SafeMarkdown :source="recipe.description" class="my-3" />
<v-divider v-if="recipe.description" /> <v-divider v-if="recipe.description" />
@ -52,7 +54,7 @@
</v-col> </v-col>
</v-row> </v-row>
</div> </div>
<div class="mx-6"> <div v-if="recipe.prepTime || recipe.totalTime || recipe.performTime" class="mx-6">
<RecipeTimeCard <RecipeTimeCard
container-class="d-flex flex-wrap justify-center" container-class="d-flex flex-wrap justify-center"
:prep-time="recipe.prepTime" :prep-time="recipe.prepTime"

View file

@ -15,12 +15,13 @@
v-for="(tool, index) in recipe.tools" v-for="(tool, index) in recipe.tools"
:key="index" :key="index"
density="compact" density="compact"
class="px-1"
> >
<template #prepend> <template #prepend>
<v-checkbox <v-checkbox
v-model="recipeTools[index].onHand" v-model="recipeTools[index].onHand"
hide-details hide-details
class="pt-0 my-auto py-auto" class="pt-0 py-auto"
color="secondary" color="secondary"
density="compact" density="compact"
@change="updateTool(index)" @change="updateTool(index)"

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

@ -60,7 +60,7 @@
{{ $t('tool.required-tools') }} {{ $t('tool.required-tools') }}
</v-card-title> </v-card-title>
<v-divider class="mx-2" /> <v-divider class="mx-2" />
<v-card-text class="pt-0"> <v-card-text>
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-model="recipe.tools" v-model="recipe.tools"
selector-type="tools" selector-type="tools"

View file

@ -141,6 +141,9 @@ export default defineNuxtComponent({
</script> </script>
<style scoped> <style scoped>
.text-center {
font-size: smaller;
}
.time-card-flex { .time-card-flex {
width: fit-content; width: fit-content;
} }

View file

@ -125,6 +125,7 @@
<v-alert <v-alert
type="info" type="info"
:text="$t('search.no-results')" :text="$t('search.no-results')"
class="mb-0"
/> />
</div> </div>
</v-card-text> </v-card-text>

View file

@ -32,11 +32,11 @@
<v-row> <v-row>
<v-col cols="9"> <v-col cols="9">
<v-text-field <v-text-field
v-model="generatedSignupLink"
:label="$t('profile.invite-link')" :label="$t('profile.invite-link')"
type="text" type="text"
readonly readonly
variant="filled" variant="filled"
:value="generatedSignupLink"
/> />
</v-col> </v-col>
<v-col <v-col

View file

@ -3,8 +3,8 @@
<div style="flex-basis: 500px"> <div style="flex-basis: 500px">
<strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong> <strong> {{ $t("user.password-strength", { strength: pwStrength.strength.value }) }}</strong>
<v-progress-linear <v-progress-linear
:value="pwStrength.score.value" v-model="pwStrength.score.value"
class="rounded-lg" rounded
:color="pwStrength.color.value" :color="pwStrength.color.value"
height="15" height="15"
/> />
@ -12,27 +12,11 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { usePasswordStrength } from "~/composables/use-passwords"; import { usePasswordStrength } from "~/composables/use-passwords";
export default defineNuxtComponent({ const modelValue = defineModel<string>({ default: "" });
props: { const i18n = useI18n();
modelValue: {
type: String,
default: "",
},
},
setup(props) {
const asRef = toRef(props, "modelValue");
const i18n = useI18n();
const pwStrength = usePasswordStrength(asRef, i18n); const pwStrength = usePasswordStrength(modelValue, i18n);
return {
pwStrength,
};
},
});
</script> </script>
<style scoped></style>

View file

@ -52,7 +52,7 @@
@click:append-inner="pwFields.togglePasswordShow" @click:append-inner="pwFields.togglePasswordShow"
/> />
<UserPasswordStrength :value="credentials.password1.value" /> <UserPasswordStrength v-model="credentials.password1.value" />
<v-text-field <v-text-field
v-model="credentials.password2.value" v-model="credentials.password2.value"
@ -86,9 +86,8 @@ import { usePasswordField } from "~/composables/use-passwords";
import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue"; import UserPasswordStrength from "~/components/Domain/User/UserPasswordStrength.vue";
const inputAttrs = { const inputAttrs = {
rounded: true,
validateOnBlur: true, validateOnBlur: true,
class: "rounded-lg pb-1", class: "pb-1",
variant: "solo-filled" as any, variant: "solo-filled" as any,
}; };

View file

@ -1,5 +1,6 @@
<template> <template>
<v-app dark> <v-app dark>
<NuxtPwaManifest />
<TheSnackbar /> <TheSnackbar />
<AppHeader> <AppHeader>
@ -104,7 +105,7 @@
</v-list-item> </v-list-item>
</template> </template>
</AppSidebar> </AppSidebar>
<v-main class="pt-16"> <v-main class="pt-12">
<v-scroll-x-transition> <v-scroll-x-transition>
<div> <div>
<NuxtPage /> <NuxtPage />
@ -118,8 +119,8 @@
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { SideBarLink } from "~/types/application-types"; import type { SideBarLink } from "~/types/application-types";
import { useAppInfo } from "~/composables/api"; import { useAppInfo } from "~/composables/api";
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
import { useCookbookPreferences } from "~/composables/use-users/preferences"; import { useCookbookPreferences } from "~/composables/use-users/preferences";
import { useCookbookStore, usePublicCookbookStore } from "~/composables/store/use-cookbook-store";
import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store"; import { useHouseholdStore, usePublicHouseholdStore } from "~/composables/store/use-household-store";
import { useToggleDarkMode } from "~/composables/use-utils"; import { useToggleDarkMode } from "~/composables/use-utils";
import type { ReadCookBook } from "~/lib/api/types/cookbook"; import type { ReadCookBook } from "~/lib/api/types/cookbook";
@ -136,13 +137,14 @@ export default defineNuxtComponent({
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const loggedInCookbooks = useCookbooks();
const publicCookbooks = usePublicCookbooks(groupSlug.value || "");
const cookbooks = computed(() =>
isOwnGroup.value ? loggedInCookbooks.cookbooks.value : publicCookbooks.cookbooks.value,
);
const cookbookPreferences = useCookbookPreferences(); const cookbookPreferences = useCookbookPreferences();
const { store: cookbooks, actions: cookbooksActions } = isOwnGroup.value ? useCookbookStore() : usePublicCookbookStore(groupSlug.value || "");
onMounted(() => {
if (!cookbooks.value.length) {
cookbooksActions.refresh();
}
});
const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value || ""); const { store: households } = isOwnGroup.value ? useHouseholdStore() : usePublicHouseholdStore(groupSlug.value || "");
const householdsById = computed(() => { const householdsById = computed(() => {
@ -176,10 +178,6 @@ export default defineNuxtComponent({
const currentUserHouseholdId = computed(() => $auth.user.value?.householdId); const currentUserHouseholdId = computed(() => $auth.user.value?.householdId);
const cookbookLinks = computed<SideBarLink[]>(() => { const cookbookLinks = computed<SideBarLink[]>(() => {
if (!cookbooks.value || !households.value) {
return [];
}
const sortedCookbooks = [...cookbooks.value].sort((a, b) => (a.position || 0) - (b.position || 0)); const sortedCookbooks = [...cookbooks.value].sort((a, b) => (a.position || 0) - (b.position || 0));
const ownLinks: SideBarLink[] = []; const ownLinks: SideBarLink[] = [];
@ -233,7 +231,7 @@ export default defineNuxtComponent({
{ {
insertDivider: false, insertDivider: false,
icon: $globals.icons.fileImage, icon: $globals.icons.fileImage,
title: i18n.t("recipe.create-from-image"), title: i18n.t("recipe.create-from-images"),
subtitle: i18n.t("recipe.create-recipe-from-an-image"), subtitle: i18n.t("recipe.create-recipe-from-an-image"),
to: `/g/${groupSlug.value}/r/create/image`, to: `/g/${groupSlug.value}/r/create/image`,
restricted: true, restricted: true,

View file

@ -1,10 +1,11 @@
<template> <template>
<v-form ref="file"> <v-form ref="files">
<input <input
ref="uploader" ref="uploader"
class="d-none" class="d-none"
type="file" type="file"
:accept="accept" :accept="accept"
:multiple="multiple"
@change="onFileChanged" @change="onFileChanged"
> >
<slot v-bind="{ isSelecting, onButtonClick }"> <slot v-bind="{ isSelecting, onButtonClick }">
@ -72,9 +73,13 @@ export default defineNuxtComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
multiple: {
type: Boolean,
default: false,
},
}, },
setup(props, context) { setup(props, context) {
const file = ref<File | null>(null); const files = ref<File[]>([]);
const uploader = ref<HTMLInputElement | null>(null); const uploader = ref<HTMLInputElement | null>(null);
const isSelecting = ref(false); const isSelecting = ref(false);
@ -86,17 +91,33 @@ export default defineNuxtComponent({
const api = useUserApi(); const api = useUserApi();
async function upload() { async function upload() {
if (file.value != null) { if (files.value.length === 0) {
return;
}
isSelecting.value = true; isSelecting.value = true;
if (!props.post) { if (!props.post) {
context.emit(UPLOAD_EVENT, file.value); // NOTE: To preserve behaviour for other parents of this component,
// we emit a single File if !props.multiple.
context.emit(UPLOAD_EVENT, props.multiple ? files.value : files.value[0]);
isSelecting.value = false; isSelecting.value = false;
return; return;
} }
// WARN: My change is only for !props.post.
// I have not added support for multiple files in the API.
// Existing call-sites never passed the `multiple` prop,
// so this case will only be hit if the prop is set to true.
if (props.multiple && files.value.length > 1) {
console.warn("Multiple file uploads are not supported by the API.");
return;
}
const file = files.value[0];
const formData = new FormData(); const formData = new FormData();
formData.append(props.fileName, file.value); formData.append(props.fileName, file);
try { try {
const response = await api.upload.file(props.url, formData); const response = await api.upload.file(props.url, formData);
if (response) { if (response) {
@ -107,14 +128,15 @@ export default defineNuxtComponent({
console.error(e); console.error(e);
context.emit(UPLOAD_EVENT, null); context.emit(UPLOAD_EVENT, null);
} }
isSelecting.value = false; isSelecting.value = false;
} }
}
function onFileChanged(e: Event) { function onFileChanged(e: Event) {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
if (target.files !== null && target.files.length > 0 && file.value !== null) {
file.value = target.files[0]; if (target.files !== null && target.files.length > 0) {
files.value = Array.from(target.files);
upload(); upload();
} }
} }
@ -132,7 +154,7 @@ export default defineNuxtComponent({
} }
return { return {
file, files,
uploader, uploader,
isSelecting, isSelecting,
effIcon, effIcon,

View file

@ -10,7 +10,6 @@
<v-col <v-col
v-for="(inputField, index) in items" v-for="(inputField, index) in items"
:key="index" :key="index"
class="py-0"
cols="12" cols="12"
sm="12" sm="12"
> >
@ -75,7 +74,6 @@
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))" :disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
variant="solo-filled" variant="solo-filled"
flat flat
class="rounded-lg"
rows="3" rows="3"
auto-grow auto-grow
density="comfortable" density="comfortable"
@ -95,7 +93,6 @@
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))" :disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
variant="solo-filled" variant="solo-filled"
flat flat
class="rounded-lg"
:prepend-icon="inputField.icons ? modelValue[inputField.varName] : null" :prepend-icon="inputField.icons ? modelValue[inputField.varName] : null"
:label="inputField.label" :label="inputField.label"
:name="inputField.varName" :name="inputField.varName"

View file

@ -10,6 +10,7 @@
<v-card-title class="text-h5 pl-0 py-0" style="font-weight: normal;"> <v-card-title class="text-h5 pl-0 py-0" style="font-weight: normal;">
<v-icon <v-icon
v-if="icon" v-if="icon"
size="small"
start start
> >
{{ icon }} {{ icon }}
@ -24,7 +25,7 @@
<slot /> <slot />
</p> </p>
</v-card-text> </v-card-text>
<v-divider class="mb-3" /> <v-divider class="mt-1 mb-3" />
</v-card> </v-card>
</template> </template>

View file

@ -29,7 +29,6 @@
<v-toolbar-title class="headline"> <v-toolbar-title class="headline">
{{ title }} {{ title }}
</v-toolbar-title> </v-toolbar-title>
<v-spacer />
</v-toolbar> </v-toolbar>
<v-progress-linear <v-progress-linear
v-if="loading" v-if="loading"

View file

@ -10,7 +10,7 @@
style="margin-bottom: 4rem" style="margin-bottom: 4rem"
dark dark
> >
<v-toolbar-title class="headline text-h4"> <v-toolbar-title class="headline text-h4 text-center mx-0">
Mealie Mealie
</v-toolbar-title> </v-toolbar-title>
</v-toolbar> </v-toolbar>

View file

@ -10,7 +10,6 @@
:min="min" :min="min"
:max="max" :max="max"
type="number" type="number"
class="rounded-xl"
size="small" size="small"
variant="plain" variant="plain"
/> />

View file

@ -1,12 +1,11 @@
<template> <template>
<MDC <!-- eslint-disable-next-line vue/no-v-html is safe here because all HTML is sanitized with DOMPurify in setup() -->
:value="value" <div v-html="value" />
tag="article"
/>
</template> </template>
<script lang="ts"> <script lang="ts">
import DOMPurify from "isomorphic-dompurify"; import DOMPurify from "isomorphic-dompurify";
import { marked } from "marked";
export default defineNuxtComponent({ export default defineNuxtComponent({
props: { props: {
@ -40,7 +39,8 @@ export default defineNuxtComponent({
} }
const value = computed(() => { const value = computed(() => {
return sanitizeMarkdown(props.source) || ""; const rawHtml = marked.parse(props.source || "", { async: false });
return sanitizeMarkdown(rawHtml);
}); });
return { return {
@ -56,7 +56,8 @@ export default defineNuxtComponent({
width: 100%; width: 100%;
} }
:deep(th, td) { :deep(th),
:deep(td) {
border: 1px solid; border: 1px solid;
padding: 8px; padding: 8px;
text-align: left; text-align: left;
@ -65,4 +66,10 @@ export default defineNuxtComponent({
:deep(th) { :deep(th) {
font-weight: bold; font-weight: bold;
} }
:deep(ul),
:deep(ol) {
margin: 8px 0;
padding-left: 20px;
}
</style> </style>

View file

@ -1,16 +1,17 @@
import type { AxiosInstance, AxiosResponse } from "axios"; import type { AxiosInstance, AxiosResponse, AxiosRequestConfig } from "axios";
import type { ApiRequestInstance, RequestResponse } from "~/lib/api/types/non-generated"; import type { ApiRequestInstance, RequestResponse } from "~/lib/api/types/non-generated";
import { AdminAPI, PublicApi, UserApi } from "~/lib/api"; import { AdminAPI, PublicApi, UserApi } from "~/lib/api";
import { PublicExploreApi } from "~/lib/api/client-public"; import { PublicExploreApi } from "~/lib/api/client-public";
const request = { const request = {
async safe<T, U>( async safe<T, U>(
funcCall: (url: string, data: U) => Promise<AxiosResponse<T>>, funcCall: (url: string, data: U, config?: AxiosRequestConfig) => Promise<AxiosResponse<T>>,
url: string, url: string,
data: U, data: U,
config?: AxiosRequestConfig,
): Promise<RequestResponse<T>> { ): Promise<RequestResponse<T>> {
let error = null; let error = null;
const response = await funcCall(url, data).catch(function (e) { const response = await funcCall(url, data, config).catch(function (e) {
console.log(e); console.log(e);
// Insert Generic Error Handling Here // Insert Generic Error Handling Here
error = e; error = e;
@ -22,9 +23,9 @@ const request = {
function getRequests(axiosInstance: AxiosInstance): ApiRequestInstance { function getRequests(axiosInstance: AxiosInstance): ApiRequestInstance {
return { return {
async get<T>(url: string, params = {}): Promise<RequestResponse<T>> { async get<T>(url: string, params = {}, config?: AxiosRequestConfig): Promise<RequestResponse<T>> {
let error = null; let error = null;
const response = await axiosInstance.get<T>(url, params).catch((e) => { const response = await axiosInstance.get<T>(url, { ...config, params }).catch((e) => {
error = e; error = e;
}); });
if (response != null) { if (response != null) {
@ -33,20 +34,20 @@ function getRequests(axiosInstance: AxiosInstance): ApiRequestInstance {
return { response: null, error, data: null }; return { response: null, error, data: null };
}, },
async post<T, U>(url: string, data: U) { async post<T, U>(url: string, data: U, config?: AxiosRequestConfig) {
return await request.safe<T, U>(axiosInstance.post, url, data); return await request.safe<T, U>(axiosInstance.post, url, data, config);
}, },
async put<T, U = T>(url: string, data: U) { async put<T, U = T>(url: string, data: U, config?: AxiosRequestConfig) {
return await request.safe<T, U>(axiosInstance.put, url, data); return await request.safe<T, U>(axiosInstance.put, url, data, config);
}, },
async patch<T, U = Partial<T>>(url: string, data: U) { async patch<T, U = Partial<T>>(url: string, data: U, config?: AxiosRequestConfig) {
return await request.safe<T, U>(axiosInstance.patch, url, data); return await request.safe<T, U>(axiosInstance.patch, url, data, config);
}, },
async delete<T>(url: string) { async delete<T>(url: string, config?: AxiosRequestConfig) {
return await request.safe<T, undefined>(axiosInstance.delete, url, undefined); return await request.safe<T, undefined>(axiosInstance.delete, url, undefined, config);
}, },
}; };
} }

View file

@ -0,0 +1,17 @@
import { useReadOnlyStore, useStore } from "../partials/use-store-factory";
import type { RecipeCookBook } from "~/lib/api/types/cookbook";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
const store: Ref<RecipeCookBook[]> = ref([]);
const loading = ref(false);
const publicLoading = ref(false);
export const useCookbookStore = function () {
const api = useUserApi();
return useStore<RecipeCookBook>(store, loading, api.cookbooks);
};
export const usePublicCookbookStore = function (groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
return useReadOnlyStore<RecipeCookBook>(store, publicLoading, api.cookbooks);
};

View file

@ -1,10 +1,6 @@
import { useAsyncKey } from "./use-utils"; import { useAsyncKey } from "./use-utils";
import { usePublicExploreApi } from "./api/api-client"; import { usePublicExploreApi } from "./api/api-client";
import { useHouseholdSelf } from "./use-households";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import type { ReadCookBook, UpdateCookBook } from "~/lib/api/types/cookbook";
let cookbookStore: Ref<ReadCookBook[] | null> | null = null;
export const useCookbook = function (publicGroupSlug: string | null = null) { export const useCookbook = function (publicGroupSlug: string | null = null) {
function getOne(id: string | number) { function getOne(id: string | number) {
@ -22,149 +18,3 @@ export const useCookbook = function (publicGroupSlug: string | null = null) {
return { getOne }; return { getOne };
}; };
export const usePublicCookbooks = function (groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
const loading = ref(false);
const actions = {
getAll() {
loading.value = true;
const { data: units } = useAsyncData(useAsyncKey(), async () => {
const { data } = await api.cookbooks.getAll(1, -1, { orderBy: "position", orderDirection: "asc" });
if (data) {
return data.items;
}
else {
return null;
}
});
loading.value = false;
return units;
},
async refreshAll() {
loading.value = true;
const { data } = await api.cookbooks.getAll(1, -1, { orderBy: "position", orderDirection: "asc" });
if (data && data.items && cookbookStore) {
cookbookStore.value = data.items;
}
loading.value = false;
},
flushStore() {
cookbookStore = null;
},
};
if (!cookbookStore) {
cookbookStore = actions.getAll();
}
return { cookbooks: cookbookStore, actions };
};
export const useCookbooks = function () {
const api = useUserApi();
const { household } = useHouseholdSelf();
const loading = ref(false);
const i18n = useI18n();
const actions = {
getAll() {
loading.value = true;
const { data: units } = useAsyncData(useAsyncKey(), async () => {
const { data } = await api.cookbooks.getAll(1, -1, { orderBy: "position", orderDirection: "asc" });
if (data) {
return data.items;
}
else {
return null;
}
});
loading.value = false;
return units;
},
async refreshAll() {
loading.value = true;
const { data } = await api.cookbooks.getAll(1, -1, { orderBy: "position", orderDirection: "asc" });
if (data && data.items && cookbookStore) {
cookbookStore.value = data.items;
}
loading.value = false;
},
async createOne(name: string | null = null) {
loading.value = true;
const { data } = await api.cookbooks.createOne({
name: name || i18n.t("cookbook.household-cookbook-name", [household.value?.name || "", String((cookbookStore?.value?.length ?? 0) + 1)]) as string,
position: (cookbookStore?.value?.length ?? 0) + 1,
queryFilterString: "",
});
if (data && cookbookStore?.value) {
cookbookStore.value.push(data);
}
else {
this.refreshAll();
}
loading.value = false;
return data;
},
async updateOne(updateData: UpdateCookBook) {
if (!updateData.id) {
return;
}
loading.value = true;
const { data } = await api.cookbooks.updateOne(updateData.id, updateData);
if (data && cookbookStore?.value) {
this.refreshAll();
}
loading.value = false;
return data;
},
async updateOrder(cookbooks: ReadCookBook[]) {
if (!cookbooks?.length) {
return;
}
loading.value = true;
cookbooks.forEach((element, index) => {
element.position = index + 1;
});
const { data } = await api.cookbooks.updateAll(cookbooks);
if (data && cookbookStore?.value) {
this.refreshAll();
}
loading.value = true;
},
async deleteOne(id: string | number) {
loading.value = true;
const { data } = await api.cookbooks.deleteOne(id);
if (data && cookbookStore?.value) {
this.refreshAll();
}
},
flushStore() {
cookbookStore = null;
},
};
if (!cookbookStore) {
cookbookStore = actions.getAll();
}
return { cookbooks: cookbookStore, actions };
};

View file

@ -596,12 +596,13 @@
"create-recipe-description": "Create a new recipe from scratch.", "create-recipe-description": "Create a new recipe from scratch.",
"create-recipes": "Create Recipes", "create-recipes": "Create Recipes",
"import-with-zip": "Import with .zip", "import-with-zip": "Import with .zip",
"create-recipe-from-an-image": "Create Recipe from an Image", "create-recipe-from-an-image": "Create Recipe from Images",
"create-recipe-from-an-image-description": "Create a recipe by uploading an image of it. Mealie will attempt to extract the text from the image using AI and create a recipe from it.", "create-recipe-from-an-image-description": "Create a recipe by uploading images of the recipe text. Mealie will attempt to extract the text from the images using AI and create a new recipe from it.",
"crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.", "crop-and-rotate-the-image": "Crop and rotate the image so that only the text is visible, and it's in the correct orientation.",
"create-from-image": "Create from Image", "create-from-images": "Create from Images",
"should-translate-description": "Translate the recipe into my language", "should-translate-description": "Translate the recipe into my language",
"please-wait-image-procesing": "Please wait, the image is processing. This may take some time.", "please-wait-image-procesing": "Please wait, the image is processing. This may take some time.",
"please-wait-images-processing": "Please wait, the images are processing. This may take some time.",
"bulk-url-import": "Bulk URL Import", "bulk-url-import": "Bulk URL Import",
"debug-scraper": "Debug Scraper", "debug-scraper": "Debug Scraper",
"create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Create a recipe by providing the name. All recipes must have unique names.", "create-a-recipe-by-providing-the-name-all-recipes-must-have-unique-names": "Create a recipe by providing the name. All recipes must have unique names.",
@ -660,7 +661,10 @@
"no-food": "No Food" "no-food": "No Food"
}, },
"reset-servings-count": "Reset Servings Count", "reset-servings-count": "Reset Servings Count",
"not-linked-ingredients": "Additional Ingredients" "not-linked-ingredients": "Additional Ingredients",
"upload-another-image": "Upload another image",
"upload-images": "Upload images",
"upload-more-images": "Upload more images"
}, },
"recipe-finder": { "recipe-finder": {
"recipe-finder": "Recipe Finder", "recipe-finder": "Recipe Finder",

View file

@ -1,5 +1,6 @@
<template> <template>
<v-app dark> <v-app dark>
<NuxtPwaManifest />
<TheSnackbar /> <TheSnackbar />
<AppHeader :menu="false" /> <AppHeader :menu="false" />

View file

@ -1,5 +1,6 @@
<template> <template>
<v-app dark> <v-app dark>
<NuxtPwaManifest />
<TheSnackbar /> <TheSnackbar />
<v-banner <v-banner

View file

@ -1,4 +1,4 @@
import type { AxiosResponse } from "axios"; import type { AxiosRequestConfig, AxiosResponse } from "axios";
export type NoUndefinedField<T> = { [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>> }; export type NoUndefinedField<T> = { [P in keyof T]-?: NoUndefinedField<NonNullable<T[P]>> };
@ -9,11 +9,11 @@ export interface RequestResponse<T> {
} }
export interface ApiRequestInstance { export interface ApiRequestInstance {
get<T>(url: string, data?: unknown): Promise<RequestResponse<T>>; get<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<RequestResponse<T>>;
post<T>(url: string, data: unknown): Promise<RequestResponse<T>>; post<T>(url: string, data: unknown, config?: AxiosRequestConfig): Promise<RequestResponse<T>>;
put<T, U = T>(url: string, data: U): Promise<RequestResponse<T>>; put<T, U = T>(url: string, data: U, config?: AxiosRequestConfig): Promise<RequestResponse<T>>;
patch<T, U = Partial<T>>(url: string, data: U): Promise<RequestResponse<T>>; patch<T, U = Partial<T>>(url: string, data: U, config?: AxiosRequestConfig): Promise<RequestResponse<T>>;
delete<T>(url: string): Promise<RequestResponse<T>>; delete<T>(url: string, config?: AxiosRequestConfig): Promise<RequestResponse<T>>;
} }
export interface PaginationData<T> { export interface PaginationData<T> {

View file

@ -157,17 +157,19 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
return await this.requests.post<string>(routes.recipesCreateUrlBulk, payload); return await this.requests.post<string>(routes.recipesCreateUrlBulk, payload);
} }
async createOneFromImage(fileObject: Blob | File, fileName: string, translateLanguage: string | null = null) { async createOneFromImages(fileObjects: (Blob | File)[], translateLanguage: string | null = null) {
const formData = new FormData(); const formData = new FormData();
formData.append("images", fileObject);
formData.append("extension", fileName.split(".").pop() ?? ""); fileObjects.forEach((file) => {
formData.append("images", file);
});
let apiRoute = routes.recipesCreateFromImage; let apiRoute = routes.recipesCreateFromImage;
if (translateLanguage) { if (translateLanguage) {
apiRoute = `${apiRoute}?translateLanguage=${translateLanguage}`; apiRoute = `${apiRoute}?translateLanguage=${translateLanguage}`;
} }
return await this.requests.post<string>(apiRoute, formData); return await this.requests.post<string>(apiRoute, formData, { timeout: 120000 });
} }
async parseIngredients(parser: Parser, ingredients: Array<string>) { async parseIngredients(parser: Parser, ingredients: Array<string>) {

View file

@ -150,6 +150,7 @@ import {
mdiCodeTags, mdiCodeTags,
mdiKnife, mdiKnife,
mdiCookie, mdiCookie,
mdiBellPlus,
} from "@mdi/js"; } from "@mdi/js";
export const icons = { export const icons = {
@ -174,6 +175,7 @@ export const icons = {
arrowUpDown: mdiDrag, arrowUpDown: mdiDrag,
backupRestore: mdiBackupRestore, backupRestore: mdiBackupRestore,
bellAlert: mdiBellAlert, bellAlert: mdiBellAlert,
bellPlus: mdiBellPlus,
broom: mdiBroom, broom: mdiBroom,
calendar: mdiCalendar, calendar: mdiCalendar,
calendarMinus: mdiCalendarMinus, calendarMinus: mdiCalendarMinus,

View file

@ -0,0 +1,10 @@
export default defineNuxtRouteMiddleware((to) => {
if (to.path === "/r/create/url") {
const { user } = useMealieAuth();
const groupSlug = user.value?.groupSlug;
if (!groupSlug) {
return navigateTo("/login", { redirectCode: 301 });
}
return navigateTo(`/g/${groupSlug}${to.fullPath}`, { redirectCode: 301 });
}
});

View file

@ -13,7 +13,6 @@ export default defineNuxtConfig({
"@sidebase/nuxt-auth", "@sidebase/nuxt-auth",
"@nuxtjs/google-fonts", "@nuxtjs/google-fonts",
"vuetify-nuxt-module", "vuetify-nuxt-module",
"@nuxtjs/mdc",
"@nuxt/eslint", "@nuxt/eslint",
], ],
ssr: false, ssr: false,
@ -67,7 +66,7 @@ export default defineNuxtConfig({
viewTransition: true, viewTransition: true,
}, },
css: ["~/assets/css/main.css", "~/assets/style-overrides.scss"], css: ["~/assets/css/main.css", "~/assets/css/fonts.css", "~/assets/style-overrides.scss"],
runtimeConfig: { runtimeConfig: {
sessionPassword: process.env.SESSION_PASSWORD || "password-with-at-least-32-characters", sessionPassword: process.env.SESSION_PASSWORD || "password-with-at-least-32-characters",
@ -172,7 +171,7 @@ export default defineNuxtConfig({
googleFonts: { googleFonts: {
fontsPath: "/assets/fonts", fontsPath: "/assets/fonts",
download: true, download: false, // Disable automatic downloading
families: { families: {
Roboto: [100, 300, 400, 500, 700, 900], Roboto: [100, 300, 400, 500, 700, 900],
}, },

View file

@ -21,7 +21,6 @@
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@nuxt/eslint": "1.2.0", "@nuxt/eslint": "1.2.0",
"@nuxtjs/i18n": "^9.2.1", "@nuxtjs/i18n": "^9.2.1",
"@nuxtjs/mdc": "0.14.0",
"@nuxtjs/proxy": "^2.1.0", "@nuxtjs/proxy": "^2.1.0",
"@sidebase/nuxt-auth": "0.10.0", "@sidebase/nuxt-auth": "0.10.0",
"@vite-pwa/nuxt": "0.10.6", "@vite-pwa/nuxt": "0.10.6",
@ -31,6 +30,7 @@
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"isomorphic-dompurify": "^2.22.0", "isomorphic-dompurify": "^2.22.0",
"json-editor-vue": "^0.18.1", "json-editor-vue": "^0.18.1",
"marked": "^15.0.12",
"next-auth": "~4.21.1", "next-auth": "~4.21.1",
"nuxt": "^3.15.4", "nuxt": "^3.15.4",
"typescript": "5.3", "typescript": "5.3",

View file

@ -26,7 +26,7 @@
<section> <section>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.wrench" :title="$t('admin.maintenance.summary-title')" /> <BaseCardSectionTitle class="pb-0" :icon="$globals.icons.wrench" :title="$t('admin.maintenance.summary-title')" />
<div class="mb-6 ml-2 d-flex" style="gap: 0.3rem"> <div class="mb-6 d-flex" style="gap: 0.3rem">
<BaseButton color="info" @click="getSummary"> <BaseButton color="info" @click="getSummary">
<template #icon> <template #icon>
{{ $globals.icons.tools }} {{ $globals.icons.tools }}
@ -40,7 +40,7 @@
{{ $t("admin.maintenance.button-label-open-details") }} {{ $t("admin.maintenance.button-label-open-details") }}
</BaseButton> </BaseButton>
</div> </div>
<v-card class="ma-2" :loading="state.fetchingInfo"> <v-card class="" :loading="state.fetchingInfo">
<template v-for="(value, idx) in info" :key="`item-${idx}`"> <template v-for="(value, idx) in info" :key="`item-${idx}`">
<v-list-item> <v-list-item>
<v-list-item-title class="py-2"> <v-list-item-title class="py-2">
@ -67,21 +67,23 @@
</template> </template>
</i18n-t> </i18n-t>
</BaseCardSectionTitle> </BaseCardSectionTitle>
<v-card class="ma-2" :loading="state.actionLoading"> <v-card class="ma-0" flat :loading="state.actionLoading">
<template v-for="(action, idx) in actions" :key="`item-${idx}`"> <template v-for="(action, idx) in actions" :key="`item-${idx}`">
<v-list-item class="py-1"> <v-list-item class="py-2 px-0">
<v-list-item-title> <v-list-item-title>
<div>{{ action.name }}</div> <div>{{ action.name }}</div>
<v-list-item-subtitle class="wrap-word"> <v-list-item-subtitle class="wrap-word">
{{ action.subtitle }} {{ action.subtitle }}
</v-list-item-subtitle> </v-list-item-subtitle>
</v-list-item-title> </v-list-item-title>
<template #append>
<BaseButton color="info" @click="action.handler"> <BaseButton color="info" @click="action.handler">
<template #icon> <template #icon>
{{ $globals.icons.robot }} {{ $globals.icons.robot }}
</template> </template>
{{ $t("general.run") }} {{ $t("general.run") }}
</BaseButton> </BaseButton>
</template>
</v-list-item> </v-list-item>
<v-divider class="mx-2" /> <v-divider class="mx-2" />
</template> </template>

View file

@ -33,7 +33,6 @@
:items="groups" :items="groups"
variant="solo-filled" variant="solo-filled"
flat flat
class="rounded-lg"
item-title="name" item-title="name"
item-value="id" item-value="id"
:return-object="false" :return-object="false"

View file

@ -12,8 +12,6 @@
v-if="groups" v-if="groups"
v-model="createHouseholdForm.data.groupId" v-model="createHouseholdForm.data.groupId"
:items="groups" :items="groups"
rounded
class="rounded-lg"
item-title="name" item-title="name"
item-value="id" item-value="id"
:return-object="false" :return-object="false"

View file

@ -24,8 +24,6 @@
v-if="groups" v-if="groups"
v-model="selectedGroupId" v-model="selectedGroupId"
:items="groups" :items="groups"
rounded
class="rounded-lg"
item-title="name" item-title="name"
item-value="id" item-value="id"
:return-object="false" :return-object="false"
@ -37,8 +35,6 @@
v-model="newUserData.household" v-model="newUserData.household"
:disabled="!selectedGroupId" :disabled="!selectedGroupId"
:items="households" :items="households"
rounded
class="rounded-lg"
item-title="name" item-title="name"
item-value="name" item-value="name"
:return-object="false" :return-object="false"

View file

@ -251,6 +251,12 @@ export default defineNuxtComponent({
layout: "admin", layout: "admin",
}); });
// For some reason the layout is not set automatically, so we set it here,
// even though it's defined above in the page meta.
onMounted(() => {
setPageLayout("admin");
});
const { $globals } = useNuxtApp(); const { $globals } = useNuxtApp();
const i18n = useI18n(); const i18n = useI18n();

View file

@ -17,10 +17,10 @@
<v-form @submit.prevent="requestLink()"> <v-form @submit.prevent="requestLink()">
<v-text-field <v-text-field
v-model="email" v-model="email"
variant="filled" :prepend-inner-icon="$globals.icons.email"
rounded variant="solo-filled"
flat
autofocus autofocus
class="rounded-lg"
name="login" name="login"
:label="$t('user.email')" :label="$t('user.email')"
type="text" type="text"

View file

@ -139,10 +139,10 @@
<script lang="ts"> <script lang="ts">
import { VueDraggable } from "vue-draggable-plus"; import { VueDraggable } from "vue-draggable-plus";
import { useCookbooks } from "@/composables/use-group-cookbooks"; import { useCookbookStore } from "~/composables/store/use-cookbook-store";
import { useHouseholdSelf } from "@/composables/use-households"; import { useHouseholdSelf } from "@/composables/use-households";
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue"; import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
import type { ReadCookBook } from "~/lib/api/types/cookbook"; import type { CreateCookBook, ReadCookBook } from "~/lib/api/types/cookbook";
import { useCookbookPreferences } from "~/composables/use-users/preferences"; import { useCookbookPreferences } from "~/composables/use-users/preferences";
export default defineNuxtComponent({ export default defineNuxtComponent({
@ -162,7 +162,7 @@ export default defineNuxtComponent({
}); });
const $auth = useMealieAuth(); const $auth = useMealieAuth();
const { cookbooks: allCookbooks, actions } = useCookbooks(); const { store: allCookbooks, actions } = useCookbookStore();
// Make a local reactive copy of myCookbooks // Make a local reactive copy of myCookbooks
const myCookbooks = ref<ReadCookBook[]>([]); const myCookbooks = ref<ReadCookBook[]>([]);
@ -188,7 +188,9 @@ export default defineNuxtComponent({
household.value?.name || "", household.value?.name || "",
String((myCookbooks.value?.length ?? 0) + 1), String((myCookbooks.value?.length ?? 0) + 1),
]) as string; ]) as string;
await actions.createOne(name).then((cookbook) => {
const data = { name } as CreateCookBook;
await actions.createOne(data).then((cookbook) => {
if (!cookbook) { if (!cookbook) {
return; return;
} }

View file

@ -80,7 +80,7 @@ export default defineNuxtComponent({
}, },
{ {
icon: $globals.icons.fileImage, icon: $globals.icons.fileImage,
text: i18n.t("recipe.create-from-image"), text: i18n.t("recipe.create-from-images"),
value: "image", value: "image",
hide: !enableOpenAIImages.value, hide: !enableOpenAIImages.value,
}, },

View file

@ -7,7 +7,7 @@
<v-card-text> <v-card-text>
{{ $t('recipe.recipe-bulk-importer-description') }} {{ $t('recipe.recipe-bulk-importer-description') }}
</v-card-text> </v-card-text>
</div> <div class="px-4">
<section class="mt-2"> <section class="mt-2">
<v-row <v-row
v-for="(_, idx) in bulkUrls" v-for="(_, idx) in bulkUrls"
@ -54,6 +54,7 @@
cols="12" cols="12"
xs="12" xs="12"
sm="6" sm="6"
class="py-0"
> >
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-model="bulkUrls[idx].categories" v-model="bulkUrls[idx].categories"
@ -73,6 +74,7 @@
cols="12" cols="12"
xs="12" xs="12"
sm="6" sm="6"
class="pt-0 pb-4"
> >
<RecipeOrganizerSelector <RecipeOrganizerSelector
v-model="bulkUrls[idx].tags" v-model="bulkUrls[idx].tags"
@ -90,8 +92,9 @@
</v-col> </v-col>
</template> </template>
</v-row> </v-row>
<v-card-actions class="justify-end flex-wrap mb-1"> <v-card-actions class="justify-end flex-wrap mt-3 pa-0">
<BaseButton <BaseButton
class="mt-1 pr-4"
delete delete
@click=" @click="
bulkUrls = []; bulkUrls = [];
@ -117,23 +120,26 @@
@bulk-data="assignUrls" @bulk-data="assignUrls"
/> />
</v-card-actions> </v-card-actions>
<div class="px-1"> <div class="px-0">
<v-checkbox <v-checkbox
v-model="showCatTags" v-model="showCatTags"
hide-details hide-details
:label="$t('recipe.set-categories-and-tags')" :label="$t('recipe.set-categories-and-tags')"
/> />
</div> </div>
<v-card-actions class="justify-end"> <v-card-actions class="justify-center">
<div style="width: 250px">
<BaseButton <BaseButton
:disabled="bulkUrls.length === 0 || lockBulkImport" :disabled="bulkUrls.length === 0 || lockBulkImport"
rounded
block
@click="bulkCreate" @click="bulkCreate"
> >
<template #icon> <template #icon>
{{ $globals.icons.check }} {{ $globals.icons.check }}
</template> </template>
{{ $t('general.submit') }}
</BaseButton> </BaseButton>
</div>
</v-card-actions> </v-card-actions>
</section> </section>
<section class="mt-12"> <section class="mt-12">
@ -144,6 +150,8 @@
/> />
</section> </section>
</div> </div>
</div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">

View file

@ -61,7 +61,6 @@
<div style="width: 250px"> <div style="width: 250px">
<BaseButton <BaseButton
:disabled="!newRecipeData" :disabled="!newRecipeData"
large
rounded rounded
block block
type="submit" type="submit"

View file

@ -1,86 +1,70 @@
<template> <template>
<div> <div>
<v-form <v-form ref="domUrlForm" @submit.prevent="createRecipe">
ref="domUrlForm"
@submit.prevent="createRecipe"
>
<div> <div>
<v-card-title class="headline"> <v-card-title class="headline">
{{ $t('recipe.create-recipe-from-an-image') }} {{ $t("recipe.create-recipe-from-an-image") }}
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<p>{{ $t('recipe.create-recipe-from-an-image-description') }}</p> <p>{{ $t("recipe.create-recipe-from-an-image-description") }}</p>
<v-container class="pa-0"> <v-container class="pa-0">
<v-row> <v-row>
<v-col <v-col cols="auto" align-self="center">
cols="auto"
align-self="center"
>
<AppButtonUpload <AppButtonUpload
v-if="!uploadedImage"
class="ml-auto" class="ml-auto"
url="none" url="none"
file-name="image" file-name="images"
accept="image/*" accept="image/*"
:text="$t('recipe.upload-image')" :text="uploadedImages.length ? $t('recipe.upload-more-images') : $t('recipe.upload-images')"
:text-btn="false" :text-btn="false"
:post="false" :post="false"
@uploaded="uploadImage" :multiple="true"
@uploaded="uploadImages"
/> />
<v-btn
v-if="!!uploadedImage"
color="error"
@click="clearImage"
>
<v-icon start>
{{ $globals.icons.close }}
</v-icon>
{{ $t('recipe.remove-image') }}
</v-btn>
</v-col> </v-col>
<v-spacer /> <v-spacer />
</v-row> </v-row>
<div <div v-if="uploadedImages.length" class="mt-3">
v-if="uploadedImage && uploadedImagePreviewUrl"
class="mt-3"
>
<v-row> <v-row>
<v-col <v-col cols="12" class="pb-0">
cols="12"
class="pb-0"
>
<v-card-text class="pa-0"> <v-card-text class="pa-0">
<p class="mb-0"> <p class="mb-0">
{{ $t('recipe.crop-and-rotate-the-image') }} {{ $t("recipe.crop-and-rotate-the-image") }}
</p> </p>
</v-card-text> </v-card-text>
</v-col> </v-col>
</v-row> </v-row>
<v-row style="max-width: 600px;"> <v-row style="max-width: 600px">
<v-spacer /> <v-spacer />
<v-col cols="12"> <v-col v-for="(imageUrl, index) in uploadedImagesPreviewUrls" :key="index" cols="12">
<v-row>
<v-col cols="auto" align-self="center">
<ImageCropper <ImageCropper
:img="uploadedImagePreviewUrl" :img="imageUrl"
cropper-height="50vh" cropper-height="100%"
cropper-width="100%" cropper-width="100%"
@save="updateUploadedImage" @save="(croppedImage) => updateUploadedImage(index, croppedImage)"
/> />
<v-btn color="error" @click="() => clearImage(index)">
<v-icon start>
{{ $globals.icons.close }}
</v-icon>
{{ $t("recipe.remove-image") }}
</v-btn>
</v-col>
</v-row>
</v-col> </v-col>
<v-spacer /> <v-spacer />
</v-row> </v-row>
</div> </div>
</v-container> </v-container>
</v-card-text> </v-card-text>
<v-card-actions v-if="uploadedImage"> <v-card-actions v-if="uploadedImages.length">
<div> <div>
<p style="width: 250px"> <p style="width: 250px">
<BaseButton <BaseButton rounded block type="submit" :loading="loading" />
rounded
block
type="submit"
:loading="loading"
/>
</p> </p>
<p> <p>
<v-checkbox <v-checkbox
@ -90,11 +74,12 @@
:disabled="loading" :disabled="loading"
/> />
</p> </p>
<p <p v-if="loading" class="mb-0">
v-if="loading" {{
class="mb-0" uploadedImages.length > 1
> ? $t("recipe.please-wait-images-processing")
{{ $t('recipe.please-wait-image-procesing') }} : $t("recipe.please-wait-image-procesing")
}}
</p> </p>
</div> </div>
</v-card-actions> </v-card-actions>
@ -121,55 +106,59 @@ export default defineNuxtComponent({
const groupSlug = computed(() => route.params.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug || "");
const domUrlForm = ref<VForm | null>(null); const domUrlForm = ref<VForm | null>(null);
const uploadedImage = ref<Blob | File>(); const uploadedImages = ref<(Blob | File)[]>([]);
const uploadedImageName = ref<string>(""); const uploadedImageNames = ref<string[]>([]);
const uploadedImagePreviewUrl = ref<string>(); const uploadedImagesPreviewUrls = ref<string[]>([]);
const shouldTranslate = ref(true); const shouldTranslate = ref(true);
function uploadImage(fileObject: File) { function uploadImages(files: File[]) {
uploadedImage.value = fileObject; uploadedImages.value = [...uploadedImages.value, ...files];
uploadedImageName.value = fileObject.name; uploadedImageNames.value = [...uploadedImageNames.value, ...files.map(file => file.name)];
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject); uploadedImagesPreviewUrls.value = [
...uploadedImagesPreviewUrls.value,
...files.map(file => URL.createObjectURL(file)),
];
} }
function updateUploadedImage(fileObject: Blob) { function clearImage(index: number) {
uploadedImage.value = fileObject; URL.revokeObjectURL(uploadedImagesPreviewUrls.value[index]);
uploadedImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
function clearImage() { uploadedImages.value = uploadedImages.value.filter((_, i) => i !== index);
uploadedImage.value = undefined; uploadedImageNames.value = uploadedImageNames.value.filter((_, i) => i !== index);
uploadedImageName.value = ""; uploadedImagesPreviewUrls.value = uploadedImagesPreviewUrls.value.filter((_, i) => i !== index);
uploadedImagePreviewUrl.value = undefined;
} }
async function createRecipe() { async function createRecipe() {
if (!uploadedImage.value) { if (uploadedImages.value.length === 0) {
return; return;
} }
state.loading = true; state.loading = true;
const translateLanguage = shouldTranslate.value ? i18n.locale : undefined; const translateLanguage = shouldTranslate.value ? i18n.locale : undefined;
const { data, error } = await api.recipes.createOneFromImage(uploadedImage.value, uploadedImageName.value, translateLanguage); const { data, error } = await api.recipes.createOneFromImages(uploadedImages.value, translateLanguage?.value);
if (error || !data) { if (error || !data) {
alert.error(i18n.t("events.something-went-wrong")); alert.error(i18n.t("events.something-went-wrong"));
state.loading = false; state.loading = false;
} }
else { else {
router.push(`/g/${groupSlug.value}/r/${data}`); router.push(`/g/${groupSlug.value}/r/${data}`);
}; }
}
function updateUploadedImage(index: number, croppedImage: Blob) {
uploadedImages.value[index] = croppedImage;
} }
return { return {
...toRefs(state), ...toRefs(state),
domUrlForm, domUrlForm,
uploadedImage, uploadedImages,
uploadedImagePreviewUrl, uploadedImagesPreviewUrls,
shouldTranslate, shouldTranslate,
uploadImage, uploadImages,
updateUploadedImage,
clearImage, clearImage,
createRecipe, createRecipe,
updateUploadedImage,
}; };
}, },
}); });

View file

@ -25,7 +25,6 @@
<div style="width: 250px"> <div style="width: 250px">
<BaseButton <BaseButton
:disabled="newRecipeZip === null" :disabled="newRecipeZip === null"
large
rounded rounded
block block
:loading="loading" :loading="loading"

View file

@ -2,7 +2,7 @@
<div> <div>
<BasePageTitle <BasePageTitle
v-if="groupName" v-if="groupName"
class="bg-grey-darken-4 mt-n4 pt-8" class="mt-n4 pt-8"
> >
<template #header> <template #header>
<v-img <v-img

View file

@ -15,11 +15,12 @@
</template> </template>
{{ $t('migration.recipe-data-migrations-explanation') }} {{ $t('migration.recipe-data-migrations-explanation') }}
</BasePageTitle> </BasePageTitle>
<v-container> <v-container :class="$vuetify.display.smAndDown ? 'px-0': ''">
<BaseCardSectionTitle :title="$t('migration.new-migration')" /> <BaseCardSectionTitle :title="$t('migration.new-migration')" />
<v-card <v-card
variant="outlined" variant="outlined"
:loading="loading" :loading="loading"
style="border-color: lightgrey;"
> >
<v-card-title> {{ $t('migration.choose-migration-type') }} </v-card-title> <v-card-title> {{ $t('migration.choose-migration-type') }} </v-card-title>
<v-card-text <v-card-text
@ -83,7 +84,7 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-container> </v-container>
<v-container> <v-container class="$vuetify.display.smAndDown ? 'px-0': ''">
<BaseCardSectionTitle :title="$t('migration.previous-migrations')" /> <BaseCardSectionTitle :title="$t('migration.previous-migrations')" />
<ReportTable <ReportTable
:items="reports" :items="reports"

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>

View file

@ -15,6 +15,7 @@
<BaseDialog <BaseDialog
v-model="createDialog" v-model="createDialog"
:title="$t('events.new-notification')" :title="$t('events.new-notification')"
:icon="$globals.icons.bellPlus"
can-submit can-submit
@submit="createNewNotifier" @submit="createNewNotifier"
> >
@ -95,7 +96,7 @@
> >
<v-expansion-panel-title <v-expansion-panel-title
disable-icon-rotate disable-icon-rotate
class="headline" class="text-h6"
> >
<div class="d-flex align-center"> <div class="d-flex align-center">
{{ notifier.name }} {{ notifier.name }}
@ -103,6 +104,7 @@
<template #actions> <template #actions>
<v-btn <v-btn
icon icon
flat
class="ml-2" class="ml-2"
> >
<v-icon> <v-icon>

View file

@ -47,7 +47,7 @@
<!-- Form Container --> <!-- Form Container -->
<div class="d-flex justify-center grow items-center my-4"> <div class="d-flex justify-center grow items-center my-4">
<template v-if="state.ctx.state === States.Initial"> <template v-if="state.ctx.state === States.Initial">
<div width="600px"> <v-container>
<v-card-title class="text-h5 my-4 mb-5 pb-0 text-center"> <v-card-title class="text-h5 my-4 mb-5 pb-0 text-center">
{{ $t("user-registration.user-registration") }} {{ $t("user-registration.user-registration") }}
</v-card-title> </v-card-title>
@ -60,7 +60,7 @@
color="primary" color="primary"
dark dark
hover hover
width="300px" width="320px"
@click="initial.joinGroup" @click="initial.joinGroup"
> >
<v-card-title class="d-flex align-center justify-center py-3"> <v-card-title class="d-flex align-center justify-center py-3">
@ -77,8 +77,8 @@
color="primary" color="primary"
dark dark
hover hover
width="300px" width="320px"
@click="initial.joinGroup" @click="initial.createGroup"
> >
<v-card-title class="d-flex align-center justify-center py-3"> <v-card-title class="d-flex align-center justify-center py-3">
<v-icon <v-icon
@ -92,7 +92,7 @@
</v-card-title> </v-card-title>
</v-card> </v-card>
</div> </div>
</div> </v-container>
</template> </template>
<template v-else-if="state.ctx.state === States.ProvideToken"> <template v-else-if="state.ctx.state === States.ProvideToken">
@ -333,9 +333,7 @@ import type { VForm } from "~/types/auto-forms";
const inputAttrs = { const inputAttrs = {
variant: "filled", variant: "filled",
rounded: true,
validateOnBlur: true, validateOnBlur: true,
class: "rounded-lg",
}; };
export default defineNuxtComponent({ export default defineNuxtComponent({

View file

@ -17,21 +17,19 @@
<v-form @submit.prevent="requestLink()"> <v-form @submit.prevent="requestLink()">
<v-text-field <v-text-field
v-model="email" v-model="email"
:prepend-icon="$globals.icons.email" :prepend-inner-icon="$globals.icons.email"
variant="filled" variant="solo-filled"
rounded flat
autofocus autofocus
class="rounded-lg"
name="login" name="login"
:label="$t('user.email')" :label="$t('user.email')"
type="text" type="text"
/> />
<v-text-field <v-text-field
v-model="password" v-model="password"
variant="filled" variant="solo-filled"
rounded flat
class="rounded-lg" :prepend-inner-icon="$globals.icons.lock"
:prepend-icon="$globals.icons.lock"
name="password" name="password"
:label="$t('user.password')" :label="$t('user.password')"
type="password" type="password"
@ -39,11 +37,10 @@
/> />
<v-text-field <v-text-field
v-model="passwordConfirm" v-model="passwordConfirm"
variant="filled" variant="solo-filled"
rounded flat
validate-on="blur" validate-on="blur"
class="rounded-lg" :prepend-inner-icon="$globals.icons.lock"
:prepend-icon="$globals.icons.lock"
name="password" name="password"
:label="$t('user.confirm-password')" :label="$t('user.confirm-password')"
type="password" type="password"

View file

@ -41,7 +41,6 @@
<v-container> <v-container>
<v-row> <v-row>
<v-col <v-col
cols="3"
class="text-left" class="text-left"
> >
<ButtonLink <ButtonLink
@ -51,8 +50,9 @@
/> />
</v-col> </v-col>
<v-col <v-col
v-if="mdAndUp"
cols="6" cols="6"
class="d-none d-lg-flex justify-center" class="d-none d-sm-flex justify-center"
> >
<v-img <v-img
max-height="100" max-height="100"
@ -434,6 +434,7 @@ export default defineNuxtComponent({
}, },
// middleware: "sidebase-auth", // middleware: "sidebase-auth",
setup() { setup() {
const { mdAndUp } = useDisplay();
const i18n = useI18n(); const i18n = useI18n();
const $auth = useMealieAuth(); const $auth = useMealieAuth();
const preferences = useShoppingListPreferences(); const preferences = useShoppingListPreferences();
@ -1251,6 +1252,7 @@ export default defineNuxtComponent({
allFoods, allFoods,
getTextColor, getTextColor,
isOffline, isOffline,
mdAndUp,
}; };
}, },
}); });

View file

@ -17,9 +17,10 @@
<section class="d-flex justify-center"> <section class="d-flex justify-center">
<v-card <v-card
class="mt-4" class="mt-4"
width="500px" width="100%"
flat
> >
<v-card-text> <v-card-text class="px-0">
<v-form <v-form
ref="domNewTokenForm" ref="domNewTokenForm"
@submit.prevent @submit.prevent
@ -38,16 +39,16 @@
readonly readonly
rows="3" rows="3"
/> />
<v-list-subheader class="text-center"> <p>
{{ {{
$t( $t(
"settings.token.copy-this-token-for-use-with-an-external-application-this-token-will-not-be-viewable-again", "settings.token.copy-this-token-for-use-with-an-external-application-this-token-will-not-be-viewable-again",
) )
}} }}
</v-list-subheader> </p>
</template> </template>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions class="px-0">
<BaseButton <BaseButton
v-if="createdToken" v-if="createdToken"
cancel cancel
@ -78,13 +79,10 @@
:title="$t('settings.token.active-tokens')" :title="$t('settings.token.active-tokens')"
/> />
<section class="d-flex flex-column"> <section class="d-flex flex-column">
<v-list>
<div <div
v-for="(token, index) in user.tokens" v-for="(token, index) in user.tokens"
:key="index" :key="index"
>
<v-card
variant="outlined"
class="mb-2"
> >
<v-list-item> <v-list-item>
<v-list-item-title> <v-list-item-title>
@ -93,16 +91,17 @@
<v-list-item-subtitle> <v-list-item-subtitle>
{{ $t('general.created-on-date', [$d(new Date(token.createdAt!))]) }} {{ $t('general.created-on-date', [$d(new Date(token.createdAt!))]) }}
</v-list-item-subtitle> </v-list-item-subtitle>
<v-list-item-action> <template #append>
<BaseButton <BaseButton
delete delete
small small
@click="deleteToken(token.id)" @click="deleteToken(token.id)"
/> />
</v-list-item-action> </template>
</v-list-item> </v-list-item>
</v-card> <v-divider class="mx-2 my-2" />
</div> </div>
</v-list>
</section> </section>
</v-container> </v-container>
</template> </template>

View file

@ -149,7 +149,7 @@
variant="underlined" variant="underlined"
@click:append="showPassword = !showPassword" @click:append="showPassword = !showPassword"
/> />
<UserPasswordStrength :value="password.newOne" /> <UserPasswordStrength v-model="password.newOne" />
</v-form> </v-form>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>

View file

@ -1,5 +1,5 @@
<template> <template>
<v-container v-if="user"> <v-container v-if="user" class="mb-8">
<section class="d-flex flex-column align-center mt-4"> <section class="d-flex flex-column align-center mt-4">
<UserAvatar <UserAvatar
:tooltip="false" :tooltip="false"
@ -7,7 +7,7 @@
:user-id="user.id" :user-id="user.id"
/> />
<h2 class="text-h4"> <h2 class="text-h4 text-center">
{{ $t('profile.welcome-user', [user.fullName]) }} {{ $t('profile.welcome-user', [user.fullName]) }}
</h2> </h2>
<p class="subtitle-1 mb-0 text-center"> <p class="subtitle-1 mb-0 text-center">

View file

@ -7,8 +7,7 @@ export default defineNuxtPlugin(() => {
baseURL: "/", // api calls already pass with /api baseURL: "/", // api calls already pass with /api
timeout: 10000, timeout: 10000,
headers: { headers: {
"Content-Type": "application/json", Authorization: "Bearer " + useCookie(tokenName).value,
"Authorization": "Bearer " + useCookie(tokenName).value,
}, },
withCredentials: true, withCredentials: true,
}); });

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
import abc import abc
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Generic, TypeVar
import jwt import jwt
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
@ -13,10 +12,8 @@ ALGORITHM = "HS256"
ISS = "mealie" ISS = "mealie"
remember_me_duration = timedelta(days=14) remember_me_duration = timedelta(days=14)
T = TypeVar("T")
class AuthProvider[T](metaclass=abc.ABCMeta):
class AuthProvider(Generic[T], metaclass=abc.ABCMeta):
"""Base Authentication Provider interface""" """Base Authentication Provider interface"""
def __init__(self, session: Session, data: T) -> None: def __init__(self, session: Session, data: T) -> None:

View file

@ -449,7 +449,7 @@ class AppSettings(AppLoggingSettings):
def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, env_encoding="utf-8") -> AppSettings: def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, env_encoding="utf-8") -> AppSettings:
""" """
app_settings_constructor is a factory function that returns an AppSettings object. It is used to inject the app_settings_constructor is a factory function that returns an AppSettings object. It is used to inject the
required dependencies into the AppSettings object and nested child objects. AppSettings should not be substantiated required dependencies into the AppSettings object and nested child objects. AppSettings should not be instantiated
directly, but rather through this factory function. directly, but rather through this factory function.
""" """
secret_settings = { secret_settings = {

View file

@ -4,7 +4,7 @@ import random
from collections.abc import Iterable from collections.abc import Iterable
from datetime import UTC, datetime from datetime import UTC, datetime
from math import ceil from math import ceil
from typing import Any, Generic, TypeVar from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import UUID4, BaseModel from pydantic import UUID4, BaseModel
@ -28,18 +28,13 @@ from mealie.schema.response.query_search import SearchFilter
from ._utils import NOT_SET, NotSet from ._utils import NOT_SET, NotSet
Schema = TypeVar("Schema", bound=MealieModel)
Model = TypeVar("Model", bound=SqlAlchemyBase)
T = TypeVar("T", bound="RepositoryGeneric") class RepositoryGeneric[Schema: MealieModel, Model: SqlAlchemyBase]:
class RepositoryGeneric(Generic[Schema, Model]):
"""A Generic BaseAccess Model method to perform common operations on the database """A Generic BaseAccess Model method to perform common operations on the database
Args: Args:
Generic ([Schema]): Represents the Pydantic Model Schema: Represents the Pydantic Model
Generic ([Model]): Represents the SqlAlchemyModel Model Model: Represents the SqlAlchemyModel Model
""" """
session: Session session: Session
@ -467,7 +462,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
return search_filter.filter_query_by_search(query, schema, self.model) return search_filter.filter_query_by_search(query, schema, self.model)
class GroupRepositoryGeneric(RepositoryGeneric[Schema, Model]): class GroupRepositoryGeneric[Schema: MealieModel, Model: SqlAlchemyBase](RepositoryGeneric[Schema, Model]):
def __init__( def __init__(
self, self,
session: Session, session: Session,
@ -483,7 +478,7 @@ class GroupRepositoryGeneric(RepositoryGeneric[Schema, Model]):
self._group_id = group_id if group_id else None self._group_id = group_id if group_id else None
class HouseholdRepositoryGeneric(RepositoryGeneric[Schema, Model]): class HouseholdRepositoryGeneric[Schema: MealieModel, Model: SqlAlchemyBase](RepositoryGeneric[Schema, Model]):
def __init__( def __init__(
self, self,
session: Session, session: Session,

View file

@ -6,20 +6,18 @@ See their repository for details -> https://github.com/dmontagu/fastapi-utils
import inspect import inspect
from collections.abc import Callable from collections.abc import Callable
from typing import Any, ClassVar, ForwardRef, TypeVar, cast, get_origin, get_type_hints from typing import Any, ClassVar, ForwardRef, cast, get_origin, get_type_hints
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from starlette.routing import Route, WebSocketRoute from starlette.routing import Route, WebSocketRoute
T = TypeVar("T")
CBV_CLASS_KEY = "__cbv_class__" CBV_CLASS_KEY = "__cbv_class__"
INCLUDE_INIT_PARAMS_KEY = "__include_init_params__" INCLUDE_INIT_PARAMS_KEY = "__include_init_params__"
RETURN_TYPES_FUNC_KEY = "__return_types_func__" RETURN_TYPES_FUNC_KEY = "__return_types_func__"
def controller(router: APIRouter, *urls: str) -> Callable[[type[T]], type[T]]: def controller[T](router: APIRouter, *urls: str) -> Callable[[type[T]], type[T]]:
""" """
This function returns a decorator that converts the decorated into a class-based view for the provided router. This function returns a decorator that converts the decorated into a class-based view for the provided router.
Any methods of the decorated class that are decorated as endpoints using the router provided to this function Any methods of the decorated class that are decorated as endpoints using the router provided to this function
@ -36,7 +34,7 @@ def controller(router: APIRouter, *urls: str) -> Callable[[type[T]], type[T]]:
return decorator return decorator
def _cbv(router: APIRouter, cls: type[T], *urls: str, instance: Any | None = None) -> type[T]: def _cbv[T](router: APIRouter, cls: type[T], *urls: str, instance: Any | None = None) -> type[T]:
""" """
Replaces any methods of the provided class `cls` that are endpoints of routes in `router` with updated Replaces any methods of the provided class `cls` that are endpoints of routes in `router` with updated
function calls that will properly inject an instance of `cls`. function calls that will properly inject an instance of `cls`.

View file

@ -1,6 +1,5 @@
from collections.abc import Callable from collections.abc import Callable
from logging import Logger from logging import Logger
from typing import Generic, TypeVar
import sqlalchemy.exc import sqlalchemy.exc
from fastapi import HTTPException, status from fastapi import HTTPException, status
@ -9,12 +8,8 @@ from pydantic import UUID4, BaseModel
from mealie.repos.repository_generic import RepositoryGeneric from mealie.repos.repository_generic import RepositoryGeneric
from mealie.schema.response import ErrorResponse from mealie.schema.response import ErrorResponse
C = TypeVar("C", bound=BaseModel)
R = TypeVar("R", bound=BaseModel)
U = TypeVar("U", bound=BaseModel)
class HttpRepo[C: BaseModel, R: BaseModel, U: BaseModel]:
class HttpRepo(Generic[C, R, U]):
""" """
The HttpRepo[C, R, U] class is a mixin class that provides a common set of methods for CRUD operations. The HttpRepo[C, R, U] class is a mixin class that provides a common set of methods for CRUD operations.
This class is intended to be used in a composition pattern where a class has a mixin property. For example: This class is intended to be used in a composition pattern where a class has a mixin property. For example:

View file

@ -4,7 +4,7 @@ import re
from collections.abc import Sequence from collections.abc import Sequence
from datetime import UTC, datetime from datetime import UTC, datetime
from enum import Enum from enum import Enum
from typing import ClassVar, Protocol, Self, TypeVar from typing import ClassVar, Protocol, Self
from humps.main import camelize from humps.main import camelize
from pydantic import UUID4, AliasChoices, BaseModel, ConfigDict, Field, model_validator from pydantic import UUID4, AliasChoices, BaseModel, ConfigDict, Field, model_validator
@ -14,8 +14,6 @@ from sqlalchemy.orm.interfaces import LoaderOption
from mealie.db.models._model_base import SqlAlchemyBase from mealie.db.models._model_base import SqlAlchemyBase
T = TypeVar("T", bound=BaseModel)
HOUR_ONLY_TZ_PATTERN = re.compile(r"[+-]\d{2}$") HOUR_ONLY_TZ_PATTERN = re.compile(r"[+-]\d{2}$")
@ -56,7 +54,7 @@ class MealieModel(BaseModel):
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
def fix_hour_only_tz(cls, data: T) -> T: def fix_hour_only_tz[T: BaseModel](cls, data: T) -> T:
""" """
Fixes datetimes with timezones that only have the hour portion. Fixes datetimes with timezones that only have the hour portion.
@ -82,7 +80,7 @@ class MealieModel(BaseModel):
Adds UTC timezone information to all datetimes in the model. Adds UTC timezone information to all datetimes in the model.
The server stores everything in UTC without timezone info. The server stores everything in UTC without timezone info.
""" """
for field in self.model_fields: for field in self.__class__.model_fields:
val = getattr(self, field) val = getattr(self, field)
if not isinstance(val, datetime): if not isinstance(val, datetime):
continue continue
@ -91,23 +89,25 @@ class MealieModel(BaseModel):
return self return self
def cast(self, cls: type[T], **kwargs) -> T: def cast[T: BaseModel](self, cls: type[T], **kwargs) -> T:
""" """
Cast the current model to another with additional arguments. Useful for Cast the current model to another with additional arguments. Useful for
transforming DTOs into models that are saved to a database transforming DTOs into models that are saved to a database
""" """
create_data = {field: getattr(self, field) for field in self.model_fields if field in cls.model_fields} create_data = {
field: getattr(self, field) for field in self.__class__.model_fields if field in cls.model_fields
}
create_data.update(kwargs or {}) create_data.update(kwargs or {})
return cls(**create_data) return cls(**create_data)
def map_to(self, dest: T) -> T: def map_to[T: BaseModel](self, dest: T) -> T:
""" """
Map matching values from the current model to another model. Model returned Map matching values from the current model to another model. Model returned
for method chaining. for method chaining.
""" """
for field in self.model_fields: for field in self.__class__.model_fields:
if field in dest.model_fields: if field in dest.__class__.model_fields:
setattr(dest, field, getattr(self, field)) setattr(dest, field, getattr(self, field))
return dest return dest
@ -117,18 +117,18 @@ class MealieModel(BaseModel):
Map matching values from another model to the current model. Map matching values from another model to the current model.
""" """
for field in src.model_fields: for field in src.__class__.model_fields:
if field in self.model_fields: if field in self.__class__.model_fields:
setattr(self, field, getattr(src, field)) setattr(self, field, getattr(src, field))
def merge(self, src: T, replace_null=False): def merge[T: BaseModel](self, src: T, replace_null=False):
""" """
Replace matching values from another instance to the current instance. Replace matching values from another instance to the current instance.
""" """
for field in src.model_fields: for field in src.__class__.model_fields:
val = getattr(src, field) val = getattr(src, field)
if field in self.model_fields and (val is not None or replace_null): if field in self.__class__.model_fields and (val is not None or replace_null):
setattr(self, field, val) setattr(self, field, val)
@classmethod @classmethod

View file

@ -1,24 +1,21 @@
from typing import TypeVar
from pydantic import BaseModel from pydantic import BaseModel
T = TypeVar("T", bound=BaseModel)
U = TypeVar("U", bound=BaseModel)
def mapper[U: BaseModel, T: BaseModel](source: U, dest: T, **_) -> T:
def mapper(source: U, dest: T, **_) -> T:
""" """
Map a source model to a destination model. Only top-level fields are mapped. Map a source model to a destination model. Only top-level fields are mapped.
""" """
for field in source.model_fields: for field in source.__class__.model_fields:
if field in dest.model_fields: if field in dest.__class__.model_fields:
setattr(dest, field, getattr(source, field)) setattr(dest, field, getattr(source, field))
return dest return dest
def cast(source: U, dest: type[T], **kwargs) -> T: def cast[U: BaseModel, T: BaseModel](source: U, dest: type[T], **kwargs) -> T:
create_data = {field: getattr(source, field) for field in source.model_fields if field in dest.model_fields} create_data = {
field: getattr(source, field) for field in source.__class__.model_fields if field in dest.model_fields
}
create_data.update(kwargs or {}) create_data.update(kwargs or {})
return dest(**create_data) return dest(**create_data)

View file

@ -184,8 +184,9 @@ class OpenAIRecipe(OpenAIBase):
[], [],
description=dedent( description=dedent(
""" """
A list of ingredients used in the recipe. Ingredients should be inserted in the order they appear in the A list of instructions for the recipe. Each instruction should represent one step in the recipe,
recipe. If the recipe has no ingredients, you should return an empty list. and should be inserted in the order they appear in the recipe. If the recipe has no instructions,
you should return an empty list.
Often times, but not always, instructions are separated by line breaks and/or separated by paragraphs. Often times, but not always, instructions are separated by line breaks and/or separated by paragraphs.
Use these as a guide to separate instructions. They also may be separated by numbers or words, such as Use these as a guide to separate instructions. They also may be separated by numbers or words, such as

View file

@ -1,5 +1,5 @@
import enum import enum
from typing import Annotated, Any, Generic, TypeVar from typing import Annotated, Any
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
from humps import camelize from humps import camelize
@ -8,8 +8,6 @@ from pydantic_core.core_schema import ValidationInfo
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
DataT = TypeVar("DataT", bound=BaseModel)
class OrderDirection(str, enum.Enum): class OrderDirection(str, enum.Enum):
asc = "asc" asc = "asc"
@ -50,7 +48,7 @@ class PaginationQuery(RequestQuery):
per_page: int = 50 per_page: int = 50
class PaginationBase(BaseModel, Generic[DataT]): class PaginationBase[DataT: BaseModel](BaseModel):
page: int = 1 page: int = 1
per_page: int = 10 per_page: int = 10
total: int = 0 total: int = 0

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import re import re
from collections import deque from collections import deque
from enum import Enum from enum import Enum
from typing import Any, TypeVar, cast from typing import Any, cast
from uuid import UUID from uuid import UUID
import sqlalchemy as sa import sqlalchemy as sa
@ -19,8 +19,6 @@ from mealie.db.models._model_utils.datetime import NaiveDateTime
from mealie.db.models._model_utils.guid import GUID from mealie.db.models._model_utils.guid import GUID
from mealie.schema._mealie.mealie_model import MealieModel from mealie.schema._mealie.mealie_model import MealieModel
Model = TypeVar("Model", bound=SqlAlchemyBase)
class RelationalKeyword(Enum): class RelationalKeyword(Enum):
IS = "IS" IS = "IS"
@ -274,7 +272,7 @@ class QueryFilterBuilder:
return consolidated_group_builder.self_group() return consolidated_group_builder.self_group()
@classmethod @classmethod
def get_model_and_model_attr_from_attr_string( def get_model_and_model_attr_from_attr_string[Model: SqlAlchemyBase](
cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, sa.Select | None]: ) -> tuple[SqlAlchemyBase, InstrumentedAttribute, sa.Select | None]:
""" """
@ -343,7 +341,7 @@ class QueryFilterBuilder:
return model_attr return model_attr
@classmethod @classmethod
def _get_filter_element( def _get_filter_element[Model: SqlAlchemyBase](
cls, cls,
query: sa.Select, query: sa.Select,
component: QueryFilterBuilderComponent, component: QueryFilterBuilderComponent,
@ -397,7 +395,7 @@ class QueryFilterBuilder:
return element return element
def filter_query( def filter_query[Model: SqlAlchemyBase](
self, query: sa.Select, model: type[Model], column_aliases: dict[str, sa.ColumnElement] | None = None self, query: sa.Select, model: type[Model], column_aliases: dict[str, sa.ColumnElement] | None = None
) -> sa.Select: ) -> sa.Select:
""" """

View file

@ -1,6 +1,6 @@
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Annotated, Any, Generic, TypeVar from typing import Annotated, Any
from uuid import UUID from uuid import UUID
from pydantic import UUID4, BaseModel, ConfigDict, Field, StringConstraints, field_validator from pydantic import UUID4, BaseModel, ConfigDict, Field, StringConstraints, field_validator
@ -20,7 +20,6 @@ from mealie.schema.response.pagination import PaginationBase
from ...db.models.group import Group from ...db.models.group import Group
from ..recipe import CategoryBase from ..recipe import CategoryBase
DataT = TypeVar("DataT", bound=BaseModel)
DEFAULT_INTEGRATION_ID = "generic" DEFAULT_INTEGRATION_ID = "generic"
settings = get_app_settings() settings = get_app_settings()
@ -102,7 +101,7 @@ class UserRatingOut(UserRatingCreate):
] ]
class UserRatings(BaseModel, Generic[DataT]): class UserRatings[DataT: BaseModel](BaseModel):
ratings: list[DataT] ratings: list[DataT]

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from typing import TYPE_CHECKING, TypeVar from typing import TYPE_CHECKING
from pydantic import BaseModel from pydantic import BaseModel
from slugify import slugify from slugify import slugify
@ -12,8 +12,6 @@ from mealie.schema.recipe import RecipeCategory
from mealie.schema.recipe.recipe import RecipeTag from mealie.schema.recipe.recipe import RecipeTag
from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagOut, TagSave from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagOut, TagSave
T = TypeVar("T", bound=BaseModel)
if TYPE_CHECKING: if TYPE_CHECKING:
from mealie.repos.repository_generic import RepositoryGeneric from mealie.repos.repository_generic import RepositoryGeneric
@ -23,7 +21,7 @@ class DatabaseMigrationHelpers:
self.session = session self.session = session
self.db = db self.db = db
def _get_or_set_generic( def _get_or_set_generic[T: BaseModel](
self, accessor: RepositoryGeneric, items: Iterable[str], create_model: type[T], out_model: type[T] self, accessor: RepositoryGeneric, items: Iterable[str], create_model: type[T], out_model: type[T]
) -> list[T]: ) -> list[T]:
""" """

View file

@ -1,5 +1,4 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import TypeVar
from pydantic import UUID4, BaseModel from pydantic import UUID4, BaseModel
from rapidfuzz import fuzz, process from rapidfuzz import fuzz, process
@ -17,8 +16,6 @@ from mealie.schema.recipe.recipe_ingredient import (
) )
from mealie.schema.response.pagination import PaginationQuery from mealie.schema.response.pagination import PaginationQuery
T = TypeVar("T", bound=BaseModel)
class DataMatcher: class DataMatcher:
def __init__( def __init__(
@ -83,7 +80,9 @@ class DataMatcher:
return self._units_by_alias return self._units_by_alias
@classmethod @classmethod
def find_match(cls, match_value: str, *, store_map: dict[str, T], fuzzy_match_threshold: int = 0) -> T | None: def find_match[T: BaseModel](
cls, match_value: str, *, store_map: dict[str, T], fuzzy_match_threshold: int = 0
) -> T | None:
# check for literal matches # check for literal matches
if match_value in store_map: if match_value in store_map:
return store_map[match_value] return store_map[match_value]

65
poetry.lock generated
View file

@ -1855,14 +1855,14 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]] [[package]]
name = "openai" name = "openai"
version = "1.88.0" version = "1.92.2"
description = "The official Python library for the openai API" description = "The official Python library for the openai API"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "openai-1.88.0-py3-none-any.whl", hash = "sha256:7edd7826b3b83f5846562a6f310f040c79576278bf8e3687b30ba05bb5dff978"}, {file = "openai-1.92.2-py3-none-any.whl", hash = "sha256:abb64bee7f2571709edf9a856f598ffe871730129a7d807a8a4d8d2958f5c842"},
{file = "openai-1.88.0.tar.gz", hash = "sha256:122d35e42998255cf1fc84560f6ee49a844e65c054cd05d3e42fda506b832bb1"}, {file = "openai-1.92.2.tar.gz", hash = "sha256:b571a79fc7e165e7d00e6963a8a95eb5f42b60ac89fd316f1dc0a2dac5c6fae1"},
] ]
[package.dependencies] [package.dependencies]
@ -1876,6 +1876,7 @@ tqdm = ">4"
typing-extensions = ">=4.11,<5" typing-extensions = ">=4.11,<5"
[package.extras] [package.extras]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.6)"]
datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
realtime = ["websockets (>=13,<16)"] realtime = ["websockets (>=13,<16)"]
voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"]
@ -2511,14 +2512,14 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]] [[package]]
name = "pydantic-settings" name = "pydantic-settings"
version = "2.9.1" version = "2.10.1"
description = "Settings management using Pydantic" description = "Settings management using Pydantic"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef"}, {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"},
{file = "pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268"}, {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"},
] ]
[package.dependencies] [package.dependencies]
@ -2675,14 +2676,14 @@ requests = ">=2.32.3"
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.4.0" version = "8.4.1"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"] groups = ["dev"]
files = [ files = [
{file = "pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e"}, {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"},
{file = "pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6"}, {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"},
] ]
[package.dependencies] [package.dependencies]
@ -2799,14 +2800,14 @@ six = ">=1.5"
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.1.0" version = "1.1.1"
description = "Read key-value pairs from a .env file and set them as environment variables" description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d"}, {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"},
{file = "python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5"}, {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"},
] ]
[package.extras] [package.extras]
@ -3249,30 +3250,30 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.11.13" version = "0.12.1"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["dev"] groups = ["dev"]
files = [ files = [
{file = "ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46"}, {file = "ruff-0.12.1-py3-none-linux_armv6l.whl", hash = "sha256:6013a46d865111e2edb71ad692fbb8262e6c172587a57c0669332a449384a36b"},
{file = "ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48"}, {file = "ruff-0.12.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b3f75a19e03a4b0757d1412edb7f27cffb0c700365e9d6b60bc1b68d35bc89e0"},
{file = "ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b"}, {file = "ruff-0.12.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9a256522893cb7e92bb1e1153283927f842dea2e48619c803243dccc8437b8be"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a"}, {file = "ruff-0.12.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:069052605fe74c765a5b4272eb89880e0ff7a31e6c0dbf8767203c1fbd31c7ff"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc"}, {file = "ruff-0.12.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a684f125a4fec2d5a6501a466be3841113ba6847827be4573fddf8308b83477d"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629"}, {file = "ruff-0.12.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdecdef753bf1e95797593007569d8e1697a54fca843d78f6862f7dc279e23bd"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933"}, {file = "ruff-0.12.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:70d52a058c0e7b88b602f575d23596e89bd7d8196437a4148381a3f73fcd5010"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165"}, {file = "ruff-0.12.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84d0a69d1e8d716dfeab22d8d5e7c786b73f2106429a933cee51d7b09f861d4e"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71"}, {file = "ruff-0.12.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cc32e863adcf9e71690248607ccdf25252eeeab5193768e6873b901fd441fed"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9"}, {file = "ruff-0.12.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fd49a4619f90d5afc65cf42e07b6ae98bb454fd5029d03b306bd9e2273d44cc"},
{file = "ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc"}, {file = "ruff-0.12.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ed5af6aaaea20710e77698e2055b9ff9b3494891e1b24d26c07055459bb717e9"},
{file = "ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7"}, {file = "ruff-0.12.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:801d626de15e6bf988fbe7ce59b303a914ff9c616d5866f8c79eb5012720ae13"},
{file = "ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432"}, {file = "ruff-0.12.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2be9d32a147f98a1972c1e4df9a6956d612ca5f5578536814372113d09a27a6c"},
{file = "ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492"}, {file = "ruff-0.12.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:49b7ce354eed2a322fbaea80168c902de9504e6e174fd501e9447cad0232f9e6"},
{file = "ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250"}, {file = "ruff-0.12.1-py3-none-win32.whl", hash = "sha256:d973fa626d4c8267848755bd0414211a456e99e125dcab147f24daa9e991a245"},
{file = "ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3"}, {file = "ruff-0.12.1-py3-none-win_amd64.whl", hash = "sha256:9e1123b1c033f77bd2590e4c1fe7e8ea72ef990a85d2484351d408224d603013"},
{file = "ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b"}, {file = "ruff-0.12.1-py3-none-win_arm64.whl", hash = "sha256:78ad09a022c64c13cc6077707f036bab0fac8cd7088772dcd1e5be21c5002efc"},
{file = "ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514"}, {file = "ruff-0.12.1.tar.gz", hash = "sha256:806bbc17f1104fd57451a98a58df35388ee3ab422e029e8f5cf30aa4af2c138c"},
] ]
[[package]] [[package]]
@ -3884,4 +3885,4 @@ pgsql = ["psycopg2-binary"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.12,<3.13" python-versions = ">=3.12,<3.13"
content-hash = "2b8479e18ef741f5254b8c9d64566bf42d597cfb6564c1aa622f6a1afb117402" content-hash = "632cd8ef199c2668bc799a1cf4f370161dc13ff7dcf76ed40f3c94a0896e304f"

View file

@ -69,7 +69,7 @@ pylint = "^3.0.0"
pytest = "^8.0.0" pytest = "^8.0.0"
pytest-asyncio = "^1.0.0" pytest-asyncio = "^1.0.0"
rich = "^14.0.0" rich = "^14.0.0"
ruff = "^0.11.0" ruff = "^0.12.0"
types-PyYAML = "^6.0.4" types-PyYAML = "^6.0.4"
types-python-dateutil = "^2.8.18" types-python-dateutil = "^2.8.18"
types-python-slugify = "^6.0.0" types-python-slugify = "^6.0.0"