Merge branch 'mealie-next' into mealie-next

This commit is contained in:
Michael Genson 2025-08-07 10:55:29 -05:00 committed by GitHub
commit 6ba67582c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
191 changed files with 7064 additions and 8025 deletions

View file

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

View file

@ -35,7 +35,7 @@ conventional_commits = true
filter_unconventional = true
# regex for preprocessing the commit messages
commit_preprocessors = [
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/hay-kot/mealie/issues/${2}))"},
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/mealie-recipes/mealie/issues/${2}))"},
]
# regex for parsing and grouping commits
commit_parsers = [

View file

@ -44,7 +44,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 cup unsalted butter, cut into cubes",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "ea3b6702-9532-4fbc-a40b-f99917831c26",
@ -54,7 +53,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 cup light brown sugar",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "c5bbfefb-1e23-4ffd-af88-c0363a0fae82",
@ -64,7 +62,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1/2 cup granulated white sugar",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "034f481b-c426-4a17-b983-5aea9be4974b",
@ -74,7 +71,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 large eggs",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "37c1f796-3bdb-4856-859f-dbec90bc27e4",
@ -84,7 +80,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 tsp vanilla extract",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "85561ace-f249-401d-834c-e600a2f6280e",
@ -94,7 +89,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1/2 cup creamy peanut butter",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "ac91bda0-e8a8-491a-976a-ae4e72418cfd",
@ -104,7 +98,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 tsp cornstarch",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "4d1256b3-115e-4475-83cd-464fbc304cb0",
@ -114,7 +107,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 tsp baking soda",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "64627441-39f9-4ee3-8494-bafe36451d12",
@ -124,7 +116,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1/2 tsp salt",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "7ae212d0-3cd1-44b0-899e-ec5bd91fd384",
@ -134,7 +125,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1 cup cake flour",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "06967994-8548-4952-a8cc-16e8db228ebd",
@ -144,7 +134,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 cups all-purpose flour",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "bdb33b23-c767-4465-acf8-3b8e79eb5691",
@ -154,7 +143,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "2 cups peanut butter chips",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "12ba0af8-affd-4fb2-9cca-6f1b3e8d3aef",
@ -164,7 +152,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"note": "1½ cups Reese's Pieces candies",
"unit": None,
"food": None,
"disableAmount": True,
"quantity": 1,
"originalText": None,
"referenceId": "4bdc0598-a3eb-41ee-8af0-4da9348fbfe2",
@ -221,7 +208,6 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
"showAssets": False,
"landscapeView": False,
"disableComments": False,
"disableAmount": True,
"locked": False,
},
"assets": [],

View file

@ -13,14 +13,14 @@ Steps:
#### 1. Get your API Token
Create an API token from Mealie's User Settings page (https://hay-kot.github.io/mealie/documentation/users-groups/user-settings/#api-key-generation)
Create an API token from Mealie's User Settings page (https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-token)
#### 2. Create Home Assistant Sensors
Create REST sensors in home assistant to get the details of today's meal.
We will create sensors to get the name and ID of the first meal in today's meal plan (note that this may not be what is wanted if there is more than one meal planned for the day). We need the ID as well as the name to be able to retrieve the image for the meal.
Make sure the url and port (`http://mealie:9000` ) matches your installation's address and _API_ port.
Make sure the url and port (`http://mealie:9000`) matches your installation's address and _API_ port.
```yaml
rest:

View file

@ -1,5 +1,8 @@
# Installing with PostgreSQL
!!! Warning
When upgrading postgresql major versions, manual steps are required [Postgres#37](https://github.com/docker-library/postgres/issues/37).
PostgreSQL might be considered if you need to support many concurrent users. In addition, some features are only enabled on PostgreSQL, such as fuzzy search.
**For Environment Variable Configuration, see** [Backend Configuration](./backend-config.md)
@ -38,7 +41,7 @@ services:
postgres:
container_name: postgres
image: postgres:15
image: postgres:17
restart: always
volumes:
- mealie-pgdata:/var/lib/postgresql/data
@ -46,6 +49,7 @@ services:
POSTGRES_PASSWORD: mealie
POSTGRES_USER: mealie
PGUSER: mealie
POSTGRES_DB: mealie
healthcheck:
test: ["CMD", "pg_isready"]
interval: 30s

View file

@ -2,6 +2,3 @@
## Feature Requests
[Please request new features on Github](https://github.com/mealie-recipes/mealie/discussions/new?category=feature-request)
## Progress
See the [Github Projects page](https://github.com/users/hay-kot/projects/2) to see what is currently being worked on

File diff suppressed because one or more lines are too long

View file

@ -351,7 +351,7 @@
<!-- Custom narrow footer -->
<div class="md-footer-meta__inner md-grid">
<div class="md-footer-social">
<a class="md-footer-social__link" href="https://github.com/hay-kot/mealie" rel="noopener" target="_blank"
<a class="md-footer-social__link" href="https://github.com/mealie-recipes/mealie" rel="noopener" target="_blank"
title="github.com">
<svg style="width: 32px; height: 32px" viewBox="0 0 480 512" xmlns="http://www.w3.org/2000/svg">
<path

View file

@ -44,36 +44,20 @@
</div>
</template>
<script lang="ts">
import type { ReadCookBook } from "~/lib/api/types/cookbook";
<script setup lang="ts">
import { Organizer } from "~/lib/api/types/non-generated";
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import type { ReadCookBook } from "~/lib/api/types/cookbook";
export default defineNuxtComponent({
components: { QueryFilterBuilder },
props: {
modelValue: {
type: Object as () => ReadCookBook,
required: true,
},
actions: {
type: Object as () => any,
required: true,
},
},
emits: ["update:modelValue"],
setup(props, { emit }) {
const i18n = useI18n();
const cookbook = toRef(() => props.modelValue);
function handleInput(value: string | undefined) {
const modelValue = defineModel<ReadCookBook>({ required: true });
const i18n = useI18n();
const cookbook = toRef(modelValue);
function handleInput(value: string | undefined) {
cookbook.value.queryFilterString = value || "";
emit("update:modelValue", cookbook.value);
}
}
const fieldDefs: FieldDefinition[] = [
const fieldDefs: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.t("category.categories"),
@ -109,13 +93,5 @@ export default defineNuxtComponent({
label: i18n.t("general.date-updated"),
type: "date",
},
];
return {
cookbook,
handleInput,
fieldDefs,
};
},
});
];
</script>

View file

@ -17,7 +17,6 @@
<v-card-text>
<CookbookEditor
v-model="editTarget"
:actions="actions"
/>
</v-card-text>
</BaseDialog>
@ -65,7 +64,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLazyRecipes } from "~/composables/recipes";
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { useCookbookStore } from "~/composables/store/use-cookbook-store";
@ -74,44 +73,40 @@ import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { RecipeCookBook } from "~/lib/api/types/cookbook";
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
export default defineNuxtComponent({
components: { RecipeCardSection, CookbookEditor },
setup() {
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const slug = route.params.slug as string;
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
const { actions } = useCookbookStore();
const router = useRouter();
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const slug = route.params.slug as string;
const { getOne } = useCookbook(isOwnGroup.value ? null : groupSlug.value);
const { actions } = useCookbookStore();
const router = useRouter();
const tab = ref(null);
const book = getOne(slug);
const book = getOne(slug);
const isOwnHousehold = computed(() => {
const isOwnHousehold = computed(() => {
if (!($auth.user.value && book.value?.householdId)) {
return false;
}
return $auth.user.value.householdId === book.value.householdId;
});
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
});
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
const dialogStates = reactive({
const dialogStates = reactive({
edit: false,
});
});
const editTarget = ref<RecipeCookBook | null>(null);
function handleEditCookbook() {
const editTarget = ref<RecipeCookBook | null>(null);
function handleEditCookbook() {
dialogStates.edit = true;
editTarget.value = book.value;
}
}
async function editCookbook() {
async function editCookbook() {
if (!editTarget.value) {
return;
}
@ -127,28 +122,9 @@ export default defineNuxtComponent({
}
dialogStates.edit = false;
editTarget.value = null;
}
}
useSeoMeta({
useSeoMeta({
title: book?.value?.name || "Cookbook",
});
return {
book,
slug,
tab,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
canEdit,
dialogStates,
editTarget,
handleEditCookbook,
editCookbook,
actions,
};
},
});
</script>

View file

@ -20,45 +20,33 @@
</v-data-table>
</template>
<script lang="ts">
<script setup lang="ts">
import { parseISO, formatDistanceToNow } from "date-fns";
import type { GroupDataExport } from "~/lib/api/types/group";
export default defineNuxtComponent({
props: {
exports: {
type: Array as () => GroupDataExport[],
required: true,
},
},
setup() {
const i18n = useI18n();
defineProps<{
exports: GroupDataExport[];
}>();
const headers = [
const i18n = useI18n();
const headers = [
{ title: i18n.t("export.export"), value: "name" },
{ title: i18n.t("export.file-name"), value: "filename" },
{ title: i18n.t("export.size"), value: "size" },
{ title: i18n.t("export.link-expires"), value: "expires" },
{ title: "", value: "actions" },
];
];
function getTimeToExpire(timeString: string) {
function getTimeToExpire(timeString: string) {
const expiresAt = parseISO(timeString);
return formatDistanceToNow(expiresAt, {
addSuffix: false,
});
}
}
function downloadData(_: any) {
function downloadData(_: any) {
console.log("Downloading data...");
}
return {
downloadData,
headers,
getTimeToExpire,
};
},
});
}
</script>

View file

@ -9,30 +9,10 @@
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
modelValue: {
type: Object,
required: true,
},
},
emits: ["update:modelValue"],
setup(props, context) {
const preferences = computed({
get() {
return props.modelValue;
},
set(val) {
context.emit("update:modelValue", val);
},
});
<script setup lang="ts">
import type { ReadGroupPreferences } from "~/lib/api/types/user";
return {
preferences,
};
},
});
const preferences = defineModel<ReadGroupPreferences>({ required: true });
</script>
<style lang="scss" scoped></style>

View file

@ -1,91 +0,0 @@
<template>
<v-select
v-model="selected"
:items="households"
:label="label"
:hint="description"
:persistent-hint="!!description"
item-title="name"
:multiple="multiselect"
:prepend-inner-icon="$globals.icons.household"
return-object
>
<template #chip="data">
<v-chip
:key="data.index"
class="ma-1"
:input-value="data.item"
size="small"
closable
label
color="accent"
dark
@click:close="removeByIndex(data.index)"
>
{{ data.item.raw.name || data.item }}
</v-chip>
</template>
</v-select>
</template>
<script lang="ts">
import { useHouseholdStore } from "~/composables/store/use-household-store";
interface HouseholdLike {
id: string;
name: string;
}
export default defineNuxtComponent({
props: {
modelValue: {
type: Array as () => HouseholdLike[],
required: true,
},
multiselect: {
type: Boolean,
default: false,
},
description: {
type: String,
default: "",
},
},
emits: ["update:modelValue"],
setup(props, context) {
const selected = computed({
get: () => props.modelValue,
set: (val) => {
context.emit("update:modelValue", val);
},
});
onMounted(() => {
if (selected.value === undefined) {
selected.value = [];
}
});
const i18n = useI18n();
const label = computed(
() => props.multiselect ? i18n.t("household.households") : i18n.t("household.household"),
);
const { store: households } = useHouseholdStore();
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
return {
selected,
label,
households,
removeByIndex,
};
},
});
</script>

View file

@ -18,7 +18,7 @@
:open-on-hover="mdAndUp"
content-class="d-print-none"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-btn
:class="{ 'rounded-circle': fab }"
:size="fab ? 'small' : undefined"
@ -26,7 +26,7 @@
:icon="!fab"
variant="text"
dark
v-bind="props"
v-bind="activatorProps"
@click.prevent
>
<v-icon>{{ icon }}</v-icon>
@ -50,7 +50,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { Recipe } from "~/lib/api/types/recipe";
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
import type { ShoppingListSummary } from "~/lib/api/types/household";
@ -64,40 +64,32 @@ export interface ContextMenuItem {
isPublic: boolean;
}
export default defineNuxtComponent({
components: {
RecipeDialogAddToShoppingList,
},
props: {
recipes: {
type: Array as () => Recipe[],
default: () => [],
},
menuTop: {
type: Boolean,
default: true,
},
fab: {
type: Boolean,
default: false,
},
color: {
type: String,
default: "primary",
},
menuIcon: {
type: String,
default: null,
},
},
setup(props, context) {
const { mdAndUp } = useDisplay();
interface Props {
recipes?: Recipe[];
menuTop?: boolean;
fab?: boolean;
color?: string;
menuIcon?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
recipes: () => [],
menuTop: true,
fab: false,
color: "primary",
menuIcon: null,
});
const i18n = useI18n();
const { $globals } = useNuxtApp();
const api = useUserApi();
const emit = defineEmits<{
[key: string]: [];
}>();
const state = reactive({
const { mdAndUp } = useDisplay();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const api = useUserApi();
const state = reactive({
loading: false,
shoppingListDialog: false,
menuItems: [
@ -109,36 +101,38 @@ export default defineNuxtComponent({
isPublic: false,
},
],
});
});
const icon = props.menuIcon || $globals.icons.dotsVertical;
const { shoppingListDialog, menuItems } = toRefs(state);
const shoppingLists = ref<ShoppingListSummary[]>();
const recipesWithScales = computed(() => {
const icon = props.menuIcon || $globals.icons.dotsVertical;
const shoppingLists = ref<ShoppingListSummary[]>();
const recipesWithScales = computed(() => {
return props.recipes.map((recipe) => {
return {
scale: 1,
...recipe,
};
});
});
});
async function getShoppingLists() {
async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (data) {
shoppingLists.value = data.items as ShoppingListSummary[] ?? [];
}
}
}
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
shoppingList: () => {
getShoppingLists();
state.shoppingListDialog = true;
},
};
};
function contextMenuEventHandler(eventKey: string) {
function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") {
@ -147,18 +141,7 @@ export default defineNuxtComponent({
return;
}
context.emit(eventKey);
emit(eventKey);
state.loading = false;
}
return {
...toRefs(state),
contextMenuEventHandler,
icon,
recipesWithScales,
shoppingLists,
mdAndUp,
};
},
});
}
</script>

View file

@ -5,12 +5,12 @@
style="gap: 10px"
>
<v-select
v-model="inputDay"
v-model="day"
:items="MEAL_DAY_OPTIONS"
:label="$t('meal-plan.rule-day')"
/>
<v-select
v-model="inputEntryType"
v-model="entryType"
:items="MEAL_TYPE_OPTIONS"
:label="$t('meal-plan.meal-type')"
/>
@ -19,64 +19,49 @@
<div class="mb-5">
<QueryFilterBuilder
:field-defs="fieldDefs"
:initial-query-filter="queryFilter"
:initial-query-filter="props.queryFilter"
@input="handleQueryFilterInput"
/>
</div>
<!-- TODO: proper pluralization of inputDay -->
{{ $t('meal-plan.this-rule-will-apply', {
dayCriteria: inputDay === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [inputDay]),
mealTypeCriteria: inputEntryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [inputEntryType]),
dayCriteria: day === "unset" ? $t('meal-plan.to-all-days') : $t('meal-plan.on-days', [day]),
mealTypeCriteria: entryType === "unset" ? $t('meal-plan.for-all-meal-types') : $t('meal-plan.for-type-meal-types', [entryType]),
}) }}
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
import type { FieldDefinition } from "~/composables/use-query-filter-builder";
import { Organizer } from "~/lib/api/types/non-generated";
import type { QueryFilterJSON } from "~/lib/api/types/response";
export default defineNuxtComponent({
components: {
QueryFilterBuilder,
},
props: {
day: {
type: String,
default: "unset",
},
entryType: {
type: String,
default: "unset",
},
queryFilterString: {
type: String,
default: "",
},
queryFilter: {
type: Object as () => QueryFilterJSON,
default: null,
},
showHelp: {
type: Boolean,
default: false,
},
},
emits: ["update:day", "update:entry-type", "update:query-filter-string"],
setup(props, context) {
const i18n = useI18n();
interface Props {
queryFilter?: QueryFilterJSON | null;
showHelp?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
queryFilter: null,
showHelp: false,
});
const MEAL_TYPE_OPTIONS = [
const day = defineModel<string>("day", { default: "unset" });
const entryType = defineModel<string>("entryType", { default: "unset" });
const queryFilterString = defineModel<string>("queryFilterString", { default: "" });
const i18n = useI18n();
const MEAL_TYPE_OPTIONS = [
{ title: i18n.t("meal-plan.breakfast"), value: "breakfast" },
{ title: i18n.t("meal-plan.lunch"), value: "lunch" },
{ title: i18n.t("meal-plan.dinner"), value: "dinner" },
{ title: i18n.t("meal-plan.side"), value: "side" },
{ title: i18n.t("meal-plan.type-any"), value: "unset" },
];
];
const MEAL_DAY_OPTIONS = [
const MEAL_DAY_OPTIONS = [
{ title: i18n.t("general.monday"), value: "monday" },
{ title: i18n.t("general.tuesday"), value: "tuesday" },
{ title: i18n.t("general.wednesday"), value: "wednesday" },
@ -85,40 +70,14 @@ export default defineNuxtComponent({
{ title: i18n.t("general.saturday"), value: "saturday" },
{ title: i18n.t("general.sunday"), value: "sunday" },
{ title: i18n.t("meal-plan.day-any"), value: "unset" },
];
];
const inputDay = computed({
get: () => {
return props.day;
},
set: (val) => {
context.emit("update:day", val);
},
});
function handleQueryFilterInput(value: string | undefined) {
console.warn("handleQueryFilterInput called with value:", value);
queryFilterString.value = value || "";
}
const inputEntryType = computed({
get: () => {
return props.entryType;
},
set: (val) => {
context.emit("update:entry-type", val);
},
});
const inputQueryFilterString = computed({
get: () => {
return props.queryFilterString;
},
set: (val) => {
context.emit("update:query-filter-string", val);
},
});
function handleQueryFilterInput(value: string | undefined) {
inputQueryFilterString.value = value || "";
};
const fieldDefs: FieldDefinition[] = [
const fieldDefs: FieldDefinition[] = [
{
name: "recipe_category.id",
label: i18n.t("category.categories"),
@ -159,17 +118,5 @@ export default defineNuxtComponent({
label: i18n.t("general.date-updated"),
type: "date",
},
];
return {
MEAL_TYPE_OPTIONS,
MEAL_DAY_OPTIONS,
inputDay,
inputEntryType,
inputQueryFilterString,
handleQueryFilterInput,
fieldDefs,
};
},
});
];
</script>

View file

@ -16,11 +16,11 @@
:label="$t('settings.webhooks.webhook-url')"
variant="underlined"
/>
<v-time-picker
<v-text-field
v-model="scheduledTime"
class="elevation-2"
ampm-in-title
format="ampm"
type="time"
clearable
variant="underlined"
/>
</v-card-text>
<v-card-actions class="py-0 justify-end">
@ -50,24 +50,25 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ReadWebhook } from "~/lib/api/types/household";
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
export default defineNuxtComponent({
props: {
webhook: {
type: Object as () => ReadWebhook,
required: true,
},
},
emits: ["delete", "save", "test"],
setup(props, { emit }) {
const i18n = useI18n();
const itemUTC = ref<string>(props.webhook.scheduledTime);
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
const props = defineProps<{
webhook: ReadWebhook;
}>();
const scheduledTime = computed({
const emit = defineEmits<{
delete: [id: string];
save: [webhook: ReadWebhook];
test: [id: string];
}>();
const i18n = useI18n();
const itemUTC = ref<string>(props.webhook.scheduledTime);
const itemLocal = ref<string>(timeUTCToLocal(props.webhook.scheduledTime));
const scheduledTime = computed({
get() {
return itemLocal.value;
},
@ -75,27 +76,17 @@ export default defineNuxtComponent({
itemUTC.value = timeLocalToUTC(v);
itemLocal.value = v;
},
});
});
const webhookCopy = ref({ ...props.webhook });
const webhookCopy = ref({ ...props.webhook });
function handleSave() {
function handleSave() {
webhookCopy.value.scheduledTime = itemLocal.value;
emit("save", webhookCopy.value);
}
}
// Set page title using useSeoMeta
useSeoMeta({
// Set page title using useSeoMeta
useSeoMeta({
title: i18n.t("settings.webhooks.webhooks"),
});
return {
webhookCopy,
scheduledTime,
handleSave,
itemUTC,
itemLocal,
};
},
});
</script>

View file

@ -41,27 +41,19 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
export default defineNuxtComponent({
props: {
modelValue: {
type: Object,
required: true,
},
},
emits: ["update:modelValue"],
setup(props, context) {
const i18n = useI18n();
const preferences = defineModel<ReadHouseholdPreferences>({ required: true });
const i18n = useI18n();
type Preference = {
type Preference = {
key: keyof ReadHouseholdPreferences;
label: string;
description: string;
};
};
const recipePreferences: Preference[] = [
const recipePreferences: Preference[] = [
{
key: "recipePublic",
label: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes"),
@ -87,14 +79,9 @@ export default defineNuxtComponent({
label: i18n.t("group.disable-users-from-commenting-on-recipes"),
description: i18n.t("group.disable-users-from-commenting-on-recipes-description"),
},
{
key: "recipeDisableAmount",
label: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food"),
description: i18n.t("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
},
];
];
const allDays = [
const allDays = [
{
name: i18n.t("general.sunday"),
value: 0,
@ -123,24 +110,7 @@ export default defineNuxtComponent({
name: i18n.t("general.saturday"),
value: 6,
},
];
const preferences = computed({
get() {
return props.modelValue;
},
set(val) {
context.emit("update:modelValue", val);
},
});
return {
allDays,
preferences,
recipePreferences,
};
},
});
];
</script>
<style lang="css">

View file

@ -147,7 +147,7 @@
:model-value="field.value"
type="number"
variant="underlined"
@:model-value="setFieldValue(field, index, $event)"
@update:model-value="setFieldValue(field, index, $event)"
/>
<v-checkbox
v-else-if="field.type === 'boolean'"
@ -163,14 +163,14 @@
max-width="290px"
min-width="auto"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="field.value"
persistent-hint
:prepend-icon="$globals.icons.calendar"
variant="underlined"
color="primary"
v-bind="props"
v-bind="activatorProps"
readonly
/>
</template>
@ -184,53 +184,53 @@
</v-menu>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Category"
:model-value="field.organizers"
v-model="field.organizers"
:selector-type="Organizer.Category"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tag"
:model-value="field.organizers"
v-model="field.organizers"
:selector-type="Organizer.Tag"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Tool"
:model-value="field.organizers"
v-model="field.organizers"
:selector-type="Organizer.Tool"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Food"
:model-value="field.organizers"
v-model="field.organizers"
:selector-type="Organizer.Food"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
<RecipeOrganizerSelector
v-else-if="field.type === Organizer.Household"
:model-value="field.organizers"
v-model="field.organizers"
:selector-type="Organizer.Household"
:show-add="false"
:show-label="false"
:show-icon="false"
variant="underlined"
@update:model-value="setOrganizerValues(field, index, $event)"
@update:model-value="setFieldOrganizers(field, index, $event)"
/>
</v-col>
<!-- right parenthesis -->
@ -297,7 +297,7 @@
</v-card>
</template>
<script lang="ts">
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import { useDebounceFn } from "@vueuse/core";
import { useHouseholdSelf } from "~/composables/use-households";
@ -307,12 +307,7 @@ import type { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalK
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
import { type Field, type FieldDefinition, type FieldValue, type OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
export default defineNuxtComponent({
components: {
VueDraggable,
RecipeOrganizerSelector,
},
props: {
const props = defineProps({
fieldDefs: {
type: Array as () => FieldDefinition[],
required: true,
@ -321,57 +316,62 @@ export default defineNuxtComponent({
type: Object as () => QueryFilterJSON | null,
default: null,
},
},
emits: ["input", "inputJSON"],
setup(props, context) {
const { household } = useHouseholdSelf();
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
});
const firstDayOfWeek = computed(() => {
const emit = defineEmits<{
(event: "input", value: string | undefined): void;
(event: "inputJSON", value: QueryFilterJSON | undefined): void;
}>();
const { household } = useHouseholdSelf();
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
});
const state = reactive({
const state = reactive({
showAdvanced: false,
qfValid: false,
datePickers: [] as boolean[],
drag: false,
});
});
const { showAdvanced, datePickers, drag } = toRefs(state);
const storeMap = {
const storeMap = {
[Organizer.Category]: useCategoryStore(),
[Organizer.Tag]: useTagStore(),
[Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(),
};
};
function onDragEnd(event: any) {
function onDragEnd(event: any) {
state.drag = false;
const oldIndex: number = event.oldIndex;
const newIndex: number = event.newIndex;
state.datePickers[oldIndex] = false;
state.datePickers[newIndex] = false;
}
}
// add id to fields to prevent reactivity issues
type FieldWithId = Field & { id: number };
const fields = ref<FieldWithId[]>([]);
// add id to fields to prevent reactivity issues
type FieldWithId = Field & { id: number };
const fields = ref<FieldWithId[]>([]);
const uid = ref(1); // init uid to pass to fields
function useUid() {
const uid = ref(1); // init uid to pass to fields
function useUid() {
return uid.value++;
}
function addField(field: FieldDefinition) {
}
function addField(field: FieldDefinition) {
fields.value.push({
...getFieldFromFieldDef(field),
id: useUid(),
});
state.datePickers.push(false);
};
}
function setField(index: number, fieldLabel: string) {
function setField(index: number, fieldLabel: string) {
state.datePickers[index] = false;
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel);
if (!fieldDef) {
@ -388,48 +388,49 @@ export default defineNuxtComponent({
...getFieldFromFieldDef(updatedField, resetValue),
id: fields.value[index].id, // keep the id
};
}
}
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value[index].leftParenthesis = value;
}
}
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
fields.value[index].rightParenthesis = value;
}
}
function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) {
function setLogicalOperatorValue(field: FieldWithId, index: number, value: LogicalOperator | undefined) {
if (!value) {
value = logOps.value.AND.value;
}
fields.value[index].logicalOperator = value ? logOps.value[value] : undefined;
}
}
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
function setRelationalOperatorValue(field: FieldWithId, index: number, value: RelationalKeyword | RelationalOperator) {
fields.value[index].relationalOperatorValue = relOps.value[value];
}
}
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
function setFieldValue(field: FieldWithId, index: number, value: FieldValue) {
state.datePickers[index] = false;
fields.value[index].value = value;
}
}
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
function setFieldValues(field: FieldWithId, index: number, values: FieldValue[]) {
fields.value[index].values = values;
}
}
function setOrganizerValues(field: FieldWithId, index: number, values: OrganizerBase[]) {
setFieldValues(field, index, values.map(value => value.id.toString()));
fields.value[index].organizers = values;
}
function setFieldOrganizers(field: FieldWithId, index: number, organizers: OrganizerBase[]) {
fields.value[index].organizers = organizers;
// Sync the values array with the organizers array
fields.value[index].values = organizers.map(org => org.id?.toString() || "").filter(id => id);
}
function removeField(index: number) {
function removeField(index: number) {
fields.value.splice(index, 1);
state.datePickers.splice(index, 1);
};
}
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
/* newFields.forEach((field, index) => {
const updatedField = getFieldFromFieldDef(field);
fields.value[index] = updatedField; // recursive!!!
@ -441,19 +442,17 @@ export default defineNuxtComponent({
}
state.qfValid = !!qf;
context.emit("input", qf || undefined);
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
}, 500);
emit("input", qf || undefined);
emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
}, 500);
watch(fields, fieldsUpdater, { deep: true });
watch(fields, fieldsUpdater, { deep: true });
async function hydrateOrganizers(field: FieldWithId, index: number) {
async function hydrateOrganizers(field: FieldWithId, _index: number) {
if (!field.values?.length || !isOrganizerType(field.type)) {
return;
}
field.organizers = [];
const { store, actions } = storeMap[field.type];
if (!store.value.length) {
await actions.refresh();
@ -467,11 +466,12 @@ export default defineNuxtComponent({
}
return organizer;
});
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
setOrganizerValues(field, index, field.organizers);
}
function initFieldsError(error = "") {
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
return field;
}
function initFieldsError(error = "") {
if (error) {
console.error(error);
}
@ -480,16 +480,17 @@ export default defineNuxtComponent({
if (props.fieldDefs.length) {
addField(props.fieldDefs[0]);
}
}
}
function initializeFields() {
async function initializeFields() {
if (!props.initialQueryFilter?.parts?.length) {
return initFieldsError();
};
}
const initFields: FieldWithId[] = [];
let error = false;
props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => {
for (const [index, part] of props.initialQueryFilter.parts.entries()) {
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.name === part.attributeName);
if (!fieldDef) {
error = true;
@ -522,7 +523,7 @@ export default defineNuxtComponent({
}
if (isOrganizerType(field.type)) {
hydrateOrganizers(field, index);
await hydrateOrganizers(field, index);
}
}
else if (field.type === "boolean") {
@ -553,7 +554,7 @@ export default defineNuxtComponent({
}
initFields.push(field);
});
}
if (initFields.length && !error) {
fields.value = initFields;
@ -561,16 +562,18 @@ export default defineNuxtComponent({
else {
initFieldsError();
}
};
}
onMounted(async () => {
try {
initializeFields();
await initializeFields();
}
catch (error) {
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
}
});
function buildQueryFilterJSON(): QueryFilterJSON {
function buildQueryFilterJSON(): QueryFilterJSON {
const parts = fields.value.map((field) => {
const part: QueryFilterJSONPart = {
attributeName: field.name,
@ -596,9 +599,9 @@ export default defineNuxtComponent({
const qfJSON = { parts } as QueryFilterJSON;
console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
return qfJSON;
}
}
const config = computed(() => {
const config = computed(() => {
const baseColMaxWidth = 55;
return {
col: {
@ -642,30 +645,6 @@ export default defineNuxtComponent({
},
},
};
});
return {
Organizer,
...toRefs(state),
logOps,
relOps,
config,
firstDayOfWeek,
onDragEnd,
// Fields
fields,
addField,
setField,
setLeftParenthesisValue,
setRightParenthesisValue,
setLogicalOperatorValue,
setRelationalOperatorValue,
setFieldValue,
setFieldValues,
setOrganizerValues,
removeField,
};
},
});
</script>

View file

@ -17,8 +17,8 @@
<RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
<RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
<div v-if="loggedIn">
<v-tooltip v-if="canEdit" bottom color="info">
<template #activator="{ props }">
<v-tooltip v-if="canEdit" location="bottom" color="info">
<template #activator="{ props: tooltipProps }">
<v-btn
icon
variant="flat"
@ -26,7 +26,7 @@
size="small"
color="info"
class="ml-1"
v-bind="props"
v-bind="tooltipProps"
@click="$emit('edit', true)"
>
<v-icon size="x-large">
@ -86,7 +86,7 @@
</v-toolbar>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
@ -97,49 +97,30 @@ const DELETE_EVENT = "delete";
const CLOSE_EVENT = "close";
const JSON_EVENT = "json";
export default defineNuxtComponent({
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
props: {
recipe: {
required: true,
type: Object as () => Recipe,
},
slug: {
required: true,
type: String,
},
recipeScale: {
type: Number,
default: 1,
},
open: {
required: true,
type: Boolean,
},
name: {
required: true,
type: String,
},
loggedIn: {
type: Boolean,
default: false,
},
recipeId: {
required: true,
type: String,
},
canEdit: {
type: Boolean,
default: false,
},
},
emits: ["print", "input", "delete", "close", "edit"],
setup(_, context) {
const deleteDialog = ref(false);
interface Props {
recipe: Recipe;
slug: string;
recipeScale?: number;
open: boolean;
name: string;
loggedIn?: boolean;
recipeId: string;
canEdit?: boolean;
}
withDefaults(defineProps<Props>(), {
recipeScale: 1,
loggedIn: false,
canEdit: false,
});
const i18n = useI18n();
const { $globals } = useNuxtApp();
const editorButtons = [
const emit = defineEmits(["print", "input", "delete", "close", "edit"]);
const deleteDialog = ref(false);
const i18n = useI18n();
const { $globals } = useNuxtApp();
const editorButtons = [
{
text: i18n.t("general.delete"),
icon: $globals.icons.delete,
@ -164,36 +145,27 @@ export default defineNuxtComponent({
event: SAVE_EVENT,
color: "success",
},
];
];
function emitHandler(event: string) {
function emitHandler(event: string) {
switch (event) {
case CLOSE_EVENT:
context.emit(CLOSE_EVENT);
context.emit("input", false);
emit("close");
emit("input", false);
break;
case DELETE_EVENT:
deleteDialog.value = true;
break;
default:
context.emit(event);
emit(event as any);
break;
}
}
}
function emitDelete() {
context.emit(DELETE_EVENT);
context.emit("input", false);
}
return {
deleteDialog,
editorButtons,
emitHandler,
emitDelete,
};
},
});
function emitDelete() {
emit("delete");
emit("input", false);
}
</script>
<style scoped>

View file

@ -15,7 +15,7 @@
>
<template #prepend>
<div class="ma-auto">
<v-tooltip bottom>
<v-tooltip location="bottom">
<template #activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps">
{{ getIconDefinition(item.icon).icon }}

View file

@ -1,13 +1,12 @@
<template>
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
<v-lazy>
<div>
<v-hover
v-slot="{ isHovering, props }"
v-slot="{ isHovering, props: hoverProps }"
:open-delay="50"
>
<v-card
v-bind="props"
v-bind="hoverProps"
:class="{ 'on-hover': isHovering }"
:style="{ cursor }"
:elevation="isHovering ? 12 : 2"
@ -99,10 +98,9 @@
</v-card>
</v-hover>
</div>
</v-lazy>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeChips from "./RecipeChips.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue";
@ -110,69 +108,41 @@ import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
export default defineNuxtComponent({
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
props: {
name: {
type: String,
required: true,
},
slug: {
type: String,
required: true,
},
description: {
type: String,
default: null,
},
rating: {
type: Number,
required: false,
default: 0,
},
ratingColor: {
type: String,
default: "secondary",
},
image: {
type: String,
required: false,
default: "abc123",
},
tags: {
type: Array,
default: () => [],
},
recipeId: {
required: true,
type: String,
},
imageHeight: {
type: Number,
default: 200,
},
},
emits: ["click", "delete"],
setup(props) {
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
});
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
return {
isOwnGroup,
recipeRoute,
showRecipeContent,
cursor,
};
},
interface Props {
name: string;
slug: string;
description?: string | null;
rating?: number;
ratingColor?: string;
image?: string;
tags?: Array<any>;
recipeId: string;
imageHeight?: number;
}
const props = withDefaults(defineProps<Props>(), {
description: null,
rating: 0,
ratingColor: "secondary",
image: "abc123",
tags: () => [],
imageHeight: 200,
});
defineEmits<{
click: [];
delete: [slug: string];
}>();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
});
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
</script>
<style>
@ -197,6 +167,7 @@ export default defineNuxtComponent({
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 8;
line-clamp: 8;
overflow: hidden;
}
</style>

View file

@ -28,66 +28,51 @@
</div>
</template>
<script lang="ts">
import { useStaticRoutes, useUserApi } from "~/composables/api";
<script setup lang="ts">
import { useStaticRoutes } from "~/composables/api";
export default defineNuxtComponent({
props: {
tiny: {
type: Boolean,
default: null,
},
small: {
type: Boolean,
default: null,
},
large: {
type: Boolean,
default: null,
},
iconSize: {
type: [Number, String],
default: 100,
},
slug: {
type: String,
default: null,
},
recipeId: {
type: String,
required: true,
},
imageVersion: {
type: String,
default: null,
},
height: {
type: [Number, String],
default: "100%",
},
},
emits: ["click"],
setup(props) {
const api = useUserApi();
interface Props {
tiny?: boolean | null;
small?: boolean | null;
large?: boolean | null;
iconSize?: number | string;
slug?: string | null;
recipeId: string;
imageVersion?: string | null;
height?: number | string;
}
const props = withDefaults(defineProps<Props>(), {
tiny: null,
small: null,
large: null,
iconSize: 100,
slug: null,
imageVersion: null,
height: "100%",
});
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
defineEmits<{
click: [];
}>();
const fallBackImage = ref(false);
const imageSize = computed(() => {
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
const fallBackImage = ref(false);
const imageSize = computed(() => {
if (props.tiny) return "tiny";
if (props.small) return "small";
if (props.large) return "large";
return "large";
});
});
watch(
watch(
() => props.recipeId,
() => {
fallBackImage.value = false;
},
);
);
function getImage(recipeId: string) {
function getImage(recipeId: string) {
switch (imageSize.value) {
case "tiny":
return recipeTinyImage(recipeId, props.imageVersion);
@ -96,16 +81,7 @@ export default defineNuxtComponent({
case "large":
return recipeImage(recipeId, props.imageVersion);
}
}
return {
api,
fallBackImage,
imageSize,
getImage,
};
},
});
}
</script>
<style scoped>

View file

@ -3,7 +3,10 @@
<v-expand-transition>
<v-card
:ripple="false"
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
:class="[
isFlat ? 'mx-auto flat' : 'mx-auto',
{ 'disable-highlight': disableHighlight },
]"
:style="{ cursor }"
hover
height="100%"
@ -123,7 +126,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue";
@ -131,78 +134,44 @@ import RecipeRating from "./RecipeRating.vue";
import RecipeChips from "./RecipeChips.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
export default defineNuxtComponent({
components: {
RecipeFavoriteBadge,
RecipeContextMenu,
RecipeRating,
RecipeCardImage,
RecipeChips,
},
props: {
name: {
type: String,
required: true,
},
slug: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
rating: {
type: Number,
default: 0,
},
image: {
type: String,
required: false,
default: "abc123",
},
tags: {
type: Array,
default: () => [],
},
recipeId: {
type: String,
required: true,
},
vertical: {
type: Boolean,
default: false,
},
isFlat: {
type: Boolean,
default: false,
},
height: {
type: [Number],
default: 150,
},
},
emits: ["selected", "delete"],
setup(props) {
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
});
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
return {
isOwnGroup,
recipeRoute,
showRecipeContent,
cursor,
};
},
interface Props {
name: string;
slug: string;
description: string;
rating?: number;
image?: string;
tags?: Array<any>;
recipeId: string;
vertical?: boolean;
isFlat?: boolean;
height?: number;
disableHighlight?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
rating: 0,
image: "abc123",
tags: () => [],
vertical: false,
isFlat: false,
height: 150,
disableHighlight: false,
});
defineEmits<{
selected: [];
delete: [slug: string];
}>();
const $auth = useMealieAuth();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const showRecipeContent = computed(() => props.recipeId && props.slug);
const recipeRoute = computed<string>(() => {
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
});
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
</script>
<style scoped>
@ -241,4 +210,8 @@ export default defineNuxtComponent({
box-shadow: none !important;
background-color: transparent !important;
}
.disable-highlight :deep(.v-card__overlay) {
opacity: 0 !important;
}
</style>

View file

@ -36,11 +36,11 @@
offset-y
start
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-btn
variant="text"
:icon="$vuetify.display.xs"
v-bind="props"
v-bind="activatorProps"
:loading="sortLoading"
>
<v-icon :start="!$vuetify.display.xs">
@ -162,7 +162,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useThrottleFn } from "@vueuse/core";
import RecipeCard from "./RecipeCard.vue";
import RecipeCardMobile from "./RecipeCardMobile.vue";
@ -175,83 +175,69 @@ import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
const REPLACE_RECIPES_EVENT = "replaceRecipes";
const APPEND_RECIPES_EVENT = "appendRecipes";
export default defineNuxtComponent({
components: {
RecipeCard,
RecipeCardMobile,
},
props: {
disableToolbar: {
type: Boolean,
default: false,
},
disableSort: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: null,
},
title: {
type: String,
default: null,
},
singleColumn: {
type: Boolean,
default: false,
},
recipes: {
type: Array as () => Recipe[],
default: () => [],
},
query: {
type: Object as () => RecipeSearchQuery,
default: null,
},
},
setup(props, context) {
const { $vuetify } = useNuxtApp();
const preferences = useUserSortPreferences();
interface Props {
disableToolbar?: boolean;
disableSort?: boolean;
icon?: string | null;
title?: string | null;
singleColumn?: boolean;
recipes?: Recipe[];
query?: RecipeSearchQuery | null;
}
const props = withDefaults(defineProps<Props>(), {
disableToolbar: false,
disableSort: false,
icon: null,
title: null,
singleColumn: false,
recipes: () => [],
query: null,
});
const EVENTS = {
const emit = defineEmits<{
replaceRecipes: [recipes: Recipe[]];
appendRecipes: [recipes: Recipe[]];
}>();
const { $vuetify } = useNuxtApp();
const preferences = useUserSortPreferences();
const EVENTS = {
az: "az",
rating: "rating",
created: "created",
updated: "updated",
lastMade: "lastMade",
shuffle: "shuffle",
};
};
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => {
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => {
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
});
});
const displayTitleIcon = computed(() => {
const displayTitleIcon = computed(() => {
return props.icon || $globals.icons.tags;
});
});
const state = reactive({
sortLoading: false,
});
const sortLoading = ref(false);
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const page = ref(1);
const perPage = 32;
const hasMore = ref(true);
const ready = ref(false);
const loading = ref(false);
const page = ref(1);
const perPage = 32;
const hasMore = ref(true);
const ready = ref(false);
const loading = ref(false);
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const router = useRouter();
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
const router = useRouter();
const queryFilter = computed(() => {
return props.query.queryFilter || null;
const queryFilter = computed(() => {
return props.query?.queryFilter || null;
// TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
@ -265,9 +251,9 @@ export default defineNuxtComponent({
// } else {
// return orderByFilter;
// }
});
});
async function fetchRecipes(pageCount = 1) {
async function fetchRecipes(pageCount = 1) {
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
return await fetchMore(
@ -280,17 +266,17 @@ export default defineNuxtComponent({
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
queryFilter.value,
);
}
}
onMounted(async () => {
onMounted(async () => {
await initRecipes();
ready.value = true;
});
});
let lastQuery: string | undefined = JSON.stringify(props.query);
watch(
let lastQuery: string | undefined = JSON.stringify(props.query);
watch(
() => props.query,
async (newValue: RecipeSearchQuery | undefined) => {
async (newValue: RecipeSearchQuery | undefined | null) => {
const newValueString = JSON.stringify(newValue);
if (lastQuery !== newValueString) {
lastQuery = newValueString;
@ -299,9 +285,9 @@ export default defineNuxtComponent({
ready.value = true;
}
},
);
);
async function initRecipes() {
async function initRecipes() {
page.value = 1;
hasMore.value = true;
@ -315,10 +301,10 @@ export default defineNuxtComponent({
// since we doubled the first call, we also need to advance the page
page.value = page.value + 1;
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
}
emit(REPLACE_RECIPES_EVENT, newRecipes);
}
const infiniteScroll = useThrottleFn(async () => {
const infiniteScroll = useThrottleFn(async () => {
if (!hasMore.value || loading.value) {
return;
}
@ -331,14 +317,14 @@ export default defineNuxtComponent({
hasMore.value = false;
}
if (newRecipes.length) {
context.emit(APPEND_RECIPES_EVENT, newRecipes);
emit(APPEND_RECIPES_EVENT, newRecipes);
}
loading.value = false;
}, 500);
}, 500);
async function sortRecipes(sortType: string) {
if (state.sortLoading || loading.value) {
async function sortRecipes(sortType: string) {
if (sortLoading.value || loading.value) {
return;
}
@ -403,45 +389,29 @@ export default defineNuxtComponent({
page.value = 1;
hasMore.value = true;
state.sortLoading = true;
sortLoading.value = true;
loading.value = true;
// fetch new recipes
const newRecipes = await fetchRecipes();
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
emit(REPLACE_RECIPES_EVENT, newRecipes);
state.sortLoading = false;
sortLoading.value = false;
loading.value = false;
}
}
async function navigateRandom() {
async function navigateRandom() {
const recipe = await getRandom(props.query, queryFilter.value);
if (!recipe?.slug) {
return;
}
router.push(`/g/${groupSlug.value}/r/${recipe.slug}`);
}
}
function toggleMobileCards() {
function toggleMobileCards() {
preferences.value.useMobileCards = !preferences.value.useMobileCards;
}
return {
...toRefs(state),
displayTitleIcon,
EVENTS,
infiniteScroll,
ready,
loading,
navigateRandom,
preferences,
sortRecipes,
toggleMobileCards,
useMobileCards,
};
},
});
}
</script>
<style>

View file

@ -23,66 +23,38 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
export type UrlPrefixParam = "tags" | "categories" | "tools";
export default defineNuxtComponent({
props: {
truncate: {
type: Boolean,
default: false,
},
items: {
type: Array as () => RecipeCategory[] | RecipeTag[] | RecipeTool[],
default: () => [],
},
title: {
type: Boolean,
default: false,
},
urlPrefix: {
type: String as () => UrlPrefixParam,
default: "categories",
},
limit: {
type: Number,
default: 999,
},
small: {
type: Boolean,
default: false,
},
maxWidth: {
type: String,
default: null,
},
},
emits: ["item-selected"],
setup(props) {
const $auth = useMealieAuth();
interface Props {
truncate?: boolean;
items?: RecipeCategory[] | RecipeTag[] | RecipeTool[];
title?: boolean;
urlPrefix?: UrlPrefixParam;
limit?: number;
small?: boolean;
maxWidth?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
truncate: false,
items: () => [],
title: false,
urlPrefix: "categories",
limit: 999,
small: false,
maxWidth: null,
});
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const baseRecipeRoute = computed<string>(() => {
return `/g/${groupSlug.value}`;
});
function truncateText(text: string, length = 20, clamp = "...") {
defineEmits(["item-selected"]);
function truncateText(text: string, length = 20, clamp = "...") {
if (!props.truncate) return text;
const node = document.createElement("div");
node.innerHTML = text;
const content = node.textContent || "";
return content.length > length ? content.slice(0, length) + clamp : content;
}
return {
baseRecipeRoute,
truncateText,
};
},
});
}
</script>
<style></style>

View file

@ -12,7 +12,12 @@
@confirm="deleteRecipe()"
>
<v-card-text>
<template v-if="isAdminAndNotOwner">
{{ $t("recipe.admin-delete-confirmation") }}
</template>
<template v-else>
{{ $t("recipe.delete-confirmation") }}
</template>
</v-card-text>
</BaseDialog>
<BaseDialog
@ -50,12 +55,12 @@
max-width="290px"
min-width="auto"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="newMealdateString"
:label="$t('general.date')"
:prepend-icon="$globals.icons.calendar"
v-bind="props"
v-bind="activatorProps"
readonly
/>
</template>
@ -95,7 +100,7 @@
:open-on-hover="$vuetify.display.mdAndUp"
content-class="d-print-none"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-btn
icon
:variant="fab ? 'flat' : undefined"
@ -103,7 +108,7 @@
:size="fab ? 'small' : undefined"
:color="fab ? 'info' : 'secondary'"
:fab="fab"
v-bind="props"
v-bind="activatorProps"
@click.prevent
>
<v-icon
@ -145,7 +150,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "./RecipeDialogShare.vue";
@ -181,16 +186,22 @@ export interface ContextMenuItem {
isPublic: boolean;
}
export default defineNuxtComponent({
components: {
RecipeDialogAddToShoppingList,
RecipeDialogPrintPreferences,
RecipeDialogShare,
},
props: {
useItems: {
type: Object as () => ContextMenuIncludes,
default: () => ({
interface Props {
useItems?: ContextMenuIncludes;
appendItems?: ContextMenuItem[];
leadingItems?: ContextMenuItem[];
menuTop?: boolean;
fab?: boolean;
color?: string;
slug: string;
menuIcon?: string | null;
name: string;
recipe?: Recipe;
recipeId: string;
recipeScale?: number;
}
const props = withDefaults(defineProps<Props>(), {
useItems: () => ({
delete: true,
edit: true,
download: true,
@ -202,98 +213,61 @@ export default defineNuxtComponent({
share: true,
recipeActions: true,
}),
},
// Append items are added at the end of the useItems list
appendItems: {
type: Array as () => ContextMenuItem[],
default: () => [],
},
// Append items are added at the beginning of the useItems list
leadingItems: {
type: Array as () => ContextMenuItem[],
default: () => [],
},
menuTop: {
type: Boolean,
default: true,
},
fab: {
type: Boolean,
default: false,
},
color: {
type: String,
default: "primary",
},
slug: {
type: String,
required: true,
},
menuIcon: {
type: String,
default: null,
},
name: {
required: true,
type: String,
},
recipe: {
type: Object as () => Recipe,
default: undefined,
},
recipeId: {
required: true,
type: String,
},
recipeScale: {
type: Number,
default: 1,
},
},
emits: ["delete"],
setup(props, context) {
const api = useUserApi();
appendItems: () => [],
leadingItems: () => [],
menuTop: true,
fab: false,
color: "primary",
menuIcon: null,
recipe: undefined,
recipeScale: 1,
});
const state = reactive({
printPreferencesDialog: false,
shareDialog: false,
recipeDeleteDialog: false,
mealplannerDialog: false,
shoppingListDialog: false,
recipeDuplicateDialog: false,
recipeName: props.name,
loading: false,
menuItems: [] as ContextMenuItem[],
newMealdate: new Date(),
newMealType: "dinner" as PlanEntryType,
pickerMenu: false,
});
const emit = defineEmits<{
[key: string]: any;
delete: [slug: string];
}>();
const newMealdateString = computed(() => {
const api = useUserApi();
const printPreferencesDialog = ref(false);
const shareDialog = ref(false);
const recipeDeleteDialog = ref(false);
const mealplannerDialog = ref(false);
const shoppingListDialog = ref(false);
const recipeDuplicateDialog = ref(false);
const recipeName = ref(props.name);
const loading = ref(false);
const menuItems = ref<ContextMenuItem[]>([]);
const newMealdate = ref(new Date());
const newMealType = ref<PlanEntryType>("dinner");
const pickerMenu = ref(false);
const newMealdateString = computed(() => {
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
const year = state.newMealdate.getFullYear();
const month = String(state.newMealdate.getMonth() + 1).padStart(2, "0");
const day = String(state.newMealdate.getDate()).padStart(2, "0");
const year = newMealdate.value.getFullYear();
const month = String(newMealdate.value.getMonth() + 1).padStart(2, "0");
const day = String(newMealdate.value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
});
});
const i18n = useI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState();
const i18n = useI18n();
const $auth = useMealieAuth();
const { $globals } = useNuxtApp();
const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => {
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
});
// ===========================================================================
// Context Menu Setup
// ===========================================================================
// Context Menu Setup
const defaultItems: { [key: string]: ContextMenuItem } = {
const defaultItems: { [key: string]: ContextMenuItem } = {
edit: {
title: i18n.t("general.edit"),
icon: $globals.icons.edit,
@ -357,50 +331,64 @@ export default defineNuxtComponent({
event: "share",
isPublic: false,
},
};
};
// Add leading and Appending Items
menuItems.value = [...menuItems.value, ...props.leadingItems, ...props.appendItems];
const icon = props.menuIcon || $globals.icons.dotsVertical;
// ===========================================================================
// Context Menu Event Handler
const shoppingLists = ref<ShoppingListSummary[]>();
const recipeRef = ref<Recipe | undefined>(props.recipe);
const recipeRefWithScale = computed(() =>
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
);
const isAdminAndNotOwner = computed(() => {
return (
$auth.user.value?.admin
&& $auth.user.value?.id !== recipeRef.value?.userId
);
});
const canDelete = computed(() => {
const user = $auth.user.value;
const recipe = recipeRef.value;
return user && recipe && (user.admin || user.id === recipe.userId);
});
// Get Default Menu Items Specified in Props
for (const [key, value] of Object.entries(props.useItems)) {
if (!value) continue;
// Skip delete if not allowed
if (key === "delete" && !canDelete.value) continue;
// Get Default Menu Items Specified in Props
for (const [key, value] of Object.entries(props.useItems)) {
if (value) {
const item = defaultItems[key];
if (item && (item.isPublic || isOwnGroup.value)) {
state.menuItems.push(item);
}
}
menuItems.value.push(item);
}
}
// Add leading and Appending Items
state.menuItems = [...state.menuItems, ...props.leadingItems, ...props.appendItems];
const icon = props.menuIcon || $globals.icons.dotsVertical;
// ===========================================================================
// Context Menu Event Handler
const shoppingLists = ref<ShoppingListSummary[]>();
const recipeRef = ref<Recipe | undefined>(props.recipe);
const recipeRefWithScale = computed(() =>
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
);
async function getShoppingLists() {
async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (data) {
shoppingLists.value = data.items ?? [];
}
}
}
async function refreshRecipe() {
async function refreshRecipe() {
const { data } = await api.recipes.getOne(props.slug);
if (data) {
recipeRef.value = data;
}
}
}
const router = useRouter();
const groupRecipeActionsStore = useGroupRecipeActions();
const router = useRouter();
const groupRecipeActionsStore = useGroupRecipeActions();
async function executeRecipeAction(action: GroupRecipeActionOut) {
async function executeRecipeAction(action: GroupRecipeActionOut) {
if (!props.recipe) return;
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
@ -412,30 +400,30 @@ export default defineNuxtComponent({
alert.error(i18n.t("events.something-went-wrong"));
}
}
}
}
async function deleteRecipe() {
async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(props.slug);
if (data?.slug) {
router.push(`/g/${groupSlug.value}`);
}
context.emit("delete", props.slug);
}
emit("delete", props.slug);
}
const download = useDownloader();
const download = useDownloader();
async function handleDownloadEvent() {
async function handleDownloadEvent() {
const { data } = await api.recipes.getZipToken(props.slug);
if (data) {
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
}
}
}
async function addRecipeToPlan() {
async function addRecipeToPlan() {
const { response } = await api.mealplans.createOne({
date: newMealdateString.value,
entryType: state.newMealType,
entryType: newMealType.value,
title: "",
text: "",
recipeId: props.recipeId,
@ -447,34 +435,34 @@ export default defineNuxtComponent({
else {
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
}
}
}
async function duplicateRecipe() {
const { data } = await api.recipes.duplicateOne(props.slug, state.recipeName);
async function duplicateRecipe() {
const { data } = await api.recipes.duplicateOne(props.slug, recipeName.value);
if (data && data.slug) {
router.push(`/g/${groupSlug.value}/r/${data.slug}`);
}
}
}
// Note: Print is handled as an event in the parent component
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
// Note: Print is handled as an event in the parent component
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
delete: () => {
state.recipeDeleteDialog = true;
recipeDeleteDialog.value = true;
},
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
download: handleDownloadEvent,
duplicate: () => {
state.recipeDuplicateDialog = true;
recipeDuplicateDialog.value = true;
},
mealplanner: () => {
state.mealplannerDialog = true;
mealplannerDialog.value = true;
},
printPreferences: async () => {
if (!recipeRef.value) {
await refreshRecipe();
}
state.printPreferencesDialog = true;
printPreferencesDialog.value = true;
},
shoppingList: () => {
const promises: Promise<void>[] = [getShoppingLists()];
@ -483,45 +471,27 @@ export default defineNuxtComponent({
}
Promise.allSettled(promises).then(() => {
state.shoppingListDialog = true;
shoppingListDialog.value = true;
});
},
share: () => {
state.shareDialog = true;
shareDialog.value = true;
},
};
};
function contextMenuEventHandler(eventKey: string) {
function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") {
handler();
state.loading = false;
loading.value = false;
return;
}
context.emit(eventKey);
state.loading = false;
}
emit(eventKey);
loading.value = false;
}
const planTypeOptions = usePlanTypeOptions();
return {
...toRefs(state),
newMealdateString,
recipeRef,
recipeRefWithScale,
executeRecipeAction,
recipeActions: groupRecipeActionsStore.recipeActions,
shoppingLists,
duplicateRecipe,
contextMenuEventHandler,
deleteRecipe,
addRecipeToPlan,
icon,
planTypeOptions,
firstDayOfWeek,
};
},
});
const planTypeOptions = usePlanTypeOptions();
const recipeActions = groupRecipeActionsStore.recipeActions;
</script>

View file

@ -33,7 +33,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { whenever } from "@vueuse/core";
import { validators } from "~/composables/use-validators";
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
@ -42,56 +42,47 @@ export interface GenericAlias {
name: string;
}
export default defineNuxtComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
data: {
type: Object as () => IngredientFood | IngredientUnit,
required: true,
},
},
emits: ["submit", "update:modelValue", "cancel"],
setup(props, context) {
// V-Model Support
const dialog = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
context.emit("update:modelValue", val);
},
});
interface Props {
data: IngredientFood | IngredientUnit;
}
function createAlias() {
const props = defineProps<Props>();
const emit = defineEmits<{
submit: [aliases: GenericAlias[]];
cancel: [];
}>();
// V-Model Support
const dialog = defineModel<boolean>({ default: false });
function createAlias() {
aliases.value.push({
name: "",
});
}
}
function deleteAlias(index: number) {
function deleteAlias(index: number) {
aliases.value.splice(index, 1);
}
}
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
function initAliases() {
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
function initAliases() {
aliases.value = [...props.data.aliases || []];
if (!aliases.value.length) {
createAlias();
}
}
}
initAliases();
whenever(
() => props.modelValue,
initAliases();
whenever(
() => dialog.value,
() => {
initAliases();
},
);
);
function saveAliases() {
function saveAliases() {
const seenAliasNames: string[] = [];
const keepAliases: GenericAlias[] = [];
aliases.value.forEach((alias) => {
@ -111,17 +102,6 @@ export default defineNuxtComponent({
});
aliases.value = keepAliases;
context.emit("submit", keepAliases);
}
return {
aliases,
createAlias,
dialog,
deleteAlias,
saveAliases,
validators,
};
},
});
emit("submit", keepAliases);
}
</script>

View file

@ -62,7 +62,7 @@
</v-data-table>
</template>
<script lang="ts">
<script setup lang="ts">
import UserAvatar from "../User/UserAvatar.vue";
import RecipeChip from "./RecipeChips.vue";
import type { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
@ -70,8 +70,6 @@ import { useUserApi } from "~/composables/api";
import type { UserSummary } from "~/lib/api/types/user";
import type { RecipeTag } from "~/lib/api/types/household";
const INPUT_EVENT = "update:modelValue";
interface ShowHeaders {
id: boolean;
owner: boolean;
@ -84,56 +82,43 @@ interface ShowHeaders {
dateAdded: boolean;
}
export default defineNuxtComponent({
components: { RecipeChip, UserAvatar },
props: {
modelValue: {
type: Array as PropType<Recipe[]>,
required: false,
default: () => [],
},
loading: {
type: Boolean,
required: false,
default: false,
},
recipes: {
type: Array as () => Recipe[],
default: () => [],
},
showHeaders: {
type: Object as () => ShowHeaders,
required: false,
default: () => {
return {
interface Props {
loading?: boolean;
recipes?: Recipe[];
showHeaders?: ShowHeaders;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
recipes: () => [],
showHeaders: () => ({
id: true,
owner: false,
tags: true,
categories: true,
tools: true,
recipeServings: true,
recipeYieldQuantity: true,
recipeYield: true,
dateAdded: true,
};
},
},
},
emits: ["click", "update:modelValue"],
setup(props, context) {
const i18n = useI18n();
const $auth = useMealieAuth();
const groupSlug = $auth.user.value?.groupSlug;
const router = useRouter();
const selected = computed({
get: () => props.modelValue,
set: value => context.emit(INPUT_EVENT, value),
});
}),
});
// Initialize sort state with default sorting by dateAdded descending
const sortBy = ref([{ key: "dateAdded", order: "desc" }]);
defineEmits<{
click: [];
}>();
const headers = computed(() => {
const hdrs: Array<{ title: string; value: string; align?: string; sortable?: boolean }> = [];
const selected = defineModel<Recipe[]>({ default: () => [] });
const i18n = useI18n();
const $auth = useMealieAuth();
const groupSlug = $auth.user.value?.groupSlug;
const router = useRouter();
// Initialize sort state with default sorting by dateAdded descending
const sortBy = ref([{ key: "dateAdded", order: "desc" as const }]);
const headers = computed(() => {
const hdrs: Array<{ title: string; value: string; align?: "center" | "start" | "end"; sortable?: boolean }> = [];
if (props.showHeaders.id) {
hdrs.push({ title: i18n.t("general.id"), value: "id" });
@ -166,58 +151,45 @@ export default defineNuxtComponent({
}
return hdrs;
});
});
function formatDate(date: string) {
function formatDate(date: string) {
try {
return i18n.d(Date.parse(date), "medium");
}
catch {
return "";
}
}
}
// ============
// Group Members
const api = useUserApi();
const members = ref<UserSummary[]>([]);
// ============
// Group Members
const api = useUserApi();
const members = ref<UserSummary[]>([]);
async function refreshMembers() {
async function refreshMembers() {
const { data } = await api.groups.fetchMembers();
if (data) {
members.value = data.items;
}
}
}
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
if (!groupSlug || !item.id) {
return;
}
router.push(`/g/${groupSlug}?${itemType}=${item.id}`);
}
}
onMounted(() => {
onMounted(() => {
refreshMembers();
});
});
function getMember(id: string) {
function getMember(id: string) {
if (members.value[0]) {
return members.value.find(m => m.id === id)?.fullName;
}
return i18n.t("general.none");
}
return {
selected,
sortBy,
groupSlug,
headers,
formatDate,
members,
getMember,
filterItems,
};
},
});
}
</script>

View file

@ -51,7 +51,7 @@
<BaseDialog
v-if="shoppingListIngredientDialog"
v-model="dialog"
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
:title="selectedShoppingList?.name || $t('recipe.add-to-list')"
:icon="$globals.icons.cartCheck"
width="70%"
:submit-text="$t('recipe.add-to-list')"
@ -130,20 +130,23 @@
.ingredients[i]
.checked"
>
<v-container class="pa-0 ma-0">
<v-row no-gutters>
<v-checkbox
hide-details
:model-value="ingredientData.checked"
class="pt-0 my-auto py-auto"
class="pt-0 my-auto py-auto mr-2"
color="secondary"
density="compact"
/>
<div :key="ingredientData.ingredient.quantity">
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
<RecipeIngredientListItem
:ingredient="ingredientData.ingredient"
:disable-amount="ingredientData.disableAmount"
:scale="recipeSection.recipeScale"
/>
</div>
</v-row>
</v-container>
</v-list-item>
</div>
</div>
@ -172,7 +175,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { toRefs } from "@vueuse/core";
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { useUserApi } from "~/composables/api";
@ -188,7 +191,6 @@ export interface RecipeWithScale extends Recipe {
export interface ShoppingListIngredient {
checked: boolean;
ingredient: RecipeIngredient;
disableAmount: boolean;
}
export interface ShoppingListIngredientSection {
@ -203,61 +205,43 @@ export interface ShoppingListRecipeIngredientSection {
ingredientSections: ShoppingListIngredientSection[];
}
export default defineNuxtComponent({
components: {
RecipeIngredientListItem,
},
props: {
modelValue: {
type: Boolean,
default: false,
},
recipes: {
type: Array as () => RecipeWithScale[],
default: undefined,
},
shoppingLists: {
type: Array as () => ShoppingListSummary[],
default: () => [],
},
},
emits: ["update:modelValue"],
setup(props, context) {
const i18n = useI18n();
const $auth = useMealieAuth();
const api = useUserApi();
const preferences = useShoppingListPreferences();
const ready = ref(false);
interface Props {
recipes?: RecipeWithScale[];
shoppingLists?: ShoppingListSummary[];
}
const props = withDefaults(defineProps<Props>(), {
recipes: undefined,
shoppingLists: () => [],
});
// v-model support
const dialog = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
context.emit("update:modelValue", val);
initState();
},
});
const dialog = defineModel<boolean>({ default: false });
const state = reactive({
const i18n = useI18n();
const $auth = useMealieAuth();
const api = useUserApi();
const preferences = useShoppingListPreferences();
const ready = ref(false);
const state = reactive({
shoppingListDialog: true,
shoppingListIngredientDialog: false,
shoppingListShowAllToggled: false,
});
});
const userHousehold = computed(() => {
const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllToggled: _shoppingListShowAllToggled } = toRefs(state);
const userHousehold = computed(() => {
return $auth.user.value?.householdSlug || "";
});
});
const shoppingListChoices = computed(() => {
const shoppingListChoices = computed(() => {
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
});
});
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
watchEffect(
watchEffect(
() => {
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
selectedShoppingList.value = shoppingListChoices.value[0];
@ -267,9 +251,15 @@ export default defineNuxtComponent({
ready.value = true;
}
},
);
);
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
watch(dialog, (val) => {
if (!val) {
initState();
}
});
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
for (const recipe of recipes) {
if (!recipe.slug) {
@ -277,7 +267,10 @@ export default defineNuxtComponent({
}
if (recipeSectionMap.has(recipe.slug)) {
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
const existingSection = recipeSectionMap.get(recipe.slug);
if (existingSection) {
existingSection.recipeScale += recipe.scale;
}
continue;
}
@ -299,7 +292,6 @@ export default defineNuxtComponent({
return {
checked: !householdsWithFood.includes(userHousehold.value),
ingredient: ing,
disableAmount: recipe.settings?.disableAmount || false,
};
});
@ -347,19 +339,19 @@ export default defineNuxtComponent({
}
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
}
}
function initState() {
function initState() {
state.shoppingListDialog = true;
state.shoppingListIngredientDialog = false;
state.shoppingListShowAllToggled = false;
recipeIngredientSections.value = [];
selectedShoppingList.value = null;
}
}
initState();
initState();
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
if (!props.recipes?.length) {
return;
}
@ -368,13 +360,13 @@ export default defineNuxtComponent({
await consolidateRecipesIntoSections(props.recipes);
state.shoppingListDialog = false;
state.shoppingListIngredientDialog = true;
}
}
function setShowAllToggled() {
function setShowAllToggled() {
state.shoppingListShowAllToggled = true;
}
}
function bulkCheckIngredients(value = true) {
function bulkCheckIngredients(value = true) {
recipeIngredientSections.value.forEach((recipeSection) => {
recipeSection.ingredientSections.forEach((ingSection) => {
ingSection.ingredients.forEach((ing) => {
@ -382,9 +374,9 @@ export default defineNuxtComponent({
});
});
});
}
}
async function addRecipesToList() {
async function addRecipesToList() {
if (!selectedShoppingList.value) {
return;
}
@ -420,23 +412,7 @@ export default defineNuxtComponent({
state.shoppingListDialog = false;
state.shoppingListIngredientDialog = false;
dialog.value = false;
}
return {
dialog,
preferences,
ready,
shoppingListChoices,
...toRefs(state),
addRecipesToList,
bulkCheckIngredients,
openShoppingListIngredientDialog,
setShowAllToggled,
recipeIngredientSections,
selectedShoppingList,
};
},
});
}
</script>
<style scoped lang="css">

View file

@ -4,9 +4,9 @@
v-model="dialog"
width="800"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<BaseButton
v-bind="props"
v-bind="activatorProps"
@click="inputText = inputTextProp"
>
{{ $t("new-recipe.bulk-add") }}
@ -89,62 +89,61 @@
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
inputTextProp: {
type: String,
required: false,
default: "",
},
},
emits: ["bulk-data"],
setup(props, context) {
const state = reactive({
dialog: false,
inputText: props.inputTextProp,
});
<script setup lang="ts">
interface Props {
inputTextProp?: string;
}
const props = withDefaults(defineProps<Props>(), {
inputTextProp: "",
});
function splitText() {
return state.inputText.split("\n").filter(line => !(line === "\n" || !line));
}
const emit = defineEmits<{
"bulk-data": [data: string[]];
}>();
function removeFirstCharacter() {
state.inputText = splitText()
const dialog = ref(false);
const inputText = ref(props.inputTextProp);
function splitText() {
return inputText.value.split("\n").filter(line => !(line === "\n" || !line));
}
function removeFirstCharacter() {
inputText.value = splitText()
.map(line => line.substring(1))
.join("\n");
}
}
const numberedLineRegex = /\d+[.):] /gm;
const numberedLineRegex = /\d+[.):] /gm;
function splitByNumberedLine() {
function splitByNumberedLine() {
// Split inputText by numberedLineRegex
const matches = state.inputText.match(numberedLineRegex);
const matches = inputText.value.match(numberedLineRegex);
matches?.forEach((match, idx) => {
const replaceText = idx === 0 ? "" : "\n";
state.inputText = state.inputText.replace(match, replaceText);
inputText.value = inputText.value.replace(match, replaceText);
});
}
}
function trimAllLines() {
function trimAllLines() {
const splitLines = splitText();
splitLines.forEach((element: string, index: number) => {
splitLines[index] = element.trim();
});
state.inputText = splitLines.join("\n");
}
inputText.value = splitLines.join("\n");
}
function save() {
context.emit("bulk-data", splitText());
state.dialog = false;
}
function save() {
emit("bulk-data", splitText());
dialog.value = false;
}
const i18n = useI18n();
const i18n = useI18n();
const utilities = [
const utilities = [
{
id: "trim-whitespace",
description: i18n.t("new-recipe.trim-whitespace-description"),
@ -160,17 +159,5 @@ export default defineNuxtComponent({
description: i18n.t("new-recipe.split-by-numbered-line-description"),
action: splitByNumberedLine,
},
];
return {
utilities,
splitText,
trimAllLines,
removeFirstCharacter,
splitByNumberedLine,
save,
...toRefs(state),
};
},
});
];
</script>

View file

@ -44,6 +44,7 @@
<v-switch
v-model="preferences.showDescription"
hide-details
color="primary"
:label="$t('recipe.description')"
/>
</v-row>
@ -51,6 +52,7 @@
<v-switch
v-model="preferences.showNotes"
hide-details
color="primary"
:label="$t('recipe.notes')"
/>
</v-row>
@ -63,6 +65,7 @@
<v-switch
v-model="preferences.showNutrition"
hide-details
color="primary"
:label="$t('recipe.nutrition')"
/>
</v-row>
@ -83,45 +86,19 @@
</BaseDialog>
</template>
<script lang="ts">
<script setup lang="ts">
import type { Recipe } from "~/lib/api/types/recipe";
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineNuxtComponent({
components: {
RecipePrintView,
},
props: {
modelValue: {
type: Boolean,
default: false,
},
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
default: undefined,
},
},
emits: ["update:modelValue"],
setup(props, context) {
const preferences = useUserPrintPreferences();
// V-Model Support
const dialog = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
context.emit("update:modelValue", val);
},
});
return {
dialog,
ImagePosition,
preferences,
};
},
interface Props {
recipe?: NoUndefinedField<Recipe>;
}
withDefaults(defineProps<Props>(), {
recipe: undefined,
});
const dialog = defineModel<boolean>({ default: false });
const preferences = useUserPrintPreferences();
</script>

View file

@ -52,10 +52,6 @@
<div class="mr-auto">
{{ $t("search.results") }}
</div>
<!-- <router-link
:to="advancedSearchUrl"
class="text-primary"
> {{ $t("search.advanced-search") }} </router-link> -->
</v-card-actions>
<RecipeCardMobile
@ -76,7 +72,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeCardMobile from "./RecipeCardMobile.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { RecipeSummary } from "~/lib/api/types/recipe";
@ -85,114 +81,104 @@ import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
import { usePublicExploreApi } from "~/composables/api/api-client";
const SELECTED_EVENT = "selected";
export default defineNuxtComponent({
components: {
RecipeCardMobile,
},
setup(_, context) {
const $auth = useMealieAuth();
const state = reactive({
loading: false,
selectedIndex: -1,
});
// Define emits
const emit = defineEmits<{
selected: [recipe: RecipeSummary];
}>();
// ===========================================================================
// Dialog State Management
const dialog = ref(false);
const $auth = useMealieAuth();
const loading = ref(false);
const selectedIndex = ref(-1);
// Reset or Grab Recipes on Change
watch(dialog, (val) => {
// ===========================================================================
// Dialog State Management
const dialog = ref(false);
// Reset or Grab Recipes on Change
watch(dialog, (val) => {
if (!val) {
search.query.value = "";
state.selectedIndex = -1;
selectedIndex.value = -1;
search.data.value = [];
}
});
});
// ===========================================================================
// Event Handlers
// ===========================================================================
// Event Handlers
function selectRecipe() {
function selectRecipe() {
const recipeCards = document.getElementsByClassName("arrow-nav");
if (recipeCards) {
if (state.selectedIndex < 0) {
state.selectedIndex = -1;
if (selectedIndex.value < 0) {
selectedIndex.value = -1;
document.getElementById("arrow-search")?.focus();
return;
}
if (state.selectedIndex >= recipeCards.length) {
state.selectedIndex = recipeCards.length - 1;
if (selectedIndex.value >= recipeCards.length) {
selectedIndex.value = recipeCards.length - 1;
}
(recipeCards[state.selectedIndex] as HTMLElement).focus();
}
(recipeCards[selectedIndex.value] as HTMLElement).focus();
}
}
function onUpDown(e: KeyboardEvent) {
function onUpDown(e: KeyboardEvent) {
if (e.key === "Enter") {
console.log(document.activeElement);
// (document.activeElement as HTMLElement).click();
}
else if (e.key === "ArrowUp") {
e.preventDefault();
state.selectedIndex--;
selectedIndex.value--;
}
else if (e.key === "ArrowDown") {
e.preventDefault();
state.selectedIndex++;
selectedIndex.value++;
}
else {
return;
}
selectRecipe();
}
}
watch(dialog, (val) => {
watch(dialog, (val) => {
if (!val) {
document.removeEventListener("keyup", onUpDown);
}
else {
document.addEventListener("keyup", onUpDown);
}
});
});
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const route = useRoute();
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`);
watch(route, close);
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
watch(route, close);
function open() {
function open() {
dialog.value = true;
}
function close() {
}
function close() {
dialog.value = false;
}
}
// ===========================================================================
// Basic Search
const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const search = useRecipeSearch(api);
// ===========================================================================
// Basic Search
const { isOwnGroup } = useLoggedInState();
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const search = useRecipeSearch(api);
// Select Handler
function handleSelect(recipe: RecipeSummary) {
// Select Handler
function handleSelect(recipe: RecipeSummary) {
close();
context.emit(SELECTED_EVENT, recipe);
}
emit(SELECTED_EVENT, recipe);
}
return {
...toRefs(state),
advancedSearchUrl,
dialog,
// Expose functions to parent components
defineExpose({
open,
close,
handleSelect,
search,
};
},
});
</script>

View file

@ -14,14 +14,14 @@
max-width="290px"
min-width="auto"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="expirationDateString"
:label="$t('recipe-share.expiration-date')"
:hint="$t('recipe-share.default-30-days')"
persistent-hint
:prepend-icon="$globals.icons.calendar"
v-bind="props"
v-bind="activatorProps"
readonly
/>
</template>
@ -92,113 +92,92 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useClipboard, useShare, whenever } from "@vueuse/core";
import type { RecipeShareToken } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api";
import { useHouseholdSelf } from "~/composables/use-households";
import { alert } from "~/composables/use-toast";
export default defineNuxtComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
recipeId: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
},
emits: ["update:modelValue"],
setup(props, context) {
// V-Model Support
const dialog = computed({
get: () => {
return props.modelValue;
},
set: (val) => {
context.emit("update:modelValue", val);
},
});
interface Props {
recipeId: string;
name: string;
}
const props = defineProps<Props>();
const state = reactive({
datePickerMenu: false,
expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
tokens: [] as RecipeShareToken[],
});
const dialog = defineModel<boolean>({ default: false });
const expirationDateString = computed(() => {
return state.expirationDate.toISOString().substring(0, 10);
});
const datePickerMenu = ref(false);
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
const tokens = ref<RecipeShareToken[]>([]);
whenever(
() => props.modelValue,
const expirationDateString = computed(() => {
return expirationDate.value.toISOString().substring(0, 10);
});
whenever(
() => dialog.value,
() => {
// Set expiration date to today + 30 Days
const today = new Date();
state.expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
expirationDate.value = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
refreshTokens();
},
);
);
const i18n = useI18n();
const $auth = useMealieAuth();
const { household } = useHouseholdSelf();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const i18n = useI18n();
const $auth = useMealieAuth();
const { household } = useHouseholdSelf();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => {
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
});
// ============================================================
// Token Actions
// ============================================================
// Token Actions
const userApi = useUserApi();
const userApi = useUserApi();
async function createNewToken() {
async function createNewToken() {
// Convert expiration date to timestamp
const { data } = await userApi.recipes.share.createOne({
recipeId: props.recipeId,
expiresAt: state.expirationDate.toISOString(),
expiresAt: expirationDate.value.toISOString(),
});
if (data) {
state.tokens.push(data);
}
tokens.value.push(data);
}
}
async function deleteToken(id: string) {
async function deleteToken(id: string) {
await userApi.recipes.share.deleteOne(id);
state.tokens = state.tokens.filter(token => token.id !== id);
}
tokens.value = tokens.value.filter(token => token.id !== id);
}
async function refreshTokens() {
async function refreshTokens() {
const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
if (data) {
// @ts-expect-error - TODO: This routes doesn't have pagination, but the type are mismatched.
state.tokens = data ?? [];
}
tokens.value = data ?? [];
}
}
const { share, isSupported: shareIsSupported } = useShare();
const { copy, copied, isSupported } = useClipboard();
const { share, isSupported: shareIsSupported } = useShare();
const { copy, copied, isSupported } = useClipboard();
function getRecipeText() {
function getRecipeText() {
return i18n.t("recipe.share-recipe-message", [props.name]);
}
}
function getTokenLink(token: string) {
function getTokenLink(token: string) {
return `${window.location.origin}/g/${groupSlug.value}/shared/r/${token}`;
}
}
async function copyTokenLink(token: string) {
async function copyTokenLink(token: string) {
if (isSupported.value) {
await copy(getTokenLink(token));
if (copied.value) {
@ -211,9 +190,9 @@ export default defineNuxtComponent({
else {
alert.error(i18n.t("general.clipboard-not-supported") as string);
}
}
}
async function shareRecipe(token: string) {
async function shareRecipe(token: string) {
if (shareIsSupported) {
share({
title: props.name,
@ -224,18 +203,5 @@ export default defineNuxtComponent({
else {
await copyTokenLink(token);
}
}
return {
...toRefs(state),
expirationDateString,
dialog,
createNewToken,
deleteToken,
firstDayOfWeek,
shareRecipe,
copyTokenLink,
};
},
});
}
</script>

View file

@ -4,7 +4,7 @@
nudge-right="50"
:color="buttonStyle ? 'info' : 'secondary'"
>
<template #activator="{ props }">
<template #activator="{ props: tooltipProps }">
<v-btn
v-if="isFavorite || showAlways"
icon
@ -13,7 +13,7 @@
size="small"
:color="buttonStyle ? 'info' : 'secondary'"
:fab="buttonStyle"
v-bind="{ ...props, ...$attrs }"
v-bind="{ ...tooltipProps, ...$attrs }"
@click.prevent="toggleFavorite"
>
<v-icon
@ -28,36 +28,31 @@
</v-tooltip>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserSelfRatings } from "~/composables/use-users";
import { useUserApi } from "~/composables/api";
export default defineNuxtComponent({
props: {
recipeId: {
type: String,
default: "",
},
showAlways: {
type: Boolean,
default: false,
},
buttonStyle: {
type: Boolean,
default: false,
},
},
setup(props) {
const api = useUserApi();
const $auth = useMealieAuth();
const { userRatings, refreshUserRatings } = useUserSelfRatings();
interface Props {
recipeId?: string;
showAlways?: boolean;
buttonStyle?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
recipeId: "",
showAlways: false,
buttonStyle: false,
});
const isFavorite = computed(() => {
const api = useUserApi();
const $auth = useMealieAuth();
const { userRatings, refreshUserRatings } = useUserSelfRatings();
const isFavorite = computed(() => {
const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
return rating?.isFavorite || false;
});
});
async function toggleFavorite() {
async function toggleFavorite() {
if (!$auth.user.value) return;
if (!isFavorite.value) {
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
@ -66,9 +61,5 @@ export default defineNuxtComponent({
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
}
await refreshUserRatings();
}
return { isFavorite, toggleFavorite };
},
});
}
</script>

View file

@ -7,11 +7,11 @@
nudge-top="6"
:close-on-content-click="false"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-btn
color="accent"
dark
v-bind="props"
v-bind="activatorProps"
>
<v-icon start>
{{ $globals.icons.fileImage }}
@ -61,52 +61,42 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload";
export default defineNuxtComponent({
props: {
slug: {
type: String,
required: true,
},
},
setup(props, context) {
const state = reactive({
url: "",
loading: false,
menu: false,
});
const props = defineProps<{ slug: string }>();
function uploadImage(fileObject: File) {
context.emit(UPLOAD_EVENT, fileObject);
state.menu = false;
const emit = defineEmits<{
refresh: [];
upload: [fileObject: File];
}>();
const url = ref("");
const loading = ref(false);
const menu = ref(false);
function uploadImage(fileObject: File) {
emit(UPLOAD_EVENT, fileObject);
menu.value = false;
}
const api = useUserApi();
async function getImageFromURL() {
loading.value = true;
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
emit(REFRESH_EVENT);
}
loading.value = false;
menu.value = false;
}
const api = useUserApi();
async function getImageFromURL() {
state.loading = true;
if (await api.recipes.updateImagebyURL(props.slug, state.url)) {
context.emit(REFRESH_EVENT);
}
state.loading = false;
state.menu = false;
}
const i18n = useI18n();
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
return {
...toRefs(state),
uploadImage,
getImageFromURL,
messages,
};
},
});
const i18n = useI18n();
const messages = computed(() =>
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
);
</script>
<style lang="scss" scoped></style>

View file

@ -17,7 +17,6 @@
class="d-flex flex-wrap my-1"
>
<v-col
v-if="!disableAmount"
sm="12"
md="2"
cols="12"
@ -42,7 +41,6 @@
</v-text-field>
</v-col>
<v-col
v-if="!disableAmount"
sm="12"
md="3"
cols="12"
@ -63,6 +61,22 @@
clearable
@keyup.enter="handleUnitEnter"
>
<template #prepend>
<v-tooltip v-if="unitError" location="bottom">
<template #activator="{ props: unitTooltipProps }">
<v-icon
v-bind="unitTooltipProps"
class="ml-2 mr-n3 opacity-100"
color="primary"
>
{{ $globals.icons.alert }}
</v-icon>
</template>
<span v-if="unitErrorTooltip">
{{ unitErrorTooltip }}
</span>
</v-tooltip>
</template>
<template #no-data>
<div class="caption text-center pb-2">
{{ $t("recipe.press-enter-to-create") }}
@ -82,7 +96,6 @@
<!-- Foods Input -->
<v-col
v-if="!disableAmount"
m="12"
md="3"
cols="12"
@ -104,6 +117,22 @@
clearable
@keyup.enter="handleFoodEnter"
>
<template #prepend>
<v-tooltip v-if="foodError" location="bottom">
<template #activator="{ props: foodTooltipProps }">
<v-icon
v-bind="foodTooltipProps"
class="ml-2 mr-n3 opacity-100"
color="primary"
>
{{ $globals.icons.alert }}
</v-icon>
</template>
<span v-if="foodErrorTooltip">
{{ foodErrorTooltip }}
</span>
</v-tooltip>
</template>
<template #no-data>
<div class="caption text-center pb-2">
{{ $t("recipe.press-enter-to-create") }}
@ -134,16 +163,7 @@
:placeholder="$t('recipe.notes')"
class="mb-auto"
@click="$emit('clickIngredientField', 'note')"
>
<template #prepend>
<v-icon
v-if="disableAmount && $attrs && $attrs.delete"
class="mr-n1 handle"
>
{{ $globals.icons.arrowUpDown }}
</v-icon>
</template>
</v-text-field>
/>
<BaseButtonGroup
hover
:large="false"
@ -153,7 +173,6 @@
@toggle-original="toggleOriginalText"
@insert-above="$emit('insert-above')"
@insert-below="$emit('insert-below')"
@insert-ingredient="$emit('insert-ingredient')"
@delete="$emit('delete')"
/>
</div>
@ -184,22 +203,29 @@ import type { RecipeIngredient } from "~/lib/api/types/recipe";
// defineModel replaces modelValue prop
const model = defineModel<RecipeIngredient>({ required: true });
const props = defineProps({
disableAmount: {
defineProps({
unitError: {
type: Boolean,
default: false,
},
allowInsertIngredient: {
unitErrorTooltip: {
type: String,
default: "",
},
foodError: {
type: Boolean,
default: false,
},
foodErrorTooltip: {
type: String,
default: "",
},
});
defineEmits([
"clickIngredientField",
"insert-above",
"insert-below",
"insert-ingredient",
"delete",
]);
@ -228,13 +254,6 @@ const contextMenuOptions = computed(() => {
},
];
if (props.allowInsertIngredient) {
options.push({
text: i18n.t("recipe.insert-ingredient"),
event: "insert-ingredient",
});
}
if (model.value.originalText) {
options.push({
text: i18n.t("recipe.see-original-text"),

View file

@ -3,21 +3,13 @@
<div v-html="safeMarkup" />
</template>
<script lang="ts">
<script setup lang="ts">
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
export default defineNuxtComponent({
props: {
markup: {
type: String,
required: true,
},
},
setup(props) {
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
return {
safeMarkup,
};
},
});
interface Props {
markup: string;
}
const props = defineProps<Props>();
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
</script>

View file

@ -28,34 +28,20 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import type { RecipeIngredient } from "~/lib/api/types/household";
import { useParsedIngredientText } from "~/composables/recipes";
export default defineNuxtComponent({
props: {
ingredient: {
type: Object as () => RecipeIngredient,
required: true,
},
disableAmount: {
type: Boolean,
default: false,
},
scale: {
type: Number,
default: 1,
},
},
setup(props) {
const parsedIng = computed(() => {
return useParsedIngredientText(props.ingredient, props.disableAmount, props.scale);
});
interface Props {
ingredient: RecipeIngredient;
scale?: number;
}
const props = withDefaults(defineProps<Props>(), {
scale: 1,
});
return {
parsedIng,
};
},
const parsedIng = computed(() => {
return useParsedIngredientText(props.ingredient, props.scale);
});
</script>

View file

@ -43,7 +43,6 @@
<v-list-item-title>
<RecipeIngredientListItem
:ingredient="ingredient"
:disable-amount="disableAmount"
:scale="scale"
/>
</v-list-item-title>
@ -53,42 +52,30 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { parseIngredientText } from "~/composables/recipes";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
export default defineNuxtComponent({
components: { RecipeIngredientListItem },
props: {
value: {
type: Array as () => RecipeIngredient[],
default: () => [],
},
disableAmount: {
type: Boolean,
default: false,
},
scale: {
type: Number,
default: 1,
},
isCookMode: {
type: Boolean,
default: false,
},
},
setup(props) {
function validateTitle(title?: string) {
interface Props {
value?: RecipeIngredient[];
scale?: number;
isCookMode?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
value: () => [],
scale: 1,
isCookMode: false,
});
function validateTitle(title?: string | null) {
return !(title === undefined || title === "" || title === null);
}
}
const state = reactive({
checked: props.value.map(() => false),
showTitleEditor: computed(() => props.value.map(x => validateTitle(x.title))),
});
const checked = ref(props.value.map(() => false));
const showTitleEditor = computed(() => props.value.map(x => validateTitle(x.title)));
const ingredientCopyText = computed(() => {
const ingredientCopyText = computed(() => {
const components: string[] = [];
props.value.forEach((ingredient) => {
if (ingredient.title) {
@ -99,25 +86,17 @@ export default defineNuxtComponent({
components.push(`[${ingredient.title}]`);
}
components.push(parseIngredientText(ingredient, props.disableAmount, props.scale, false));
components.push(parseIngredientText(ingredient, props.scale, false));
});
return components.join("\n");
});
});
function toggleChecked(index: number) {
function toggleChecked(index: number) {
// TODO Find a better way to do this - $set is not available, and
// direct array modifications are not propagated for some reason
state.checked.splice(index, 1, !state.checked[index]);
}
return {
...toRefs(state),
ingredientCopyText,
toggleChecked,
};
},
});
checked.value.splice(index, 1, !checked.value[index]);
}
</script>
<style>

View file

@ -30,11 +30,11 @@
offset-y
max-width="290px"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-text-field
v-model="newTimelineEventTimestampString"
:prepend-icon="$globals.icons.calendar"
v-bind="props"
v-bind="activatorProps"
readonly
/>
</template>
@ -86,13 +86,13 @@
<div>
<div v-if="lastMadeReady" class="d-flex justify-center flex-wrap">
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger">
<v-tooltip bottom>
<template #activator="{ props }">
<v-tooltip location="bottom">
<template #activator="{ props: tooltipProps }">
<v-btn
rounded
variant="outlined"
size="x-large"
v-bind="props"
v-bind="tooltipProps"
style="border-color: rgb(var(--v-theme-primary));"
@click="madeThisDialog = true"
>
@ -117,7 +117,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { whenever } from "@vueuse/core";
import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast";
@ -125,39 +125,35 @@ import { useHouseholdSelf } from "~/composables/use-households";
import type { Recipe, RecipeTimelineEventIn, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
import type { VForm } from "~/types/auto-forms";
export default defineNuxtComponent({
props: {
recipe: {
type: Object as () => Recipe,
required: true,
},
},
emits: ["eventCreated"],
setup(props, context) {
const madeThisDialog = ref(false);
const userApi = useUserApi();
const { household } = useHouseholdSelf();
const i18n = useI18n();
const $auth = useMealieAuth();
const domMadeThisForm = ref<VForm>();
const newTimelineEvent = ref<RecipeTimelineEventIn>({
const props = defineProps<{ recipe: Recipe }>();
const emit = defineEmits<{
eventCreated: [event: RecipeTimelineEventOut];
}>();
const madeThisDialog = ref(false);
const userApi = useUserApi();
const { household } = useHouseholdSelf();
const i18n = useI18n();
const $auth = useMealieAuth();
const domMadeThisForm = ref<VForm>();
const newTimelineEvent = ref<RecipeTimelineEventIn>({
subject: "",
eventType: "comment",
eventMessage: "",
timestamp: undefined,
recipeId: props.recipe?.id || "",
});
const newTimelineEventImage = ref<Blob | File>();
const newTimelineEventImageName = ref<string>("");
const newTimelineEventImagePreviewUrl = ref<string>();
const newTimelineEventTimestamp = ref<Date>(new Date());
const newTimelineEventTimestampString = computed(() => {
});
const newTimelineEventImage = ref<Blob | File>();
const newTimelineEventImageName = ref<string>("");
const newTimelineEventImagePreviewUrl = ref<string>();
const newTimelineEventTimestamp = ref<Date>(new Date());
const newTimelineEventTimestampString = computed(() => {
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
});
});
const lastMade = ref(props.recipe.lastMade);
const lastMadeReady = ref(false);
onMounted(async () => {
const lastMade = ref(props.recipe.lastMade);
const lastMadeReady = ref(false);
onMounted(async () => {
if (!$auth.user?.value?.householdSlug) {
lastMade.value = props.recipe.lastMade;
}
@ -167,55 +163,56 @@ export default defineNuxtComponent({
}
lastMadeReady.value = true;
});
});
whenever(
whenever(
() => madeThisDialog.value,
() => {
// Set timestamp to now
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
},
);
);
const firstDayOfWeek = computed(() => {
const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0;
});
});
function clearImage() {
function clearImage() {
newTimelineEventImage.value = undefined;
newTimelineEventImageName.value = "";
newTimelineEventImagePreviewUrl.value = undefined;
}
}
function uploadImage(fileObject: File) {
function uploadImage(fileObject: File) {
newTimelineEventImage.value = fileObject;
newTimelineEventImageName.value = fileObject.name;
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
}
function updateUploadedImage(fileObject: Blob) {
function updateUploadedImage(fileObject: Blob) {
newTimelineEventImage.value = fileObject;
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
}
}
const state = reactive({ datePickerMenu: false, madeThisFormLoading: false });
const datePickerMenu = ref(false);
const madeThisFormLoading = ref(false);
function resetMadeThisForm() {
state.madeThisFormLoading = false;
function resetMadeThisForm() {
madeThisFormLoading.value = false;
newTimelineEvent.value.eventMessage = "";
newTimelineEvent.value.timestamp = undefined;
clearImage();
madeThisDialog.value = false;
domMadeThisForm.value?.reset();
}
}
async function createTimelineEvent() {
async function createTimelineEvent() {
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
return;
}
state.madeThisFormLoading = true;
madeThisFormLoading.value = true;
newTimelineEvent.value.recipeId = props.recipe.id;
// Note: $auth.user is now a ref
@ -279,26 +276,6 @@ export default defineNuxtComponent({
}
resetMadeThisForm();
context.emit("eventCreated", newEvent);
}
return {
...toRefs(state),
domMadeThisForm,
madeThisDialog,
firstDayOfWeek,
newTimelineEvent,
newTimelineEventImage,
newTimelineEventImagePreviewUrl,
newTimelineEventTimestamp,
newTimelineEventTimestampString,
lastMade,
lastMadeReady,
createTimelineEvent,
clearImage,
uploadImage,
updateUploadedImage,
};
},
});
emit("eventCreated", newEvent);
}
</script>

View file

@ -51,46 +51,34 @@
</v-list>
</template>
<script lang="ts">
<script setup lang="ts">
import DOMPurify from "dompurify";
import { useFraction } from "~/composables/recipes/use-fraction";
import type { ShoppingListItemOut } from "~/lib/api/types/household";
import type { RecipeSummary } from "~/lib/api/types/recipe";
export default defineNuxtComponent({
props: {
recipes: {
type: Array as () => RecipeSummary[],
required: true,
},
listItem: {
type: Object as () => ShoppingListItemOut | undefined,
default: undefined,
},
small: {
type: Boolean,
default: false,
},
tile: {
type: Boolean,
default: false,
},
showDescription: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props) {
const $auth = useMealieAuth();
const { frac } = useFraction();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
interface Props {
recipes: RecipeSummary[];
listItem?: ShoppingListItemOut;
small?: boolean;
tile?: boolean;
showDescription?: boolean;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
listItem: undefined,
small: false,
tile: false,
showDescription: false,
disabled: false,
});
const attrs = computed(() => {
const $auth = useMealieAuth();
const { frac } = useFraction();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
const attrs = computed(() => {
return props.small
? {
class: {
@ -122,16 +110,16 @@ export default defineNuxtComponent({
},
},
};
});
});
function sanitizeHTML(rawHtml: string) {
function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
}
}
const listItemDescriptions = computed<string[]>(() => {
const listItemDescriptions = computed<string[]>(() => {
if (
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref
|| !props.listItem?.recipeReferences
@ -179,13 +167,5 @@ export default defineNuxtComponent({
}
return listItemDescriptions;
});
return {
attrs,
groupSlug,
listItemDescriptions,
};
},
});
</script>

View file

@ -45,62 +45,48 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useNutritionLabels } from "~/composables/recipes";
import type { Nutrition } from "~/lib/api/types/recipe";
import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
export default defineNuxtComponent({
props: {
modelValue: {
type: Object as () => Nutrition,
required: true,
},
edit: {
type: Boolean,
default: true,
},
},
emits: ["update:modelValue"],
setup(props, context) {
const { labels } = useNutritionLabels();
const valueNotNull = computed(() => {
interface Props {
edit?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
edit: true,
});
const modelValue = defineModel<Nutrition>({ required: true });
const { labels } = useNutritionLabels();
const valueNotNull = computed(() => {
let key: keyof Nutrition;
for (key in props.modelValue) {
if (props.modelValue[key] !== null) {
for (key in modelValue.value) {
if (modelValue.value[key] !== null) {
return true;
}
}
return false;
});
});
const showViewer = computed(() => !props.edit && valueNotNull.value);
const showViewer = computed(() => !props.edit && valueNotNull.value);
function updateValue(key: number | string, event: Event) {
context.emit("update:modelValue", { ...props.modelValue, [key]: event });
}
function updateValue(key: number | string, event: Event) {
modelValue.value = { ...modelValue.value, [key]: event };
}
// Build a new list that only contains nutritional information that has a value
const renderedList = computed(() => {
// Build a new list that only contains nutritional information that has a value
const renderedList = computed(() => {
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
if (props.modelValue[key]?.trim()) {
if (modelValue.value[key]?.trim()) {
item[key] = {
...label,
value: props.modelValue[key],
value: modelValue.value[key],
};
}
return item;
}, {});
});
return {
labels,
valueNotNull,
showViewer,
updateValue,
renderedList,
};
},
});
</script>

View file

@ -60,60 +60,45 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useUserApi } from "~/composables/api";
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
const CREATED_ITEM_EVENT = "created-item";
export default defineNuxtComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
color: {
type: String,
default: null,
},
tagDialog: {
type: Boolean,
default: true,
},
itemType: {
type: String as () => RecipeOrganizer,
default: "category",
},
},
emits: ["update:modelValue"],
setup(props, context) {
const i18n = useI18n();
interface Props {
color?: string | null;
tagDialog?: boolean;
itemType?: RecipeOrganizer;
}
const props = withDefaults(defineProps<Props>(), {
color: null,
tagDialog: true,
itemType: "category" as RecipeOrganizer,
});
const state = reactive({
name: "",
onHand: false,
});
const emit = defineEmits<{
"created-item": [item: any];
}>();
const dialog = computed({
get() {
return props.modelValue;
},
set(value) {
context.emit("update:modelValue", value);
},
});
const dialog = defineModel<boolean>({ default: false });
watch(
() => props.modelValue,
const i18n = useI18n();
const name = ref("");
const onHand = ref(false);
watch(
dialog,
(val: boolean) => {
if (!val) state.name = "";
if (!val) name.value = "";
},
);
);
const userApi = useUserApi();
const userApi = useUserApi();
const store = (() => {
const store = (() => {
switch (props.itemType) {
case Organizer.Tag:
return useTagStore();
@ -122,9 +107,9 @@ export default defineNuxtComponent({
default:
return useCategoryStore();
}
})();
})();
const properties = computed(() => {
const properties = computed(() => {
switch (props.itemType) {
case Organizer.Tag:
return {
@ -145,34 +130,23 @@ export default defineNuxtComponent({
api: userApi.categories,
};
}
});
});
const rules = {
const rules = {
required: (val: string) => !!val || (i18n.t("general.a-name-is-required") as string),
};
};
async function select() {
async function select() {
if (store) {
// @ts-expect-error the same state is used for different organizer types, which have different requirements
await store.actions.createOne({ ...state });
await store.actions.createOne({ name: name.value, onHand: onHand.value });
}
const newItem = store.store.value.find(item => item.name === state.name);
const newItem = store.store.value.find(item => item.name === name.value);
context.emit(CREATED_ITEM_EVENT, newItem);
emit(CREATED_ITEM_EVENT, newItem);
dialog.value = false;
}
return {
Organizer,
...toRefs(state),
dialog,
properties,
rules,
select,
};
},
});
}
</script>
<style></style>

View file

@ -122,9 +122,8 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import Fuse from "fuse.js";
import { useContextPresets } from "~/composables/use-context-presents";
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
@ -138,27 +137,18 @@ interface GenericItem {
onHand: boolean;
}
export default defineNuxtComponent({
components: {
RecipeOrganizerDialog,
},
props: {
items: {
type: Array as () => GenericItem[],
required: true,
},
icon: {
type: String,
required: true,
},
itemType: {
type: String as () => RecipeOrganizer,
required: true,
},
},
emits: ["update", "delete"],
setup(props, { emit }) {
const state = reactive({
const props = defineProps<{
items: GenericItem[];
icon: string;
itemType: RecipeOrganizer;
}>();
const emit = defineEmits<{
update: [item: GenericItem];
delete: [id: string];
}>();
const state = reactive({
// Search Options
options: {
ignoreLocation: true,
@ -171,24 +161,24 @@ export default defineNuxtComponent({
minMatchCharLength: 1,
keys: ["name"],
},
});
});
const $auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
const $auth = useMealieAuth();
const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
// =================================================================
// Context Menu
// =================================================================
// Context Menu
const dialogs = ref({
const dialogs = ref({
organizer: false,
update: false,
delete: false,
});
});
const presets = useContextPresets();
const presets = useContextPresets();
const translationKey = computed<string>(() => {
const translationKey = computed<string>(() => {
const typeMap = {
categories: "category.category",
tags: "tag.tag",
@ -197,58 +187,58 @@ export default defineNuxtComponent({
households: "household.household",
};
return typeMap[props.itemType] || "";
});
});
const deleteTarget = ref<GenericItem | null>(null);
const updateTarget = ref<GenericItem | null>(null);
const deleteTarget = ref<GenericItem | null>(null);
const updateTarget = ref<GenericItem | null>(null);
function confirmDelete(item: GenericItem) {
function confirmDelete(item: GenericItem) {
deleteTarget.value = item;
dialogs.value.delete = true;
}
}
function deleteOne() {
function deleteOne() {
if (!deleteTarget.value) {
return;
}
emit("delete", deleteTarget.value.id);
}
}
function openUpdateDialog(item: GenericItem) {
function openUpdateDialog(item: GenericItem) {
updateTarget.value = deepCopy(item);
dialogs.value.update = true;
}
}
function updateOne() {
function updateOne() {
if (!updateTarget.value) {
return;
}
emit("update", updateTarget.value);
}
}
// ================================================================
// Search Functions
// ================================================================
// Search Functions
const searchString = useRouteQuery("q", "");
const searchString = useRouteQuery("q", "");
const fuse = computed(() => {
const fuse = computed(() => {
return new Fuse(props.items, state.options);
});
});
const fuzzyItems = computed<GenericItem[]>(() => {
const fuzzyItems = computed<GenericItem[]>(() => {
if (searchString.value.trim() === "") {
return props.items;
}
const result = fuse.value.search(searchString.value.trim() as string);
return result.map(x => x.item);
});
});
// =================================================================
// Sorted Items
// =================================================================
// Sorted Items
const itemsSorted = computed(() => {
const itemsSorted = computed(() => {
const byLetter: { [key: string]: Array<GenericItem> } = {};
if (!fuzzyItems.value) {
@ -266,28 +256,9 @@ export default defineNuxtComponent({
});
return byLetter;
});
function isTitle(str: number | string) {
return typeof str === "string" && str.length === 1;
}
return {
groupSlug,
isTitle,
dialogs,
confirmDelete,
openUpdateDialog,
updateOne,
updateTarget,
deleteOne,
deleteTarget,
Organizer,
presets,
itemsSorted,
searchString,
translationKey,
};
},
});
function isTitle(str: number | string) {
return typeof str === "string" && str.length === 1;
}
</script>

View file

@ -3,7 +3,7 @@
v-model="selected"
v-bind="inputAttrs"
v-model:search="searchInput"
:items="storeItem"
:items="items"
:label="label"
chips
closable-chips
@ -46,78 +46,51 @@
</v-autocomplete>
</template>
<script lang="ts">
<script setup lang="ts">
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
import type { RecipeTool } from "~/lib/api/types/admin";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
import type { HouseholdSummary } from "~/lib/api/types/household";
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
export default defineNuxtComponent({
props: {
modelValue: {
type: Array as () => (
interface Props {
selectorType: RecipeOrganizer;
inputAttrs?: Record<string, any>;
returnObject?: boolean;
showAdd?: boolean;
showLabel?: boolean;
showIcon?: boolean;
variant?: "filled" | "underlined" | "outlined" | "plain" | "solo" | "solo-inverted" | "solo-filled";
}
const props = withDefaults(defineProps<Props>(), {
inputAttrs: () => ({}),
returnObject: true,
showAdd: true,
showLabel: true,
showIcon: true,
variant: "outlined",
});
const selected = defineModel<(
| HouseholdSummary
| RecipeTag
| RecipeCategory
| RecipeTool
| IngredientFood
| string
)[] | undefined,
required: true,
},
/**
* The type of organizer to use.
*/
selectorType: {
type: String as () => RecipeOrganizer,
required: true,
},
inputAttrs: {
type: Object as () => Record<string, any>,
default: () => ({}),
},
returnObject: {
type: Boolean,
default: true,
},
showAdd: {
type: Boolean,
default: true,
},
showLabel: {
type: Boolean,
default: true,
},
showIcon: {
type: Boolean,
default: true,
},
variant: {
type: String as () => "filled" | "underlined" | "outlined" | "plain" | "solo" | "solo-inverted" | "solo-filled",
default: "outlined",
},
},
emits: ["update:modelValue"],
)[] | undefined>({ required: true });
setup(props, context) {
const selected = computed({
get: () => props.modelValue,
set: (val) => {
context.emit("update:modelValue", val);
},
});
onMounted(() => {
onMounted(() => {
if (selected.value === undefined) {
selected.value = [];
}
});
});
const i18n = useI18n();
const { $globals } = useNuxtApp();
const i18n = useI18n();
const { $globals } = useNuxtApp();
const label = computed(() => {
const label = computed(() => {
if (!props.showLabel) {
return "";
}
@ -136,9 +109,9 @@ export default defineNuxtComponent({
default:
return i18n.t("general.organizer");
}
});
});
const icon = computed(() => {
const icon = computed(() => {
if (!props.showIcon) {
return "";
}
@ -157,69 +130,54 @@ export default defineNuxtComponent({
default:
return $globals.icons.tags;
}
});
});
// ===========================================================================
// Store & Items Setup
// ===========================================================================
// Store & Items Setup
const storeMap = {
const storeMap = {
[Organizer.Category]: useCategoryStore(),
[Organizer.Tag]: useTagStore(),
[Organizer.Tool]: useToolStore(),
[Organizer.Food]: useFoodStore(),
[Organizer.Household]: useHouseholdStore(),
};
};
const store = computed(() => {
const store = computed(() => {
const { store } = storeMap[props.selectorType];
return store.value;
});
});
const items = computed(() => {
const items = computed(() => {
if (!props.returnObject) {
return store.value.map(item => item.name);
}
return store.value;
});
});
function removeByIndex(index: number) {
function removeByIndex(index: number) {
if (selected.value === undefined) {
return;
}
const newSelected = selected.value.filter((_, i) => i !== index);
selected.value = [...newSelected];
}
}
function appendCreated(item: any) {
function appendCreated(item: any) {
if (selected.value === undefined) {
return;
}
selected.value = [...selected.value, item];
}
}
const dialog = ref(false);
const dialog = ref(false);
const searchInput = ref("");
const searchInput = ref("");
function resetSearchInput() {
function resetSearchInput() {
searchInput.value = "";
}
return {
Organizer,
appendCreated,
dialog,
storeItem: items,
label,
icon,
selected,
removeByIndex,
searchInput,
resetSearchInput,
};
},
});
}
</script>
<style scoped>

View file

@ -37,7 +37,7 @@
<RecipePageIngredientEditor v-if="isEditForm" v-model="recipe" />
</div>
<div>
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
<RecipePageScale v-model="scale" :recipe="recipe" />
</div>
<!--
@ -81,7 +81,7 @@
</v-card>
<WakelockSwitch />
<RecipePageComments
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
v-if="!recipe.settings?.disableComments && !isEditForm && !isCookMode"
v-model="recipe"
class="px-1 my-4 d-print-none"
/>
@ -96,7 +96,7 @@
<v-row style="height: 100%" no-gutters class="overflow-hidden">
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%">
<div class="d-flex align-center">
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
<RecipePageScale v-model="scale" :recipe="recipe" />
</div>
<RecipePageIngredientToolsView
v-if="!isEditForm"
@ -124,7 +124,7 @@
</v-sheet>
<v-sheet v-show="isCookMode && hasLinkedIngredients">
<div class="mt-2 px-2 px-md-4">
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
<RecipePageScale v-model="scale" :recipe="recipe" />
</div>
<RecipePageInstructions
v-model="recipe.recipeInstructions"
@ -141,7 +141,6 @@
<RecipeIngredients
:value="notLinkedIngredients"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
:is-cook-mode="isCookMode"
/>
</v-card>
@ -278,7 +277,7 @@ async function deleteRecipe() {
* View Preferences
*/
const landscape = computed(() => {
const preferLandscape = recipe.value.settings.landscapeView;
const preferLandscape = recipe.value.settings?.landscapeView;
const smallScreen = !$vuetify.display.smAndUp.value;
if (preferLandscape) {

View file

@ -26,7 +26,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useRecipePermissions } from "~/composables/recipes";
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
@ -35,82 +35,48 @@ import { useStaticRoutes, useUserApi } from "~/composables/api";
import type { HouseholdSummary } from "~/lib/api/types/household";
import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
import { usePageState, usePageUser, PageMode } from "~/composables/recipe-page/shared-state";
export default defineNuxtComponent({
components: {
RecipePageInfoCard,
RecipeActionMenu,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
recipeScale: {
type: Number,
default: 1,
},
landscape: {
type: Boolean,
default: false,
},
},
emits: ["save", "delete"],
setup(props) {
const { $vuetify } = useNuxtApp();
const { recipeImage } = useStaticRoutes();
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const { isOwnGroup } = useLoggedInState();
interface Props {
recipe: NoUndefinedField<Recipe>;
recipeScale?: number;
landscape?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
recipeScale: 1,
landscape: false,
});
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
defineEmits(["save", "delete"]);
const { recipeImage } = useStaticRoutes();
const { imageKey, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const { isOwnGroup } = useLoggedInState();
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const userApi = useUserApi();
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined;
});
}
const { canEditRecipe } = useRecipePermissions(props.recipe, recipeHousehold, user);
}
const { canEditRecipe } = useRecipePermissions(props.recipe, recipeHousehold, user);
function printRecipe() {
function printRecipe() {
window.print();
}
}
const hideImage = ref(false);
const imageHeight = computed(() => {
return $vuetify.display.xs.value ? "200" : "400";
});
const hideImage = ref(false);
const recipeImageUrl = computed(() => {
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
});
watch(
watch(
() => recipeImageUrl.value,
() => {
hideImage.value = false;
},
);
return {
isOwnGroup,
setMode,
toggleEditMode,
recipeImage,
canEditRecipe,
imageKey,
user,
PageMode,
pageMode,
EditorMode,
editMode,
printRecipe,
imageHeight,
hideImage,
isEditMode,
recipeImageUrl,
};
},
});
);
</script>

View file

@ -35,7 +35,7 @@
>
<RecipeYield
:yield-quantity="recipe.recipeYieldQuantity"
:yield="recipe.recipeYield"
:yield-text="recipe.recipeYield"
:scale="recipeScale"
class="mb-4"
/>
@ -76,7 +76,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
@ -86,34 +86,15 @@ import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/Recip
import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineNuxtComponent({
components: {
RecipeRating,
RecipeLastMade,
RecipeTimeCard,
RecipeYield,
RecipePageInfoCardImage,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
recipeScale: {
type: Number,
default: 1,
},
landscape: {
type: Boolean,
required: true,
},
},
setup() {
const { isOwnGroup } = useLoggedInState();
interface Props {
recipe: NoUndefinedField<Recipe>;
recipeScale?: number;
landscape: boolean;
}
return {
isOwnGroup,
};
},
withDefaults(defineProps<Props>(), {
recipeScale: 1,
});
const { isOwnGroup } = useLoggedInState();
</script>

View file

@ -12,60 +12,47 @@
/>
</template>
<script lang="ts">
<script setup lang="ts">
import { useStaticRoutes, useUserApi } from "~/composables/api";
import type { HouseholdSummary } from "~/lib/api/types/household";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineNuxtComponent({
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
maxWidth: {
type: String,
default: undefined,
},
},
setup(props) {
const { $vuetify } = useNuxtApp();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser();
interface Props {
recipe: NoUndefinedField<Recipe>;
maxWidth?: string;
}
const props = withDefaults(defineProps<Props>(), {
maxWidth: undefined,
});
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const { $vuetify } = useNuxtApp();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const recipeHousehold = ref<HouseholdSummary>();
if (user) {
const userApi = useUserApi();
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
recipeHousehold.value = data || undefined;
});
}
}
const hideImage = ref(false);
const imageHeight = computed(() => {
const hideImage = ref(false);
const imageHeight = computed(() => {
return $vuetify.display.xs.value ? "200" : "400";
});
});
const recipeImageUrl = computed(() => {
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
});
watch(
watch(
() => recipeImageUrl.value,
() => {
hideImage.value = false;
},
);
return {
recipeImageUrl,
imageKey,
hideImage,
imageHeight,
};
},
});
);
</script>

View file

@ -1,9 +1,14 @@
<!-- eslint-disable vue/no-mutating-props -->
<template>
<div>
<div class="mb-4">
<h2 class="mb-4 text-h5 font-weight-medium opacity-80">
{{ $t("recipe.ingredients") }}
</h2>
<BannerWarning v-if="!hasFoodOrUnit">
{{ $t("recipe.ingredients-not-parsed-description", { parse: $t('recipe.parse') }) }}
</BannerWarning>
</div>
<VueDraggable
v-if="recipe.recipeIngredient.length > 0"
v-model="recipe.recipeIngredient"
@ -27,7 +32,6 @@
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
class="list-group-item"
:disable-amount="recipe.settings.disableAmount"
@delete="recipe.recipeIngredient.splice(index, 1)"
@insert-above="insertNewIngredient(index)"
@insert-below="insertNewIngredient(index + 1)"
@ -42,14 +46,14 @@
/>
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
<v-tooltip
top
location="top"
color="accent"
>
<template #activator="{ props }">
<span>
<BaseButton
class="mb-1"
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
:disabled="hasFoodOrUnit"
color="accent"
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
v-bind="props"
@ -109,10 +113,7 @@ const hasFoodOrUnit = computed(() => {
});
const parserToolTip = computed(() => {
if (recipe.value.settings.disableAmount) {
return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature");
}
else if (hasFoodOrUnit.value) {
if (hasFoodOrUnit.value) {
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
}
return i18n.t("recipe.parse-ingredients");
@ -127,7 +128,6 @@ function addIngredient(ingredients: Array<string> | null = null) {
note: x,
unit: undefined,
food: undefined,
disableAmount: true,
quantity: 1,
};
});
@ -146,7 +146,6 @@ function addIngredient(ingredients: Array<string> | null = null) {
unit: undefined,
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
food: undefined,
disableAmount: true,
quantity: 1,
});
}
@ -161,7 +160,6 @@ function insertNewIngredient(dest: number) {
unit: undefined,
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
food: undefined,
disableAmount: true,
quantity: 1,
});
}

View file

@ -3,7 +3,6 @@
<RecipeIngredients
:value="recipe.recipeIngredient"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
:is-cook-mode="isCookMode"
/>
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
@ -36,7 +35,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { useToolStore } from "~/composables/store";
@ -48,32 +47,22 @@ interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean;
}
export default defineNuxtComponent({
components: {
RecipeIngredients,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
scale: {
type: Number,
required: true,
},
isCookMode: {
type: Boolean,
default: false,
},
},
setup(props) {
const { isOwnGroup } = useLoggedInState();
interface Props {
recipe: NoUndefinedField<Recipe>;
scale: number;
isCookMode?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isCookMode: false,
});
const toolStore = isOwnGroup.value ? useToolStore() : null;
const { user } = usePageUser();
const { isEditMode } = usePageState(props.recipe.slug);
const { isOwnGroup } = useLoggedInState();
const recipeTools = computed(() => {
const toolStore = isOwnGroup.value ? useToolStore() : null;
const { user } = usePageUser();
const { isEditMode } = usePageState(props.recipe.slug);
const recipeTools = computed(() => {
if (!(user.householdSlug && toolStore)) {
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
}
@ -83,9 +72,9 @@ export default defineNuxtComponent({
return { ...tool, onHand } as RecipeToolWithOnHand;
});
}
});
});
function updateTool(index: number) {
function updateTool(index: number) {
if (user.id && user.householdSlug && toolStore) {
const tool = recipeTools.value[index];
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
@ -105,14 +94,5 @@ export default defineNuxtComponent({
else {
console.log("no user, skipping server update");
}
}
return {
toolStore,
recipeTools,
isEditMode,
updateTool,
};
},
});
}
</script>

View file

@ -29,33 +29,31 @@
{{ activeText }}
</p>
<v-divider class="mb-4" />
<v-checkbox
<v-checkbox-btn
v-for="ing in unusedIngredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
class="mb-n2 mt-n2"
>
<template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing, recipe.settings.disableAmount)" />
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
</template>
</v-checkbox>
</v-checkbox-btn>
<template v-if="usedIngredients.length > 0">
<h4 class="py-3 ml-1">
{{ $t("recipe.linked-to-other-step") }}
</h4>
<v-checkbox
<v-checkbox-btn
v-for="ing in usedIngredients"
:key="ing.referenceId"
v-model="activeRefs"
:value="ing.referenceId"
class="mb-n2 mt-n2"
>
<template #label>
<RecipeIngredientHtml :markup="parseIngredientText(ing, recipe.settings.disableAmount)" />
<RecipeIngredientHtml :markup="parseIngredientText(ing)" />
</template>
</v-checkbox>
</v-checkbox-btn>
</template>
</v-card-text>
@ -325,7 +323,6 @@
return step.ingredientReferences.map((ref) => ref.referenceId).includes(ing.referenceId || '')
})"
:scale="scale"
:disable-amount="recipe.settings.disableAmount"
:is-cook-mode="isCookMode"
/>
</div>
@ -554,7 +551,6 @@ function autoSetReferences() {
props.recipe.recipeIngredient,
activeRefs.value,
activeText.value,
props.recipe.settings.disableAmount,
).forEach((ingredient: string) => activeRefs.value.push(ingredient));
}
@ -576,7 +572,7 @@ function getIngredientByRefId(refId: string | undefined) {
const ing = ingredientLookup.value[refId];
if (!ing) return "";
return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale);
return parseIngredientText(ing, props.scale);
}
// ===============================================================

View file

@ -2,55 +2,37 @@
<div class="d-flex justify-space-between align-center pt-2 pb-3">
<RecipeScaleEditButton
v-if="!isEditMode"
v-model.number="scaleValue"
v-model.number="scale"
:recipe-servings="recipeServings"
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
:edit-scale="hasFoodOrUnit && !isEditMode"
/>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
import { usePageState } from "~/composables/recipe-page/shared-state";
export default defineNuxtComponent({
components: {
RecipeScaleEditButton,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
scale: {
type: Number,
default: 1,
},
},
emits: ["update:scale"],
setup(props, { emit }) {
const { isEditMode } = usePageState(props.recipe.slug);
const props = defineProps<{ recipe: NoUndefinedField<Recipe> }>();
const recipeServings = computed<number>(() => {
const scale = defineModel<number>({ default: 1 });
const { isEditMode } = usePageState(props.recipe.slug);
const recipeServings = computed<number>(() => {
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
});
});
const scaleValue = computed<number>({
get() {
return props.scale;
},
set(val) {
emit("update:scale", val);
},
});
return {
recipeServings,
scaleValue,
isEditMode,
};
},
const hasFoodOrUnit = computed(() => {
if (props.recipe.recipeIngredient) {
for (const ingredient of props.recipe.recipeIngredient) {
if (ingredient.food || ingredient.unit) {
return true;
}
}
}
return false;
});
</script>

View file

@ -8,24 +8,17 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import type { Recipe } from "~/lib/api/types/recipe";
export default defineNuxtComponent({
components: {
RecipePrintView,
},
props: {
recipe: {
type: Object as () => Recipe,
required: true,
},
scale: {
type: Number,
default: 1,
},
},
interface Props {
recipe: Recipe;
scale?: number;
}
withDefaults(defineProps<Props>(), {
scale: 1,
});
</script>

View file

@ -166,7 +166,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import DOMPurify from "dompurify";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { useStaticRoutes } from "~/composables/api";
@ -188,68 +188,58 @@ type InstructionSection = {
instructions: RecipeStep[];
};
export default defineNuxtComponent({
components: {
RecipeTimeCard,
},
props: {
recipe: {
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
scale: {
type: Number,
default: 1,
},
dense: {
type: Boolean,
default: false,
},
},
setup(props) {
const i18n = useI18n();
const preferences = useUserPrintPreferences();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { labels } = useNutritionLabels();
interface Props {
recipe: NoUndefinedField<Recipe>;
scale?: number;
dense?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
scale: 1,
dense: false,
});
function sanitizeHTML(rawHtml: string) {
const i18n = useI18n();
const preferences = useUserPrintPreferences();
const { recipeImage } = useStaticRoutes();
const { imageKey } = usePageState(props.recipe.slug);
const { labels } = useNutritionLabels();
function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
}
const servingsDisplay = computed(() => {
}
const servingsDisplay = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
return scaledAmountDisplay
return scaledAmountDisplay || props.recipe.recipeYield
? i18n.t("recipe.yields-amount-with-text", {
amount: scaledAmountDisplay,
text: props.recipe.recipeYield,
}) as string
: "";
});
});
const yieldDisplay = computed(() => {
const yieldDisplay = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
return scaledAmountDisplay ? i18n.t("recipe.serves-amount", { amount: scaledAmountDisplay }) as string : "";
});
});
const recipeYield = computed(() => {
const recipeYield = computed(() => {
if (servingsDisplay.value && yieldDisplay.value) {
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
}
else {
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
}
});
});
const recipeImageUrl = computed(() => {
const recipeImageUrl = computed(() => {
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
});
});
// Group ingredients by section so we can style them independently
const ingredientSections = computed<IngredientSection[]>(() => {
// Group ingredients by section so we can style them independently
const ingredientSections = computed<IngredientSection[]>(() => {
if (!props.recipe.recipeIngredient) {
return [];
}
@ -279,10 +269,10 @@ export default defineNuxtComponent({
sections[sections.length - 1].ingredients.push(ingredient);
return sections;
}, [] as IngredientSection[]);
});
});
// Group instructions by section so we can style them independently
const instructionSections = computed<InstructionSection[]>(() => {
// Group instructions by section so we can style them independently
const instructionSections = computed<InstructionSection[]>(() => {
if (!props.recipe.recipeInstructions) {
return [];
}
@ -324,31 +314,15 @@ export default defineNuxtComponent({
sections[sections.length - 1].instructions.push(step);
return sections;
}, [] as InstructionSection[]);
});
const hasNotes = computed(() => {
return props.recipe.notes && props.recipe.notes.length > 0;
});
function parseText(ingredient: RecipeIngredient) {
return parseIngredientText(ingredient, props.recipe.settings?.disableAmount || false, props.scale);
}
return {
labels,
hasNotes,
imageKey,
ImagePosition,
parseText,
parseIngredientText,
preferences,
recipeImageUrl,
recipeYield,
ingredientSections,
instructionSections,
};
},
});
const hasNotes = computed(() => {
return props.recipe.notes && props.recipe.notes.length > 0;
});
function parseText(ingredient: RecipeIngredient) {
return parseIngredientText(ingredient, props.scale);
}
</script>
<style scoped>

View file

@ -10,11 +10,11 @@
nudge-top="6"
:close-on-content-click="false"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-tooltip
v-if="canEditScale"
size="small"
top
location="top"
color="secondary-darken-1"
>
<template #activator="{ props: tooltipProps }">
@ -23,7 +23,7 @@
dark
color="secondary-darken-1"
size="small"
v-bind="{ ...props, ...tooltipProps }"
v-bind="{ ...activatorProps, ...tooltipProps }"
:style="{ cursor: canEditScale ? '' : 'default' }"
>
<v-icon
@ -45,7 +45,7 @@
dark
color="secondary-darken-1"
size="small"
v-bind="props"
v-bind="activatorProps"
:style="{ cursor: canEditScale ? '' : 'default' }"
>
<v-icon
@ -66,21 +66,22 @@
<v-card-text class="mt-n5">
<div class="mt-4 d-flex align-center">
<v-text-field
:model-value="yieldQuantityEditorValue"
:model-value="yieldQuantity"
type="number"
:min="0"
variant="underlined"
hide-spin-buttons
@update:model-value="recalculateScale(yieldQuantityEditorValue)"
@update:model-value="recalculateScale(parseFloat($event) || 0)"
/>
<v-tooltip
end
location="end"
color="secondary-darken-1"
>
<template #activator="{ props }">
<template #activator="{ props: resetTooltipProps }">
<v-btn
v-bind="props"
v-bind="resetTooltipProps"
icon
flat
class="mx-1"
size="small"
@click="scale = 1"
@ -121,39 +122,25 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
export default defineNuxtComponent({
props: {
modelValue: {
type: Number,
required: true,
},
recipeServings: {
type: Number,
default: 0,
},
editScale: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue"],
setup(props, { emit }) {
const i18n = useI18n();
const menu = ref<boolean>(false);
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
interface Props {
recipeServings?: number;
editScale?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
recipeServings: 0,
editScale: false,
});
const scale = computed({
get: () => props.modelValue,
set: (value) => {
const newScaleNumber = parseFloat(`${value}`);
emit("update:modelValue", isNaN(newScaleNumber) ? 0 : newScaleNumber);
},
});
const scale = defineModel<number>({ required: true });
function recalculateScale(newYield: number) {
const i18n = useI18n();
const menu = ref<boolean>(false);
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
function recalculateScale(newYield: number) {
if (isNaN(newYield) || newYield <= 0) {
return;
}
@ -164,47 +151,21 @@ export default defineNuxtComponent({
else {
scale.value = newYield / props.recipeServings;
}
}
}
const recipeYieldAmount = computed(() => {
const recipeYieldAmount = computed(() => {
return useScaledAmount(props.recipeServings, scale.value);
});
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
const yieldDisplay = computed(() => {
});
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
const yieldDisplay = computed(() => {
return yieldQuantity.value
? i18n.t(
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay },
) as string
: "";
});
});
// only update yield quantity when the menu opens, so we don't override the user's input
const yieldQuantityEditorValue = ref(recipeYieldAmount.value.scaledAmount);
watch(
() => menu.value,
() => {
if (!menu.value) {
return;
}
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
},
);
const disableDecrement = computed(() => {
return recipeYieldAmount.value.scaledAmount <= 1;
});
return {
menu,
canEditScale,
scale,
recalculateScale,
yieldDisplay,
yieldQuantity,
yieldQuantityEditorValue,
disableDecrement,
};
},
const disableDecrement = computed(() => {
return yieldQuantity.value <= 1;
});
</script>

View file

@ -1,54 +0,0 @@
<template>
<div class="d-flex justify-center align-center">
<v-btn-toggle v-model="selected" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
<v-btn size="small" :value="false">
{{ $t("search.include") }}
</v-btn>
<v-btn size="small" :value="true">
{{ $t("search.exclude") }}
</v-btn>
</v-btn-toggle>
<v-btn-toggle v-model="match" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
<v-btn size="small" :value="false" class="text-uppercase">
{{ $t("search.and") }}
</v-btn>
<v-btn size="small" :value="true" class="text-uppercase">
{{ $t("search.or") }}
</v-btn>
</v-btn-toggle>
</div>
</template>
<script lang="ts">
type SelectionValue = "include" | "exclude" | "any";
export default defineNuxtComponent({
props: {
modelValue: {
type: String as () => SelectionValue,
default: "include",
},
},
emits: ["update:modelValue", "update"],
data() {
return {
selected: false,
match: false,
};
},
methods: {
emitChange() {
this.$emit("update:modelValue", this.selected);
},
emitMulti() {
const updateData = {
exclude: this.selected,
matchAny: this.match,
};
this.$emit("update", updateData);
},
},
});
</script>
<style lang="scss" scoped></style>

View file

@ -31,7 +31,6 @@ const labels: Record<keyof RecipeSettings, string> = {
showAssets: i18n.t("asset.show-assets"),
landscapeView: i18n.t("recipe.landscape-view-coming-soon"),
disableComments: i18n.t("recipe.disable-comments"),
disableAmount: i18n.t("recipe.disable-amount"),
locked: i18n.t("recipe.locked"),
};
</script>

View file

@ -14,9 +14,7 @@
<div v-for="(organizer, idx) in missingOrganizers" :key="idx">
<v-col v-if="organizer.show" cols="12">
<div class="d-flex flex-row flex-wrap align-center pt-2">
<v-icon class="ma-0 pa-0">
{{ organizer.icon }}
</v-icon>
<v-icon class="ma-0 pa-0" />
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content">
{{ $t("recipe-finder.missing") }}:
</v-card-text>
@ -41,7 +39,7 @@
</v-container>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeCardMobile from "./RecipeCardMobile.vue";
import type { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe";
@ -51,29 +49,27 @@ interface Organizer {
selected: boolean;
}
export default defineNuxtComponent({
components: { RecipeCardMobile },
props: {
recipe: {
type: Object as () => RecipeSummary,
required: true,
},
missingFoods: {
type: Array as () => IngredientFood[] | null,
default: null,
},
missingTools: {
type: Array as () => RecipeTool[] | null,
default: null,
},
disableCheckbox: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const { $globals } = useNuxtApp();
const missingOrganizers = computed(() => [
interface Props {
recipe: RecipeSummary;
missingFoods?: IngredientFood[] | null;
missingTools?: RecipeTool[] | null;
disableCheckbox?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
missingFoods: null,
missingTools: null,
disableCheckbox: false,
});
const emit = defineEmits<{
"add-food": [food: IngredientFood];
"remove-food": [food: IngredientFood];
"add-tool": [tool: RecipeTool];
"remove-tool": [tool: RecipeTool];
}>();
const { $globals } = useNuxtApp();
const missingOrganizers = computed(() => [
{
type: "food",
show: props.missingFoods?.length,
@ -96,26 +92,29 @@ export default defineNuxtComponent({
: [],
getLabel: (item: RecipeTool) => item.name,
},
]);
]);
function handleCheckbox(organizer: Organizer) {
function handleCheckbox(organizer: Organizer) {
if (props.disableCheckbox) {
return;
}
organizer.selected = !organizer.selected;
if (organizer.selected) {
context.emit(`add-${organizer.type}`, organizer.item);
if (organizer.type === "food") {
emit("add-food", organizer.item as IngredientFood);
}
else {
context.emit(`remove-${organizer.type}`, organizer.item);
emit("add-tool", organizer.item as RecipeTool);
}
}
return {
missingOrganizers,
handleCheckbox,
};
},
});
else {
if (organizer.type === "food") {
emit("remove-food", organizer.item as IngredientFood);
}
else {
emit("remove-tool", organizer.item as RecipeTool);
}
}
}
</script>

View file

@ -1,4 +1,4 @@
<template v-if="showCards">
<template v-if="_showCards">
<div class="text-center">
<!-- Total Time -->
<div
@ -78,65 +78,46 @@
</div>
</template>
<script lang="ts">
export default defineNuxtComponent({
props: {
prepTime: {
type: String,
default: null,
},
totalTime: {
type: String,
default: null,
},
performTime: {
type: String,
default: null,
},
color: {
type: String,
default: "accent custom-transparent",
},
small: {
type: Boolean,
default: false,
},
},
setup(props) {
const i18n = useI18n();
<script setup lang="ts">
interface Props {
prepTime?: string | null;
totalTime?: string | null;
performTime?: string | null;
color?: string;
small?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
prepTime: null,
totalTime: null,
performTime: null,
color: "accent custom-transparent",
small: false,
});
function isEmpty(str: string | null) {
const i18n = useI18n();
function isEmpty(str: string | null) {
return !str || str.length === 0;
}
}
const showCards = computed(() => {
const _showCards = computed(() => {
return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x));
});
});
const validateTotalTime = computed(() => {
const validateTotalTime = computed(() => {
return !isEmpty(props.totalTime) ? { name: i18n.t("recipe.total-time"), value: props.totalTime } : null;
});
});
const validatePrepTime = computed(() => {
const validatePrepTime = computed(() => {
return !isEmpty(props.prepTime) ? { name: i18n.t("recipe.prep-time"), value: props.prepTime } : null;
});
});
const validatePerformTime = computed(() => {
const validatePerformTime = computed(() => {
return !isEmpty(props.performTime) ? { name: i18n.t("recipe.perform-time"), value: props.performTime } : null;
});
});
const fontSize = computed(() => {
const fontSize = computed(() => {
return props.small ? { fontSize: "smaller" } : { fontSize: "larger" };
});
return {
showCards,
validateTotalTime,
validatePrepTime,
validatePerformTime,
fontSize,
};
},
});
</script>

View file

@ -11,7 +11,7 @@
nudge-bottom="3"
:close-on-content-click="false"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-badge
:content="filterBadgeCount"
:model-value="filterBadgeCount > 0"
@ -21,7 +21,7 @@
class="rounded-circle"
size="small"
color="info"
v-bind="props"
v-bind="activatorProps"
icon
>
<v-icon> {{ $globals.icons.filter }} </v-icon>
@ -105,7 +105,7 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { useThrottleFn, whenever } from "@vueuse/core";
import RecipeTimelineItem from "./RecipeTimelineItem.vue";
import { useTimelinePreferences } from "~/composables/use-users/preferences";
@ -115,89 +115,61 @@ import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api";
import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe";
export default defineNuxtComponent({
components: { RecipeTimelineItem },
interface Props {
modelValue?: boolean;
queryFilter: string;
maxHeight?: number | string;
showRecipeCards?: boolean;
}
props: {
modelValue: {
type: Boolean,
default: false,
},
queryFilter: {
type: String,
required: true,
},
maxHeight: {
type: [Number, String],
default: undefined,
},
showRecipeCards: {
type: Boolean,
default: false,
},
},
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
maxHeight: undefined,
showRecipeCards: false,
});
setup(props) {
const api = useUserApi();
const i18n = useI18n();
const preferences = useTimelinePreferences();
const { eventTypeOptions } = useTimelineEventTypes();
const loading = ref(true);
const ready = ref(false);
const api = useUserApi();
const i18n = useI18n();
const preferences = useTimelinePreferences();
const { eventTypeOptions } = useTimelineEventTypes();
const loading = ref(true);
const ready = ref(false);
const page = ref(1);
const perPage = 32;
const hasMore = ref(true);
const page = ref(1);
const perPage = 32;
const hasMore = ref(true);
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
const recipes = new Map<string, Recipe>();
const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length);
const eventTypeFilterState = computed(() => {
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
const recipes = new Map<string, Recipe>();
const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length);
const eventTypeFilterState = computed(() => {
return eventTypeOptions.value.map((option) => {
return {
...option,
checked: preferences.value.types.includes(option.value),
};
});
});
});
const screenBuffer = 4;
interface ScrollEvent extends Event {
target: HTMLInputElement;
}
const screenBuffer = 4;
const onScroll = (event: ScrollEvent) => {
if (!event.target) {
return;
}
const { scrollTop, offsetHeight, scrollHeight } = event.target;
// trigger when the user is getting close to the bottom
const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight * screenBuffer);
if (bottomOfElement) {
infiniteScroll();
}
};
whenever(
whenever(
() => props.modelValue,
() => {
initializeTimelineEvents();
},
);
);
// Preferences
function reverseSort() {
// Preferences
function reverseSort() {
if (loading.value) {
return;
}
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
initializeTimelineEvents();
}
}
function toggleEventTypeOption(option: TimelineEventType) {
function toggleEventTypeOption(option: TimelineEventType) {
if (loading.value) {
return;
}
@ -211,10 +183,10 @@ export default defineNuxtComponent({
}
initializeTimelineEvents();
}
}
// Timeline Actions
async function updateTimelineEvent(index: number) {
// Timeline Actions
async function updateTimelineEvent(index: number) {
const event = timelineEvents.value[index];
const payload: RecipeTimelineEventUpdate = {
subject: event.subject,
@ -229,9 +201,9 @@ export default defineNuxtComponent({
}
alert.success(i18n.t("events.event-updated") as string);
};
}
async function deleteTimelineEvent(index: number) {
async function deleteTimelineEvent(index: number) {
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
if (response?.status !== 200) {
alert.error(i18n.t("events.something-went-wrong") as string);
@ -240,34 +212,34 @@ export default defineNuxtComponent({
timelineEvents.value.splice(index, 1);
alert.success(i18n.t("events.event-deleted") as string);
};
}
async function getRecipe(recipeId: string): Promise<Recipe | null> {
const { data } = await api.recipes.getOne(recipeId);
return data;
};
async function getRecipes(recipeIds: string[]): Promise<Recipe[]> {
const qf = "id IN [" + recipeIds.map(id => `"${id}"`).join(", ") + "]";
const { data } = await api.recipes.getAll(1, -1, { queryFilter: qf });
return data?.items || [];
}
async function updateRecipes(events: RecipeTimelineEventOut[]) {
const recipePromises: Promise<Recipe | null>[] = [];
const seenRecipeIds: string[] = [];
async function updateRecipes(events: RecipeTimelineEventOut[]) {
const recipeIds: string[] = [];
events.forEach((event) => {
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
if (recipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
return;
}
seenRecipeIds.push(event.recipeId);
recipePromises.push(getRecipe(event.recipeId));
recipeIds.push(event.recipeId);
});
const results = await Promise.all(recipePromises);
const results = await getRecipes(recipeIds);
results.forEach((result) => {
if (result && result.id) {
if (!result?.id) {
return;
}
recipes.set(result.id, result);
}
});
}
}
async function scrollTimelineEvents() {
async function scrollTimelineEvents() {
const orderBy = "timestamp";
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
@ -295,9 +267,9 @@ export default defineNuxtComponent({
// this is set last so Vue knows to re-render
timelineEvents.value.push(...events);
};
}
async function initializeTimelineEvents() {
async function initializeTimelineEvents() {
loading.value = true;
ready.value = false;
@ -308,9 +280,9 @@ export default defineNuxtComponent({
ready.value = true;
loading.value = false;
}
}
const infiniteScroll = useThrottleFn(() => {
const infiniteScroll = useThrottleFn(() => {
useAsyncData(useAsyncKey(), async () => {
if (!hasMore.value || loading.value) {
return;
@ -320,12 +292,12 @@ export default defineNuxtComponent({
await scrollTimelineEvents();
loading.value = false;
});
}, 500);
}, 500);
// preload events
initializeTimelineEvents();
// preload events
initializeTimelineEvents();
onMounted(
onMounted(
() => {
document.onscroll = () => {
// if the inner element is scrollable, let its scroll event handle the infiniteScroll
@ -346,21 +318,5 @@ export default defineNuxtComponent({
}
};
},
);
return {
deleteTimelineEvent,
filterBadgeCount,
loading,
onScroll,
preferences,
eventTypeFilterState,
recipes,
reverseSort,
toggleEventTypeOption,
timelineEvents,
updateTimelineEvent,
};
},
});
);
</script>

View file

@ -1,10 +1,10 @@
<template>
<v-tooltip
bottom
location="bottom"
nudge-right="50"
:color="buttonStyle ? 'info' : 'secondary'"
>
<template #activator="{ props }">
<template #activator="{ props: activatorProps }">
<v-btn
icon
:variant="buttonStyle ? 'flat' : undefined"
@ -12,7 +12,7 @@
size="small"
:color="buttonStyle ? 'info' : 'secondary'"
:fab="buttonStyle"
v-bind="{ ...props, ...$attrs }"
v-bind="{ ...activatorProps, ...$attrs }"
@click.prevent="toggleTimeline"
>
<v-icon
@ -39,36 +39,29 @@
</v-tooltip>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeTimeline from "./RecipeTimeline.vue";
export default defineNuxtComponent({
components: { RecipeTimeline },
interface Props {
buttonStyle?: boolean;
slug?: string;
recipeName?: string;
}
const props = withDefaults(defineProps<Props>(), {
buttonStyle: false,
slug: "",
recipeName: "",
});
props: {
buttonStyle: {
type: Boolean,
default: false,
},
slug: {
type: String,
default: "",
},
recipeName: {
type: String,
default: "",
},
},
const i18n = useI18n();
const { smAndDown } = useDisplay();
const showTimeline = ref(false);
setup(props) {
const i18n = useI18n();
const { smAndDown } = useDisplay();
const showTimeline = ref(false);
function toggleTimeline() {
function toggleTimeline() {
showTimeline.value = !showTimeline.value;
}
}
const timelineAttrs = computed(() => {
const timelineAttrs = computed(() => {
let title = i18n.t("recipe.timeline");
if (smAndDown.value) {
title += ` ${props.recipeName}`;
@ -78,9 +71,5 @@ export default defineNuxtComponent({
title,
queryFilter: `recipe.slug="${props.slug}"`,
};
});
return { showTimeline, timelineAttrs, toggleTimeline };
},
});
</script>

View file

@ -53,6 +53,7 @@
<v-row :class="useMobileFormat ? 'py-3 mx-0' : 'py-3 mx-0'" style="max-width: 100%">
<v-col align-self="center" class="pa-0">
<RecipeCardMobile
disable-highlight
:vertical="useMobileFormat"
:name="recipe.name"
:slug="recipe.slug"
@ -90,7 +91,7 @@
</v-timeline-item>
</template>
<script lang="ts">
<script setup lang="ts">
import RecipeCardMobile from "./RecipeCardMobile.vue";
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
import { useStaticRoutes } from "~/composables/api";
@ -99,41 +100,37 @@ import type { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
export default defineNuxtComponent({
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar, SafeMarkdown },
interface Props {
event: RecipeTimelineEventOut;
recipe?: Recipe;
showRecipeCards?: boolean;
}
props: {
event: {
type: Object as () => RecipeTimelineEventOut,
required: true,
},
recipe: {
type: Object as () => Recipe,
default: undefined,
},
showRecipeCards: {
type: Boolean,
default: false,
},
},
emits: ["selected", "update", "delete"],
const props = withDefaults(defineProps<Props>(), {
recipe: undefined,
showRecipeCards: false,
});
setup(props) {
const { $vuetify, $globals } = useNuxtApp();
const { recipeTimelineEventImage } = useStaticRoutes();
const { eventTypeOptions } = useTimelineEventTypes();
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
defineEmits<{
selected: [];
update: [];
delete: [];
}>();
const { user: currentUser } = useMealieAuth();
const { $vuetify, $globals } = useNuxtApp();
const { recipeTimelineEventImage } = useStaticRoutes();
const { eventTypeOptions } = useTimelineEventTypes();
const route = useRoute();
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
const { user: currentUser } = useMealieAuth();
const useMobileFormat = computed(() => {
const route = useRoute();
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
const useMobileFormat = computed(() => {
return $vuetify.display.smAndDown.value;
});
});
const attrs = computed(() => {
const attrs = computed(() => {
if (useMobileFormat.value) {
return {
class: "px-0",
@ -162,33 +159,20 @@ export default defineNuxtComponent({
},
};
}
});
});
const icon = computed(() => {
const icon = computed(() => {
const option = eventTypeOptions.value.find(option => option.value === props.event.eventType);
return option ? option.icon : $globals.icons.informationVariant;
});
});
const hideImage = ref(false);
const eventImageUrl = computed<string>(() => {
const hideImage = ref(false);
const eventImageUrl = computed<string>(() => {
if (props.event.image !== "has image") {
return "";
}
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
});
return {
attrs,
groupSlug,
icon,
eventImageUrl,
hideImage,
timelineEvents,
useMobileFormat,
currentUser,
};
},
});
</script>

View file

@ -1,6 +1,6 @@
<template>
<div
v-if="scaledAmount"
v-if="yieldDisplay"
class="d-flex align-center"
>
<v-row
@ -18,53 +18,49 @@
<p class="my-0 opacity-80">
<span class="font-weight-bold">{{ $t("recipe.yield") }}</span><br>
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="scaledAmount" /> {{ text }}
<span v-html="yieldDisplay" />
</p>
</v-row>
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import DOMPurify from "dompurify";
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
export default defineNuxtComponent({
props: {
yieldQuantity: {
type: Number,
default: 0,
},
yield: {
type: String,
default: "",
},
scale: {
type: Number,
default: 1,
},
color: {
type: String,
default: "accent custom-transparent",
},
},
setup(props) {
function sanitizeHTML(rawHtml: string) {
interface Props {
yieldQuantity?: number;
yieldText?: string;
scale?: number;
color?: string;
}
const props = withDefaults(defineProps<Props>(), {
yieldQuantity: 0,
yieldText: "",
scale: 1,
color: "accent custom-transparent",
});
function sanitizeHTML(rawHtml: string) {
return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
}
const yieldDisplay = computed<string>(() => {
const components: string[] = [];
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
if (scaledAmountDisplay) {
components.push(scaledAmountDisplay);
}
const scaledAmount = computed(() => {
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
return scaledAmountDisplay;
});
const text = sanitizeHTML(props.yield);
const text = props.yieldText;
if (text) {
components.push(text);
}
return {
scaledAmount,
text,
};
},
return sanitizeHTML(components.join(" "));
});
</script>

View file

@ -40,7 +40,6 @@
v-if="requireAll != undefined"
v-model="requireAllValue"
density="compact"
size="small"
hide-details
class="my-auto"
color="primary"

View file

@ -8,24 +8,23 @@
class="flex-nowrap align-center"
>
<v-col :cols="itemLabelCols">
<div class="d-flex align-center flex-nowrap">
<v-checkbox
v-model="listItem.checked"
class="mt-0"
color="null"
hide-details
density="compact"
:label="listItem.note!"
class="mt-0"
color="null"
@change="$emit('checked', listItem)"
>
<template #label>
<div :class="listItem.checked ? 'strike-through' : ''">
<RecipeIngredientListItem
:ingredient="listItem"
:disable-amount="!(listItem.isFood || listItem.quantity !== 1)"
/>
<div
class="ml-2 text-truncate"
:class="listItem.checked ? 'strike-through' : ''"
style="min-width: 0;"
>
<RecipeIngredientListItem :ingredient="listItem" />
</div>
</div>
</template>
</v-checkbox>
</v-col>
<v-spacer />
<v-col
@ -57,7 +56,7 @@
open-delay="200"
transition="slide-x-reverse-transition"
density="compact"
right
location="end"
content-class="text-caption"
>
<template #activator="{ props: tooltipProps }">
@ -170,7 +169,6 @@
@save="save"
@cancel="toggleEdit(false)"
@delete="$emit('delete')"
@toggle-foods="localListItem.isFood = !localListItem.isFood"
/>
</div>
</template>

View file

@ -2,7 +2,7 @@
<div>
<v-card variant="outlined">
<v-card-text class="pb-3 pt-1">
<div v-if="listItem.isFood" class="d-md-flex align-center mb-2" style="gap: 20px">
<div class="d-md-flex align-center mb-2" style="gap: 20px">
<div>
<InputQuantity v-model="listItem.quantity" />
</div>
@ -26,9 +26,6 @@
/>
</div>
<div class="d-md-flex align-center" style="gap: 20px">
<div v-if="!listItem.isFood">
<InputQuantity v-model="listItem.quantity" />
</div>
<v-textarea
v-model="listItem.note"
hide-details
@ -99,11 +96,6 @@
text: $t('general.cancel'),
event: 'cancel',
},
{
icon: $globals.icons.foods,
text: $t('shopping-list.toggle-food'),
event: 'toggle-foods',
},
{
icon: $globals.icons.save,
text: $t('general.save'),
@ -113,7 +105,6 @@
@save="$emit('save')"
@cancel="$emit('cancel')"
@delete="$emit('delete')"
@toggle-foods="listItem.isFood = !listItem.isFood"
/>
</v-card-actions>
</v-card>

View file

@ -2,7 +2,7 @@
<v-tooltip
v-if="userId"
:disabled="!user || !tooltip"
right
location="end"
>
<template #activator="{ props }">
<v-avatar

View file

@ -17,7 +17,7 @@
<v-btn
color="white"
icon
href="https://github.com/hay-kot/mealie"
href="https://github.com/mealie-recipes/mealie"
target="_blank"
>
<v-icon>

View file

@ -2,7 +2,7 @@
<v-tooltip
ref="copyToolTip"
v-model="show"
top
location="top"
:open-on-hover="false"
:open-on-click="true"
close-delay="500"

View file

@ -48,7 +48,7 @@
open-delay="200"
transition="slide-y-reverse-transition"
density="compact"
bottom
location="bottom"
content-class="text-caption"
>
<template #activator="{ props }">

View file

@ -22,10 +22,9 @@
<v-card>
<v-card-text>
<v-checkbox
v-for="itemValue in headers"
v-for="itemValue in localHeaders"
:key="itemValue.text + itemValue.show"
v-model="filteredHeaders"
:value="itemValue.value"
v-model="itemValue.show"
density="compact"
flat
inset
@ -172,12 +171,20 @@ export default defineNuxtComponent({
// ===========================================================
// Reactive Headers
// Create a local reactive copy of headers that we can modify
const localHeaders = ref([...props.headers]);
// Watch for changes in props.headers and update local copy
watch(() => props.headers, (newHeaders) => {
localHeaders.value = [...newHeaders];
}, { deep: true });
const filteredHeaders = computed<string[]>(() => {
return props.headers.filter(header => header.show).map(header => header.value);
return localHeaders.value.filter(header => header.show).map(header => header.value);
});
const headersWithoutActions = computed(() =>
props.headers
localHeaders.value
.filter(header => filteredHeaders.value.includes(header.value))
.map(header => ({
...header,
@ -214,6 +221,7 @@ export default defineNuxtComponent({
return {
sortBy,
selected,
localHeaders,
filteredHeaders,
headersWithoutActions,
activeHeaders,

View file

@ -26,10 +26,10 @@ export default defineComponent({
},
},
emits: ["update:modelValue"],
setup(_, { emit }) {
setup(props, { emit }) {
function parseEvent(event: any): object {
if (!event) {
return {};
return props.modelValue || {};
}
try {
if (event.json) {
@ -43,11 +43,14 @@ export default defineComponent({
}
}
catch {
return {};
return props.modelValue || {};
}
}
function onChange(event: any) {
emit("update:modelValue", parseEvent(event));
const parsed = parseEvent(event);
if (parsed !== props.modelValue) {
emit("update:modelValue", parsed);
}
}
return {
onChange,

View file

@ -18,8 +18,8 @@ function removeStartingPunctuation(word: string): string {
return word.replace(punctuationAtBeginning, "");
}
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string, recipeIngredientAmountsDisabled: boolean) {
const searchText = parseIngredientText(ingredient, recipeIngredientAmountsDisabled);
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string) {
const searchText = parseIngredientText(ingredient);
return searchText.toLowerCase().includes(word.toLowerCase());
}
@ -39,7 +39,7 @@ function isBlackListedWord(word: string) {
return blackListedText.includes(word) || word.match(blackListedRegexMatch);
}
export function useExtractIngredientReferences(recipeIngredients: RecipeIngredient[], activeRefs: string[], text: string, recipeIngredientAmountsDisabled: boolean): Set<string> {
export function useExtractIngredientReferences(recipeIngredients: RecipeIngredient[], activeRefs: string[], text: string): Set<string> {
const availableIngredients = recipeIngredients
.filter(ingredient => ingredient.referenceId !== undefined)
.filter(ingredient => !activeRefs.includes(ingredient.referenceId as string));
@ -50,7 +50,7 @@ export function useExtractIngredientReferences(recipeIngredients: RecipeIngredie
.map(normalize)
.filter(word => word.length > 2)
.filter(word => !isBlackListedWord(word))
.flatMap(word => availableIngredients.filter(ingredient => ingredientMatchesWord(ingredient, word, recipeIngredientAmountsDisabled)))
.flatMap(word => availableIngredients.filter(ingredient => ingredientMatchesWord(ingredient, word)))
.map(ingredient => ingredient.referenceId as string);
// deduplicate

View file

@ -16,33 +16,27 @@ describe(parseIngredientText.name, () => {
...overrides,
});
test("uses ingredient note if disableAmount: true", () => {
const ingredient = createRecipeIngredient({ note: "foo" });
expect(parseIngredientText(ingredient, true)).toEqual("foo");
});
test("adds note section if note present", () => {
const ingredient = createRecipeIngredient({ note: "custom note" });
expect(parseIngredientText(ingredient, false)).toContain("custom note");
expect(parseIngredientText(ingredient)).toContain("custom note");
});
test("ingredient text with fraction", () => {
const ingredient = createRecipeIngredient({ quantity: 1.5, unit: { fraction: true, id: "1", name: "cup" } });
expect(parseIngredientText(ingredient, false, 1, true)).contain("1<sup>1</sup>").and.to.contain("<sub>2</sub>");
expect(parseIngredientText(ingredient, 1, true)).contain("1<sup>1</sup>").and.to.contain("<sub>2</sub>");
});
test("ingredient text with fraction when unit is null", () => {
const ingredient = createRecipeIngredient({ quantity: 1.5, unit: undefined });
expect(parseIngredientText(ingredient, false, 1, true)).contain("1<sup>1</sup>").and.to.contain("<sub>2</sub>");
expect(parseIngredientText(ingredient, 1, true)).contain("1<sup>1</sup>").and.to.contain("<sub>2</sub>");
});
test("ingredient text with fraction no formatting", () => {
const ingredient = createRecipeIngredient({ quantity: 1.5, unit: { fraction: true, id: "1", name: "cup" } });
const result = parseIngredientText(ingredient, false, 1, false);
const result = parseIngredientText(ingredient, 1, false);
expect(result).not.contain("<");
expect(result).not.contain(">");
@ -52,7 +46,7 @@ describe(parseIngredientText.name, () => {
test("sanitizes html", () => {
const ingredient = createRecipeIngredient({ note: "<script>alert('foo')</script>" });
expect(parseIngredientText(ingredient, false)).not.toContain("<script>");
expect(parseIngredientText(ingredient)).not.toContain("<script>");
});
test("plural test : plural qty : use abbreviation", () => {
@ -62,7 +56,7 @@ describe(parseIngredientText.name, () => {
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient, false)).toEqual("2 tbsps diced onions");
expect(parseIngredientText(ingredient)).toEqual("2 tbsps diced onions");
});
test("plural test : plural qty : not abbreviation", () => {
@ -72,7 +66,7 @@ describe(parseIngredientText.name, () => {
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient, false)).toEqual("2 tablespoons diced onions");
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onions");
});
test("plural test : single qty : use abbreviation", () => {
@ -82,7 +76,7 @@ describe(parseIngredientText.name, () => {
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient, false)).toEqual("1 tbsp diced onion");
expect(parseIngredientText(ingredient)).toEqual("1 tbsp diced onion");
});
test("plural test : single qty : not abbreviation", () => {
@ -92,7 +86,7 @@ describe(parseIngredientText.name, () => {
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient, false)).toEqual("1 tablespoon diced onion");
expect(parseIngredientText(ingredient)).toEqual("1 tablespoon diced onion");
});
test("plural test : small qty : use abbreviation", () => {
@ -102,7 +96,7 @@ describe(parseIngredientText.name, () => {
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient, false)).toEqual("0.5 tbsp diced onion");
expect(parseIngredientText(ingredient)).toEqual("0.5 tbsp diced onion");
});
test("plural test : small qty : not abbreviation", () => {
@ -112,7 +106,7 @@ describe(parseIngredientText.name, () => {
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient, false)).toEqual("0.5 tablespoon diced onion");
expect(parseIngredientText(ingredient)).toEqual("0.5 tablespoon diced onion");
});
test("plural test : zero qty", () => {
@ -122,7 +116,7 @@ describe(parseIngredientText.name, () => {
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient, false)).toEqual("diced onions");
expect(parseIngredientText(ingredient)).toEqual("diced onions");
});
test("plural test : single qty, scaled", () => {
@ -132,6 +126,6 @@ describe(parseIngredientText.name, () => {
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
});
expect(parseIngredientText(ingredient, false, 2)).toEqual("2 tablespoons diced onions");
expect(parseIngredientText(ingredient, 2)).toEqual("2 tablespoons diced onions");
});
});

View file

@ -36,16 +36,7 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
return returnVal;
}
export function useParsedIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1, includeFormating = true) {
if (disableAmount) {
return {
name: ingredient.note ? sanitizeIngredientHTML(ingredient.note) : undefined,
quantity: undefined,
unit: undefined,
note: undefined,
};
}
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) {
const { quantity, food, unit, note } = ingredient;
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
const usePluralFood = (!quantity) || quantity * scale > 1;
@ -82,8 +73,8 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, disableAmo
};
}
export function parseIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1, includeFormating = true): string {
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, disableAmount, scale, includeFormating);
export function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
return sanitizeIngredientHTML(text);

View file

@ -38,7 +38,7 @@ export const useGroupWebhooks = function () {
loading.value = true;
const payload = {
enabled: false,
enabled: true,
name: "New Webhook",
url: "",
scheduledTime: "00:00",

View file

@ -1,5 +1,5 @@
import { useUserApi } from "~/composables/api";
import type { GroupBase, GroupSummary } from "~/lib/api/types/user";
import type { GroupBase, GroupInDB, GroupSummary } from "~/lib/api/types/user";
const groupSelfRef = ref<GroupSummary | null>(null);
const loading = ref(false);
@ -45,7 +45,7 @@ export const useGroupSelf = function () {
export const useGroups = function () {
const api = useUserApi();
const loading = ref(false);
const groups = ref<GroupSummary[] | null>(null);
const groups = ref<GroupInDB[] | null>(null);
async function getAllGroups() {
loading.value = true;

View file

@ -3,13 +3,13 @@ export const LOCALES = [
{
name: "繁體中文 (Chinese traditional)",
value: "zh-TW",
progress: 8,
progress: 9,
dir: "ltr",
},
{
name: "简体中文 (Chinese simplified)",
value: "zh-CN",
progress: 33,
progress: 35,
dir: "ltr",
},
{
@ -33,7 +33,7 @@ export const LOCALES = [
{
name: "Svenska (Swedish)",
value: "sv-SE",
progress: 47,
progress: 50,
dir: "ltr",
},
{
@ -69,7 +69,7 @@ export const LOCALES = [
{
name: "Português (Portuguese)",
value: "pt-PT",
progress: 37,
progress: 38,
dir: "ltr",
},
{
@ -81,19 +81,19 @@ export const LOCALES = [
{
name: "Polski (Polish)",
value: "pl-PL",
progress: 37,
progress: 39,
dir: "ltr",
},
{
name: "Norsk (Norwegian)",
value: "no-NO",
progress: 38,
progress: 39,
dir: "ltr",
},
{
name: "Nederlands (Dutch)",
value: "nl-NL",
progress: 44,
progress: 45,
dir: "ltr",
},
{
@ -147,7 +147,7 @@ export const LOCALES = [
{
name: "עברית (Hebrew)",
value: "he-IL",
progress: 45,
progress: 73,
dir: "rtl",
},
{
@ -159,7 +159,7 @@ export const LOCALES = [
{
name: "Français (French)",
value: "fr-FR",
progress: 49,
progress: 50,
dir: "ltr",
},
{
@ -189,7 +189,7 @@ export const LOCALES = [
{
name: "Español (Spanish)",
value: "es-ES",
progress: 40,
progress: 41,
dir: "ltr",
},
{
@ -207,19 +207,19 @@ export const LOCALES = [
{
name: "Ελληνικά (Greek)",
value: "el-GR",
progress: 38,
progress: 39,
dir: "ltr",
},
{
name: "Deutsch (German)",
value: "de-DE",
progress: 63,
progress: 65,
dir: "ltr",
},
{
name: "Dansk (Danish)",
value: "da-DK",
progress: 38,
progress: 39,
dir: "ltr",
},
{

View file

@ -472,6 +472,7 @@
"comment": "Lewer kommentaar",
"comments": "Kommentaar",
"delete-confirmation": "Is jy seker jy wil hierdie resep uitvee?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Verwyder resep",
"description": "Beskrywing",
"disable-amount": "Skakel bestanddeelhoeveelhede af",
@ -586,6 +587,7 @@
"api-extras-description": "Recipes extras are a key feature of the Mealie API. They allow you to create custom JSON key/value pairs within a recipe, to reference from 3rd party applications. You can use these keys to provide information, for example to trigger automations or custom messages to relay to your desired device.",
"message-key": "Boodskap sleutel",
"parse": "Verwerk",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Voeg prente by deur dit in die bewerker te sleep en los",
"drop-image": "Drop image",
"enable-ingredient-amounts-to-use-this-feature": "Skakel bestanddeelhoeveelhede aan om hierdie funksie te gebruik",
@ -662,6 +664,8 @@
"no-unit": "No unit",
"missing-unit": "Create missing unit: {unit}",
"missing-food": "Create missing food: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "No Food"
},
"reset-servings-count": "Reset Servings Count",

View file

@ -472,6 +472,7 @@
"comment": "أضف تعليق ",
"comments": "التعليقات",
"delete-confirmation": "هل انت متأكد من رغبتك بحذف هذه الوصفة؟",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "حذف الوصفة",
"description": "الوصف",
"disable-amount": "إيقاف إظهار كميات المكونات",
@ -586,6 +587,7 @@
"api-extras-description": "Recipes extras are a key feature of the Mealie API. They allow you to create custom JSON key/value pairs within a recipe, to reference from 3rd party applications. You can use these keys to provide information, for example to trigger automations or custom messages to relay to your desired device.",
"message-key": "مفتاح الرساله",
"parse": "تحليل",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Attach images by dragging & dropping them into the editor",
"drop-image": "وضع الصورة",
"enable-ingredient-amounts-to-use-this-feature": "Enable ingredient amounts to use this feature",
@ -662,6 +664,8 @@
"no-unit": "لا توجد وحدة",
"missing-unit": "إنشاء وحدة مفقودة: {unit}",
"missing-food": "إنشاء طعام مفقود: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "لا يوجد طعام"
},
"reset-servings-count": "إعادة تعيين عدد الحصص",

View file

@ -472,6 +472,7 @@
"comment": "Коментар",
"comments": "Коментари",
"delete-confirmation": "Сигурни ли сте, че желаете да изтриете тази рецепта?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Изтрий рецептата",
"description": "Описание",
"disable-amount": "Изключи количествата за съставките",
@ -586,6 +587,7 @@
"api-extras-description": "Екстрите за рецепти са ключова характеристика на Mealie API. Те Ви позволяват да създавате персонализирани JSON двойки ключ/стойност в рамките на рецепта, за да ги препращате към други приложения. Можете да използвате тези ключове, за да предоставите информация за задействане на автоматизация или персонализирани съобщения, за препращане към желаното от Вас устройство.",
"message-key": "Ключ на съобщението",
"parse": "Анализирай",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Прикачете снимки като ги влачете и пуснете в редактора",
"drop-image": "Премахване на изображение",
"enable-ingredient-amounts-to-use-this-feature": "Пуснете количествата на съставките за да използвате функционалността",
@ -662,6 +664,8 @@
"no-unit": "No unit",
"missing-unit": "Create missing unit: {unit}",
"missing-food": "Create missing food: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "No Food"
},
"reset-servings-count": "Reset Servings Count",

View file

@ -472,6 +472,7 @@
"comment": "Comentari",
"comments": "Comentaris",
"delete-confirmation": "Estàs segur que vols suprimir-la?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Suprimeix la recepta",
"description": "Descripció",
"disable-amount": "Oculta les quantitats",
@ -586,6 +587,7 @@
"api-extras-description": "Els extres de receptes són una funcionalitat clau de l'API de Mealie. Permeten crear parells clau/valor JSON personalitzats dins una recepta, per referenciar-los des d'aplicacions de tercers. Pots emprar aquestes claus per proveir informació, per exemple per a desencadenar automatitzacions o missatges personlitzats per a propagar al teu dispositiu desitjat.",
"message-key": "Clau del missatge",
"parse": "Analitzar",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Afegeix imatges arrossegant i deixant anar la imatge a l'editor",
"drop-image": "Deixa anar la imatge",
"enable-ingredient-amounts-to-use-this-feature": "Habilita les quantitats d'ingredients per a poder fer servir aquesta característica",
@ -662,6 +664,8 @@
"no-unit": "Sense unitat",
"missing-unit": "Crear unitat que manca: {unit}",
"missing-food": "Crear menjar que manca: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "Sense menjar"
},
"reset-servings-count": "Reiniciar racions servides",

View file

@ -472,6 +472,7 @@
"comment": "Komentář",
"comments": "Komentáře",
"delete-confirmation": "Opravdu chcete smazat tento recept?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Smazat recept",
"description": "Popis",
"disable-amount": "Nezobrazovat množství ingrediencí",
@ -586,6 +587,7 @@
"api-extras-description": "Recepty jsou klíčovým rysem rozhraní pro API Mealie. Umožňují vytvářet vlastní klíče/hodnoty JSON v rámci receptu pro odkazy na aplikace třetích stran. Tyto klíče můžete použít pro poskytnutí informací, například pro aktivaci automatizace nebo vlastních zpráv pro přenos do požadovaného zařízení.",
"message-key": "Klíč zprávy",
"parse": "Analyzovat",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Přiložit obrázky přetažením jich do editoru",
"drop-image": "Vložit obrázek",
"enable-ingredient-amounts-to-use-this-feature": "Chcete-li tuto funkci používat, povolte množství ingrediencí",
@ -662,6 +664,8 @@
"no-unit": "Žádná jednotka",
"missing-unit": "Vytvořit chybějící jednotku: {unit}",
"missing-food": "Vytvořit chybějící jídlo: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "Žádné jídlo"
},
"reset-servings-count": "Resetovat počet porcí",

View file

@ -472,6 +472,7 @@
"comment": "Kommentar",
"comments": "Kommentarer",
"delete-confirmation": "Er du sikker på, du vil slette denne opskrift?",
"admin-delete-confirmation": "Du er ved at slette en opskrift, som du ikke er ejer af ved at bruge administrator rettigheder. Er du sikker?",
"delete-recipe": "Slet opskrift",
"description": "Beskrivelse",
"disable-amount": "Slå ingrediensmængder fra",
@ -579,13 +580,14 @@
"made-this": "Jeg har lavet denne",
"how-did-it-turn-out": "Hvordan blev det?",
"user-made-this": "{user} lavede denne",
"added-to-timeline": "Added to timeline",
"failed-to-add-to-timeline": "Failed to add to timeline",
"failed-to-update-recipe": "Failed to update recipe",
"added-to-timeline-but-failed-to-add-image": "Added to timeline, but failed to add image",
"added-to-timeline": "Tilføjet til tidslinjen",
"failed-to-add-to-timeline": "Kunne ikke tilføje til tidslinjen",
"failed-to-update-recipe": "Kunne ikke opdatere opskrift",
"added-to-timeline-but-failed-to-add-image": "Tilføjet til tidslinjen, men kunne ikke tilføje billede",
"api-extras-description": "Opskrifter ekstra er en central feature i Mealie API. De giver dig mulighed for at oprette brugerdefinerede JSON nøgle / værdi par inden for en opskrift, at henvise til fra 3. parts applikationer. Du kan bruge disse nøgler til at give oplysninger, for eksempel til at udløse automatiseringer eller brugerdefinerede beskeder til at videresende til din ønskede enhed.",
"message-key": "Beskednøgle",
"parse": "Behandl data",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Vedhæft billeder ved at trække dem ind i redigeringsværktøjet",
"drop-image": "Slet billede",
"enable-ingredient-amounts-to-use-this-feature": "Aktiver mængde af ingredienser for at bruge denne funktion",
@ -662,6 +664,8 @@
"no-unit": "Ingen enhed",
"missing-unit": "Opret manglende måleenhed: {unit}",
"missing-food": "Opret manglende fødevare: {food}",
"this-unit-could-not-be-parsed-automatically": "Denne enhed kunne ikke fortolkes automatisk",
"this-food-could-not-be-parsed-automatically": "Denne fødevare kunne ikke fortolkes automatisk",
"no-food": "Ingen fødevarer"
},
"reset-servings-count": "Nulstil antal serveringer",

View file

@ -472,6 +472,7 @@
"comment": "Kommentar",
"comments": "Kommentare",
"delete-confirmation": "Bist du dir sicher, dass du dieses Rezept löschen möchtest?",
"admin-delete-confirmation": "Du bist dabei ein Rezept, welches nicht deins ist, mit Admin-Rechten zu löschen. Bist du sicher?",
"delete-recipe": "Rezept löschen",
"description": "Beschreibung",
"disable-amount": "Zutatenmenge deaktivieren",
@ -586,6 +587,7 @@
"api-extras-description": "Rezepte-Extras sind ein Hauptmerkmal der Mealie API. Sie ermöglichen es dir, benutzerdefinierte JSON Key-Value-Paare zu einem Rezept zu erstellen, um Drittanbieter-Anwendungen zu steuern. Du kannst diese dazu verwenden, um Automatisierungen auszulösen oder benutzerdefinierte Nachrichten an bestimmte Geräte zu senden.",
"message-key": "Nachrichten-Schlüssel",
"parse": "Parsen",
"ingredients-not-parsed-description": "Es scheint als ob deine Zutaten noch nicht eingelesen wurden. Klick unten auf \"{parse}\" um deine Zutaten einzulesen.",
"attach-images-hint": "Bilder durch Ziehen & Ablegen in den Editor hinzufügen",
"drop-image": "Bild hier ablegen",
"enable-ingredient-amounts-to-use-this-feature": "Aktiviere Zutatenmengen, um diese Funktion zu nutzen",
@ -662,6 +664,8 @@
"no-unit": "Keine Einheit",
"missing-unit": "Fehlende Einheit erstellen: {unit}",
"missing-food": "Fehlendes Lebensmittel erstellen: {food}",
"this-unit-could-not-be-parsed-automatically": "Diese Einheit konnte nicht automatisch analysiert werden",
"this-food-could-not-be-parsed-automatically": "Dieses Lebensmittel konnte nicht automatisch analysiert werden",
"no-food": "Kein Lebensmittel"
},
"reset-servings-count": "Portionen zurücksetzen",

View file

@ -472,6 +472,7 @@
"comment": "Σχόλιο",
"comments": "Σχόλια",
"delete-confirmation": "Θέλετε σίγουρα να διαγράψετε αυτή τη συνταγή;",
"admin-delete-confirmation": "Πρόκειται να διαγράψετε μια συνταγή που δεν είναι δική σας χρησιμοποιώντας δικαιώματα διαχειριστή. Είστε σίγουρος/η;",
"delete-recipe": "Διαγραφή Συνταγής",
"description": "Περιγραφή",
"disable-amount": "Απενεργοποίηση Ποσοτήτων Συστατικών",
@ -586,6 +587,7 @@
"api-extras-description": "Τα extras συνταγών αποτελούν βασικό χαρακτηριστικό του Mealie API. Σας επιτρέπουν να δημιουργήσετε προσαρμοσμένα ζεύγη κλειδιού/τιμής JSON μέσα σε μια συνταγή, να παραπέμψετε σε εφαρμογές τρίτων. Μπορείτε να χρησιμοποιήσετε αυτά τα κλειδιά για την παροχή πληροφοριών, για παράδειγμα πυροδότηση αυτοματισμών ή μετάδοση προσαρμοσμένων μηνυμάτων στη συσκευή που επιθυμείτε.",
"message-key": "Κλειδί Μηνύματος",
"parse": "Ανάλυση",
"ingredients-not-parsed-description": "Φαίνεται ότι τα συστατικά σας δεν έχουν αναλυθεί ακόμα. Κάντε κλικ στο κουμπί \"{parse}\" παρακάτω για να αναλύσετε τα συστατικά σας σε δομημένα τρόφιμα.",
"attach-images-hint": "Επισυνάψτε εικόνες σύροντας τις & αφήνοντάς τις στον επεξεργαστή",
"drop-image": "Απόθεση εικόνας",
"enable-ingredient-amounts-to-use-this-feature": "Ενεργοποιήστε τις ποσότητες συστατικών για να χρησιμοποιήσετε αυτήν τη δυνατότητα",
@ -662,6 +664,8 @@
"no-unit": "Καμία μονάδα",
"missing-unit": "Δημιουργία μονάδας που λείπει: {unit}",
"missing-food": "Δημιουργία τροφίμου που λείπει: {food}",
"this-unit-could-not-be-parsed-automatically": "Δεν ήταν δυνατή η αυτόματη ανάλυση αυτής της μονάδας",
"this-food-could-not-be-parsed-automatically": "Δεν ήταν δυνατή η αυτόματη ανάλυση αυτού του φαγητού",
"no-food": "Χωρίς Τρόφιμο"
},
"reset-servings-count": "Επαναφορά μέτρησης μερίδων",

View file

@ -472,6 +472,7 @@
"comment": "Comment",
"comments": "Comments",
"delete-confirmation": "Are you sure you want to delete this recipe?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Delete Recipe",
"description": "Description",
"disable-amount": "Disable Ingredient Amounts",
@ -586,6 +587,7 @@
"api-extras-description": "Recipes extras are a key feature of the Mealie API. They allow you to create custom JSON key/value pairs within a recipe, to reference from 3rd party applications. You can use these keys to provide information, for example to trigger automations or custom messages to relay to your desired device.",
"message-key": "Message Key",
"parse": "Parse",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Attach images by dragging & dropping them into the editor",
"drop-image": "Drop image",
"enable-ingredient-amounts-to-use-this-feature": "Enable ingredient amounts to use this feature",
@ -662,6 +664,8 @@
"no-unit": "No unit",
"missing-unit": "Create missing unit: {unit}",
"missing-food": "Create missing food: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "No Food"
},
"reset-servings-count": "Reset Servings Count",

View file

@ -472,6 +472,7 @@
"comment": "Comment",
"comments": "Comments",
"delete-confirmation": "Are you sure you want to delete this recipe?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Delete Recipe",
"description": "Description",
"disable-amount": "Disable Ingredient Amounts",
@ -586,6 +587,7 @@
"api-extras-description": "Recipes extras are a key feature of the Mealie API. They allow you to create custom JSON key/value pairs within a recipe, to reference from 3rd party applications. You can use these keys to provide information, for example to trigger automations or custom messages to relay to your desired device.",
"message-key": "Message Key",
"parse": "Parse",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Attach images by dragging & dropping them into the editor",
"drop-image": "Drop image",
"enable-ingredient-amounts-to-use-this-feature": "Enable ingredient amounts to use this feature",
@ -662,6 +664,8 @@
"no-unit": "No unit",
"missing-unit": "Create missing unit: {unit}",
"missing-food": "Create missing food: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "No Food"
},
"reset-servings-count": "Reset Servings Count",

View file

@ -2,8 +2,8 @@
"about": {
"about": "Acerca de",
"about-mealie": "Acerca de Mealie",
"api-docs": "Documentación API",
"api-port": "Puerto API",
"api-docs": "Documentación de API",
"api-port": "Puerto de API",
"application-mode": "Modo de Aplicación",
"database-type": "Tipo de base de datos",
"database-url": "URL de base de datos",
@ -472,6 +472,7 @@
"comment": "Comentario",
"comments": "Comentarios",
"delete-confirmation": "¿Estás seguro de eliminar esta receta?",
"admin-delete-confirmation": "Estás a punto de eliminar una receta que no es tuya usando permisos de administrador. ¿Estás seguro?",
"delete-recipe": "Borrar receta",
"description": "Descripción",
"disable-amount": "Desactivar cantidades de ingredientes",
@ -579,13 +580,14 @@
"made-this": "Lo hice",
"how-did-it-turn-out": "¿Cómo resultó esto?",
"user-made-this": "{user} hizo esto",
"added-to-timeline": "Added to timeline",
"failed-to-add-to-timeline": "Failed to add to timeline",
"failed-to-update-recipe": "Failed to update recipe",
"added-to-timeline-but-failed-to-add-image": "Added to timeline, but failed to add image",
"added-to-timeline": "Añadido a la línea de tiempo",
"failed-to-add-to-timeline": "No se pudo agregar a la línea de tiempo",
"failed-to-update-recipe": "Error al actualizar la receta",
"added-to-timeline-but-failed-to-add-image": "Añadido a la línea de tiempo, pero no se pudo agregar la imagen",
"api-extras-description": "Los extras de las recetas son una característica clave de la API de Mealie. Permiten crear pares json clave/valor personalizados dentro de una receta para acceder desde aplicaciones de terceros. Puede utilizar estas claves para almacenar información, para activar la automatización o mensajes personalizados para transmitir al dispositivo deseado.",
"message-key": "Clave de mensaje",
"parse": "Analizar",
"ingredients-not-parsed-description": "Parece que tus ingredientes no han sido analizados aún. Haz clic en el botón \"{parse}\" de abajo para analizarlos y convertirlos en alimentos estructurados.",
"attach-images-hint": "Adjuntar imágenes arrastrando y soltando en el editor",
"drop-image": "Soltar imagen",
"enable-ingredient-amounts-to-use-this-feature": "Habilitar la cantidad de ingredientes para usar esta característica",
@ -662,6 +664,8 @@
"no-unit": "Sin unidad",
"missing-unit": "Crear unidad faltante: {unit}",
"missing-food": "Crear comida faltante: {food}",
"this-unit-could-not-be-parsed-automatically": "Esta unidad no pudo ser procesada automáticamente",
"this-food-could-not-be-parsed-automatically": "Esta comida no pudo ser procesada automáticamente",
"no-food": "Sin Comida"
},
"reset-servings-count": "Restablecer contador de porciones",

View file

@ -472,6 +472,7 @@
"comment": "Kommentaar",
"comments": "Kommentaarid",
"delete-confirmation": "Kas sa oled kindel, et tahad seda retsepti kustutada?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Kustuta retsept",
"description": "Kirjeldus",
"disable-amount": "Deaktiveeri koostisosade kogused",
@ -586,6 +587,7 @@
"api-extras-description": "Retsepti väljavõtted on Meali API oluline funktsioon. Neid saab kasutada kohandatud JSON-võtme/väärtuse paaride loomiseks retseptis, et viidata kolmandate osapoolte rakendustele. Neid klahve saab kasutada teabe edastamiseks, näiteks automaatse toimingu või kohandatud sõnumi käivitamiseks teie valitud seadmele.",
"message-key": "Sõnumi võti",
"parse": "Analüüsi",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Lisa pildid manustesse neid lohistades ja vabastades need redaktorisse",
"drop-image": "Vabasta pilt",
"enable-ingredient-amounts-to-use-this-feature": "Luba koostisosa kogused, et kasutada seda omadust",
@ -662,6 +664,8 @@
"no-unit": "Ilma ühikuta",
"missing-unit": "Loo puuduv ühik: {unit}",
"missing-food": "Loo puuduv toit: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "Toit puudub"
},
"reset-servings-count": "Lähtesta portsionite arv",

View file

@ -472,6 +472,7 @@
"comment": "Kommentti",
"comments": "Kommentit",
"delete-confirmation": "Haluatko varmasti poistaa reseptin?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Poista resepti",
"description": "Kuvaus",
"disable-amount": "Poista ainesosien määrät käytöstä",
@ -586,6 +587,7 @@
"api-extras-description": "Reseptiekstrat ovat Mealien API:n tärkeä ominaisuus. Niiden avulla voidaan luoda mukautettuja JSON-avain/arvo-pareja reseptin sisällä viitaten kolmannen osapuolen sovelluksiin. Näitä avaimia voi käyttää tiedon antamiseksi, esimerkiksi automaattisen toiminnon tai mukautetun viestin käynnistämiseksi haluamaasi laitteeseen.",
"message-key": "Viestiavain",
"parse": "Jäsennä",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Liitä kuvia vetämällä ja pudottamalla ne editoriin",
"drop-image": "Tuo kuva",
"enable-ingredient-amounts-to-use-this-feature": "Käytä ainesosan määriä käyttääksesi tätä ominaisuutta",
@ -662,6 +664,8 @@
"no-unit": "Ei yksikköä",
"missing-unit": "Luo puuttuva yksikkö: {unit}",
"missing-food": "Luo puuttuva ruoka: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "Ei ruokaa"
},
"reset-servings-count": "Palauta Annoksien Määrä",

View file

@ -472,6 +472,7 @@
"comment": "Commentaire",
"comments": "Commentaires",
"delete-confirmation": "Voulez-vous vraiment supprimer cette recette?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Supprimer la recette",
"description": "Description",
"disable-amount": "Désactiver les quantités des ingrédients",
@ -586,6 +587,7 @@
"api-extras-description": "Les suppléments des recettes sont une fonctionnalité clé de lAPI Mealie. Ils permettent de créer des paires JSON clé/valeur personnalisées dans une recette, qui peuvent être référencées depuis des applications tierces. Ces clés peuvent être utilisées par exemple pour déclencher des tâches automatisées ou des messages personnalisés à transmettre à lappareil souhaité.",
"message-key": "Clé de message",
"parse": "Analyser",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Ajouter des images en les glissant-déposant dans l'éditeur",
"drop-image": "Déposer l'image",
"enable-ingredient-amounts-to-use-this-feature": "Activez les quantités d'ingrédients pour utiliser cette fonctionnalité",
@ -662,6 +664,8 @@
"no-unit": "Pas d'unité",
"missing-unit": "Créer une unité manquante : {unit}",
"missing-food": "Créer un aliment manquant : {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "Aucun aliment"
},
"reset-servings-count": "Réinitialiser le nombre de portions",

View file

@ -472,6 +472,7 @@
"comment": "Commentaire",
"comments": "Commentaires",
"delete-confirmation": "Êtes-vous sûr(e) de vouloir supprimer cette recette?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Supprimer la recette",
"description": "Description",
"disable-amount": "Désactiver les quantités d'ingrédients",
@ -586,6 +587,7 @@
"api-extras-description": "Les suppléments des recettes sont une fonctionnalité clé de lAPI Mealie. Ils permettent de créer des paires JSON clé/valeur personnalisées dans une recette, qui peuvent être référencées depuis des applications tierces. Ces clés peuvent être utilisées par exemple pour déclencher des tâches automatisées ou des messages personnalisés à transmettre à lappareil souhaité.",
"message-key": "Clé de message",
"parse": "Analyser",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Ajouter des images en les glissant-déposant dans l'éditeur",
"drop-image": "Déposer l'image",
"enable-ingredient-amounts-to-use-this-feature": "Activez les quantités d'ingrédients pour utiliser cette fonctionnalité",
@ -662,6 +664,8 @@
"no-unit": "Pas d'unité",
"missing-unit": "Créer une unité manquante : {unit}",
"missing-food": "Créer un aliment manquant : {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "Aucun aliment"
},
"reset-servings-count": "Réinitialiser le nombre de portions",

View file

@ -113,7 +113,7 @@
"get": "Envoyer",
"home": "Accueil",
"image": "Image",
"image-upload-failed": "Le téléchargement de limage a échoué",
"image-upload-failed": "Le téléversement de limage a échoué",
"import": "Importer",
"json": "JSON",
"keyword": "Mot-clé",
@ -207,7 +207,7 @@
"selected-count": "Sélectionné : {count}",
"export-all": "Exporter tout",
"refresh": "Actualiser",
"upload-file": "Transférer un fichier",
"upload-file": "Téléverser un fichier",
"created-on-date": "Créé le {0}",
"unsaved-changes": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer avant de partir? OK pour enregistrer, Annuler pour ignorer les modifications.",
"clipboard-copy-failure": "Échec de la copie dans le presse-papiers.",
@ -472,6 +472,7 @@
"comment": "Commentaire",
"comments": "Commentaires",
"delete-confirmation": "Voulez-vous vraiment supprimer cette recette?",
"admin-delete-confirmation": "Vous êtes sur le point de supprimer une recette qui n'est pas la vôtre en utilisant les permissions d'administrateur. Êtes-vous sûr(e) ?",
"delete-recipe": "Supprimer la recette",
"description": "Description",
"disable-amount": "Désactiver les quantités des ingrédients",
@ -586,6 +587,7 @@
"api-extras-description": "Les suppléments des recettes sont une fonctionnalité clé de lAPI Mealie. Ils permettent de créer des paires JSON clé/valeur personnalisées dans une recette, qui peuvent être référencées depuis des applications tierces. Ces clés peuvent être utilisées par exemple pour déclencher des tâches automatisées ou des messages personnalisés à transmettre à lappareil souhaité.",
"message-key": "Clé de message",
"parse": "Analyser",
"ingredients-not-parsed-description": "Il semble que vos ingrédients ne soient pas encore analysés. Cliquez sur le bouton \"{parse}\" ci-dessous pour analyser vos ingrédients en aliments structurés.",
"attach-images-hint": "Ajouter des images en les glissant-déposant dans l'éditeur",
"drop-image": "Déposer l'image",
"enable-ingredient-amounts-to-use-this-feature": "Activez les quantités d'ingrédients pour utiliser cette fonctionnalité",
@ -662,6 +664,8 @@
"no-unit": "Pas d'unité",
"missing-unit": "Créer une unité manquante : {unit}",
"missing-food": "Créer un aliment manquant : {food}",
"this-unit-could-not-be-parsed-automatically": "Cette unité n'a pas pu être analysée automatiquement",
"this-food-could-not-be-parsed-automatically": "Cet aliment n'a pas pu être analysé automatiquement",
"no-food": "Aucun aliment"
},
"reset-servings-count": "Réinitialiser le nombre de portions",

View file

@ -472,6 +472,7 @@
"comment": "Comentario",
"comments": "Comentarios",
"delete-confirmation": "Estás seguro de que queres eliminar esta receita?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Eliminar Receita",
"description": "Descrición",
"disable-amount": "Desactivar as Cantidades de Ingredientes",
@ -586,6 +587,7 @@
"api-extras-description": "Os extras de receitas son unha característica clave da API de Mealie. Permítenche crear pares de clave/valor JSON personalizados dentro dunha receita, para facer referencia desde aplicacións de terceiros. Podes usar estas teclas para proporcionar información, por exemplo, para activar automatizacións ou mensaxes personalizadas para transmitir ao dispositivo que desexes.",
"message-key": "Chave de Mensaxen",
"parse": "Interpretar",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Anexe imaxens arrastrando e soltandoas no editor",
"drop-image": "Arrastrar imaxen",
"enable-ingredient-amounts-to-use-this-feature": "Ative cantidades de ingredientes para usar esta funcionalidade",
@ -662,6 +664,8 @@
"no-unit": "Sen unidades",
"missing-unit": "Crear a unidade que falta: {unit}",
"missing-food": "Crear a comida que falta: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "Sen Comida"
},
"reset-servings-count": "Reiniciar Contador de Porcións",

View file

@ -472,6 +472,7 @@
"comment": "הערה",
"comments": "הערות",
"delete-confirmation": "למחוק את המתכון הזה?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "מחיקת מתכון",
"description": "תיאור",
"disable-amount": "ביטול כמויות מרכיבים",
@ -579,13 +580,14 @@
"made-this": "הכנתי את זה",
"how-did-it-turn-out": "איך יצא?",
"user-made-this": "{user} הכין את זה",
"added-to-timeline": "Added to timeline",
"failed-to-add-to-timeline": "Failed to add to timeline",
"failed-to-update-recipe": "Failed to update recipe",
"added-to-timeline-but-failed-to-add-image": "Added to timeline, but failed to add image",
"added-to-timeline": "נוסף לציר הזמן",
"failed-to-add-to-timeline": "כישלון בהוספה לציר הזמן",
"failed-to-update-recipe": "כישלון בעדכון מתכון",
"added-to-timeline-but-failed-to-add-image": "נוסף לציר הזמן עם כישלון בהוספת תמונה",
"api-extras-description": "מתכונים נוספים הם יכולת מפתח של Mealie API. הם מאפשרים ליצור צמדי key/value בצורת JSON על מנת לקרוא אותם בתוכנת צד שלישית. תוכלו להשתמש בצמדים האלה כדי לספק מידע, לדוגמא להפעיל אוטומציות או הודעות מותאמות אישית למכשירים מסויימים.",
"message-key": "מפתח הודעה",
"parse": "ניתוח",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "הוסף תמונות ע\"י גרירה ושחרור אל תוך העורך",
"drop-image": "גרור תמונה",
"enable-ingredient-amounts-to-use-this-feature": "אפשר לכמות המרכיבים להשתמש בפונקציה",
@ -662,6 +664,8 @@
"no-unit": "ללא יחידות",
"missing-unit": "יצירת יחידה חסרה: {unit}",
"missing-food": "יצירת אוכל חסר: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "אין אוכל"
},
"reset-servings-count": "איפוס מספר המנות",

View file

@ -472,6 +472,7 @@
"comment": "Komentar",
"comments": "Komentari",
"delete-confirmation": "Jeste li sigurni da želite izbrisati ovaj recept?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Obriši Recept",
"description": "Opis",
"disable-amount": "Onemogući količine sastojaka",
@ -586,6 +587,7 @@
"api-extras-description": "Recipes extras are a key feature of the Mealie API. They allow you to create custom JSON key/value pairs within a recipe, to reference from 3rd party applications. You can use these keys to provide information, for example to trigger automations or custom messages to relay to your desired device.",
"message-key": "Ključ poruke",
"parse": "Razluči (parsiraj)",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Priložite slike povlačenjem i ispuštanjem u uređivaču",
"drop-image": "Drop image",
"enable-ingredient-amounts-to-use-this-feature": "Omogući korištenje količina sastojaka za ovu značajku",
@ -662,6 +664,8 @@
"no-unit": "No unit",
"missing-unit": "Create missing unit: {unit}",
"missing-food": "Create missing food: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "No Food"
},
"reset-servings-count": "Reset Servings Count",

View file

@ -472,6 +472,7 @@
"comment": "Megjegyzés",
"comments": "Megjegyzések",
"delete-confirmation": "Biztosan törli ezt a receptet?",
"admin-delete-confirmation": "Adminisztrátori jogosultságokkal törölni készül egy receptet, amely nem a tiéd. Biztosan törölni szeretné?",
"delete-recipe": "Recept törlése",
"description": "Leírás",
"disable-amount": "Hozzávalók mennyiségének letiltása",
@ -580,12 +581,13 @@
"how-did-it-turn-out": "Hogyan sikerült?",
"user-made-this": "ezt {user} készítette el",
"added-to-timeline": "Idővonalhoz hozzáadva",
"failed-to-add-to-timeline": "Failed to add to timeline",
"failed-to-add-to-timeline": "Nem sikerült az idővonalhoz hozzáadni",
"failed-to-update-recipe": "Nem sikerült frissíteni a receptet",
"added-to-timeline-but-failed-to-add-image": "Idővonalhoz hozzáadva, azonban a kép hozzáadása sikertelen",
"api-extras-description": "A receptek extrái a Mealie API egyik legfontosabb szolgáltatása. Lehetővé teszik, hogy egyéni JSON kulcs/érték párokat hozzon létre egy receptben, amelyekre harmadik féltől származó alkalmazásokból hivatkozhat. Ezeket a kulcsokat információszolgáltatásra használhatja, például automatizmusok vagy egyéni üzenetek indítására, amelyeket a kívánt eszközre küldhet.",
"message-key": "Üzenetkulcs",
"parse": "Előkészítés",
"ingredients-not-parsed-description": "Úgy tűnik, hogy az összetevőket még nem elemezték. Kattintson az alábbi „{parse}” gombra, hogy az összetevőket strukturált élelmiszerekké alakítsa.",
"attach-images-hint": "Képek csatolása a szerkesztőbe történő húzásával és ejtésével",
"drop-image": "Dobd ide a képet",
"enable-ingredient-amounts-to-use-this-feature": "Engedélyezze az összetevők mennyiségét a funkció használatához",
@ -662,6 +664,8 @@
"no-unit": "Mértékegység nélkül",
"missing-unit": "Hiányzó mértékegység létrehozása: {unit}",
"missing-food": "Hiányzó élelmiszer létrehozása: {food}",
"this-unit-could-not-be-parsed-automatically": "Ez az egység nem tudja automatikusan értelmezni",
"this-food-could-not-be-parsed-automatically": "Ezt az ételt nem lehetett automatikusan feldolgozni",
"no-food": "Élelmiszer nélküli"
},
"reset-servings-count": "Adagok számának visszaállítása",

View file

@ -472,6 +472,7 @@
"comment": "Comment",
"comments": "Comments",
"delete-confirmation": "Are you sure you want to delete this recipe?",
"admin-delete-confirmation": "You're about to delete a recipe that isn't yours using admin permissions. Are you sure?",
"delete-recipe": "Delete Recipe",
"description": "Description",
"disable-amount": "Disable Ingredient Amounts",
@ -586,6 +587,7 @@
"api-extras-description": "Recipes extras are a key feature of the Mealie API. They allow you to create custom JSON key/value pairs within a recipe, to reference from 3rd party applications. You can use these keys to provide information, for example to trigger automations or custom messages to relay to your desired device.",
"message-key": "Message Key",
"parse": "Parse",
"ingredients-not-parsed-description": "It looks like your ingredients aren't parsed yet. Click the \"{parse}\" button below to parse your ingredients into structured foods.",
"attach-images-hint": "Attach images by dragging & dropping them into the editor",
"drop-image": "Drop image",
"enable-ingredient-amounts-to-use-this-feature": "Enable ingredient amounts to use this feature",
@ -662,6 +664,8 @@
"no-unit": "No unit",
"missing-unit": "Create missing unit: {unit}",
"missing-food": "Create missing food: {food}",
"this-unit-could-not-be-parsed-automatically": "This unit could not be parsed automatically",
"this-food-could-not-be-parsed-automatically": "This food could not be parsed automatically",
"no-food": "No Food"
},
"reset-servings-count": "Reset Servings Count",

Some files were not shown because too many files have changed in this diff Show more