mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-31 02:18:22 -05:00
Compare commits
2 Commits
v3.1.2
...
reset-scro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e16a16cd8 | ||
|
|
78a0f74f33 |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
name: Build Package
|
||||
uses: ./.github/workflows/build-package.yml
|
||||
with:
|
||||
tag: ${{ github.event.release.tag_name }}
|
||||
tag: release
|
||||
|
||||
publish:
|
||||
permissions:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
exclude: "mkdocs.yml"
|
||||
@@ -12,7 +12,7 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.12.10
|
||||
rev: v0.12.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
@@ -71,7 +71,6 @@ tasks:
|
||||
desc: run code generators
|
||||
cmds:
|
||||
- poetry run python dev/code-generation/main.py {{ .CLI_ARGS }}
|
||||
- task: docs:gen
|
||||
- task: py:format
|
||||
|
||||
dev:services:
|
||||
|
||||
@@ -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/mealie-recipes/mealie/issues/${2}))"},
|
||||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/hay-kot/mealie/issues/${2}))"},
|
||||
]
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
|
||||
@@ -179,15 +179,9 @@ def inject_nuxt_values():
|
||||
|
||||
all_langs = []
|
||||
for match in locales_dir.glob("*.json"):
|
||||
match_data = LOCALE_DATA.get(match.stem)
|
||||
match_dir = match_data.dir if match_data else "ltr"
|
||||
|
||||
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}", dir: "{match_dir}" }},'
|
||||
lang_string = f'{{ code: "{match.stem}", file: "{match.name.replace(".json", ".ts")}" }},'
|
||||
all_langs.append(lang_string)
|
||||
|
||||
all_langs.sort()
|
||||
all_date_locales.sort()
|
||||
|
||||
log.debug(f"injecting locales into nuxt config -> {nuxt_config}")
|
||||
inject_inline(nuxt_config, CodeKeys.nuxt_local_messages, all_langs)
|
||||
inject_inline(i18n_config, CodeKeys.nuxt_local_dates, all_date_locales)
|
||||
|
||||
@@ -8,8 +8,8 @@ from utils import log
|
||||
# ============================================================
|
||||
|
||||
template = """// This Code is auto generated by gen_ts_types.py
|
||||
{% for name in global %}import type {{ name }} from "@/components/global/{{ name }}.vue";
|
||||
{% endfor %}{% for name in layout %}import type {{ name }} from "@/components/layout/{{ name }}.vue";
|
||||
{% for name in global %}import {{ name }} from "@/components/global/{{ name }}.vue";
|
||||
{% endfor %}{% for name in layout %}import {{ name }} from "@/components/layout/{{ name }}.vue";
|
||||
{% endfor %}
|
||||
declare module "vue" {
|
||||
export interface GlobalComponents {
|
||||
|
||||
@@ -44,6 +44,7 @@ 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",
|
||||
@@ -53,6 +54,7 @@ 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",
|
||||
@@ -62,6 +64,7 @@ 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",
|
||||
@@ -71,6 +74,7 @@ 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",
|
||||
@@ -80,6 +84,7 @@ 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",
|
||||
@@ -89,6 +94,7 @@ 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",
|
||||
@@ -98,6 +104,7 @@ 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",
|
||||
@@ -107,6 +114,7 @@ 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",
|
||||
@@ -116,6 +124,7 @@ 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",
|
||||
@@ -125,6 +134,7 @@ 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",
|
||||
@@ -134,6 +144,7 @@ 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",
|
||||
@@ -143,6 +154,7 @@ 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",
|
||||
@@ -152,6 +164,7 @@ 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",
|
||||
@@ -208,6 +221,7 @@ 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": [],
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
###############################################
|
||||
# Frontend Build
|
||||
###############################################
|
||||
FROM node:20@sha256:572a90df10a58ebb7d3f223d661d964a6c2383a9c2b5763162b4f631c53dc56a \
|
||||
AS frontend-builder
|
||||
FROM node:20 AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
@@ -21,8 +20,7 @@ RUN yarn generate
|
||||
###############################################
|
||||
# Base Image - Python
|
||||
###############################################
|
||||
FROM python:3.12-slim@sha256:2267adc248a477c1f1a852a07a5a224d42abe54c28aafa572efa157dfb001bba \
|
||||
AS python-base
|
||||
FROM python:3.12-slim AS python-base
|
||||
|
||||
ENV MEALIE_HOME="/app"
|
||||
|
||||
@@ -134,7 +132,7 @@ RUN apt-get update \
|
||||
gosu \
|
||||
iproute2 \
|
||||
libldap-common \
|
||||
libldap2 \
|
||||
libldap-2.5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# create directory used for Docker Secrets
|
||||
|
||||
@@ -13,14 +13,14 @@ Steps:
|
||||
|
||||
#### 1. Get your API Token
|
||||
|
||||
Create an API token from Mealie's User Settings page (https://docs.mealie.io/documentation/getting-started/api-usage/#getting-a-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)
|
||||
|
||||
#### 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:
|
||||
|
||||
@@ -131,7 +131,7 @@ For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values
|
||||
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 300 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
| OPENAI_REQUEST_TIMEOUT | 60 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
|
||||
### Theming
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||
|
||||
1. Take a backup just in case!
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.1.1`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v3.0.0`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
4. Restart the container
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# 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)
|
||||
@@ -10,7 +7,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.1.1 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.0.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
@@ -41,7 +38,7 @@ services:
|
||||
|
||||
postgres:
|
||||
container_name: postgres
|
||||
image: postgres:17
|
||||
image: postgres:15
|
||||
restart: always
|
||||
volumes:
|
||||
- mealie-pgdata:/var/lib/postgresql/data
|
||||
@@ -49,7 +46,6 @@ services:
|
||||
POSTGRES_PASSWORD: mealie
|
||||
POSTGRES_USER: mealie
|
||||
PGUSER: mealie
|
||||
POSTGRES_DB: mealie
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready"]
|
||||
interval: 30s
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.1.1 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v3.0.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
|
||||
@@ -2,3 +2,6 @@
|
||||
|
||||
## 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
@@ -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/mealie-recipes/mealie" rel="noopener" target="_blank"
|
||||
<a class="md-footer-social__link" href="https://github.com/hay-kot/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
|
||||
|
||||
@@ -44,54 +44,78 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
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";
|
||||
|
||||
const modelValue = defineModel<ReadCookBook>({ required: true });
|
||||
const i18n = useI18n();
|
||||
const cookbook = toRef(modelValue);
|
||||
function handleInput(value: string | undefined) {
|
||||
cookbook.value.queryFilterString = value || "";
|
||||
}
|
||||
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 fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.t("category.categories"),
|
||||
type: Organizer.Category,
|
||||
const cookbook = toRef(() => props.modelValue);
|
||||
|
||||
function handleInput(value: string | undefined) {
|
||||
cookbook.value.queryFilterString = value || "";
|
||||
emit("update:modelValue", cookbook.value);
|
||||
}
|
||||
|
||||
const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.t("category.categories"),
|
||||
type: Organizer.Category,
|
||||
},
|
||||
{
|
||||
name: "tags.id",
|
||||
label: i18n.t("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
{
|
||||
name: "recipe_ingredient.food.id",
|
||||
label: i18n.t("recipe.ingredients"),
|
||||
type: Organizer.Food,
|
||||
},
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.t("tool.tools"),
|
||||
type: Organizer.Tool,
|
||||
},
|
||||
{
|
||||
name: "household_id",
|
||||
label: i18n.t("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
label: i18n.t("general.date-created"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
label: i18n.t("general.date-updated"),
|
||||
type: "date",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
cookbook,
|
||||
handleInput,
|
||||
fieldDefs,
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "tags.id",
|
||||
label: i18n.t("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
{
|
||||
name: "recipe_ingredient.food.id",
|
||||
label: i18n.t("recipe.ingredients"),
|
||||
type: Organizer.Food,
|
||||
},
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.t("tool.tools"),
|
||||
type: Organizer.Tool,
|
||||
},
|
||||
{
|
||||
name: "household_id",
|
||||
label: i18n.t("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
label: i18n.t("general.date-created"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
label: i18n.t("general.date-updated"),
|
||||
type: "date",
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<v-card-text>
|
||||
<CookbookEditor
|
||||
v-model="editTarget"
|
||||
:actions="actions"
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
@@ -64,67 +65,90 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { useLazyRecipes } from "~/composables/recipes";
|
||||
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import { useCookbookStore } from "~/composables/store/use-cookbook-store";
|
||||
import { useCookbook } from "~/composables/use-group-cookbooks";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import type { RecipeCookBook } from "~/lib/api/types/cookbook";
|
||||
import CookbookEditor from "~/components/Domain/Cookbook/CookbookEditor.vue";
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardSection, CookbookEditor },
|
||||
setup() {
|
||||
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 book = getOne(slug);
|
||||
const tab = ref(null);
|
||||
const book = getOne(slug);
|
||||
|
||||
const isOwnHousehold = computed(() => {
|
||||
if (!($auth.user.value && book.value?.householdId)) {
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
return $auth.user.value.householdId === book.value.householdId;
|
||||
});
|
||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||
|
||||
const dialogStates = reactive({
|
||||
edit: false,
|
||||
});
|
||||
const dialogStates = reactive({
|
||||
edit: false,
|
||||
});
|
||||
|
||||
const editTarget = ref<ReadCookBook | null>(null);
|
||||
function handleEditCookbook() {
|
||||
dialogStates.edit = true;
|
||||
editTarget.value = book.value;
|
||||
}
|
||||
const editTarget = ref<RecipeCookBook | null>(null);
|
||||
function handleEditCookbook() {
|
||||
dialogStates.edit = true;
|
||||
editTarget.value = book.value;
|
||||
}
|
||||
|
||||
async function editCookbook() {
|
||||
if (!editTarget.value) {
|
||||
return;
|
||||
}
|
||||
const response = await actions.updateOne(editTarget.value);
|
||||
async function editCookbook() {
|
||||
if (!editTarget.value) {
|
||||
return;
|
||||
}
|
||||
const response = await actions.updateOne(editTarget.value);
|
||||
|
||||
if (response?.slug && book.value?.slug !== response?.slug) {
|
||||
// if name changed, redirect to new slug
|
||||
router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
|
||||
}
|
||||
else {
|
||||
// otherwise reload the page, since the recipe criteria changed
|
||||
router.go(0);
|
||||
}
|
||||
dialogStates.edit = false;
|
||||
editTarget.value = null;
|
||||
}
|
||||
if (response?.slug && book.value?.slug !== response?.slug) {
|
||||
// if name changed, redirect to new slug
|
||||
router.push(`/g/${route.params.groupSlug}/cookbooks/${response?.slug}`);
|
||||
}
|
||||
else {
|
||||
// otherwise reload the page, since the recipe criteria changed
|
||||
router.go(0);
|
||||
}
|
||||
dialogStates.edit = false;
|
||||
editTarget.value = null;
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: book?.value?.name || "Cookbook",
|
||||
useSeoMeta({
|
||||
title: book?.value?.name || "Cookbook",
|
||||
});
|
||||
|
||||
return {
|
||||
book,
|
||||
slug,
|
||||
tab,
|
||||
appendRecipes,
|
||||
assignSorted,
|
||||
recipes,
|
||||
removeRecipe,
|
||||
replaceRecipes,
|
||||
canEdit,
|
||||
dialogStates,
|
||||
editTarget,
|
||||
handleEditCookbook,
|
||||
editCookbook,
|
||||
actions,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -20,33 +20,45 @@
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { parseISO, formatDistanceToNow } from "date-fns";
|
||||
import type { GroupDataExport } from "~/lib/api/types/group";
|
||||
|
||||
defineProps<{
|
||||
exports: GroupDataExport[];
|
||||
}>();
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
exports: {
|
||||
type: Array as () => GroupDataExport[],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const i18n = useI18n();
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
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) {
|
||||
const expiresAt = parseISO(timeString);
|
||||
|
||||
function getTimeToExpire(timeString: string) {
|
||||
const expiresAt = parseISO(timeString);
|
||||
return formatDistanceToNow(expiresAt, {
|
||||
addSuffix: false,
|
||||
});
|
||||
}
|
||||
|
||||
return formatDistanceToNow(expiresAt, {
|
||||
addSuffix: false,
|
||||
});
|
||||
}
|
||||
function downloadData(_: any) {
|
||||
console.log("Downloading data...");
|
||||
}
|
||||
|
||||
function downloadData(_: any) {
|
||||
console.log("Downloading data...");
|
||||
}
|
||||
return {
|
||||
downloadData,
|
||||
headers,
|
||||
getTimeToExpire,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -9,10 +9,30 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReadGroupPreferences } from "~/lib/api/types/user";
|
||||
<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);
|
||||
},
|
||||
});
|
||||
|
||||
const preferences = defineModel<ReadGroupPreferences>({ required: true });
|
||||
return {
|
||||
preferences,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<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>
|
||||
@@ -18,7 +18,7 @@
|
||||
:open-on-hover="mdAndUp"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
:class="{ 'rounded-circle': fab }"
|
||||
:size="fab ? 'small' : undefined"
|
||||
@@ -26,7 +26,7 @@
|
||||
:icon="!fab"
|
||||
variant="text"
|
||||
dark
|
||||
v-bind="activatorProps"
|
||||
v-bind="props"
|
||||
@click.prevent
|
||||
>
|
||||
<v-icon>{{ icon }}</v-icon>
|
||||
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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,84 +64,101 @@ export interface ContextMenuItem {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
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 emit = defineEmits<{
|
||||
[key: string]: [];
|
||||
}>();
|
||||
|
||||
const { mdAndUp } = useDisplay();
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const api = useUserApi();
|
||||
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
shoppingListDialog: false,
|
||||
menuItems: [
|
||||
{
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
isPublic: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { shoppingListDialog, menuItems } = toRefs(state);
|
||||
|
||||
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() {
|
||||
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> } = {
|
||||
shoppingList: () => {
|
||||
getShoppingLists();
|
||||
state.shoppingListDialog = true;
|
||||
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();
|
||||
|
||||
function contextMenuEventHandler(eventKey: string) {
|
||||
const handler = eventHandlers[eventKey];
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const api = useUserApi();
|
||||
|
||||
if (handler && typeof handler === "function") {
|
||||
handler();
|
||||
state.loading = false;
|
||||
return;
|
||||
}
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
shoppingListDialog: false,
|
||||
menuItems: [
|
||||
{
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
isPublic: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
emit(eventKey);
|
||||
state.loading = false;
|
||||
}
|
||||
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() {
|
||||
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> } = {
|
||||
shoppingList: () => {
|
||||
getShoppingLists();
|
||||
state.shoppingListDialog = true;
|
||||
},
|
||||
};
|
||||
|
||||
function contextMenuEventHandler(eventKey: string) {
|
||||
const handler = eventHandlers[eventKey];
|
||||
|
||||
if (handler && typeof handler === "function") {
|
||||
handler();
|
||||
state.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
context.emit(eventKey);
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
contextMenuEventHandler,
|
||||
icon,
|
||||
recipesWithScales,
|
||||
shoppingLists,
|
||||
mdAndUp,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
style="gap: 10px"
|
||||
>
|
||||
<v-select
|
||||
v-model="day"
|
||||
v-model="inputDay"
|
||||
:items="MEAL_DAY_OPTIONS"
|
||||
:label="$t('meal-plan.rule-day')"
|
||||
/>
|
||||
<v-select
|
||||
v-model="entryType"
|
||||
v-model="inputEntryType"
|
||||
:items="MEAL_TYPE_OPTIONS"
|
||||
:label="$t('meal-plan.meal-type')"
|
||||
/>
|
||||
@@ -19,104 +19,157 @@
|
||||
<div class="mb-5">
|
||||
<QueryFilterBuilder
|
||||
:field-defs="fieldDefs"
|
||||
:initial-query-filter="props.queryFilter"
|
||||
:initial-query-filter="queryFilter"
|
||||
@input="handleQueryFilterInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TODO: proper pluralization of inputDay -->
|
||||
{{ $t('meal-plan.this-rule-will-apply', {
|
||||
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]),
|
||||
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]),
|
||||
}) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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";
|
||||
|
||||
interface Props {
|
||||
queryFilter?: QueryFilterJSON | null;
|
||||
showHelp?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
queryFilter: null,
|
||||
showHelp: false,
|
||||
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();
|
||||
|
||||
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 = [
|
||||
{ title: i18n.t("general.monday"), value: "monday" },
|
||||
{ title: i18n.t("general.tuesday"), value: "tuesday" },
|
||||
{ title: i18n.t("general.wednesday"), value: "wednesday" },
|
||||
{ title: i18n.t("general.thursday"), value: "thursday" },
|
||||
{ title: i18n.t("general.friday"), value: "friday" },
|
||||
{ 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);
|
||||
},
|
||||
});
|
||||
|
||||
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[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.t("category.categories"),
|
||||
type: Organizer.Category,
|
||||
},
|
||||
{
|
||||
name: "tags.id",
|
||||
label: i18n.t("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
{
|
||||
name: "recipe_ingredient.food.id",
|
||||
label: i18n.t("recipe.ingredients"),
|
||||
type: Organizer.Food,
|
||||
},
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.t("tool.tools"),
|
||||
type: Organizer.Tool,
|
||||
},
|
||||
{
|
||||
name: "household_id",
|
||||
label: i18n.t("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "last_made",
|
||||
label: i18n.t("general.last-made"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
label: i18n.t("general.date-created"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
label: i18n.t("general.date-updated"),
|
||||
type: "date",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
MEAL_TYPE_OPTIONS,
|
||||
MEAL_DAY_OPTIONS,
|
||||
inputDay,
|
||||
inputEntryType,
|
||||
inputQueryFilterString,
|
||||
handleQueryFilterInput,
|
||||
fieldDefs,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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 = [
|
||||
{ title: i18n.t("general.monday"), value: "monday" },
|
||||
{ title: i18n.t("general.tuesday"), value: "tuesday" },
|
||||
{ title: i18n.t("general.wednesday"), value: "wednesday" },
|
||||
{ title: i18n.t("general.thursday"), value: "thursday" },
|
||||
{ title: i18n.t("general.friday"), value: "friday" },
|
||||
{ title: i18n.t("general.saturday"), value: "saturday" },
|
||||
{ title: i18n.t("general.sunday"), value: "sunday" },
|
||||
{ title: i18n.t("meal-plan.day-any"), value: "unset" },
|
||||
];
|
||||
|
||||
function handleQueryFilterInput(value: string | undefined) {
|
||||
console.warn("handleQueryFilterInput called with value:", value);
|
||||
queryFilterString.value = value || "";
|
||||
}
|
||||
|
||||
const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.t("category.categories"),
|
||||
type: Organizer.Category,
|
||||
},
|
||||
{
|
||||
name: "tags.id",
|
||||
label: i18n.t("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
{
|
||||
name: "recipe_ingredient.food.id",
|
||||
label: i18n.t("recipe.ingredients"),
|
||||
type: Organizer.Food,
|
||||
},
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.t("tool.tools"),
|
||||
type: Organizer.Tool,
|
||||
},
|
||||
{
|
||||
name: "household_id",
|
||||
label: i18n.t("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "last_made",
|
||||
label: i18n.t("general.last-made"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
label: i18n.t("general.date-created"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
label: i18n.t("general.date-updated"),
|
||||
type: "date",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
:label="$t('settings.webhooks.webhook-url')"
|
||||
variant="underlined"
|
||||
/>
|
||||
<v-text-field
|
||||
<v-time-picker
|
||||
v-model="scheduledTime"
|
||||
type="time"
|
||||
clearable
|
||||
variant="underlined"
|
||||
class="elevation-2"
|
||||
ampm-in-title
|
||||
format="ampm"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="py-0 justify-end">
|
||||
@@ -50,43 +50,52 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import type { ReadWebhook } from "~/lib/api/types/household";
|
||||
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
||||
|
||||
const props = defineProps<{
|
||||
webhook: ReadWebhook;
|
||||
}>();
|
||||
|
||||
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;
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
webhook: {
|
||||
type: Object as () => ReadWebhook,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
set(v: string) {
|
||||
itemUTC.value = timeLocalToUTC(v);
|
||||
itemLocal.value = v;
|
||||
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 scheduledTime = computed({
|
||||
get() {
|
||||
return itemLocal.value;
|
||||
},
|
||||
set(v: string) {
|
||||
itemUTC.value = timeLocalToUTC(v);
|
||||
itemLocal.value = v;
|
||||
},
|
||||
});
|
||||
|
||||
const webhookCopy = ref({ ...props.webhook });
|
||||
|
||||
function handleSave() {
|
||||
webhookCopy.value.scheduledTime = itemLocal.value;
|
||||
emit("save", webhookCopy.value);
|
||||
}
|
||||
|
||||
// Set page title using useSeoMeta
|
||||
useSeoMeta({
|
||||
title: i18n.t("settings.webhooks.webhooks"),
|
||||
});
|
||||
|
||||
return {
|
||||
webhookCopy,
|
||||
scheduledTime,
|
||||
handleSave,
|
||||
itemUTC,
|
||||
itemLocal,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const webhookCopy = ref({ ...props.webhook });
|
||||
|
||||
function handleSave() {
|
||||
webhookCopy.value.scheduledTime = itemLocal.value;
|
||||
emit("save", webhookCopy.value);
|
||||
}
|
||||
|
||||
// Set page title using useSeoMeta
|
||||
useSeoMeta({
|
||||
title: i18n.t("settings.webhooks.webhooks"),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -41,76 +41,106 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import type { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||
|
||||
const preferences = defineModel<ReadHouseholdPreferences>({ required: true });
|
||||
const i18n = useI18n();
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
|
||||
type Preference = {
|
||||
key: keyof ReadHouseholdPreferences;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
type Preference = {
|
||||
key: keyof ReadHouseholdPreferences;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const recipePreferences: Preference[] = [
|
||||
{
|
||||
key: "recipePublic",
|
||||
label: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes"),
|
||||
description: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeShowNutrition",
|
||||
label: i18n.t("group.show-nutrition-information"),
|
||||
description: i18n.t("group.show-nutrition-information-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeShowAssets",
|
||||
label: i18n.t("group.show-recipe-assets"),
|
||||
description: i18n.t("group.show-recipe-assets-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeLandscapeView",
|
||||
label: i18n.t("group.default-to-landscape-view"),
|
||||
description: i18n.t("group.default-to-landscape-view-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeDisableComments",
|
||||
label: i18n.t("group.disable-users-from-commenting-on-recipes"),
|
||||
description: i18n.t("group.disable-users-from-commenting-on-recipes-description"),
|
||||
},
|
||||
];
|
||||
const recipePreferences: Preference[] = [
|
||||
{
|
||||
key: "recipePublic",
|
||||
label: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes"),
|
||||
description: i18n.t("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeShowNutrition",
|
||||
label: i18n.t("group.show-nutrition-information"),
|
||||
description: i18n.t("group.show-nutrition-information-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeShowAssets",
|
||||
label: i18n.t("group.show-recipe-assets"),
|
||||
description: i18n.t("group.show-recipe-assets-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeLandscapeView",
|
||||
label: i18n.t("group.default-to-landscape-view"),
|
||||
description: i18n.t("group.default-to-landscape-view-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeDisableComments",
|
||||
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 = [
|
||||
{
|
||||
name: i18n.t("general.sunday"),
|
||||
value: 0,
|
||||
const allDays = [
|
||||
{
|
||||
name: i18n.t("general.sunday"),
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.monday"),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.tuesday"),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.wednesday"),
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.thursday"),
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.friday"),
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
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,
|
||||
};
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.monday"),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.tuesday"),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.wednesday"),
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.thursday"),
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.friday"),
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.saturday"),
|
||||
value: 6,
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="css">
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
:model-value="field.value"
|
||||
type="number"
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldValue(field, index, $event)"
|
||||
@: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: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-text-field
|
||||
v-model="field.value"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
variant="underlined"
|
||||
color="primary"
|
||||
v-bind="activatorProps"
|
||||
v-bind="props"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
@@ -184,53 +184,53 @@
|
||||
</v-menu>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Category"
|
||||
v-model="field.organizers"
|
||||
:model-value="field.organizers"
|
||||
:selector-type="Organizer.Category"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Tag"
|
||||
v-model="field.organizers"
|
||||
:model-value="field.organizers"
|
||||
:selector-type="Organizer.Tag"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Tool"
|
||||
v-model="field.organizers"
|
||||
:model-value="field.organizers"
|
||||
:selector-type="Organizer.Tool"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Food"
|
||||
v-model="field.organizers"
|
||||
:model-value="field.organizers"
|
||||
:selector-type="Organizer.Food"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Household"
|
||||
v-model="field.organizers"
|
||||
:model-value="field.organizers"
|
||||
:selector-type="Organizer.Household"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
variant="underlined"
|
||||
@update:model-value="setFieldOrganizers(field, index, $event)"
|
||||
@update:model-value="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
</v-col>
|
||||
<!-- right parenthesis -->
|
||||
@@ -297,7 +297,7 @@
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
@@ -307,344 +307,365 @@ 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";
|
||||
|
||||
const props = defineProps({
|
||||
fieldDefs: {
|
||||
type: Array as () => FieldDefinition[],
|
||||
required: true,
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
VueDraggable,
|
||||
RecipeOrganizerSelector,
|
||||
},
|
||||
initialQueryFilter: {
|
||||
type: Object as () => QueryFilterJSON | null,
|
||||
default: null,
|
||||
props: {
|
||||
fieldDefs: {
|
||||
type: Array as () => FieldDefinition[],
|
||||
required: true,
|
||||
},
|
||||
initialQueryFilter: {
|
||||
type: Object as () => QueryFilterJSON | null,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
emits: ["input", "inputJSON"],
|
||||
setup(props, context) {
|
||||
const { household } = useHouseholdSelf();
|
||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "input", value: string | undefined): void;
|
||||
(event: "inputJSON", value: QueryFilterJSON | undefined): void;
|
||||
}>();
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
const { household } = useHouseholdSelf();
|
||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||
const state = reactive({
|
||||
showAdvanced: false,
|
||||
qfValid: false,
|
||||
datePickers: [] as boolean[],
|
||||
drag: false,
|
||||
});
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
showAdvanced: false,
|
||||
qfValid: false,
|
||||
datePickers: [] as boolean[],
|
||||
drag: false,
|
||||
});
|
||||
const { showAdvanced, datePickers, drag } = toRefs(state);
|
||||
|
||||
const storeMap = {
|
||||
[Organizer.Category]: useCategoryStore(),
|
||||
[Organizer.Tag]: useTagStore(),
|
||||
[Organizer.Tool]: useToolStore(),
|
||||
[Organizer.Food]: useFoodStore(),
|
||||
[Organizer.Household]: useHouseholdStore(),
|
||||
};
|
||||
|
||||
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[]>([]);
|
||||
|
||||
const uid = ref(1); // init uid to pass to fields
|
||||
function useUid() {
|
||||
return uid.value++;
|
||||
}
|
||||
function addField(field: FieldDefinition) {
|
||||
fields.value.push({
|
||||
...getFieldFromFieldDef(field),
|
||||
id: useUid(),
|
||||
});
|
||||
state.datePickers.push(false);
|
||||
}
|
||||
|
||||
function setField(index: number, fieldLabel: string) {
|
||||
state.datePickers[index] = false;
|
||||
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel);
|
||||
if (!fieldDef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
||||
const updatedField = { ...fields.value[index], ...fieldDef };
|
||||
|
||||
// we have to set this explicitly since it might be undefined
|
||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||
|
||||
fields.value[index] = {
|
||||
...getFieldFromFieldDef(updatedField, resetValue),
|
||||
id: fields.value[index].id, // keep the id
|
||||
};
|
||||
}
|
||||
|
||||
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||
fields.value[index].leftParenthesis = value;
|
||||
}
|
||||
|
||||
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||
fields.value[index].rightParenthesis = value;
|
||||
}
|
||||
|
||||
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) {
|
||||
fields.value[index].relationalOperatorValue = relOps.value[value];
|
||||
}
|
||||
|
||||
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[]) {
|
||||
fields.value[index].values = 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) {
|
||||
fields.value.splice(index, 1);
|
||||
state.datePickers.splice(index, 1);
|
||||
}
|
||||
|
||||
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
||||
/* newFields.forEach((field, index) => {
|
||||
const updatedField = getFieldFromFieldDef(field);
|
||||
fields.value[index] = updatedField; // recursive!!!
|
||||
}); */
|
||||
|
||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||
if (qf) {
|
||||
console.debug(`Set query filter: ${qf}`);
|
||||
}
|
||||
state.qfValid = !!qf;
|
||||
|
||||
emit("input", qf || undefined);
|
||||
emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
|
||||
}, 500);
|
||||
|
||||
watch(fields, fieldsUpdater, { deep: true });
|
||||
|
||||
async function hydrateOrganizers(field: FieldWithId, _index: number) {
|
||||
if (!field.values?.length || !isOrganizerType(field.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { store, actions } = storeMap[field.type];
|
||||
if (!store.value.length) {
|
||||
await actions.refresh();
|
||||
}
|
||||
|
||||
const organizers = field.values.map((value) => {
|
||||
const organizer = store.value.find(item => item?.id?.toString() === value);
|
||||
if (!organizer) {
|
||||
console.error(`Could not find organizer with id ${value}`);
|
||||
return undefined;
|
||||
}
|
||||
return organizer;
|
||||
});
|
||||
|
||||
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
|
||||
return field;
|
||||
}
|
||||
|
||||
function initFieldsError(error = "") {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
fields.value = [];
|
||||
if (props.fieldDefs.length) {
|
||||
addField(props.fieldDefs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeFields() {
|
||||
if (!props.initialQueryFilter?.parts?.length) {
|
||||
return initFieldsError();
|
||||
}
|
||||
|
||||
const initFields: FieldWithId[] = [];
|
||||
let error = false;
|
||||
|
||||
for (const [index, part] of props.initialQueryFilter.parts.entries()) {
|
||||
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.name === part.attributeName);
|
||||
if (!fieldDef) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
|
||||
}
|
||||
|
||||
const field: FieldWithId = {
|
||||
...getFieldFromFieldDef(fieldDef),
|
||||
id: useUid(),
|
||||
};
|
||||
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
||||
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
||||
field.logicalOperator = part.logicalOperator
|
||||
? logOps.value[part.logicalOperator]
|
||||
: field.logicalOperator;
|
||||
field.relationalOperatorValue = part.relationalOperator
|
||||
? relOps.value[part.relationalOperator]
|
||||
: field.relationalOperatorValue;
|
||||
|
||||
if (field.leftParenthesis || field.rightParenthesis) {
|
||||
state.showAdvanced = true;
|
||||
}
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (typeof part.value === "string") {
|
||||
field.values = part.value ? [part.value] : [];
|
||||
}
|
||||
else {
|
||||
field.values = part.value || [];
|
||||
}
|
||||
|
||||
if (isOrganizerType(field.type)) {
|
||||
await hydrateOrganizers(field, index);
|
||||
}
|
||||
}
|
||||
else if (field.type === "boolean") {
|
||||
const boolString = part.value || "false";
|
||||
field.value = (
|
||||
boolString[0].toLowerCase() === "t"
|
||||
|| boolString[0].toLowerCase() === "y"
|
||||
|| boolString[0] === "1"
|
||||
);
|
||||
}
|
||||
else if (field.type === "number") {
|
||||
field.value = Number(part.value as string || "0");
|
||||
if (isNaN(field.value)) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
|
||||
}
|
||||
}
|
||||
else if (field.type === "date") {
|
||||
field.value = part.value as string || "";
|
||||
const date = new Date(field.value);
|
||||
if (isNaN(date.getTime())) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
field.value = part.value as string || "";
|
||||
}
|
||||
|
||||
initFields.push(field);
|
||||
}
|
||||
|
||||
if (initFields.length && !error) {
|
||||
fields.value = initFields;
|
||||
}
|
||||
else {
|
||||
initFieldsError();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await initializeFields();
|
||||
}
|
||||
catch (error) {
|
||||
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
|
||||
}
|
||||
});
|
||||
|
||||
function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
const parts = fields.value.map((field) => {
|
||||
const part: QueryFilterJSONPart = {
|
||||
attributeName: field.name,
|
||||
leftParenthesis: field.leftParenthesis,
|
||||
rightParenthesis: field.rightParenthesis,
|
||||
logicalOperator: field.logicalOperator?.value,
|
||||
relationalOperator: field.relationalOperatorValue?.value,
|
||||
const storeMap = {
|
||||
[Organizer.Category]: useCategoryStore(),
|
||||
[Organizer.Tag]: useTagStore(),
|
||||
[Organizer.Tool]: useToolStore(),
|
||||
[Organizer.Food]: useFoodStore(),
|
||||
[Organizer.Household]: useHouseholdStore(),
|
||||
};
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
part.value = field.values.map(value => value.toString());
|
||||
}
|
||||
else if (field.type === "boolean") {
|
||||
part.value = field.value ? "true" : "false";
|
||||
}
|
||||
else {
|
||||
part.value = (field.value || "").toString();
|
||||
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;
|
||||
}
|
||||
|
||||
return part;
|
||||
});
|
||||
// add id to fields to prevent reactivity issues
|
||||
type FieldWithId = Field & { id: number };
|
||||
const fields = ref<FieldWithId[]>([]);
|
||||
|
||||
const qfJSON = { parts } as QueryFilterJSON;
|
||||
console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
|
||||
return qfJSON;
|
||||
}
|
||||
const uid = ref(1); // init uid to pass to fields
|
||||
function useUid() {
|
||||
return uid.value++;
|
||||
}
|
||||
function addField(field: FieldDefinition) {
|
||||
fields.value.push({
|
||||
...getFieldFromFieldDef(field),
|
||||
id: useUid(),
|
||||
});
|
||||
state.datePickers.push(false);
|
||||
};
|
||||
|
||||
const config = computed(() => {
|
||||
const baseColMaxWidth = 55;
|
||||
return {
|
||||
col: {
|
||||
class: "d-flex justify-center align-end field-col pa-1",
|
||||
},
|
||||
select: {
|
||||
textClass: "d-flex justify-center text-center",
|
||||
},
|
||||
items: {
|
||||
icon: {
|
||||
cols: 1,
|
||||
style: "width: fit-content;",
|
||||
},
|
||||
leftParens: {
|
||||
cols: state.showAdvanced ? 1 : 0,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||
},
|
||||
logicalOperator: {
|
||||
cols: 1,
|
||||
style: `min-width: ${baseColMaxWidth}px;`,
|
||||
},
|
||||
fieldName: {
|
||||
cols: state.showAdvanced ? 2 : 3,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||
},
|
||||
relationalOperator: {
|
||||
cols: 2,
|
||||
style: `min-width: ${baseColMaxWidth * 2}px;`,
|
||||
},
|
||||
fieldValue: {
|
||||
cols: state.showAdvanced ? 3 : 4,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||
},
|
||||
rightParens: {
|
||||
cols: state.showAdvanced ? 1 : 0,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||
},
|
||||
fieldActions: {
|
||||
cols: 1,
|
||||
style: `min-width: ${baseColMaxWidth}px;`,
|
||||
},
|
||||
},
|
||||
};
|
||||
function setField(index: number, fieldLabel: string) {
|
||||
state.datePickers[index] = false;
|
||||
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.label === fieldLabel);
|
||||
if (!fieldDef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
||||
const updatedField = { ...fields.value[index], ...fieldDef };
|
||||
|
||||
// we have to set this explicitly since it might be undefined
|
||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||
|
||||
fields.value[index] = {
|
||||
...getFieldFromFieldDef(updatedField, resetValue),
|
||||
id: fields.value[index].id, // keep the id
|
||||
};
|
||||
}
|
||||
|
||||
function setLeftParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||
fields.value[index].leftParenthesis = value;
|
||||
}
|
||||
|
||||
function setRightParenthesisValue(field: FieldWithId, index: number, value: string) {
|
||||
fields.value[index].rightParenthesis = value;
|
||||
}
|
||||
|
||||
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) {
|
||||
fields.value[index].relationalOperatorValue = relOps.value[value];
|
||||
}
|
||||
|
||||
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[]) {
|
||||
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 removeField(index: number) {
|
||||
fields.value.splice(index, 1);
|
||||
state.datePickers.splice(index, 1);
|
||||
};
|
||||
|
||||
const fieldsUpdater = useDebounceFn((/* newFields: typeof fields.value */) => {
|
||||
/* newFields.forEach((field, index) => {
|
||||
const updatedField = getFieldFromFieldDef(field);
|
||||
fields.value[index] = updatedField; // recursive!!!
|
||||
}); */
|
||||
|
||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||
if (qf) {
|
||||
console.debug(`Set query filter: ${qf}`);
|
||||
}
|
||||
state.qfValid = !!qf;
|
||||
|
||||
context.emit("input", qf || undefined);
|
||||
context.emit("inputJSON", qf ? buildQueryFilterJSON() : undefined);
|
||||
}, 500);
|
||||
|
||||
watch(fields, fieldsUpdater, { deep: true });
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
const organizers = field.values.map((value) => {
|
||||
const organizer = store.value.find(item => item?.id?.toString() === value);
|
||||
if (!organizer) {
|
||||
console.error(`Could not find organizer with id ${value}`);
|
||||
return undefined;
|
||||
}
|
||||
return organizer;
|
||||
});
|
||||
field.organizers = organizers.filter(organizer => organizer !== undefined) as OrganizerBase[];
|
||||
setOrganizerValues(field, index, field.organizers);
|
||||
}
|
||||
|
||||
function initFieldsError(error = "") {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
fields.value = [];
|
||||
if (props.fieldDefs.length) {
|
||||
addField(props.fieldDefs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFields() {
|
||||
if (!props.initialQueryFilter?.parts?.length) {
|
||||
return initFieldsError();
|
||||
};
|
||||
|
||||
const initFields: FieldWithId[] = [];
|
||||
let error = false;
|
||||
props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => {
|
||||
const fieldDef = props.fieldDefs.find(fieldDef => fieldDef.name === part.attributeName);
|
||||
if (!fieldDef) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
|
||||
}
|
||||
|
||||
const field: FieldWithId = {
|
||||
...getFieldFromFieldDef(fieldDef),
|
||||
id: useUid(),
|
||||
};
|
||||
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
||||
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
||||
field.logicalOperator = part.logicalOperator
|
||||
? logOps.value[part.logicalOperator]
|
||||
: field.logicalOperator;
|
||||
field.relationalOperatorValue = part.relationalOperator
|
||||
? relOps.value[part.relationalOperator]
|
||||
: field.relationalOperatorValue;
|
||||
|
||||
if (field.leftParenthesis || field.rightParenthesis) {
|
||||
state.showAdvanced = true;
|
||||
}
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (typeof part.value === "string") {
|
||||
field.values = part.value ? [part.value] : [];
|
||||
}
|
||||
else {
|
||||
field.values = part.value || [];
|
||||
}
|
||||
|
||||
if (isOrganizerType(field.type)) {
|
||||
hydrateOrganizers(field, index);
|
||||
}
|
||||
}
|
||||
else if (field.type === "boolean") {
|
||||
const boolString = part.value || "false";
|
||||
field.value = (
|
||||
boolString[0].toLowerCase() === "t"
|
||||
|| boolString[0].toLowerCase() === "y"
|
||||
|| boolString[0] === "1"
|
||||
);
|
||||
}
|
||||
else if (field.type === "number") {
|
||||
field.value = Number(part.value as string || "0");
|
||||
if (isNaN(field.value)) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
|
||||
}
|
||||
}
|
||||
else if (field.type === "date") {
|
||||
field.value = part.value as string || "";
|
||||
const date = new Date(field.value);
|
||||
if (isNaN(date.getTime())) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
field.value = part.value as string || "";
|
||||
}
|
||||
|
||||
initFields.push(field);
|
||||
});
|
||||
|
||||
if (initFields.length && !error) {
|
||||
fields.value = initFields;
|
||||
}
|
||||
else {
|
||||
initFieldsError();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
initializeFields();
|
||||
}
|
||||
catch (error) {
|
||||
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
|
||||
}
|
||||
|
||||
function buildQueryFilterJSON(): QueryFilterJSON {
|
||||
const parts = fields.value.map((field) => {
|
||||
const part: QueryFilterJSONPart = {
|
||||
attributeName: field.name,
|
||||
leftParenthesis: field.leftParenthesis,
|
||||
rightParenthesis: field.rightParenthesis,
|
||||
logicalOperator: field.logicalOperator?.value,
|
||||
relationalOperator: field.relationalOperatorValue?.value,
|
||||
};
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
part.value = field.values.map(value => value.toString());
|
||||
}
|
||||
else if (field.type === "boolean") {
|
||||
part.value = field.value ? "true" : "false";
|
||||
}
|
||||
else {
|
||||
part.value = (field.value || "").toString();
|
||||
}
|
||||
|
||||
return part;
|
||||
});
|
||||
|
||||
const qfJSON = { parts } as QueryFilterJSON;
|
||||
console.debug(`Built query filter JSON: ${JSON.stringify(qfJSON)}`);
|
||||
return qfJSON;
|
||||
}
|
||||
|
||||
const config = computed(() => {
|
||||
const baseColMaxWidth = 55;
|
||||
return {
|
||||
col: {
|
||||
class: "d-flex justify-center align-end field-col pa-1",
|
||||
},
|
||||
select: {
|
||||
textClass: "d-flex justify-center text-center",
|
||||
},
|
||||
items: {
|
||||
icon: {
|
||||
cols: 1,
|
||||
style: "width: fit-content;",
|
||||
},
|
||||
leftParens: {
|
||||
cols: state.showAdvanced ? 1 : 0,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||
},
|
||||
logicalOperator: {
|
||||
cols: 1,
|
||||
style: `min-width: ${baseColMaxWidth}px;`,
|
||||
},
|
||||
fieldName: {
|
||||
cols: state.showAdvanced ? 2 : 3,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||
},
|
||||
relationalOperator: {
|
||||
cols: 2,
|
||||
style: `min-width: ${baseColMaxWidth * 2}px;`,
|
||||
},
|
||||
fieldValue: {
|
||||
cols: state.showAdvanced ? 3 : 4,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||
},
|
||||
rightParens: {
|
||||
cols: state.showAdvanced ? 1 : 0,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||
},
|
||||
fieldActions: {
|
||||
cols: 1,
|
||||
style: `min-width: ${baseColMaxWidth}px;`,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
Organizer,
|
||||
...toRefs(state),
|
||||
logOps,
|
||||
relOps,
|
||||
config,
|
||||
firstDayOfWeek,
|
||||
onDragEnd,
|
||||
// Fields
|
||||
fields,
|
||||
addField,
|
||||
setField,
|
||||
setLeftParenthesisValue,
|
||||
setRightParenthesisValue,
|
||||
setLogicalOperatorValue,
|
||||
setRelationalOperatorValue,
|
||||
setFieldValue,
|
||||
setFieldValues,
|
||||
setOrganizerValues,
|
||||
removeField,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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" location="bottom" color="info">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-tooltip v-if="canEdit" bottom color="info">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon
|
||||
variant="flat"
|
||||
@@ -26,7 +26,7 @@
|
||||
size="small"
|
||||
color="info"
|
||||
class="ml-1"
|
||||
v-bind="tooltipProps"
|
||||
v-bind="props"
|
||||
@click="$emit('edit', true)"
|
||||
>
|
||||
<v-icon size="x-large">
|
||||
@@ -86,7 +86,7 @@
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
|
||||
@@ -97,75 +97,103 @@ const DELETE_EVENT = "delete";
|
||||
const CLOSE_EVENT = "close";
|
||||
const JSON_EVENT = "json";
|
||||
|
||||
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,
|
||||
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);
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const editorButtons = [
|
||||
{
|
||||
text: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
event: DELETE_EVENT,
|
||||
color: "error",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.json"),
|
||||
icon: $globals.icons.codeBraces,
|
||||
event: JSON_EVENT,
|
||||
color: "accent",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.close"),
|
||||
icon: $globals.icons.close,
|
||||
event: CLOSE_EVENT,
|
||||
color: "",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.save"),
|
||||
icon: $globals.icons.save,
|
||||
event: SAVE_EVENT,
|
||||
color: "success",
|
||||
},
|
||||
];
|
||||
|
||||
function emitHandler(event: string) {
|
||||
switch (event) {
|
||||
case CLOSE_EVENT:
|
||||
context.emit(CLOSE_EVENT);
|
||||
context.emit("input", false);
|
||||
break;
|
||||
case DELETE_EVENT:
|
||||
deleteDialog.value = true;
|
||||
break;
|
||||
default:
|
||||
context.emit(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function emitDelete() {
|
||||
context.emit(DELETE_EVENT);
|
||||
context.emit("input", false);
|
||||
}
|
||||
|
||||
return {
|
||||
deleteDialog,
|
||||
editorButtons,
|
||||
emitHandler,
|
||||
emitDelete,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
event: DELETE_EVENT,
|
||||
color: "error",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.json"),
|
||||
icon: $globals.icons.codeBraces,
|
||||
event: JSON_EVENT,
|
||||
color: "accent",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.close"),
|
||||
icon: $globals.icons.close,
|
||||
event: CLOSE_EVENT,
|
||||
color: "",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.save"),
|
||||
icon: $globals.icons.save,
|
||||
event: SAVE_EVENT,
|
||||
color: "success",
|
||||
},
|
||||
];
|
||||
|
||||
function emitHandler(event: string) {
|
||||
switch (event) {
|
||||
case CLOSE_EVENT:
|
||||
emit("close");
|
||||
emit("input", false);
|
||||
break;
|
||||
case DELETE_EVENT:
|
||||
deleteDialog.value = true;
|
||||
break;
|
||||
default:
|
||||
emit(event as any);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function emitDelete() {
|
||||
emit("delete");
|
||||
emit("input", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="ma-auto">
|
||||
<v-tooltip location="bottom">
|
||||
<v-tooltip bottom>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<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: hoverProps }"
|
||||
v-slot="{ isHovering, props }"
|
||||
:open-delay="50"
|
||||
>
|
||||
<v-card
|
||||
v-bind="hoverProps"
|
||||
v-bind="props"
|
||||
:class="{ 'on-hover': isHovering }"
|
||||
:style="{ cursor }"
|
||||
:elevation="isHovering ? 12 : 2"
|
||||
@@ -57,7 +58,7 @@
|
||||
|
||||
<RecipeRating
|
||||
class="ml-n2"
|
||||
:model-value="rating"
|
||||
:value="rating"
|
||||
:recipe-id="recipeId"
|
||||
:slug="slug"
|
||||
small
|
||||
@@ -98,9 +99,10 @@
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</div>
|
||||
</v-lazy>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeChips from "./RecipeChips.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
@@ -108,41 +110,69 @@ import RecipeCardImage from "./RecipeCardImage.vue";
|
||||
import RecipeRating from "./RecipeRating.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
|
||||
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,
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -167,7 +197,6 @@ const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 8;
|
||||
line-clamp: 8;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,60 +28,84 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
<script lang="ts">
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
|
||||
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%",
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
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(
|
||||
() => props.recipeId,
|
||||
() => {
|
||||
fallBackImage.value = false;
|
||||
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();
|
||||
|
||||
function getImage(recipeId: string) {
|
||||
switch (imageSize.value) {
|
||||
case "tiny":
|
||||
return recipeTinyImage(recipeId, props.imageVersion);
|
||||
case "small":
|
||||
return recipeSmallImage(recipeId, props.imageVersion);
|
||||
case "large":
|
||||
return recipeImage(recipeId, props.imageVersion);
|
||||
}
|
||||
}
|
||||
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(
|
||||
() => props.recipeId,
|
||||
() => {
|
||||
fallBackImage.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
function getImage(recipeId: string) {
|
||||
switch (imageSize.value) {
|
||||
case "tiny":
|
||||
return recipeTinyImage(recipeId, props.imageVersion);
|
||||
case "small":
|
||||
return recipeSmallImage(recipeId, props.imageVersion);
|
||||
case "large":
|
||||
return recipeImage(recipeId, props.imageVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
api,
|
||||
fallBackImage,
|
||||
imageSize,
|
||||
getImage,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
<v-expand-transition>
|
||||
<v-card
|
||||
:ripple="false"
|
||||
:class="[
|
||||
isFlat ? 'mx-auto flat' : 'mx-auto',
|
||||
{ 'disable-highlight': disableHighlight },
|
||||
]"
|
||||
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
|
||||
:style="{ cursor }"
|
||||
hover
|
||||
height="100%"
|
||||
@@ -126,7 +123,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||
@@ -134,44 +131,78 @@ import RecipeRating from "./RecipeRating.vue";
|
||||
import RecipeChips from "./RecipeChips.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
|
||||
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,
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
@@ -210,8 +241,4 @@ const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
||||
box-shadow: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.disable-highlight :deep(.v-card__overlay) {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
offset-y
|
||||
start
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
:icon="$vuetify.display.xs"
|
||||
v-bind="activatorProps"
|
||||
v-bind="props"
|
||||
:loading="sortLoading"
|
||||
>
|
||||
<v-icon :start="!$vuetify.display.xs">
|
||||
@@ -123,6 +123,7 @@
|
||||
:image="recipe.image!"
|
||||
:tags="recipe.tags!"
|
||||
:recipe-id="recipe.id!"
|
||||
@click="handleRecipeNavigation"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -147,6 +148,7 @@
|
||||
:image="recipe.image!"
|
||||
:tags="recipe.tags!"
|
||||
:recipe-id="recipe.id!"
|
||||
@selected="handleRecipeNavigation"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -162,7 +164,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { useThrottleFn } from "@vueuse/core";
|
||||
import RecipeCard from "./RecipeCard.vue";
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
@@ -171,247 +173,346 @@ import { useLazyRecipes } from "~/composables/recipes";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import { useUserSortPreferences } from "~/composables/use-users/preferences";
|
||||
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
import { useRecipeListState } from "~/composables/recipe-page/use-recipe-list-state";
|
||||
|
||||
const REPLACE_RECIPES_EVENT = "replaceRecipes";
|
||||
const APPEND_RECIPES_EVENT = "appendRecipes";
|
||||
|
||||
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 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(() => {
|
||||
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
|
||||
});
|
||||
|
||||
const displayTitleIcon = computed(() => {
|
||||
return props.icon || $globals.icons.tags;
|
||||
});
|
||||
|
||||
const sortLoading = ref(false);
|
||||
|
||||
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 { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const router = useRouter();
|
||||
|
||||
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)
|
||||
|
||||
// const orderBy = props.query?.orderBy || preferences.value.orderBy;
|
||||
// const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
|
||||
|
||||
// if (props.query.queryFilter && orderByFilter) {
|
||||
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
|
||||
// } else if (props.query.queryFilter) {
|
||||
// return props.query.queryFilter;
|
||||
// } else {
|
||||
// return orderByFilter;
|
||||
// }
|
||||
});
|
||||
|
||||
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(
|
||||
page.value,
|
||||
perPage * pageCount,
|
||||
props.query?.orderBy || preferences.value.orderBy,
|
||||
orderDir,
|
||||
orderByNullPosition,
|
||||
props.query,
|
||||
// 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 () => {
|
||||
await initRecipes();
|
||||
ready.value = true;
|
||||
});
|
||||
|
||||
let lastQuery: string | undefined = JSON.stringify(props.query);
|
||||
watch(
|
||||
() => props.query,
|
||||
async (newValue: RecipeSearchQuery | undefined | null) => {
|
||||
const newValueString = JSON.stringify(newValue);
|
||||
if (lastQuery !== newValueString) {
|
||||
lastQuery = newValueString;
|
||||
ready.value = false;
|
||||
await initRecipes();
|
||||
ready.value = true;
|
||||
}
|
||||
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();
|
||||
|
||||
async function initRecipes() {
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
const EVENTS = {
|
||||
az: "az",
|
||||
rating: "rating",
|
||||
created: "created",
|
||||
updated: "updated",
|
||||
lastMade: "lastMade",
|
||||
shuffle: "shuffle",
|
||||
};
|
||||
|
||||
// we double-up the first call to avoid a bug with large screens that render
|
||||
// the entire first page without scrolling, preventing additional loading
|
||||
const newRecipes = await fetchRecipes(page.value + 1);
|
||||
if (newRecipes.length < perPage) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
|
||||
});
|
||||
|
||||
// since we doubled the first call, we also need to advance the page
|
||||
page.value = page.value + 1;
|
||||
const displayTitleIcon = computed(() => {
|
||||
return props.icon || $globals.icons.tags;
|
||||
});
|
||||
|
||||
emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
const state = reactive({
|
||||
sortLoading: false,
|
||||
});
|
||||
|
||||
const infiniteScroll = useThrottleFn(async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
loading.value = true;
|
||||
page.value = page.value + 1;
|
||||
const recipeListState = useRecipeListState(props.query);
|
||||
|
||||
const newRecipes = await fetchRecipes();
|
||||
if (newRecipes.length < perPage) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
if (newRecipes.length) {
|
||||
emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
const page = ref(recipeListState.state.page || 1);
|
||||
const perPage = 32;
|
||||
const hasMore = ref(recipeListState.state.hasMore);
|
||||
const ready = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const router = useRouter();
|
||||
|
||||
async function sortRecipes(sortType: string) {
|
||||
if (sortLoading.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
const queryFilter = computed(() => {
|
||||
return props.query.queryFilter || null;
|
||||
|
||||
function setter(
|
||||
orderBy: string,
|
||||
ascIcon: string,
|
||||
descIcon: string,
|
||||
defaultOrderDirection = "asc",
|
||||
filterNull = false,
|
||||
) {
|
||||
if (preferences.value.orderBy !== orderBy) {
|
||||
preferences.value.orderBy = orderBy;
|
||||
preferences.value.orderDirection = defaultOrderDirection;
|
||||
preferences.value.filterNull = filterNull;
|
||||
// TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
|
||||
|
||||
// const orderBy = props.query?.orderBy || preferences.value.orderBy;
|
||||
// const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
|
||||
|
||||
// if (props.query.queryFilter && orderByFilter) {
|
||||
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
|
||||
// } else if (props.query.queryFilter) {
|
||||
// return props.query.queryFilter;
|
||||
// } else {
|
||||
// return orderByFilter;
|
||||
// }
|
||||
});
|
||||
|
||||
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(
|
||||
page.value,
|
||||
perPage * pageCount,
|
||||
props.query?.orderBy || preferences.value.orderBy,
|
||||
orderDir,
|
||||
orderByNullPosition,
|
||||
props.query,
|
||||
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
|
||||
queryFilter.value,
|
||||
);
|
||||
}
|
||||
else {
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
|
||||
// Save scroll position
|
||||
const throttledScrollSave = useThrottleFn(() => {
|
||||
recipeListState.saveScrollPosition();
|
||||
}, 1000);
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener("scroll", throttledScrollSave);
|
||||
|
||||
// cached state with scroll position
|
||||
if (recipeListState.hasValidState() && recipeListState.isQueryMatch(props.query)) {
|
||||
// Restore from cached state
|
||||
page.value = recipeListState.state.page;
|
||||
hasMore.value = recipeListState.state.hasMore;
|
||||
ready.value = recipeListState.state.ready;
|
||||
|
||||
// Emit cached recipes
|
||||
context.emit(REPLACE_RECIPES_EVENT, recipeListState.state.recipes);
|
||||
|
||||
// Restore scroll position after recipes are rendered
|
||||
nextTick(() => {
|
||||
recipeListState.restoreScrollPosition();
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Initialize fresh recipes
|
||||
await initRecipes();
|
||||
}
|
||||
ready.value = true;
|
||||
});
|
||||
|
||||
let lastQuery: string | undefined = JSON.stringify(props.query);
|
||||
watch(
|
||||
() => props.query,
|
||||
async (newValue: RecipeSearchQuery | undefined) => {
|
||||
const newValueString = JSON.stringify(newValue);
|
||||
if (lastQuery !== newValueString) {
|
||||
lastQuery = newValueString;
|
||||
|
||||
// Save scroll position before query change
|
||||
recipeListState.saveScrollPosition();
|
||||
|
||||
ready.value = false;
|
||||
await initRecipes();
|
||||
ready.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function initRecipes() {
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
|
||||
// we double-up the first call to avoid a bug with large screens that render
|
||||
// the entire first page without scrolling, preventing additional loading
|
||||
const newRecipes = await fetchRecipes(page.value + 1);
|
||||
if (newRecipes.length < perPage) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
|
||||
// since we doubled the first call, we also need to advance the page
|
||||
page.value = page.value + 1;
|
||||
|
||||
// Save state after fetching recipes
|
||||
recipeListState.saveState({
|
||||
recipes: newRecipes,
|
||||
page: page.value,
|
||||
hasMore: hasMore.value,
|
||||
ready: true,
|
||||
});
|
||||
|
||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
|
||||
}
|
||||
|
||||
switch (sortType) {
|
||||
case EVENTS.az:
|
||||
setter(
|
||||
"name",
|
||||
$globals.icons.sortAlphabeticalAscending,
|
||||
$globals.icons.sortAlphabeticalDescending,
|
||||
"asc",
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.rating:
|
||||
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending, "desc", true);
|
||||
break;
|
||||
case EVENTS.created:
|
||||
setter(
|
||||
"created_at",
|
||||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.updated:
|
||||
setter("updated_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending, "desc", false);
|
||||
break;
|
||||
case EVENTS.lastMade:
|
||||
setter(
|
||||
"last_made",
|
||||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
true,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown Event", sortType);
|
||||
return;
|
||||
}
|
||||
const infiniteScroll = useThrottleFn(async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// reset pagination
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
loading.value = true;
|
||||
page.value = page.value + 1;
|
||||
|
||||
sortLoading.value = true;
|
||||
loading.value = true;
|
||||
const newRecipes = await fetchRecipes();
|
||||
if (newRecipes.length < perPage) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
if (newRecipes.length) {
|
||||
// Update cached state with new recipes
|
||||
const allRecipes = [...(recipeListState.state.recipes || []), ...newRecipes] as Recipe[];
|
||||
recipeListState.saveState({
|
||||
recipes: allRecipes,
|
||||
page: page.value,
|
||||
hasMore: hasMore.value,
|
||||
});
|
||||
|
||||
// fetch new recipes
|
||||
const newRecipes = await fetchRecipes();
|
||||
emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
context.emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
|
||||
sortLoading.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
|
||||
async function navigateRandom() {
|
||||
const recipe = await getRandom(props.query, queryFilter.value);
|
||||
if (!recipe?.slug) {
|
||||
return;
|
||||
}
|
||||
async function sortRecipes(sortType: string) {
|
||||
if (state.sortLoading || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/g/${groupSlug.value}/r/${recipe.slug}`);
|
||||
}
|
||||
function setter(
|
||||
orderBy: string,
|
||||
ascIcon: string,
|
||||
descIcon: string,
|
||||
defaultOrderDirection = "asc",
|
||||
filterNull = false,
|
||||
) {
|
||||
if (preferences.value.orderBy !== orderBy) {
|
||||
preferences.value.orderBy = orderBy;
|
||||
preferences.value.orderDirection = defaultOrderDirection;
|
||||
preferences.value.filterNull = filterNull;
|
||||
}
|
||||
else {
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
}
|
||||
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
|
||||
}
|
||||
|
||||
function toggleMobileCards() {
|
||||
preferences.value.useMobileCards = !preferences.value.useMobileCards;
|
||||
}
|
||||
switch (sortType) {
|
||||
case EVENTS.az:
|
||||
setter(
|
||||
"name",
|
||||
$globals.icons.sortAlphabeticalAscending,
|
||||
$globals.icons.sortAlphabeticalDescending,
|
||||
"asc",
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.rating:
|
||||
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending, "desc", true);
|
||||
break;
|
||||
case EVENTS.created:
|
||||
setter(
|
||||
"created_at",
|
||||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.updated:
|
||||
setter("updated_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending, "desc", false);
|
||||
break;
|
||||
case EVENTS.lastMade:
|
||||
setter(
|
||||
"last_made",
|
||||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
true,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown Event", sortType);
|
||||
return;
|
||||
}
|
||||
|
||||
// reset pagination
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
|
||||
state.sortLoading = true;
|
||||
loading.value = true;
|
||||
|
||||
// fetch new recipes
|
||||
const newRecipes = await fetchRecipes();
|
||||
|
||||
// Update cached state
|
||||
recipeListState.saveState({
|
||||
recipes: newRecipes,
|
||||
page: page.value,
|
||||
hasMore: hasMore.value,
|
||||
ready: true,
|
||||
});
|
||||
|
||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
|
||||
state.sortLoading = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
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() {
|
||||
preferences.value.useMobileCards = !preferences.value.useMobileCards;
|
||||
}
|
||||
|
||||
// Save scroll position when component is unmounted or when navigating away
|
||||
onBeforeUnmount(() => {
|
||||
recipeListState.saveScrollPosition();
|
||||
window.removeEventListener("scroll", throttledScrollSave);
|
||||
});
|
||||
|
||||
// Save scroll position when navigating to recipe pages
|
||||
function handleRecipeNavigation() {
|
||||
recipeListState.saveScrollPosition();
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
displayTitleIcon,
|
||||
EVENTS,
|
||||
infiniteScroll,
|
||||
ready,
|
||||
loading,
|
||||
navigateRandom,
|
||||
preferences,
|
||||
sortRecipes,
|
||||
toggleMobileCards,
|
||||
useMobileCards,
|
||||
handleRecipeNavigation,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -23,38 +23,66 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
|
||||
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||
|
||||
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,
|
||||
});
|
||||
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();
|
||||
|
||||
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;
|
||||
}
|
||||
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 = "...") {
|
||||
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>
|
||||
|
||||
@@ -12,12 +12,7 @@
|
||||
@confirm="deleteRecipe()"
|
||||
>
|
||||
<v-card-text>
|
||||
<template v-if="isAdminAndNotOwner">
|
||||
{{ $t("recipe.admin-delete-confirmation") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t("recipe.delete-confirmation") }}
|
||||
</template>
|
||||
{{ $t("recipe.delete-confirmation") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<BaseDialog
|
||||
@@ -55,12 +50,12 @@
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-text-field
|
||||
v-model="newMealdateString"
|
||||
:label="$t('general.date')"
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="activatorProps"
|
||||
v-bind="props"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
@@ -100,7 +95,7 @@
|
||||
:open-on-hover="$vuetify.display.mdAndUp"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon
|
||||
:variant="fab ? 'flat' : undefined"
|
||||
@@ -108,7 +103,7 @@
|
||||
:size="fab ? 'small' : undefined"
|
||||
:color="fab ? 'info' : 'secondary'"
|
||||
:fab="fab"
|
||||
v-bind="activatorProps"
|
||||
v-bind="props"
|
||||
@click.prevent
|
||||
>
|
||||
<v-icon
|
||||
@@ -130,27 +125,32 @@
|
||||
</v-list-item>
|
||||
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
v-for="(action, index) in recipeActions"
|
||||
:key="index"
|
||||
@click="executeRecipeAction(action)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon color="undefined">
|
||||
{{ $globals.icons.linkVariantPlus }}
|
||||
</v-icon>
|
||||
<v-list-group @click.stop>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item-title v-bind="props">
|
||||
{{ $t("recipe.recipe-actions") }}
|
||||
</v-list-item-title>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ action.title }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list density="compact" class="ma-0 pa-0">
|
||||
<v-list-item
|
||||
v-for="(action, index) in recipeActions"
|
||||
:key="index"
|
||||
class="pl-6"
|
||||
@click="executeRecipeAction(action)"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ action.title }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-list-group>
|
||||
</div>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
|
||||
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
||||
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
||||
@@ -186,312 +186,343 @@ export interface ContextMenuItem {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
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,
|
||||
duplicate: false,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: true,
|
||||
printPreferences: true,
|
||||
share: true,
|
||||
recipeActions: true,
|
||||
}),
|
||||
appendItems: () => [],
|
||||
leadingItems: () => [],
|
||||
menuTop: true,
|
||||
fab: false,
|
||||
color: "primary",
|
||||
menuIcon: null,
|
||||
recipe: undefined,
|
||||
recipeScale: 1,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
[key: string]: any;
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
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 = 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 route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Context Menu Setup
|
||||
|
||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
edit: {
|
||||
title: i18n.t("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: undefined,
|
||||
event: "edit",
|
||||
isPublic: false,
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeDialogAddToShoppingList,
|
||||
RecipeDialogPrintPreferences,
|
||||
RecipeDialogShare,
|
||||
},
|
||||
delete: {
|
||||
title: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: undefined,
|
||||
event: "delete",
|
||||
isPublic: false,
|
||||
props: {
|
||||
useItems: {
|
||||
type: Object as () => ContextMenuIncludes,
|
||||
default: () => ({
|
||||
delete: true,
|
||||
edit: true,
|
||||
download: true,
|
||||
duplicate: false,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: true,
|
||||
printPreferences: true,
|
||||
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,
|
||||
},
|
||||
},
|
||||
download: {
|
||||
title: i18n.t("general.download"),
|
||||
icon: $globals.icons.download,
|
||||
color: undefined,
|
||||
event: "download",
|
||||
isPublic: false,
|
||||
},
|
||||
duplicate: {
|
||||
title: i18n.t("general.duplicate"),
|
||||
icon: $globals.icons.duplicate,
|
||||
color: undefined,
|
||||
event: "duplicate",
|
||||
isPublic: false,
|
||||
},
|
||||
mealplanner: {
|
||||
title: i18n.t("recipe.add-to-plan"),
|
||||
icon: $globals.icons.calendar,
|
||||
color: undefined,
|
||||
event: "mealplanner",
|
||||
isPublic: false,
|
||||
},
|
||||
shoppingList: {
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
isPublic: false,
|
||||
},
|
||||
print: {
|
||||
title: i18n.t("general.print"),
|
||||
icon: $globals.icons.printer,
|
||||
color: undefined,
|
||||
event: "print",
|
||||
isPublic: true,
|
||||
},
|
||||
printPreferences: {
|
||||
title: i18n.t("general.print-preferences"),
|
||||
icon: $globals.icons.printerSettings,
|
||||
color: undefined,
|
||||
event: "printPreferences",
|
||||
isPublic: true,
|
||||
},
|
||||
share: {
|
||||
title: i18n.t("general.share"),
|
||||
icon: $globals.icons.shareVariant,
|
||||
color: undefined,
|
||||
event: "share",
|
||||
isPublic: false,
|
||||
},
|
||||
};
|
||||
emits: ["delete"],
|
||||
setup(props, context) {
|
||||
const api = useUserApi();
|
||||
|
||||
// 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;
|
||||
|
||||
const item = defaultItems[key];
|
||||
if (item && (item.isPublic || isOwnGroup.value)) {
|
||||
menuItems.value.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
const { data } = await api.recipes.getOne(props.slug);
|
||||
if (data) {
|
||||
recipeRef.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const groupRecipeActionsStore = useGroupRecipeActions();
|
||||
|
||||
async function executeRecipeAction(action: GroupRecipeActionOut) {
|
||||
if (!props.recipe) return;
|
||||
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
|
||||
|
||||
if (action.actionType === "post") {
|
||||
if (!response?.error) {
|
||||
alert.success(i18n.t("events.message-sent"));
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
const { data } = await api.recipes.deleteOne(props.slug);
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}`);
|
||||
}
|
||||
emit("delete", props.slug);
|
||||
}
|
||||
|
||||
const download = useDownloader();
|
||||
|
||||
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() {
|
||||
const { response } = await api.mealplans.createOne({
|
||||
date: newMealdateString.value,
|
||||
entryType: newMealType.value,
|
||||
title: "",
|
||||
text: "",
|
||||
recipeId: props.recipeId,
|
||||
});
|
||||
|
||||
if (response?.status === 201) {
|
||||
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
|
||||
}
|
||||
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, 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> } = {
|
||||
delete: () => {
|
||||
recipeDeleteDialog.value = true;
|
||||
},
|
||||
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
|
||||
download: handleDownloadEvent,
|
||||
duplicate: () => {
|
||||
recipeDuplicateDialog.value = true;
|
||||
},
|
||||
mealplanner: () => {
|
||||
mealplannerDialog.value = true;
|
||||
},
|
||||
printPreferences: async () => {
|
||||
if (!recipeRef.value) {
|
||||
await refreshRecipe();
|
||||
}
|
||||
printPreferencesDialog.value = true;
|
||||
},
|
||||
shoppingList: () => {
|
||||
const promises: Promise<void>[] = [getShoppingLists()];
|
||||
if (!recipeRef.value) {
|
||||
promises.push(refreshRecipe());
|
||||
}
|
||||
|
||||
Promise.allSettled(promises).then(() => {
|
||||
shoppingListDialog.value = true;
|
||||
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(Date.now() - new Date().getTimezoneOffset() * 60000),
|
||||
newMealType: "dinner" as PlanEntryType,
|
||||
pickerMenu: false,
|
||||
});
|
||||
|
||||
const newMealdateString = computed(() => {
|
||||
return state.newMealdate.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
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 firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Context Menu Setup
|
||||
|
||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
edit: {
|
||||
title: i18n.t("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: undefined,
|
||||
event: "edit",
|
||||
isPublic: false,
|
||||
},
|
||||
delete: {
|
||||
title: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: undefined,
|
||||
event: "delete",
|
||||
isPublic: false,
|
||||
},
|
||||
download: {
|
||||
title: i18n.t("general.download"),
|
||||
icon: $globals.icons.download,
|
||||
color: undefined,
|
||||
event: "download",
|
||||
isPublic: false,
|
||||
},
|
||||
duplicate: {
|
||||
title: i18n.t("general.duplicate"),
|
||||
icon: $globals.icons.duplicate,
|
||||
color: undefined,
|
||||
event: "duplicate",
|
||||
isPublic: false,
|
||||
},
|
||||
mealplanner: {
|
||||
title: i18n.t("recipe.add-to-plan"),
|
||||
icon: $globals.icons.calendar,
|
||||
color: undefined,
|
||||
event: "mealplanner",
|
||||
isPublic: false,
|
||||
},
|
||||
shoppingList: {
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
isPublic: false,
|
||||
},
|
||||
print: {
|
||||
title: i18n.t("general.print"),
|
||||
icon: $globals.icons.printer,
|
||||
color: undefined,
|
||||
event: "print",
|
||||
isPublic: true,
|
||||
},
|
||||
printPreferences: {
|
||||
title: i18n.t("general.print-preferences"),
|
||||
icon: $globals.icons.printerSettings,
|
||||
color: undefined,
|
||||
event: "printPreferences",
|
||||
isPublic: true,
|
||||
},
|
||||
share: {
|
||||
title: i18n.t("general.share"),
|
||||
icon: $globals.icons.shareVariant,
|
||||
color: undefined,
|
||||
event: "share",
|
||||
isPublic: false,
|
||||
},
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||
if (data) {
|
||||
shoppingLists.value = data.items ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRecipe() {
|
||||
const { data } = await api.recipes.getOne(props.slug);
|
||||
if (data) {
|
||||
recipeRef.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const groupRecipeActionsStore = useGroupRecipeActions();
|
||||
|
||||
async function executeRecipeAction(action: GroupRecipeActionOut) {
|
||||
if (!props.recipe) return;
|
||||
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
|
||||
|
||||
if (action.actionType === "post") {
|
||||
if (!response?.error) {
|
||||
alert.success(i18n.t("events.message-sent"));
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
const { data } = await api.recipes.deleteOne(props.slug);
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}`);
|
||||
}
|
||||
context.emit("delete", props.slug);
|
||||
}
|
||||
|
||||
const download = useDownloader();
|
||||
|
||||
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() {
|
||||
const { response } = await api.mealplans.createOne({
|
||||
date: newMealdateString.value,
|
||||
entryType: state.newMealType,
|
||||
title: "",
|
||||
text: "",
|
||||
recipeId: props.recipeId,
|
||||
});
|
||||
|
||||
if (response?.status === 201) {
|
||||
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
|
||||
}
|
||||
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);
|
||||
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> } = {
|
||||
delete: () => {
|
||||
state.recipeDeleteDialog = true;
|
||||
},
|
||||
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
|
||||
download: handleDownloadEvent,
|
||||
duplicate: () => {
|
||||
state.recipeDuplicateDialog = true;
|
||||
},
|
||||
mealplanner: () => {
|
||||
state.mealplannerDialog = true;
|
||||
},
|
||||
printPreferences: async () => {
|
||||
if (!recipeRef.value) {
|
||||
await refreshRecipe();
|
||||
}
|
||||
state.printPreferencesDialog = true;
|
||||
},
|
||||
shoppingList: () => {
|
||||
const promises: Promise<void>[] = [getShoppingLists()];
|
||||
if (!recipeRef.value) {
|
||||
promises.push(refreshRecipe());
|
||||
}
|
||||
|
||||
Promise.allSettled(promises).then(() => {
|
||||
state.shoppingListDialog = true;
|
||||
});
|
||||
},
|
||||
share: () => {
|
||||
state.shareDialog = true;
|
||||
},
|
||||
};
|
||||
|
||||
function contextMenuEventHandler(eventKey: string) {
|
||||
const handler = eventHandlers[eventKey];
|
||||
|
||||
if (handler && typeof handler === "function") {
|
||||
handler();
|
||||
state.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
context.emit(eventKey);
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
const planTypeOptions = usePlanTypeOptions();
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
newMealdateString,
|
||||
recipeRef,
|
||||
recipeRefWithScale,
|
||||
executeRecipeAction,
|
||||
recipeActions: groupRecipeActionsStore.recipeActions,
|
||||
shoppingLists,
|
||||
duplicateRecipe,
|
||||
contextMenuEventHandler,
|
||||
deleteRecipe,
|
||||
addRecipeToPlan,
|
||||
icon,
|
||||
planTypeOptions,
|
||||
firstDayOfWeek,
|
||||
};
|
||||
},
|
||||
share: () => {
|
||||
shareDialog.value = true;
|
||||
},
|
||||
};
|
||||
|
||||
function contextMenuEventHandler(eventKey: string) {
|
||||
const handler = eventHandlers[eventKey];
|
||||
|
||||
if (handler && typeof handler === "function") {
|
||||
handler();
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
emit(eventKey);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const planTypeOptions = usePlanTypeOptions();
|
||||
const recipeActions = groupRecipeActionsStore.recipeActions;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { whenever } from "@vueuse/core";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
@@ -42,66 +42,86 @@ export interface GenericAlias {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: IngredientFood | IngredientUnit;
|
||||
}
|
||||
|
||||
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) {
|
||||
aliases.value.splice(index, 1);
|
||||
}
|
||||
|
||||
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
|
||||
function initAliases() {
|
||||
aliases.value = [...props.data.aliases || []];
|
||||
if (!aliases.value.length) {
|
||||
createAlias();
|
||||
}
|
||||
}
|
||||
|
||||
initAliases();
|
||||
whenever(
|
||||
() => dialog.value,
|
||||
() => {
|
||||
initAliases();
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
function saveAliases() {
|
||||
const seenAliasNames: string[] = [];
|
||||
const keepAliases: GenericAlias[] = [];
|
||||
aliases.value.forEach((alias) => {
|
||||
if (
|
||||
!alias.name
|
||||
|| alias.name === props.data.name
|
||||
|| alias.name === props.data.pluralName
|
||||
|| alias.name === props.data.abbreviation
|
||||
|| alias.name === props.data.pluralAbbreviation
|
||||
|| seenAliasNames.includes(alias.name)
|
||||
) {
|
||||
return;
|
||||
function createAlias() {
|
||||
aliases.value.push({
|
||||
name: "",
|
||||
});
|
||||
}
|
||||
|
||||
keepAliases.push(alias);
|
||||
seenAliasNames.push(alias.name);
|
||||
});
|
||||
function deleteAlias(index: number) {
|
||||
aliases.value.splice(index, 1);
|
||||
}
|
||||
|
||||
aliases.value = keepAliases;
|
||||
emit("submit", keepAliases);
|
||||
}
|
||||
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
|
||||
function initAliases() {
|
||||
aliases.value = [...props.data.aliases || []];
|
||||
if (!aliases.value.length) {
|
||||
createAlias();
|
||||
}
|
||||
}
|
||||
|
||||
initAliases();
|
||||
whenever(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initAliases();
|
||||
},
|
||||
);
|
||||
|
||||
function saveAliases() {
|
||||
const seenAliasNames: string[] = [];
|
||||
const keepAliases: GenericAlias[] = [];
|
||||
aliases.value.forEach((alias) => {
|
||||
if (
|
||||
!alias.name
|
||||
|| alias.name === props.data.name
|
||||
|| alias.name === props.data.pluralName
|
||||
|| alias.name === props.data.abbreviation
|
||||
|| alias.name === props.data.pluralAbbreviation
|
||||
|| seenAliasNames.includes(alias.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
keepAliases.push(alias);
|
||||
seenAliasNames.push(alias.name);
|
||||
});
|
||||
|
||||
aliases.value = keepAliases;
|
||||
context.emit("submit", keepAliases);
|
||||
}
|
||||
|
||||
return {
|
||||
aliases,
|
||||
createAlias,
|
||||
dialog,
|
||||
deleteAlias,
|
||||
saveAliases,
|
||||
validators,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
v-model="selected"
|
||||
item-key="id"
|
||||
show-select
|
||||
:sort-by="sortBy"
|
||||
:sort-by="[{ key: 'dateAdded', order: 'desc' }]"
|
||||
:headers="headers"
|
||||
:items="recipes"
|
||||
:items-per-page="15"
|
||||
class="elevation-0"
|
||||
:loading="loading"
|
||||
return-object
|
||||
>
|
||||
<template #[`item.name`]="{ item }">
|
||||
<a
|
||||
@@ -62,7 +61,7 @@
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import UserAvatar from "../User/UserAvatar.vue";
|
||||
import RecipeChip from "./RecipeChips.vue";
|
||||
import type { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
|
||||
@@ -70,6 +69,8 @@ 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;
|
||||
@@ -82,114 +83,136 @@ interface ShowHeaders {
|
||||
dateAdded: boolean;
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
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 {
|
||||
id: true,
|
||||
owner: false,
|
||||
tags: true,
|
||||
categories: true,
|
||||
recipeServings: true,
|
||||
recipeYieldQuantity: true,
|
||||
recipeYield: true,
|
||||
dateAdded: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
emits: ["click"],
|
||||
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),
|
||||
});
|
||||
|
||||
const headers = computed(() => {
|
||||
const hdrs: Array<{ title: string; value: string; align?: string; sortable?: boolean }> = [];
|
||||
|
||||
if (props.showHeaders.id) {
|
||||
hdrs.push({ title: i18n.t("general.id"), value: "id" });
|
||||
}
|
||||
if (props.showHeaders.owner) {
|
||||
hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
|
||||
}
|
||||
hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
|
||||
if (props.showHeaders.categories) {
|
||||
hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
|
||||
}
|
||||
|
||||
if (props.showHeaders.tags) {
|
||||
hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.tools) {
|
||||
hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeServings) {
|
||||
hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeYieldQuantity) {
|
||||
hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeYield) {
|
||||
hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.dateAdded) {
|
||||
hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
|
||||
}
|
||||
|
||||
return hdrs;
|
||||
});
|
||||
|
||||
function formatDate(date: string) {
|
||||
try {
|
||||
return i18n.d(Date.parse(date), "medium");
|
||||
}
|
||||
catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Group Members
|
||||
const api = useUserApi();
|
||||
const members = ref<UserSummary[]>([]);
|
||||
|
||||
async function refreshMembers() {
|
||||
const { data } = await api.groups.fetchMembers();
|
||||
if (data) {
|
||||
members.value = data.items;
|
||||
}
|
||||
}
|
||||
|
||||
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
|
||||
if (!groupSlug || !item.id) {
|
||||
return;
|
||||
}
|
||||
router.push(`/g/${groupSlug}?${itemType}=${item.id}`);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshMembers();
|
||||
});
|
||||
|
||||
function getMember(id: string) {
|
||||
if (members.value[0]) {
|
||||
return members.value.find(m => m.id === id)?.fullName;
|
||||
}
|
||||
|
||||
return i18n.t("general.none");
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
groupSlug,
|
||||
headers,
|
||||
formatDate,
|
||||
members,
|
||||
getMember,
|
||||
filterItems,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
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" });
|
||||
}
|
||||
if (props.showHeaders.owner) {
|
||||
hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
|
||||
}
|
||||
hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
|
||||
if (props.showHeaders.categories) {
|
||||
hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
|
||||
}
|
||||
|
||||
if (props.showHeaders.tags) {
|
||||
hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.tools) {
|
||||
hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeServings) {
|
||||
hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeYieldQuantity) {
|
||||
hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeYield) {
|
||||
hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.dateAdded) {
|
||||
hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
|
||||
}
|
||||
|
||||
return hdrs;
|
||||
});
|
||||
|
||||
function formatDate(date: string) {
|
||||
try {
|
||||
return i18n.d(Date.parse(date), "medium");
|
||||
}
|
||||
catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Group Members
|
||||
const api = useUserApi();
|
||||
const members = ref<UserSummary[]>([]);
|
||||
|
||||
async function refreshMembers() {
|
||||
const { data } = await api.groups.fetchMembers();
|
||||
if (data) {
|
||||
members.value = data.items;
|
||||
}
|
||||
}
|
||||
|
||||
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
|
||||
if (!groupSlug || !item.id) {
|
||||
return;
|
||||
}
|
||||
router.push(`/g/${groupSlug}?${itemType}=${item.id}`);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshMembers();
|
||||
});
|
||||
|
||||
function getMember(id: string) {
|
||||
if (members.value[0]) {
|
||||
return members.value.find(m => m.id === id)?.fullName;
|
||||
}
|
||||
|
||||
return i18n.t("general.none");
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<BaseDialog
|
||||
v-if="shoppingListIngredientDialog"
|
||||
v-model="dialog"
|
||||
:title="selectedShoppingList?.name || $t('recipe.add-to-list')"
|
||||
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
|
||||
:icon="$globals.icons.cartCheck"
|
||||
width="70%"
|
||||
:submit-text="$t('recipe.add-to-list')"
|
||||
@@ -130,23 +130,20 @@
|
||||
.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 mr-2"
|
||||
color="secondary"
|
||||
density="compact"
|
||||
/>
|
||||
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
|
||||
<RecipeIngredientListItem
|
||||
:ingredient="ingredientData.ingredient"
|
||||
:scale="recipeSection.recipeScale"
|
||||
/>
|
||||
</div>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<v-checkbox
|
||||
hide-details
|
||||
:model-value="ingredientData.checked"
|
||||
class="pt-0 my-auto py-auto"
|
||||
color="secondary"
|
||||
density="compact"
|
||||
/>
|
||||
<div :key="ingredientData.ingredient.quantity">
|
||||
<RecipeIngredientListItem
|
||||
:ingredient="ingredientData.ingredient"
|
||||
:disable-amount="ingredientData.disableAmount"
|
||||
:scale="recipeSection.recipeScale"
|
||||
/>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,7 +172,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { toRefs } from "@vueuse/core";
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
@@ -191,6 +188,7 @@ export interface RecipeWithScale extends Recipe {
|
||||
export interface ShoppingListIngredient {
|
||||
checked: boolean;
|
||||
ingredient: RecipeIngredient;
|
||||
disableAmount: boolean;
|
||||
}
|
||||
|
||||
export interface ShoppingListIngredientSection {
|
||||
@@ -205,214 +203,240 @@ export interface ShoppingListRecipeIngredientSection {
|
||||
ingredientSections: ShoppingListIngredientSection[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
recipes?: RecipeWithScale[];
|
||||
shoppingLists?: ShoppingListSummary[];
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipes: undefined,
|
||||
shoppingLists: () => [],
|
||||
});
|
||||
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
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 { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllToggled: _shoppingListShowAllToggled } = toRefs(state);
|
||||
|
||||
const userHousehold = computed(() => {
|
||||
return $auth.user.value?.householdSlug || "";
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = shoppingListChoices.value[0];
|
||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||
}
|
||||
else {
|
||||
ready.value = true;
|
||||
}
|
||||
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);
|
||||
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recipeSectionMap.has(recipe.slug)) {
|
||||
const existingSection = recipeSectionMap.get(recipe.slug);
|
||||
if (existingSection) {
|
||||
existingSection.recipeScale += recipe.scale;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
|
||||
const { data } = await api.recipes.getOne(recipe.slug);
|
||||
if (!data?.recipeIngredient?.length) {
|
||||
continue;
|
||||
}
|
||||
recipe.id = data.id || "";
|
||||
recipe.name = data.name || "";
|
||||
recipe.recipeIngredient = data.recipeIngredient;
|
||||
}
|
||||
else if (!recipe.recipeIngredient.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
|
||||
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
|
||||
return {
|
||||
checked: !householdsWithFood.includes(userHousehold.value),
|
||||
ingredient: ing,
|
||||
};
|
||||
// v-model support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:modelValue", val);
|
||||
initState();
|
||||
},
|
||||
});
|
||||
|
||||
let currentTitle = "";
|
||||
const onHandIngs: ShoppingListIngredient[] = [];
|
||||
const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
|
||||
if (ing.ingredient.title) {
|
||||
currentTitle = ing.ingredient.title;
|
||||
}
|
||||
const state = reactive({
|
||||
shoppingListDialog: true,
|
||||
shoppingListIngredientDialog: false,
|
||||
shoppingListShowAllToggled: false,
|
||||
});
|
||||
|
||||
// If this is the first item in the section, create a new section
|
||||
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
||||
if (sections.length) {
|
||||
// Add the on-hand ingredients to the previous section
|
||||
sections[sections.length - 1].ingredients.push(...onHandIngs);
|
||||
onHandIngs.length = 0;
|
||||
const userHousehold = computed(() => {
|
||||
return $auth.user.value?.householdSlug || "";
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = shoppingListChoices.value[0];
|
||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||
}
|
||||
sections.push({
|
||||
sectionName: currentTitle,
|
||||
ingredients: [],
|
||||
else {
|
||||
ready.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
|
||||
for (const recipe of recipes) {
|
||||
if (!recipe.slug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recipeSectionMap.has(recipe.slug)) {
|
||||
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
|
||||
const { data } = await api.recipes.getOne(recipe.slug);
|
||||
if (!data?.recipeIngredient?.length) {
|
||||
continue;
|
||||
}
|
||||
recipe.id = data.id || "";
|
||||
recipe.name = data.name || "";
|
||||
recipe.recipeIngredient = data.recipeIngredient;
|
||||
}
|
||||
else if (!recipe.recipeIngredient.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
|
||||
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
|
||||
return {
|
||||
checked: !householdsWithFood.includes(userHousehold.value),
|
||||
ingredient: ing,
|
||||
disableAmount: recipe.settings?.disableAmount || false,
|
||||
};
|
||||
});
|
||||
|
||||
let currentTitle = "";
|
||||
const onHandIngs: ShoppingListIngredient[] = [];
|
||||
const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
|
||||
if (ing.ingredient.title) {
|
||||
currentTitle = ing.ingredient.title;
|
||||
}
|
||||
|
||||
// If this is the first item in the section, create a new section
|
||||
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
||||
if (sections.length) {
|
||||
// Add the on-hand ingredients to the previous section
|
||||
sections[sections.length - 1].ingredients.push(...onHandIngs);
|
||||
onHandIngs.length = 0;
|
||||
}
|
||||
sections.push({
|
||||
sectionName: currentTitle,
|
||||
ingredients: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Store the on-hand ingredients for later
|
||||
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
|
||||
if (householdsWithFood.includes(userHousehold.value)) {
|
||||
onHandIngs.push(ing);
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Add the ingredient to previous section
|
||||
sections[sections.length - 1].ingredients.push(ing);
|
||||
return sections;
|
||||
}, [] as ShoppingListIngredientSection[]);
|
||||
|
||||
// Add remaining on-hand ingredients to the previous section
|
||||
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
|
||||
|
||||
recipeSectionMap.set(recipe.slug, {
|
||||
recipeId: recipe.id,
|
||||
recipeName: recipe.name,
|
||||
recipeScale: recipe.scale,
|
||||
ingredientSections: shoppingListIngredientSections,
|
||||
});
|
||||
}
|
||||
|
||||
// Store the on-hand ingredients for later
|
||||
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
|
||||
if (householdsWithFood.includes(userHousehold.value)) {
|
||||
onHandIngs.push(ing);
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Add the ingredient to previous section
|
||||
sections[sections.length - 1].ingredients.push(ing);
|
||||
return sections;
|
||||
}, [] as ShoppingListIngredientSection[]);
|
||||
|
||||
// Add remaining on-hand ingredients to the previous section
|
||||
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
|
||||
|
||||
recipeSectionMap.set(recipe.slug, {
|
||||
recipeId: recipe.id,
|
||||
recipeName: recipe.name,
|
||||
recipeScale: recipe.scale,
|
||||
ingredientSections: shoppingListIngredientSections,
|
||||
});
|
||||
}
|
||||
|
||||
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
||||
}
|
||||
|
||||
function initState() {
|
||||
state.shoppingListDialog = true;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
state.shoppingListShowAllToggled = false;
|
||||
recipeIngredientSections.value = [];
|
||||
selectedShoppingList.value = null;
|
||||
}
|
||||
|
||||
initState();
|
||||
|
||||
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
|
||||
if (!props.recipes?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedShoppingList.value = list;
|
||||
await consolidateRecipesIntoSections(props.recipes);
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListIngredientDialog = true;
|
||||
}
|
||||
|
||||
function setShowAllToggled() {
|
||||
state.shoppingListShowAllToggled = true;
|
||||
}
|
||||
|
||||
function bulkCheckIngredients(value = true) {
|
||||
recipeIngredientSections.value.forEach((recipeSection) => {
|
||||
recipeSection.ingredientSections.forEach((ingSection) => {
|
||||
ingSection.ingredients.forEach((ing) => {
|
||||
ing.checked = value;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function addRecipesToList() {
|
||||
if (!selectedShoppingList.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recipeData: ShoppingListAddRecipeParamsBulk[] = [];
|
||||
recipeIngredientSections.value.forEach((section) => {
|
||||
const ingredients: RecipeIngredient[] = [];
|
||||
section.ingredientSections.forEach((ingSection) => {
|
||||
ingSection.ingredients.forEach((ing) => {
|
||||
if (ing.checked) {
|
||||
ingredients.push(ing.ingredient);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!ingredients.length) {
|
||||
return;
|
||||
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
||||
}
|
||||
|
||||
recipeData.push(
|
||||
{
|
||||
recipeId: section.recipeId,
|
||||
recipeIncrementQuantity: section.recipeScale,
|
||||
recipeIngredients: ingredients,
|
||||
},
|
||||
);
|
||||
});
|
||||
function initState() {
|
||||
state.shoppingListDialog = true;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
state.shoppingListShowAllToggled = false;
|
||||
recipeIngredientSections.value = [];
|
||||
selectedShoppingList.value = null;
|
||||
}
|
||||
|
||||
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
error ? alert.error(i18n.t("recipe.failed-to-add-recipes-to-list")) : alert.success(i18n.t("recipe.successfully-added-to-list"));
|
||||
initState();
|
||||
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
dialog.value = false;
|
||||
}
|
||||
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
|
||||
if (!props.recipes?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedShoppingList.value = list;
|
||||
await consolidateRecipesIntoSections(props.recipes);
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListIngredientDialog = true;
|
||||
}
|
||||
|
||||
function setShowAllToggled() {
|
||||
state.shoppingListShowAllToggled = true;
|
||||
}
|
||||
|
||||
function bulkCheckIngredients(value = true) {
|
||||
recipeIngredientSections.value.forEach((recipeSection) => {
|
||||
recipeSection.ingredientSections.forEach((ingSection) => {
|
||||
ingSection.ingredients.forEach((ing) => {
|
||||
ing.checked = value;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function addRecipesToList() {
|
||||
if (!selectedShoppingList.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recipeData: ShoppingListAddRecipeParamsBulk[] = [];
|
||||
recipeIngredientSections.value.forEach((section) => {
|
||||
const ingredients: RecipeIngredient[] = [];
|
||||
section.ingredientSections.forEach((ingSection) => {
|
||||
ingSection.ingredients.forEach((ing) => {
|
||||
if (ing.checked) {
|
||||
ingredients.push(ing.ingredient);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!ingredients.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
recipeData.push(
|
||||
{
|
||||
recipeId: section.recipeId,
|
||||
recipeIncrementQuantity: section.recipeScale,
|
||||
recipeIngredients: ingredients,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
error ? alert.error(i18n.t("recipe.failed-to-add-recipes-to-list")) : alert.success(i18n.t("recipe.successfully-added-to-list"));
|
||||
|
||||
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">
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
v-model="dialog"
|
||||
width="800"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<BaseButton
|
||||
v-bind="activatorProps"
|
||||
v-bind="props"
|
||||
@click="inputText = inputTextProp"
|
||||
>
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
@@ -89,75 +89,88 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
inputTextProp?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
inputTextProp: "",
|
||||
<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,
|
||||
});
|
||||
|
||||
function splitText() {
|
||||
return state.inputText.split("\n").filter(line => !(line === "\n" || !line));
|
||||
}
|
||||
|
||||
function removeFirstCharacter() {
|
||||
state.inputText = splitText()
|
||||
.map(line => line.substring(1))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const numberedLineRegex = /\d+[.):] /gm;
|
||||
|
||||
function splitByNumberedLine() {
|
||||
// Split inputText by numberedLineRegex
|
||||
const matches = state.inputText.match(numberedLineRegex);
|
||||
|
||||
matches?.forEach((match, idx) => {
|
||||
const replaceText = idx === 0 ? "" : "\n";
|
||||
state.inputText = state.inputText.replace(match, replaceText);
|
||||
});
|
||||
}
|
||||
|
||||
function trimAllLines() {
|
||||
const splitLines = splitText();
|
||||
|
||||
splitLines.forEach((element: string, index: number) => {
|
||||
splitLines[index] = element.trim();
|
||||
});
|
||||
|
||||
state.inputText = splitLines.join("\n");
|
||||
}
|
||||
|
||||
function save() {
|
||||
context.emit("bulk-data", splitText());
|
||||
state.dialog = false;
|
||||
}
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const utilities = [
|
||||
{
|
||||
id: "trim-whitespace",
|
||||
description: i18n.t("new-recipe.trim-whitespace-description"),
|
||||
action: trimAllLines,
|
||||
},
|
||||
{
|
||||
id: "trim-prefix",
|
||||
description: i18n.t("new-recipe.trim-prefix-description"),
|
||||
action: removeFirstCharacter,
|
||||
},
|
||||
{
|
||||
id: "split-by-numbered-line",
|
||||
description: i18n.t("new-recipe.split-by-numbered-line-description"),
|
||||
action: splitByNumberedLine,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
utilities,
|
||||
splitText,
|
||||
trimAllLines,
|
||||
removeFirstCharacter,
|
||||
splitByNumberedLine,
|
||||
save,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"bulk-data": [data: string[]];
|
||||
}>();
|
||||
|
||||
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;
|
||||
|
||||
function splitByNumberedLine() {
|
||||
// Split inputText by numberedLineRegex
|
||||
const matches = inputText.value.match(numberedLineRegex);
|
||||
|
||||
matches?.forEach((match, idx) => {
|
||||
const replaceText = idx === 0 ? "" : "\n";
|
||||
inputText.value = inputText.value.replace(match, replaceText);
|
||||
});
|
||||
}
|
||||
|
||||
function trimAllLines() {
|
||||
const splitLines = splitText();
|
||||
|
||||
splitLines.forEach((element: string, index: number) => {
|
||||
splitLines[index] = element.trim();
|
||||
});
|
||||
|
||||
inputText.value = splitLines.join("\n");
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit("bulk-data", splitText());
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const utilities = [
|
||||
{
|
||||
id: "trim-whitespace",
|
||||
description: i18n.t("new-recipe.trim-whitespace-description"),
|
||||
action: trimAllLines,
|
||||
},
|
||||
{
|
||||
id: "trim-prefix",
|
||||
description: i18n.t("new-recipe.trim-prefix-description"),
|
||||
action: removeFirstCharacter,
|
||||
},
|
||||
{
|
||||
id: "split-by-numbered-line",
|
||||
description: i18n.t("new-recipe.split-by-numbered-line-description"),
|
||||
action: splitByNumberedLine,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
<v-switch
|
||||
v-model="preferences.showDescription"
|
||||
hide-details
|
||||
color="primary"
|
||||
:label="$t('recipe.description')"
|
||||
/>
|
||||
</v-row>
|
||||
@@ -52,7 +51,6 @@
|
||||
<v-switch
|
||||
v-model="preferences.showNotes"
|
||||
hide-details
|
||||
color="primary"
|
||||
:label="$t('recipe.notes')"
|
||||
/>
|
||||
</v-row>
|
||||
@@ -65,7 +63,6 @@
|
||||
<v-switch
|
||||
v-model="preferences.showNutrition"
|
||||
hide-details
|
||||
color="primary"
|
||||
:label="$t('recipe.nutrition')"
|
||||
/>
|
||||
</v-row>
|
||||
@@ -86,19 +83,45 @@
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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";
|
||||
|
||||
interface Props {
|
||||
recipe?: NoUndefinedField<Recipe>;
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
recipe: undefined,
|
||||
});
|
||||
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();
|
||||
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
const preferences = useUserPrintPreferences();
|
||||
// V-Model Support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
dialog,
|
||||
ImagePosition,
|
||||
preferences,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
<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
|
||||
@@ -72,7 +76,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
||||
@@ -81,104 +85,114 @@ import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
|
||||
import { usePublicExploreApi } from "~/composables/api/api-client";
|
||||
|
||||
const SELECTED_EVENT = "selected";
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeCardMobile,
|
||||
},
|
||||
|
||||
// Define emits
|
||||
const emit = defineEmits<{
|
||||
selected: [recipe: RecipeSummary];
|
||||
}>();
|
||||
setup(_, context) {
|
||||
const $auth = useMealieAuth();
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
selectedIndex: -1,
|
||||
});
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const loading = ref(false);
|
||||
const selectedIndex = ref(-1);
|
||||
// ===========================================================================
|
||||
// Dialog State Management
|
||||
const dialog = ref(false);
|
||||
|
||||
// ===========================================================================
|
||||
// Dialog State Management
|
||||
const dialog = ref(false);
|
||||
// Reset or Grab Recipes on Change
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
search.query.value = "";
|
||||
state.selectedIndex = -1;
|
||||
search.data.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
// Reset or Grab Recipes on Change
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
search.query.value = "";
|
||||
selectedIndex.value = -1;
|
||||
search.data.value = [];
|
||||
}
|
||||
});
|
||||
// ===========================================================================
|
||||
// Event Handlers
|
||||
|
||||
// ===========================================================================
|
||||
// Event Handlers
|
||||
function selectRecipe() {
|
||||
const recipeCards = document.getElementsByClassName("arrow-nav");
|
||||
if (recipeCards) {
|
||||
if (state.selectedIndex < 0) {
|
||||
state.selectedIndex = -1;
|
||||
document.getElementById("arrow-search")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
function selectRecipe() {
|
||||
const recipeCards = document.getElementsByClassName("arrow-nav");
|
||||
if (recipeCards) {
|
||||
if (selectedIndex.value < 0) {
|
||||
selectedIndex.value = -1;
|
||||
document.getElementById("arrow-search")?.focus();
|
||||
return;
|
||||
if (state.selectedIndex >= recipeCards.length) {
|
||||
state.selectedIndex = recipeCards.length - 1;
|
||||
}
|
||||
|
||||
(recipeCards[state.selectedIndex] as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedIndex.value >= recipeCards.length) {
|
||||
selectedIndex.value = recipeCards.length - 1;
|
||||
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--;
|
||||
}
|
||||
else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
state.selectedIndex++;
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
selectRecipe();
|
||||
}
|
||||
|
||||
(recipeCards[selectedIndex.value] as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
document.removeEventListener("keyup", onUpDown);
|
||||
}
|
||||
else {
|
||||
document.addEventListener("keyup", onUpDown);
|
||||
}
|
||||
});
|
||||
|
||||
function onUpDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
console.log(document.activeElement);
|
||||
// (document.activeElement as HTMLElement).click();
|
||||
}
|
||||
else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
selectedIndex.value--;
|
||||
}
|
||||
else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
selectedIndex.value++;
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
selectRecipe();
|
||||
}
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const route = useRoute();
|
||||
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`);
|
||||
watch(route, close);
|
||||
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
document.removeEventListener("keyup", onUpDown);
|
||||
}
|
||||
else {
|
||||
document.addEventListener("keyup", onUpDown);
|
||||
}
|
||||
});
|
||||
function open() {
|
||||
dialog.value = true;
|
||||
}
|
||||
function close() {
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
watch(route, close);
|
||||
// ===========================================================================
|
||||
// Basic Search
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||
const search = useRecipeSearch(api);
|
||||
|
||||
function open() {
|
||||
dialog.value = true;
|
||||
}
|
||||
function close() {
|
||||
dialog.value = false;
|
||||
}
|
||||
// Select Handler
|
||||
|
||||
// ===========================================================================
|
||||
// Basic Search
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||
const search = useRecipeSearch(api);
|
||||
function handleSelect(recipe: RecipeSummary) {
|
||||
close();
|
||||
context.emit(SELECTED_EVENT, recipe);
|
||||
}
|
||||
|
||||
// Select Handler
|
||||
function handleSelect(recipe: RecipeSummary) {
|
||||
close();
|
||||
emit(SELECTED_EVENT, recipe);
|
||||
}
|
||||
|
||||
// Expose functions to parent components
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
return {
|
||||
...toRefs(state),
|
||||
advancedSearchUrl,
|
||||
dialog,
|
||||
open,
|
||||
close,
|
||||
handleSelect,
|
||||
search,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<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="activatorProps"
|
||||
v-bind="props"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
@@ -92,116 +92,150 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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";
|
||||
|
||||
interface Props {
|
||||
recipeId: string;
|
||||
name: string;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const datePickerMenu = ref(false);
|
||||
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
|
||||
const tokens = ref<RecipeShareToken[]>([]);
|
||||
|
||||
const expirationDateString = computed(() => {
|
||||
return expirationDate.value.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
whenever(
|
||||
() => dialog.value,
|
||||
() => {
|
||||
// Set expiration date to today + 30 Days
|
||||
const today = new Date();
|
||||
expirationDate.value = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
refreshTokens();
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
recipeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Token Actions
|
||||
|
||||
const userApi = useUserApi();
|
||||
|
||||
async function createNewToken() {
|
||||
// Convert expiration date to timestamp
|
||||
const { data } = await userApi.recipes.share.createOne({
|
||||
recipeId: props.recipeId,
|
||||
expiresAt: expirationDate.value.toISOString(),
|
||||
});
|
||||
|
||||
if (data) {
|
||||
tokens.value.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteToken(id: string) {
|
||||
await userApi.recipes.share.deleteOne(id);
|
||||
tokens.value = tokens.value.filter(token => token.id !== id);
|
||||
}
|
||||
|
||||
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.
|
||||
tokens.value = data ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
const { share, isSupported: shareIsSupported } = useShare();
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
|
||||
function getRecipeText() {
|
||||
return i18n.t("recipe.share-recipe-message", [props.name]);
|
||||
}
|
||||
|
||||
function getTokenLink(token: string) {
|
||||
return `${window.location.origin}/g/${groupSlug.value}/shared/r/${token}`;
|
||||
}
|
||||
|
||||
async function copyTokenLink(token: string) {
|
||||
if (isSupported.value) {
|
||||
await copy(getTokenLink(token));
|
||||
if (copied.value) {
|
||||
alert.success(i18n.t("recipe-share.recipe-link-copied-message") as string);
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("general.clipboard-copy-failure") as string);
|
||||
}
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("general.clipboard-not-supported") as string);
|
||||
}
|
||||
}
|
||||
|
||||
async function shareRecipe(token: string) {
|
||||
if (shareIsSupported) {
|
||||
share({
|
||||
title: props.name,
|
||||
url: getTokenLink(token),
|
||||
text: getRecipeText() as string,
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
// V-Model Support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
}
|
||||
else {
|
||||
await copyTokenLink(token);
|
||||
}
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
datePickerMenu: false,
|
||||
expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
|
||||
tokens: [] as RecipeShareToken[],
|
||||
});
|
||||
|
||||
const expirationDateString = computed(() => {
|
||||
return state.expirationDate.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
whenever(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
// Set expiration date to today + 30 Days
|
||||
const today = new Date();
|
||||
state.expirationDate = 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 firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Token Actions
|
||||
|
||||
const userApi = useUserApi();
|
||||
|
||||
async function createNewToken() {
|
||||
// Convert expiration date to timestamp
|
||||
const { data } = await userApi.recipes.share.createOne({
|
||||
recipeId: props.recipeId,
|
||||
expiresAt: state.expirationDate.toISOString(),
|
||||
});
|
||||
|
||||
if (data) {
|
||||
state.tokens.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteToken(id: string) {
|
||||
await userApi.recipes.share.deleteOne(id);
|
||||
state.tokens = state.tokens.filter(token => token.id !== id);
|
||||
}
|
||||
|
||||
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 ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
const { share, isSupported: shareIsSupported } = useShare();
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
|
||||
function getRecipeText() {
|
||||
return i18n.t("recipe.share-recipe-message", [props.name]);
|
||||
}
|
||||
|
||||
function getTokenLink(token: string) {
|
||||
return `${window.location.origin}/g/${groupSlug.value}/shared/r/${token}`;
|
||||
}
|
||||
|
||||
async function copyTokenLink(token: string) {
|
||||
if (isSupported.value) {
|
||||
await copy(getTokenLink(token));
|
||||
if (copied.value) {
|
||||
alert.success(i18n.t("recipe-share.recipe-link-copied-message") as string);
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("general.clipboard-copy-failure") as string);
|
||||
}
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("general.clipboard-not-supported") as string);
|
||||
}
|
||||
}
|
||||
|
||||
async function shareRecipe(token: string) {
|
||||
if (shareIsSupported) {
|
||||
share({
|
||||
title: props.name,
|
||||
url: getTokenLink(token),
|
||||
text: getRecipeText() as string,
|
||||
});
|
||||
}
|
||||
else {
|
||||
await copyTokenLink(token);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
expirationDateString,
|
||||
dialog,
|
||||
createNewToken,
|
||||
deleteToken,
|
||||
firstDayOfWeek,
|
||||
shareRecipe,
|
||||
copyTokenLink,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
density="comfortable"
|
||||
:prepend-icon="v.icon"
|
||||
:title="v.name"
|
||||
@click="setOrderBy(v.value)"
|
||||
@click="state.orderBy = v.value"
|
||||
/>
|
||||
</v-list>
|
||||
</v-card>
|
||||
@@ -219,7 +219,7 @@ import {
|
||||
useToolStore,
|
||||
usePublicToolStore,
|
||||
} from "~/composables/store";
|
||||
import { useUserSearchQuerySession, useUserSortPreferences } from "~/composables/use-users/preferences";
|
||||
import { useUserSearchQuerySession } from "~/composables/use-users/preferences";
|
||||
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
@@ -253,15 +253,6 @@ export default defineNuxtComponent({
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const searchQuerySession = useUserSearchQuerySession();
|
||||
const sortPreferences = useUserSortPreferences();
|
||||
|
||||
watch(() => state.value.orderBy, (newValue) => {
|
||||
sortPreferences.value.orderBy = newValue;
|
||||
});
|
||||
|
||||
watch(() => state.value.orderDirection, (newValue) => {
|
||||
sortPreferences.value.orderDirection = newValue;
|
||||
});
|
||||
|
||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const categories = isOwnGroup.value ? useCategoryStore() : usePublicCategoryStore(groupSlug.value);
|
||||
@@ -321,8 +312,6 @@ export default defineNuxtComponent({
|
||||
state.value.search = queryDefaults.search;
|
||||
state.value.orderBy = queryDefaults.orderBy;
|
||||
state.value.orderDirection = queryDefaults.orderDirection;
|
||||
sortPreferences.value.orderBy = queryDefaults.orderBy;
|
||||
sortPreferences.value.orderDirection = queryDefaults.orderDirection;
|
||||
state.value.requireAllCategories = queryDefaults.requireAllCategories;
|
||||
state.value.requireAllTags = queryDefaults.requireAllTags;
|
||||
state.value.requireAllTools = queryDefaults.requireAllTools;
|
||||
@@ -336,12 +325,6 @@ export default defineNuxtComponent({
|
||||
|
||||
function toggleOrderDirection() {
|
||||
state.value.orderDirection = state.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
sortPreferences.value.orderDirection = state.value.orderDirection;
|
||||
}
|
||||
|
||||
function setOrderBy(value: string) {
|
||||
state.value.orderBy = value;
|
||||
sortPreferences.value.orderBy = value;
|
||||
}
|
||||
|
||||
function toIDArray(array: { id: string }[]) {
|
||||
@@ -373,6 +356,8 @@ export default defineNuxtComponent({
|
||||
...{
|
||||
auto: state.value.auto ? undefined : "false",
|
||||
search: passedQuery.value.search === queryDefaults.search ? undefined : passedQuery.value.search,
|
||||
orderBy: passedQuery.value.orderBy === queryDefaults.orderBy ? undefined : passedQuery.value.orderBy,
|
||||
orderDirection: passedQuery.value.orderDirection === queryDefaults.orderDirection ? undefined : passedQuery.value.orderDirection,
|
||||
households: !passedQuery.value.households?.length || passedQuery.value.households?.length === households.store.value.length ? undefined : passedQuery.value.households,
|
||||
requireAllCategories: passedQuery.value.requireAllCategories ? "true" : undefined,
|
||||
requireAllTags: passedQuery.value.requireAllTags ? "true" : undefined,
|
||||
@@ -489,8 +474,19 @@ export default defineNuxtComponent({
|
||||
state.value.search = queryDefaults.search;
|
||||
}
|
||||
|
||||
state.value.orderBy = sortPreferences.value.orderBy;
|
||||
state.value.orderDirection = sortPreferences.value.orderDirection as "asc" | "desc";
|
||||
if (query.orderBy?.length) {
|
||||
state.value.orderBy = query.orderBy as string;
|
||||
}
|
||||
else {
|
||||
state.value.orderBy = queryDefaults.orderBy;
|
||||
}
|
||||
|
||||
if (query.orderDirection?.length) {
|
||||
state.value.orderDirection = query.orderDirection as "asc" | "desc";
|
||||
}
|
||||
else {
|
||||
state.value.orderDirection = queryDefaults.orderDirection;
|
||||
}
|
||||
|
||||
if (query.requireAllCategories?.length) {
|
||||
state.value.requireAllCategories = query.requireAllCategories === "true";
|
||||
@@ -669,7 +665,6 @@ export default defineNuxtComponent({
|
||||
|
||||
sortable,
|
||||
toggleOrderDirection,
|
||||
setOrderBy,
|
||||
hideKeyboard,
|
||||
input,
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
nudge-right="50"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-if="isFavorite || showAlways"
|
||||
icon
|
||||
@@ -13,7 +13,7 @@
|
||||
size="small"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
:fab="buttonStyle"
|
||||
v-bind="{ ...tooltipProps, ...$attrs }"
|
||||
v-bind="{ ...props, ...$attrs }"
|
||||
@click.prevent="toggleFavorite"
|
||||
>
|
||||
<v-icon
|
||||
@@ -28,38 +28,47 @@
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { useUserSelfRatings } from "~/composables/use-users";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
interface Props {
|
||||
recipeId?: string;
|
||||
showAlways?: boolean;
|
||||
buttonStyle?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipeId: "",
|
||||
showAlways: false,
|
||||
buttonStyle: false,
|
||||
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();
|
||||
|
||||
const isFavorite = computed(() => {
|
||||
const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
|
||||
return rating?.isFavorite || false;
|
||||
});
|
||||
|
||||
async function toggleFavorite() {
|
||||
if (!$auth.user.value) return;
|
||||
if (!isFavorite.value) {
|
||||
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
else {
|
||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
await refreshUserRatings();
|
||||
}
|
||||
|
||||
return { isFavorite, toggleFavorite };
|
||||
},
|
||||
});
|
||||
|
||||
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() {
|
||||
if (!$auth.user.value) return;
|
||||
if (!isFavorite.value) {
|
||||
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
else {
|
||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
await refreshUserRatings();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
nudge-top="6"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
color="accent"
|
||||
dark
|
||||
v-bind="activatorProps"
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.fileImage }}
|
||||
@@ -61,42 +61,52 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
const REFRESH_EVENT = "refresh";
|
||||
const UPLOAD_EVENT = "upload";
|
||||
|
||||
const props = defineProps<{ slug: string }>();
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
url: "",
|
||||
loading: false,
|
||||
menu: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
upload: [fileObject: File];
|
||||
}>();
|
||||
function uploadImage(fileObject: File) {
|
||||
context.emit(UPLOAD_EVENT, fileObject);
|
||||
state.menu = false;
|
||||
}
|
||||
|
||||
const url = ref("");
|
||||
const loading = ref(false);
|
||||
const menu = ref(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;
|
||||
}
|
||||
|
||||
function uploadImage(fileObject: File) {
|
||||
emit(UPLOAD_EVENT, fileObject);
|
||||
menu.value = false;
|
||||
}
|
||||
const i18n = useI18n();
|
||||
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
|
||||
|
||||
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 i18n = useI18n();
|
||||
const messages = computed(() =>
|
||||
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
|
||||
);
|
||||
return {
|
||||
...toRefs(state),
|
||||
uploadImage,
|
||||
getImageFromURL,
|
||||
messages,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
class="d-flex flex-wrap my-1"
|
||||
>
|
||||
<v-col
|
||||
v-if="!disableAmount"
|
||||
sm="12"
|
||||
md="2"
|
||||
cols="12"
|
||||
@@ -41,6 +42,7 @@
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="!disableAmount"
|
||||
sm="12"
|
||||
md="3"
|
||||
cols="12"
|
||||
@@ -61,22 +63,6 @@
|
||||
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") }}
|
||||
@@ -96,6 +82,7 @@
|
||||
|
||||
<!-- Foods Input -->
|
||||
<v-col
|
||||
v-if="!disableAmount"
|
||||
m="12"
|
||||
md="3"
|
||||
cols="12"
|
||||
@@ -117,22 +104,6 @@
|
||||
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") }}
|
||||
@@ -163,7 +134,16 @@
|
||||
: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"
|
||||
@@ -173,6 +153,7 @@
|
||||
@toggle-original="toggleOriginalText"
|
||||
@insert-above="$emit('insert-above')"
|
||||
@insert-below="$emit('insert-below')"
|
||||
@insert-ingredient="$emit('insert-ingredient')"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
@@ -203,29 +184,22 @@ import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
// defineModel replaces modelValue prop
|
||||
const model = defineModel<RecipeIngredient>({ required: true });
|
||||
|
||||
defineProps({
|
||||
unitError: {
|
||||
const props = defineProps({
|
||||
disableAmount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
unitErrorTooltip: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
foodError: {
|
||||
allowInsertIngredient: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
foodErrorTooltip: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
"clickIngredientField",
|
||||
"insert-above",
|
||||
"insert-below",
|
||||
"insert-ingredient",
|
||||
"delete",
|
||||
]);
|
||||
|
||||
@@ -254,6 +228,13 @@ 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"),
|
||||
|
||||
@@ -3,13 +3,21 @@
|
||||
<div v-html="safeMarkup" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
|
||||
|
||||
interface Props {
|
||||
markup: string;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
markup: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
||||
return {
|
||||
safeMarkup,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -28,20 +28,34 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
|
||||
interface Props {
|
||||
ingredient: RecipeIngredient;
|
||||
scale?: number;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
const parsedIng = computed(() => {
|
||||
return useParsedIngredientText(props.ingredient, props.scale);
|
||||
return {
|
||||
parsedIng,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<v-list-item-title>
|
||||
<RecipeIngredientListItem
|
||||
:ingredient="ingredient"
|
||||
:disable-amount="disableAmount"
|
||||
:scale="scale"
|
||||
/>
|
||||
</v-list-item-title>
|
||||
@@ -52,51 +53,71 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
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 checked = ref(props.value.map(() => false));
|
||||
const showTitleEditor = computed(() => props.value.map(x => validateTitle(x.title)));
|
||||
|
||||
const ingredientCopyText = computed(() => {
|
||||
const components: string[] = [];
|
||||
props.value.forEach((ingredient) => {
|
||||
if (ingredient.title) {
|
||||
if (components.length) {
|
||||
components.push("");
|
||||
}
|
||||
|
||||
components.push(`[${ingredient.title}]`);
|
||||
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) {
|
||||
return !(title === undefined || title === "" || title === null);
|
||||
}
|
||||
|
||||
components.push(parseIngredientText(ingredient, props.scale, false));
|
||||
});
|
||||
const state = reactive({
|
||||
checked: props.value.map(() => false),
|
||||
showTitleEditor: computed(() => props.value.map(x => validateTitle(x.title))),
|
||||
});
|
||||
|
||||
return components.join("\n");
|
||||
const ingredientCopyText = computed(() => {
|
||||
const components: string[] = [];
|
||||
props.value.forEach((ingredient) => {
|
||||
if (ingredient.title) {
|
||||
if (components.length) {
|
||||
components.push("");
|
||||
}
|
||||
|
||||
components.push(`[${ingredient.title}]`);
|
||||
}
|
||||
|
||||
components.push(parseIngredientText(ingredient, props.disableAmount, props.scale, false));
|
||||
});
|
||||
|
||||
return components.join("\n");
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
checked.value.splice(index, 1, !checked.value[index]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div>
|
||||
<BaseDialog
|
||||
v-model="madeThisDialog"
|
||||
:loading="madeThisFormLoading"
|
||||
:icon="$globals.icons.chefHat"
|
||||
:title="$t('recipe.made-this')"
|
||||
:submit-text="$t('recipe.add-to-timeline')"
|
||||
@@ -30,11 +29,11 @@
|
||||
offset-y
|
||||
max-width="290px"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-text-field
|
||||
v-model="newTimelineEventTimestampString"
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="activatorProps"
|
||||
v-bind="props"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
@@ -86,13 +85,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 location="bottom">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-tooltip bottom>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
rounded
|
||||
variant="outlined"
|
||||
size="x-large"
|
||||
v-bind="tooltipProps"
|
||||
v-bind="props"
|
||||
style="border-color: rgb(var(--v-theme-primary));"
|
||||
@click="madeThisDialog = true"
|
||||
>
|
||||
@@ -117,165 +116,148 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { whenever } from "@vueuse/core";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import type { Recipe, RecipeTimelineEventIn, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||
import type { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
|
||||
import type { VForm } from "~/types/auto-forms";
|
||||
|
||||
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(() => {
|
||||
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
const lastMade = ref(props.recipe.lastMade);
|
||||
const lastMadeReady = ref(false);
|
||||
onMounted(async () => {
|
||||
if (!$auth.user?.value?.householdSlug) {
|
||||
lastMade.value = props.recipe.lastMade;
|
||||
}
|
||||
else {
|
||||
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
|
||||
lastMade.value = data?.lastMade;
|
||||
}
|
||||
|
||||
lastMadeReady.value = true;
|
||||
});
|
||||
|
||||
whenever(
|
||||
() => madeThisDialog.value,
|
||||
() => {
|
||||
// Set timestamp to now
|
||||
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
|
||||
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>({
|
||||
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(() => {
|
||||
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
function clearImage() {
|
||||
newTimelineEventImage.value = undefined;
|
||||
newTimelineEventImageName.value = "";
|
||||
newTimelineEventImagePreviewUrl.value = undefined;
|
||||
}
|
||||
|
||||
function uploadImage(fileObject: File) {
|
||||
newTimelineEventImage.value = fileObject;
|
||||
newTimelineEventImageName.value = fileObject.name;
|
||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
function updateUploadedImage(fileObject: Blob) {
|
||||
newTimelineEventImage.value = fileObject;
|
||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
const datePickerMenu = ref(false);
|
||||
const madeThisFormLoading = ref(false);
|
||||
|
||||
function resetMadeThisForm() {
|
||||
madeThisFormLoading.value = false;
|
||||
|
||||
newTimelineEvent.value.eventMessage = "";
|
||||
newTimelineEvent.value.timestamp = undefined;
|
||||
clearImage();
|
||||
madeThisDialog.value = false;
|
||||
domMadeThisForm.value?.reset();
|
||||
}
|
||||
|
||||
async function createTimelineEvent() {
|
||||
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
madeThisFormLoading.value = true;
|
||||
|
||||
newTimelineEvent.value.recipeId = props.recipe.id;
|
||||
// Note: $auth.user is now a ref
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
||||
|
||||
// the user only selects the date, so we set the time to end of day local time
|
||||
// we choose the end of day so it always comes after "new recipe" events
|
||||
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestampString.value + "T23:59:59").toISOString();
|
||||
|
||||
let newEvent: RecipeTimelineEventOut | null = null;
|
||||
try {
|
||||
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
|
||||
newEvent = eventResponse.data;
|
||||
if (!newEvent) {
|
||||
throw new Error("No event created");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to create timeline event:", error);
|
||||
alert.error(i18n.t("recipe.failed-to-add-to-timeline"));
|
||||
resetMadeThisForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// we also update the recipe's last made value
|
||||
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
|
||||
try {
|
||||
lastMade.value = newTimelineEvent.value.timestamp;
|
||||
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to update last made date:", error);
|
||||
alert.error(i18n.t("recipe.failed-to-update-recipe"));
|
||||
}
|
||||
}
|
||||
|
||||
// update the image, if provided
|
||||
let imageError = false;
|
||||
if (newTimelineEventImage.value) {
|
||||
try {
|
||||
const imageResponse = await userApi.recipes.updateTimelineEventImage(
|
||||
newEvent.id,
|
||||
newTimelineEventImage.value,
|
||||
newTimelineEventImageName.value,
|
||||
);
|
||||
if (imageResponse.data) {
|
||||
newEvent.image = imageResponse.data.image;
|
||||
const lastMade = ref(props.recipe.lastMade);
|
||||
const lastMadeReady = ref(false);
|
||||
onMounted(async () => {
|
||||
if (!$auth.user?.value?.householdSlug) {
|
||||
lastMade.value = props.recipe.lastMade;
|
||||
}
|
||||
else {
|
||||
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
|
||||
lastMade.value = data?.lastMade;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
imageError = true;
|
||||
console.error("Failed to upload image for timeline event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageError) {
|
||||
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
|
||||
}
|
||||
else {
|
||||
alert.success(i18n.t("recipe.added-to-timeline"));
|
||||
}
|
||||
lastMadeReady.value = true;
|
||||
});
|
||||
|
||||
resetMadeThisForm();
|
||||
emit("eventCreated", newEvent);
|
||||
}
|
||||
whenever(
|
||||
() => madeThisDialog.value,
|
||||
() => {
|
||||
// Set timestamp to now
|
||||
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
|
||||
},
|
||||
);
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
function clearImage() {
|
||||
newTimelineEventImage.value = undefined;
|
||||
newTimelineEventImageName.value = "";
|
||||
newTimelineEventImagePreviewUrl.value = undefined;
|
||||
}
|
||||
|
||||
function uploadImage(fileObject: File) {
|
||||
newTimelineEventImage.value = fileObject;
|
||||
newTimelineEventImageName.value = fileObject.name;
|
||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
function updateUploadedImage(fileObject: Blob) {
|
||||
newTimelineEventImage.value = fileObject;
|
||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
const state = reactive({ datePickerMenu: false });
|
||||
async function createTimelineEvent() {
|
||||
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
newTimelineEvent.value.recipeId = props.recipe.id;
|
||||
// Note: $auth.user is now a ref
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
||||
|
||||
// the user only selects the date, so we set the time to end of day local time
|
||||
// we choose the end of day so it always comes after "new recipe" events
|
||||
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestampString.value + "T23:59:59").toISOString();
|
||||
|
||||
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
|
||||
const newEvent = eventResponse.data;
|
||||
|
||||
// we also update the recipe's last made value
|
||||
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
|
||||
lastMade.value = newTimelineEvent.value.timestamp;
|
||||
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
|
||||
}
|
||||
|
||||
// update the image, if provided
|
||||
if (newTimelineEventImage.value && newEvent) {
|
||||
const imageResponse = await userApi.recipes.updateTimelineEventImage(
|
||||
newEvent.id,
|
||||
newTimelineEventImage.value,
|
||||
newTimelineEventImageName.value,
|
||||
);
|
||||
if (imageResponse.data) {
|
||||
newEvent.image = imageResponse.data.image;
|
||||
}
|
||||
}
|
||||
|
||||
// reset form
|
||||
newTimelineEvent.value.eventMessage = "";
|
||||
newTimelineEvent.value.timestamp = undefined;
|
||||
clearImage();
|
||||
madeThisDialog.value = false;
|
||||
domMadeThisForm.value?.reset();
|
||||
|
||||
context.emit("eventCreated", newEvent);
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
domMadeThisForm,
|
||||
madeThisDialog,
|
||||
firstDayOfWeek,
|
||||
newTimelineEvent,
|
||||
newTimelineEventImage,
|
||||
newTimelineEventImagePreviewUrl,
|
||||
newTimelineEventTimestamp,
|
||||
newTimelineEventTimestampString,
|
||||
lastMade,
|
||||
lastMadeReady,
|
||||
createTimelineEvent,
|
||||
clearImage,
|
||||
uploadImage,
|
||||
updateUploadedImage,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'" style="background-color: transparent;">
|
||||
<v-list :class="tile ? 'd-flex flex-wrap background' : 'background'">
|
||||
<v-sheet
|
||||
v-for="recipe, index in recipes"
|
||||
:key="recipe.id"
|
||||
@@ -41,131 +41,151 @@
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
<template #append>
|
||||
<slot
|
||||
:name="'actions-' + recipe.id"
|
||||
:v-bind="{ item: recipe }"
|
||||
/>
|
||||
<slot
|
||||
:name="'actions-' + recipe.id"
|
||||
:v-bind="{ item: recipe }"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-sheet>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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";
|
||||
|
||||
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,
|
||||
});
|
||||
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 || "");
|
||||
|
||||
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: {
|
||||
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||
listItem: "px-0",
|
||||
avatar: "ma-0",
|
||||
icon: "ma-0 pa-0 primary",
|
||||
text: "pa-0",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
title: "font-size: small;",
|
||||
subTitle: "font-size: x-small;",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
class: {
|
||||
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||
listItem: "px-4",
|
||||
avatar: "",
|
||||
icon: "pa-1 primary",
|
||||
text: "",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
title: "",
|
||||
subTitle: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attrs = computed(() => {
|
||||
return props.small
|
||||
? {
|
||||
class: {
|
||||
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||
listItem: "px-0",
|
||||
avatar: "ma-0",
|
||||
icon: "ma-0 pa-0 primary",
|
||||
text: "pa-0",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
title: "font-size: small;",
|
||||
subTitle: "font-size: x-small;",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
class: {
|
||||
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||
listItem: "px-4",
|
||||
avatar: "",
|
||||
icon: "pa-1 primary",
|
||||
text: "",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
title: "",
|
||||
subTitle: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
|| props.listItem.recipeReferences.length !== props.recipes.length
|
||||
) {
|
||||
return props.recipes.map(_ => "");
|
||||
}
|
||||
|
||||
const listItemDescriptions: string[] = [];
|
||||
for (let i = 0; i < props.recipes.length; i++) {
|
||||
const itemRef = props.listItem?.recipeReferences[i];
|
||||
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
|
||||
|
||||
let listItemDescription = "";
|
||||
if (props.listItem.unit?.fraction) {
|
||||
const fraction = frac(quantity, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
listItemDescription += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
||||
}
|
||||
else {
|
||||
listItemDescription = (quantity).toString();
|
||||
}
|
||||
}
|
||||
else {
|
||||
listItemDescription = (Math.round(quantity * 100) / 100).toString();
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
|
||||
if (props.listItem.unit) {
|
||||
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
||||
? props.listItem.unit.abbreviation
|
||||
: props.listItem.unit.name;
|
||||
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
|
||||
|| props.listItem.recipeReferences.length !== props.recipes.length
|
||||
) {
|
||||
return props.recipes.map(_ => "");
|
||||
}
|
||||
|
||||
listItemDescription += ` ${unitDisplay}`;
|
||||
}
|
||||
const listItemDescriptions: string[] = [];
|
||||
for (let i = 0; i < props.recipes.length; i++) {
|
||||
const itemRef = props.listItem?.recipeReferences[i];
|
||||
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
|
||||
|
||||
if (itemRef.recipeNote) {
|
||||
listItemDescription += `, ${itemRef.recipeNote}`;
|
||||
}
|
||||
let listItemDescription = "";
|
||||
if (props.listItem.unit?.fraction) {
|
||||
const fraction = frac(quantity, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
listItemDescription += fraction[0];
|
||||
}
|
||||
|
||||
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
||||
}
|
||||
if (fraction[1] > 0) {
|
||||
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
||||
}
|
||||
else {
|
||||
listItemDescription = (quantity).toString();
|
||||
}
|
||||
}
|
||||
else {
|
||||
listItemDescription = (Math.round(quantity * 100) / 100).toString();
|
||||
}
|
||||
|
||||
return listItemDescriptions;
|
||||
if (props.listItem.unit) {
|
||||
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
||||
? props.listItem.unit.abbreviation
|
||||
: props.listItem.unit.name;
|
||||
|
||||
listItemDescription += ` ${unitDisplay}`;
|
||||
}
|
||||
|
||||
if (itemRef.recipeNote) {
|
||||
listItemDescription += `, ${itemRef.recipeNote}`;
|
||||
}
|
||||
|
||||
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
||||
}
|
||||
|
||||
return listItemDescriptions;
|
||||
});
|
||||
|
||||
return {
|
||||
attrs,
|
||||
groupSlug,
|
||||
listItemDescriptions,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -45,48 +45,62 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { useNutritionLabels } from "~/composables/recipes";
|
||||
import type { Nutrition } from "~/lib/api/types/recipe";
|
||||
import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
|
||||
|
||||
interface Props {
|
||||
edit?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
edit: true,
|
||||
});
|
||||
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(() => {
|
||||
let key: keyof Nutrition;
|
||||
for (key in props.modelValue) {
|
||||
if (props.modelValue[key] !== null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const modelValue = defineModel<Nutrition>({ required: true });
|
||||
const showViewer = computed(() => !props.edit && valueNotNull.value);
|
||||
|
||||
const { labels } = useNutritionLabels();
|
||||
const valueNotNull = computed(() => {
|
||||
let key: keyof Nutrition;
|
||||
for (key in modelValue.value) {
|
||||
if (modelValue.value[key] !== null) {
|
||||
return true;
|
||||
function updateValue(key: number | string, event: Event) {
|
||||
context.emit("update:modelValue", { ...props.modelValue, [key]: event });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const showViewer = computed(() => !props.edit && valueNotNull.value);
|
||||
// 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()) {
|
||||
item[key] = {
|
||||
...label,
|
||||
value: props.modelValue[key],
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}, {});
|
||||
});
|
||||
|
||||
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(() => {
|
||||
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
|
||||
if (modelValue.value[key]?.trim()) {
|
||||
item[key] = {
|
||||
...label,
|
||||
value: modelValue.value[key],
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}, {});
|
||||
return {
|
||||
labels,
|
||||
valueNotNull,
|
||||
showViewer,
|
||||
updateValue,
|
||||
renderedList,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -60,93 +60,119 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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";
|
||||
|
||||
interface Props {
|
||||
color?: string | null;
|
||||
tagDialog?: boolean;
|
||||
itemType?: RecipeOrganizer;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: null,
|
||||
tagDialog: true,
|
||||
itemType: "category" as RecipeOrganizer,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"created-item": [item: any];
|
||||
}>();
|
||||
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const name = ref("");
|
||||
const onHand = ref(false);
|
||||
|
||||
watch(
|
||||
dialog,
|
||||
(val: boolean) => {
|
||||
if (!val) name.value = "";
|
||||
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();
|
||||
|
||||
const userApi = useUserApi();
|
||||
const state = reactive({
|
||||
name: "",
|
||||
onHand: false,
|
||||
});
|
||||
|
||||
const store = (() => {
|
||||
switch (props.itemType) {
|
||||
case Organizer.Tag:
|
||||
return useTagStore();
|
||||
case Organizer.Tool:
|
||||
return useToolStore();
|
||||
default:
|
||||
return useCategoryStore();
|
||||
}
|
||||
})();
|
||||
const dialog = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
context.emit("update:modelValue", value);
|
||||
},
|
||||
});
|
||||
|
||||
const properties = computed(() => {
|
||||
switch (props.itemType) {
|
||||
case Organizer.Tag:
|
||||
return {
|
||||
title: i18n.t("tag.create-a-tag"),
|
||||
label: i18n.t("tag.tag-name"),
|
||||
api: userApi.tags,
|
||||
};
|
||||
case Organizer.Tool:
|
||||
return {
|
||||
title: i18n.t("tool.create-a-tool"),
|
||||
label: i18n.t("tool.tool-name"),
|
||||
api: userApi.tools,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: i18n.t("category.create-a-category"),
|
||||
label: i18n.t("category.category-name"),
|
||||
api: userApi.categories,
|
||||
};
|
||||
}
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val: boolean) => {
|
||||
if (!val) state.name = "";
|
||||
},
|
||||
);
|
||||
|
||||
const userApi = useUserApi();
|
||||
|
||||
const store = (() => {
|
||||
switch (props.itemType) {
|
||||
case Organizer.Tag:
|
||||
return useTagStore();
|
||||
case Organizer.Tool:
|
||||
return useToolStore();
|
||||
default:
|
||||
return useCategoryStore();
|
||||
}
|
||||
})();
|
||||
|
||||
const properties = computed(() => {
|
||||
switch (props.itemType) {
|
||||
case Organizer.Tag:
|
||||
return {
|
||||
title: i18n.t("tag.create-a-tag"),
|
||||
label: i18n.t("tag.tag-name"),
|
||||
api: userApi.tags,
|
||||
};
|
||||
case Organizer.Tool:
|
||||
return {
|
||||
title: i18n.t("tool.create-a-tool"),
|
||||
label: i18n.t("tool.tool-name"),
|
||||
api: userApi.tools,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: i18n.t("category.create-a-category"),
|
||||
label: i18n.t("category.category-name"),
|
||||
api: userApi.categories,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const rules = {
|
||||
required: (val: string) => !!val || (i18n.t("general.a-name-is-required") as string),
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const newItem = store.store.value.find(item => item.name === state.name);
|
||||
|
||||
context.emit(CREATED_ITEM_EVENT, newItem);
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
Organizer,
|
||||
...toRefs(state),
|
||||
dialog,
|
||||
properties,
|
||||
rules,
|
||||
select,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const rules = {
|
||||
required: (val: string) => !!val || (i18n.t("general.a-name-is-required") as string),
|
||||
};
|
||||
|
||||
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({ name: name.value, onHand: onHand.value });
|
||||
}
|
||||
|
||||
const newItem = store.store.value.find(item => item.name === name.value);
|
||||
|
||||
emit(CREATED_ITEM_EVENT, newItem);
|
||||
dialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
|
||||
@@ -122,8 +122,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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";
|
||||
@@ -137,128 +138,156 @@ interface GenericItem {
|
||||
onHand: boolean;
|
||||
}
|
||||
|
||||
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,
|
||||
shouldSort: true,
|
||||
threshold: 0.2,
|
||||
location: 0,
|
||||
distance: 20,
|
||||
findAllMatches: true,
|
||||
maxPatternLength: 32,
|
||||
minMatchCharLength: 1,
|
||||
keys: ["name"],
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeOrganizerDialog,
|
||||
},
|
||||
});
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
// =================================================================
|
||||
// Context Menu
|
||||
|
||||
const dialogs = ref({
|
||||
organizer: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
});
|
||||
|
||||
const presets = useContextPresets();
|
||||
|
||||
const translationKey = computed<string>(() => {
|
||||
const typeMap = {
|
||||
categories: "category.category",
|
||||
tags: "tag.tag",
|
||||
tools: "tool.tool",
|
||||
foods: "shopping-list.food",
|
||||
households: "household.household",
|
||||
};
|
||||
return typeMap[props.itemType] || "";
|
||||
});
|
||||
|
||||
const deleteTarget = ref<GenericItem | null>(null);
|
||||
const updateTarget = ref<GenericItem | null>(null);
|
||||
|
||||
function confirmDelete(item: GenericItem) {
|
||||
deleteTarget.value = item;
|
||||
dialogs.value.delete = true;
|
||||
}
|
||||
|
||||
function deleteOne() {
|
||||
if (!deleteTarget.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("delete", deleteTarget.value.id);
|
||||
}
|
||||
|
||||
function openUpdateDialog(item: GenericItem) {
|
||||
updateTarget.value = deepCopy(item);
|
||||
dialogs.value.update = true;
|
||||
}
|
||||
|
||||
function updateOne() {
|
||||
if (!updateTarget.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("update", updateTarget.value);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Search Functions
|
||||
|
||||
const searchString = useRouteQuery("q", "");
|
||||
|
||||
const fuse = computed(() => {
|
||||
return new Fuse(props.items, state.options);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
const itemsSorted = computed(() => {
|
||||
const byLetter: { [key: string]: Array<GenericItem> } = {};
|
||||
|
||||
if (!fuzzyItems.value) {
|
||||
return byLetter;
|
||||
}
|
||||
|
||||
[...fuzzyItems.value]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach((item) => {
|
||||
const letter = item.name[0].toUpperCase();
|
||||
if (!byLetter[letter]) {
|
||||
byLetter[letter] = [];
|
||||
}
|
||||
byLetter[letter].push(item);
|
||||
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({
|
||||
// Search Options
|
||||
options: {
|
||||
ignoreLocation: true,
|
||||
shouldSort: true,
|
||||
threshold: 0.2,
|
||||
location: 0,
|
||||
distance: 20,
|
||||
findAllMatches: true,
|
||||
maxPatternLength: 32,
|
||||
minMatchCharLength: 1,
|
||||
keys: ["name"],
|
||||
},
|
||||
});
|
||||
|
||||
return byLetter;
|
||||
});
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
function isTitle(str: number | string) {
|
||||
return typeof str === "string" && str.length === 1;
|
||||
}
|
||||
// =================================================================
|
||||
// Context Menu
|
||||
|
||||
const dialogs = ref({
|
||||
organizer: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
});
|
||||
|
||||
const presets = useContextPresets();
|
||||
|
||||
const translationKey = computed<string>(() => {
|
||||
const typeMap = {
|
||||
categories: "category.category",
|
||||
tags: "tag.tag",
|
||||
tools: "tool.tool",
|
||||
foods: "shopping-list.food",
|
||||
households: "household.household",
|
||||
};
|
||||
return typeMap[props.itemType] || "";
|
||||
});
|
||||
|
||||
const deleteTarget = ref<GenericItem | null>(null);
|
||||
const updateTarget = ref<GenericItem | null>(null);
|
||||
|
||||
function confirmDelete(item: GenericItem) {
|
||||
deleteTarget.value = item;
|
||||
dialogs.value.delete = true;
|
||||
}
|
||||
|
||||
function deleteOne() {
|
||||
if (!deleteTarget.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("delete", deleteTarget.value.id);
|
||||
}
|
||||
|
||||
function openUpdateDialog(item: GenericItem) {
|
||||
updateTarget.value = deepCopy(item);
|
||||
dialogs.value.update = true;
|
||||
}
|
||||
|
||||
function updateOne() {
|
||||
if (!updateTarget.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("update", updateTarget.value);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Search Functions
|
||||
|
||||
const searchString = useRouteQuery("q", "");
|
||||
|
||||
const fuse = computed(() => {
|
||||
return new Fuse(props.items, state.options);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
const itemsSorted = computed(() => {
|
||||
const byLetter: { [key: string]: Array<GenericItem> } = {};
|
||||
|
||||
if (!fuzzyItems.value) {
|
||||
return byLetter;
|
||||
}
|
||||
|
||||
[...fuzzyItems.value]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach((item) => {
|
||||
const letter = item.name[0].toUpperCase();
|
||||
if (!byLetter[letter]) {
|
||||
byLetter[letter] = [];
|
||||
}
|
||||
byLetter[letter].push(item);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model="selected"
|
||||
v-bind="inputAttrs"
|
||||
v-model:search="searchInput"
|
||||
:items="items"
|
||||
:items="storeItem"
|
||||
:label="label"
|
||||
chips
|
||||
closable-chips
|
||||
@@ -46,138 +46,180 @@
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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";
|
||||
|
||||
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";
|
||||
}
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array as () => (
|
||||
| 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"],
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
inputAttrs: () => ({}),
|
||||
returnObject: true,
|
||||
showAdd: true,
|
||||
showLabel: true,
|
||||
showIcon: true,
|
||||
variant: "outlined",
|
||||
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 { $globals } = useNuxtApp();
|
||||
|
||||
const label = computed(() => {
|
||||
if (!props.showLabel) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (props.selectorType) {
|
||||
case Organizer.Tag:
|
||||
return i18n.t("tag.tags");
|
||||
case Organizer.Category:
|
||||
return i18n.t("category.categories");
|
||||
case Organizer.Tool:
|
||||
return i18n.t("tool.tools");
|
||||
case Organizer.Food:
|
||||
return i18n.t("general.foods");
|
||||
case Organizer.Household:
|
||||
return i18n.t("household.households");
|
||||
default:
|
||||
return i18n.t("general.organizer");
|
||||
}
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
if (!props.showIcon) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (props.selectorType) {
|
||||
case Organizer.Tag:
|
||||
return $globals.icons.tags;
|
||||
case Organizer.Category:
|
||||
return $globals.icons.categories;
|
||||
case Organizer.Tool:
|
||||
return $globals.icons.tools;
|
||||
case Organizer.Food:
|
||||
return $globals.icons.foods;
|
||||
case Organizer.Household:
|
||||
return $globals.icons.household;
|
||||
default:
|
||||
return $globals.icons.tags;
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Store & Items Setup
|
||||
|
||||
const storeMap = {
|
||||
[Organizer.Category]: useCategoryStore(),
|
||||
[Organizer.Tag]: useTagStore(),
|
||||
[Organizer.Tool]: useToolStore(),
|
||||
[Organizer.Food]: useFoodStore(),
|
||||
[Organizer.Household]: useHouseholdStore(),
|
||||
};
|
||||
|
||||
const store = computed(() => {
|
||||
const { store } = storeMap[props.selectorType];
|
||||
return store.value;
|
||||
});
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.returnObject) {
|
||||
return store.value.map(item => item.name);
|
||||
}
|
||||
return store.value;
|
||||
});
|
||||
|
||||
function removeByIndex(index: number) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
const newSelected = selected.value.filter((_, i) => i !== index);
|
||||
selected.value = [...newSelected];
|
||||
}
|
||||
|
||||
function appendCreated(item: any) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
selected.value = [...selected.value, item];
|
||||
}
|
||||
|
||||
const dialog = ref(false);
|
||||
|
||||
const searchInput = ref("");
|
||||
|
||||
function resetSearchInput() {
|
||||
searchInput.value = "";
|
||||
}
|
||||
|
||||
return {
|
||||
Organizer,
|
||||
appendCreated,
|
||||
dialog,
|
||||
storeItem: items,
|
||||
label,
|
||||
icon,
|
||||
selected,
|
||||
removeByIndex,
|
||||
searchInput,
|
||||
resetSearchInput,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const selected = defineModel<(
|
||||
| HouseholdSummary
|
||||
| RecipeTag
|
||||
| RecipeCategory
|
||||
| RecipeTool
|
||||
| IngredientFood
|
||||
| string
|
||||
)[] | undefined>({ required: true });
|
||||
|
||||
onMounted(() => {
|
||||
if (selected.value === undefined) {
|
||||
selected.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const label = computed(() => {
|
||||
if (!props.showLabel) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (props.selectorType) {
|
||||
case Organizer.Tag:
|
||||
return i18n.t("tag.tags");
|
||||
case Organizer.Category:
|
||||
return i18n.t("category.categories");
|
||||
case Organizer.Tool:
|
||||
return i18n.t("tool.tools");
|
||||
case Organizer.Food:
|
||||
return i18n.t("general.foods");
|
||||
case Organizer.Household:
|
||||
return i18n.t("household.households");
|
||||
default:
|
||||
return i18n.t("general.organizer");
|
||||
}
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
if (!props.showIcon) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (props.selectorType) {
|
||||
case Organizer.Tag:
|
||||
return $globals.icons.tags;
|
||||
case Organizer.Category:
|
||||
return $globals.icons.categories;
|
||||
case Organizer.Tool:
|
||||
return $globals.icons.tools;
|
||||
case Organizer.Food:
|
||||
return $globals.icons.foods;
|
||||
case Organizer.Household:
|
||||
return $globals.icons.household;
|
||||
default:
|
||||
return $globals.icons.tags;
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Store & Items Setup
|
||||
|
||||
const storeMap = {
|
||||
[Organizer.Category]: useCategoryStore(),
|
||||
[Organizer.Tag]: useTagStore(),
|
||||
[Organizer.Tool]: useToolStore(),
|
||||
[Organizer.Food]: useFoodStore(),
|
||||
[Organizer.Household]: useHouseholdStore(),
|
||||
};
|
||||
|
||||
const store = computed(() => {
|
||||
const { store } = storeMap[props.selectorType];
|
||||
return store.value;
|
||||
});
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.returnObject) {
|
||||
return store.value.map(item => item.name);
|
||||
}
|
||||
return store.value;
|
||||
});
|
||||
|
||||
function removeByIndex(index: number) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
const newSelected = selected.value.filter((_, i) => i !== index);
|
||||
selected.value = [...newSelected];
|
||||
}
|
||||
|
||||
function appendCreated(item: any) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
selected.value = [...selected.value, item];
|
||||
}
|
||||
|
||||
const dialog = ref(false);
|
||||
|
||||
const searchInput = ref("");
|
||||
|
||||
function resetSearchInput() {
|
||||
searchInput.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<RecipePageIngredientEditor v-if="isEditForm" v-model="recipe" />
|
||||
</div>
|
||||
<div>
|
||||
<RecipePageScale v-model="scale" :recipe="recipe" />
|
||||
<RecipePageScale v-model:scale="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" :recipe="recipe" />
|
||||
<RecipePageScale v-model:scale="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" :recipe="recipe" />
|
||||
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
||||
</div>
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
@@ -141,6 +141,7 @@
|
||||
<RecipeIngredients
|
||||
:value="notLinkedIngredients"
|
||||
:scale="scale"
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
:is-cook-mode="isCookMode"
|
||||
/>
|
||||
</v-card>
|
||||
@@ -277,7 +278,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) {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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,48 +35,82 @@ 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 } from "~/composables/recipe-page/shared-state";
|
||||
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
||||
|
||||
interface Props {
|
||||
recipe: NoUndefinedField<Recipe>;
|
||||
recipeScale?: number;
|
||||
landscape?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipeScale: 1,
|
||||
landscape: false,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
function printRecipe() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
const hideImage = ref(false);
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
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();
|
||||
|
||||
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);
|
||||
|
||||
function printRecipe() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
const hideImage = ref(false);
|
||||
const imageHeight = computed(() => {
|
||||
return $vuetify.display.xs.value ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
isOwnGroup,
|
||||
setMode,
|
||||
toggleEditMode,
|
||||
recipeImage,
|
||||
canEditRecipe,
|
||||
imageKey,
|
||||
user,
|
||||
PageMode,
|
||||
pageMode,
|
||||
EditorMode,
|
||||
editMode,
|
||||
printRecipe,
|
||||
imageHeight,
|
||||
hideImage,
|
||||
isEditMode,
|
||||
recipeImageUrl,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
>
|
||||
<RecipeYield
|
||||
:yield-quantity="recipe.recipeYieldQuantity"
|
||||
:yield-text="recipe.recipeYield"
|
||||
:yield="recipe.recipeYield"
|
||||
:scale="recipeScale"
|
||||
class="mb-4"
|
||||
/>
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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,15 +86,34 @@ 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";
|
||||
|
||||
interface Props {
|
||||
recipe: NoUndefinedField<Recipe>;
|
||||
recipeScale?: number;
|
||||
landscape: boolean;
|
||||
}
|
||||
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();
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
recipeScale: 1,
|
||||
return {
|
||||
isOwnGroup,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
</script>
|
||||
|
||||
@@ -12,47 +12,60 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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";
|
||||
|
||||
interface Props {
|
||||
recipe: NoUndefinedField<Recipe>;
|
||||
maxWidth?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxWidth: undefined,
|
||||
});
|
||||
|
||||
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(() => {
|
||||
return $vuetify.display.xs.value ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
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();
|
||||
|
||||
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(() => {
|
||||
return $vuetify.display.xs.value ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
recipeImageUrl,
|
||||
imageKey,
|
||||
hideImage,
|
||||
imageHeight,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<!-- 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>
|
||||
<h2 class="mb-4 text-h5 font-weight-medium opacity-80">
|
||||
{{ $t("recipe.ingredients") }}
|
||||
</h2>
|
||||
<VueDraggable
|
||||
v-if="recipe.recipeIngredient.length > 0"
|
||||
v-model="recipe.recipeIngredient"
|
||||
@@ -32,6 +27,7 @@
|
||||
: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)"
|
||||
@@ -46,14 +42,14 @@
|
||||
/>
|
||||
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
|
||||
<v-tooltip
|
||||
location="top"
|
||||
top
|
||||
color="accent"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<span>
|
||||
<BaseButton
|
||||
class="mb-1"
|
||||
:disabled="hasFoodOrUnit"
|
||||
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
|
||||
color="accent"
|
||||
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
|
||||
v-bind="props"
|
||||
@@ -113,7 +109,10 @@ const hasFoodOrUnit = computed(() => {
|
||||
});
|
||||
|
||||
const parserToolTip = computed(() => {
|
||||
if (hasFoodOrUnit.value) {
|
||||
if (recipe.value.settings.disableAmount) {
|
||||
return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature");
|
||||
}
|
||||
else if (hasFoodOrUnit.value) {
|
||||
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
|
||||
}
|
||||
return i18n.t("recipe.parse-ingredients");
|
||||
@@ -128,6 +127,7 @@ function addIngredient(ingredients: Array<string> | null = null) {
|
||||
note: x,
|
||||
unit: undefined,
|
||||
food: undefined,
|
||||
disableAmount: true,
|
||||
quantity: 1,
|
||||
};
|
||||
});
|
||||
@@ -146,6 +146,7 @@ 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,
|
||||
});
|
||||
}
|
||||
@@ -160,6 +161,7 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<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">
|
||||
@@ -35,7 +36,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { useToolStore } from "~/composables/store";
|
||||
@@ -47,52 +48,71 @@ interface RecipeToolWithOnHand extends RecipeTool {
|
||||
onHand: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
recipe: NoUndefinedField<Recipe>;
|
||||
scale: number;
|
||||
isCookMode?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isCookMode: false,
|
||||
});
|
||||
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();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const toolStore = isOwnGroup.value ? useToolStore() : null;
|
||||
const { user } = usePageUser();
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
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);
|
||||
}
|
||||
else {
|
||||
return props.recipe.tools.map((tool) => {
|
||||
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
||||
return { ...tool, onHand } as RecipeToolWithOnHand;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function updateTool(index: number) {
|
||||
if (user.id && user.householdSlug && toolStore) {
|
||||
const tool = recipeTools.value[index];
|
||||
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
if (!tool.householdsWithTool) {
|
||||
tool.householdsWithTool = [user.householdSlug];
|
||||
const recipeTools = computed(() => {
|
||||
if (!(user.householdSlug && toolStore)) {
|
||||
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
||||
}
|
||||
else {
|
||||
tool.householdsWithTool.push(user.householdSlug);
|
||||
return props.recipe.tools.map((tool) => {
|
||||
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
||||
return { ...tool, onHand } as RecipeToolWithOnHand;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function updateTool(index: number) {
|
||||
if (user.id && user.householdSlug && toolStore) {
|
||||
const tool = recipeTools.value[index];
|
||||
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
if (!tool.householdsWithTool) {
|
||||
tool.householdsWithTool = [user.householdSlug];
|
||||
}
|
||||
else {
|
||||
tool.householdsWithTool.push(user.householdSlug);
|
||||
}
|
||||
}
|
||||
else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
|
||||
}
|
||||
|
||||
toolStore.actions.updateOne(tool);
|
||||
}
|
||||
else {
|
||||
console.log("no user, skipping server update");
|
||||
}
|
||||
}
|
||||
else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
|
||||
}
|
||||
|
||||
toolStore.actions.updateOne(tool);
|
||||
}
|
||||
else {
|
||||
console.log("no user, skipping server update");
|
||||
}
|
||||
}
|
||||
return {
|
||||
toolStore,
|
||||
recipeTools,
|
||||
isEditMode,
|
||||
updateTool,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -29,31 +29,33 @@
|
||||
{{ activeText }}
|
||||
</p>
|
||||
<v-divider class="mb-4" />
|
||||
<v-checkbox-btn
|
||||
<v-checkbox
|
||||
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)" />
|
||||
<RecipeIngredientHtml :markup="parseIngredientText(ing, recipe.settings.disableAmount)" />
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</v-checkbox>
|
||||
|
||||
<template v-if="usedIngredients.length > 0">
|
||||
<h4 class="py-3 ml-1">
|
||||
{{ $t("recipe.linked-to-other-step") }}
|
||||
</h4>
|
||||
<v-checkbox-btn
|
||||
<v-checkbox
|
||||
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)" />
|
||||
<RecipeIngredientHtml :markup="parseIngredientText(ing, recipe.settings.disableAmount)" />
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</v-checkbox>
|
||||
</template>
|
||||
</v-card-text>
|
||||
|
||||
@@ -323,6 +325,7 @@
|
||||
return step.ingredientReferences.map((ref) => ref.referenceId).includes(ing.referenceId || '')
|
||||
})"
|
||||
:scale="scale"
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
:is-cook-mode="isCookMode"
|
||||
/>
|
||||
</div>
|
||||
@@ -390,6 +393,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(["click-instruction-field", "update:assets"]);
|
||||
|
||||
const BASE_URL = useRequestURL().origin;
|
||||
|
||||
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
|
||||
|
||||
const dialog = ref(false);
|
||||
@@ -549,6 +554,7 @@ function autoSetReferences() {
|
||||
props.recipe.recipeIngredient,
|
||||
activeRefs.value,
|
||||
activeText.value,
|
||||
props.recipe.settings.disableAmount,
|
||||
).forEach((ingredient: string) => activeRefs.value.push(ingredient));
|
||||
}
|
||||
|
||||
@@ -570,7 +576,7 @@ function getIngredientByRefId(refId: string | undefined) {
|
||||
|
||||
const ing = ingredientLookup.value[refId];
|
||||
if (!ing) return "";
|
||||
return parseIngredientText(ing, props.scale);
|
||||
return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale);
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
@@ -693,7 +699,7 @@ async function handleImageDrop(index: number, files: File[]) {
|
||||
}
|
||||
|
||||
emit("update:assets", [...assets.value, data]);
|
||||
const assetUrl = recipeAssetPath(props.recipe.id, data.fileName as string);
|
||||
const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string);
|
||||
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
|
||||
instructionList.value[index].text += text;
|
||||
}
|
||||
|
||||
@@ -2,37 +2,55 @@
|
||||
<div class="d-flex justify-space-between align-center pt-2 pb-3">
|
||||
<RecipeScaleEditButton
|
||||
v-if="!isEditMode"
|
||||
v-model.number="scale"
|
||||
v-model.number="scaleValue"
|
||||
:recipe-servings="recipeServings"
|
||||
:edit-scale="hasFoodOrUnit && !isEditMode"
|
||||
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script 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";
|
||||
|
||||
const props = defineProps<{ recipe: NoUndefinedField<Recipe> }>();
|
||||
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 scale = defineModel<number>({ default: 1 });
|
||||
const recipeServings = computed<number>(() => {
|
||||
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
|
||||
});
|
||||
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
const scaleValue = computed<number>({
|
||||
get() {
|
||||
return props.scale;
|
||||
},
|
||||
set(val) {
|
||||
emit("update:scale", val);
|
||||
},
|
||||
});
|
||||
|
||||
const recipeServings = computed<number>(() => {
|
||||
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
|
||||
});
|
||||
|
||||
const hasFoodOrUnit = computed(() => {
|
||||
if (props.recipe.recipeIngredient) {
|
||||
for (const ingredient of props.recipe.recipeIngredient) {
|
||||
if (ingredient.food || ingredient.unit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return {
|
||||
recipeServings,
|
||||
scaleValue,
|
||||
isEditMode,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -8,17 +8,24 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
interface Props {
|
||||
recipe: Recipe;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipePrintView,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => Recipe,
|
||||
required: true,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import DOMPurify from "dompurify";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
@@ -188,141 +188,167 @@ type InstructionSection = {
|
||||
instructions: RecipeStep[];
|
||||
};
|
||||
|
||||
interface Props {
|
||||
recipe: NoUndefinedField<Recipe>;
|
||||
scale?: number;
|
||||
dense?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
dense: false,
|
||||
});
|
||||
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();
|
||||
|
||||
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 { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
|
||||
return scaledAmountDisplay || props.recipe.recipeYield
|
||||
? i18n.t("recipe.yields-amount-with-text", {
|
||||
amount: scaledAmountDisplay,
|
||||
text: props.recipe.recipeYield,
|
||||
}) as string
|
||||
: "";
|
||||
});
|
||||
|
||||
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(() => {
|
||||
if (servingsDisplay.value && yieldDisplay.value) {
|
||||
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
||||
}
|
||||
else {
|
||||
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
||||
}
|
||||
});
|
||||
|
||||
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[]>(() => {
|
||||
if (!props.recipe.recipeIngredient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
|
||||
// if title append new section to the end of the array
|
||||
if (ingredient.title) {
|
||||
sections.push({
|
||||
sectionName: ingredient.title,
|
||||
ingredients: [ingredient],
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// append new section if first
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
const servingsDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
|
||||
return scaledAmountDisplay
|
||||
? i18n.t("recipe.yields-amount-with-text", {
|
||||
amount: scaledAmountDisplay,
|
||||
text: props.recipe.recipeYield,
|
||||
}) as string
|
||||
: "";
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
const yieldDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
|
||||
return scaledAmountDisplay ? i18n.t("recipe.serves-amount", { amount: scaledAmountDisplay }) as string : "";
|
||||
});
|
||||
|
||||
// otherwise add ingredient to last section in the array
|
||||
sections[sections.length - 1].ingredients.push(ingredient);
|
||||
return sections;
|
||||
}, [] as IngredientSection[]);
|
||||
});
|
||||
const recipeYield = computed(() => {
|
||||
if (servingsDisplay.value && yieldDisplay.value) {
|
||||
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
||||
}
|
||||
else {
|
||||
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Group instructions by section so we can style them independently
|
||||
const instructionSections = computed<InstructionSection[]>(() => {
|
||||
if (!props.recipe.recipeInstructions) {
|
||||
return [];
|
||||
}
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
return props.recipe.recipeInstructions.reduce((sections, step) => {
|
||||
const offset = (() => {
|
||||
if (sections.length === 0) {
|
||||
return 0;
|
||||
// Group ingredients by section so we can style them independently
|
||||
const ingredientSections = computed<IngredientSection[]>(() => {
|
||||
if (!props.recipe.recipeIngredient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lastOffset = sections[sections.length - 1].stepOffset;
|
||||
const lastNumSteps = sections[sections.length - 1].instructions.length;
|
||||
return lastOffset + lastNumSteps;
|
||||
})();
|
||||
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
|
||||
// if title append new section to the end of the array
|
||||
if (ingredient.title) {
|
||||
sections.push({
|
||||
sectionName: ingredient.title,
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
|
||||
// if title append new section to the end of the array
|
||||
if (step.title) {
|
||||
sections.push({
|
||||
sectionName: step.title,
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
return sections;
|
||||
}
|
||||
|
||||
return sections;
|
||||
// append new section if first
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// otherwise add ingredient to last section in the array
|
||||
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[]>(() => {
|
||||
if (!props.recipe.recipeInstructions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.recipe.recipeInstructions.reduce((sections, step) => {
|
||||
const offset = (() => {
|
||||
if (sections.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lastOffset = sections[sections.length - 1].stepOffset;
|
||||
const lastNumSteps = sections[sections.length - 1].instructions.length;
|
||||
return lastOffset + lastNumSteps;
|
||||
})();
|
||||
|
||||
// if title append new section to the end of the array
|
||||
if (step.title) {
|
||||
sections.push({
|
||||
sectionName: step.title,
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// append if first element
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// otherwise add step to last section in the array
|
||||
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);
|
||||
}
|
||||
|
||||
// append if first element
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// otherwise add step to last section in the array
|
||||
sections[sections.length - 1].instructions.push(step);
|
||||
return sections;
|
||||
}, [] as InstructionSection[]);
|
||||
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>
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
nudge-top="6"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-tooltip
|
||||
v-if="canEditScale"
|
||||
size="small"
|
||||
location="top"
|
||||
top
|
||||
color="secondary-darken-1"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
@@ -23,7 +23,7 @@
|
||||
dark
|
||||
color="secondary-darken-1"
|
||||
size="small"
|
||||
v-bind="{ ...activatorProps, ...tooltipProps }"
|
||||
v-bind="{ ...props, ...tooltipProps }"
|
||||
:style="{ cursor: canEditScale ? '' : 'default' }"
|
||||
>
|
||||
<v-icon
|
||||
@@ -45,7 +45,7 @@
|
||||
dark
|
||||
color="secondary-darken-1"
|
||||
size="small"
|
||||
v-bind="activatorProps"
|
||||
v-bind="props"
|
||||
:style="{ cursor: canEditScale ? '' : 'default' }"
|
||||
>
|
||||
<v-icon
|
||||
@@ -66,22 +66,21 @@
|
||||
<v-card-text class="mt-n5">
|
||||
<div class="mt-4 d-flex align-center">
|
||||
<v-text-field
|
||||
:model-value="yieldQuantity"
|
||||
:model-value="yieldQuantityEditorValue"
|
||||
type="number"
|
||||
:min="0"
|
||||
variant="underlined"
|
||||
hide-spin-buttons
|
||||
@update:model-value="recalculateScale(parseFloat($event) || 0)"
|
||||
@update:model-value="recalculateScale(yieldQuantityEditorValue)"
|
||||
/>
|
||||
<v-tooltip
|
||||
location="end"
|
||||
end
|
||||
color="secondary-darken-1"
|
||||
>
|
||||
<template #activator="{ props: resetTooltipProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="resetTooltipProps"
|
||||
v-bind="props"
|
||||
icon
|
||||
flat
|
||||
class="mx-1"
|
||||
size="small"
|
||||
@click="scale = 1"
|
||||
@@ -122,50 +121,90 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
interface Props {
|
||||
recipeServings?: number;
|
||||
editScale?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipeServings: 0,
|
||||
editScale: false,
|
||||
});
|
||||
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);
|
||||
|
||||
const scale = defineModel<number>({ required: true });
|
||||
const scale = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
const newScaleNumber = parseFloat(`${value}`);
|
||||
emit("update:modelValue", isNaN(newScaleNumber) ? 0 : newScaleNumber);
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function recalculateScale(newYield: number) {
|
||||
if (isNaN(newYield) || newYield <= 0) {
|
||||
return;
|
||||
}
|
||||
if (props.recipeServings <= 0) {
|
||||
scale.value = 1;
|
||||
}
|
||||
else {
|
||||
scale.value = newYield / props.recipeServings;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.recipeServings <= 0) {
|
||||
scale.value = 1;
|
||||
}
|
||||
else {
|
||||
scale.value = newYield / props.recipeServings;
|
||||
}
|
||||
}
|
||||
const recipeYieldAmount = computed(() => {
|
||||
return useScaledAmount(props.recipeServings, scale.value);
|
||||
});
|
||||
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
|
||||
const yieldDisplay = computed(() => {
|
||||
return yieldQuantity.value
|
||||
? i18n.t(
|
||||
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay },
|
||||
) as string
|
||||
: "";
|
||||
});
|
||||
|
||||
const recipeYieldAmount = computed(() => {
|
||||
return useScaledAmount(props.recipeServings, scale.value);
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
const disableDecrement = computed(() => {
|
||||
return yieldQuantity.value <= 1;
|
||||
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
|
||||
},
|
||||
);
|
||||
|
||||
const disableDecrement = computed(() => {
|
||||
return recipeYieldAmount.value.scaledAmount <= 1;
|
||||
});
|
||||
|
||||
return {
|
||||
menu,
|
||||
canEditScale,
|
||||
scale,
|
||||
recalculateScale,
|
||||
yieldDisplay,
|
||||
yieldQuantity,
|
||||
yieldQuantityEditorValue,
|
||||
disableDecrement,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<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>
|
||||
@@ -31,6 +31,7 @@ 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>
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
<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" />
|
||||
<v-icon class="ma-0 pa-0">
|
||||
{{ organizer.icon }}
|
||||
</v-icon>
|
||||
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content">
|
||||
{{ $t("recipe-finder.missing") }}:
|
||||
</v-card-text>
|
||||
@@ -39,7 +41,7 @@
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import type { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe";
|
||||
|
||||
@@ -49,72 +51,71 @@ interface Organizer {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
recipe: RecipeSummary;
|
||||
missingFoods?: IngredientFood[] | null;
|
||||
missingTools?: RecipeTool[] | null;
|
||||
disableCheckbox?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
missingFoods: null,
|
||||
missingTools: null,
|
||||
disableCheckbox: false,
|
||||
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(() => [
|
||||
{
|
||||
type: "food",
|
||||
show: props.missingFoods?.length,
|
||||
icon: $globals.icons.foods,
|
||||
items: props.missingFoods
|
||||
? props.missingFoods.map((food) => {
|
||||
return reactive({ type: "food", item: food, selected: false } as Organizer);
|
||||
})
|
||||
: [],
|
||||
getLabel: (item: IngredientFood) => item.pluralName || item.name,
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
show: props.missingTools?.length,
|
||||
icon: $globals.icons.tools,
|
||||
items: props.missingTools
|
||||
? props.missingTools.map((tool) => {
|
||||
return reactive({ type: "tool", item: tool, selected: false } as Organizer);
|
||||
})
|
||||
: [],
|
||||
getLabel: (item: RecipeTool) => item.name,
|
||||
},
|
||||
]);
|
||||
|
||||
function handleCheckbox(organizer: Organizer) {
|
||||
if (props.disableCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
organizer.selected = !organizer.selected;
|
||||
if (organizer.selected) {
|
||||
context.emit(`add-${organizer.type}`, organizer.item);
|
||||
}
|
||||
else {
|
||||
context.emit(`remove-${organizer.type}`, organizer.item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
missingOrganizers,
|
||||
handleCheckbox,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
icon: $globals.icons.foods,
|
||||
items: props.missingFoods
|
||||
? props.missingFoods.map((food) => {
|
||||
return reactive({ type: "food", item: food, selected: false } as Organizer);
|
||||
})
|
||||
: [],
|
||||
getLabel: (item: IngredientFood) => item.pluralName || item.name,
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
show: props.missingTools?.length,
|
||||
icon: $globals.icons.tools,
|
||||
items: props.missingTools
|
||||
? props.missingTools.map((tool) => {
|
||||
return reactive({ type: "tool", item: tool, selected: false } as Organizer);
|
||||
})
|
||||
: [],
|
||||
getLabel: (item: RecipeTool) => item.name,
|
||||
},
|
||||
]);
|
||||
|
||||
function handleCheckbox(organizer: Organizer) {
|
||||
if (props.disableCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
organizer.selected = !organizer.selected;
|
||||
if (organizer.selected) {
|
||||
if (organizer.type === "food") {
|
||||
emit("add-food", organizer.item as IngredientFood);
|
||||
}
|
||||
else {
|
||||
emit("add-tool", organizer.item as RecipeTool);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (organizer.type === "food") {
|
||||
emit("remove-food", organizer.item as IngredientFood);
|
||||
}
|
||||
else {
|
||||
emit("remove-tool", organizer.item as RecipeTool);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template v-if="_showCards">
|
||||
<template v-if="showCards">
|
||||
<div class="text-center">
|
||||
<!-- Total Time -->
|
||||
<div
|
||||
@@ -78,46 +78,65 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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,
|
||||
});
|
||||
<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();
|
||||
|
||||
const i18n = useI18n();
|
||||
function isEmpty(str: string | null) {
|
||||
return !str || str.length === 0;
|
||||
}
|
||||
|
||||
function isEmpty(str: string | null) {
|
||||
return !str || str.length === 0;
|
||||
}
|
||||
const showCards = computed(() => {
|
||||
return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x));
|
||||
});
|
||||
|
||||
const _showCards = computed(() => {
|
||||
return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x));
|
||||
});
|
||||
const validateTotalTime = computed(() => {
|
||||
return !isEmpty(props.totalTime) ? { name: i18n.t("recipe.total-time"), value: props.totalTime } : null;
|
||||
});
|
||||
|
||||
const validateTotalTime = computed(() => {
|
||||
return !isEmpty(props.totalTime) ? { name: i18n.t("recipe.total-time"), value: props.totalTime } : null;
|
||||
});
|
||||
const validatePrepTime = computed(() => {
|
||||
return !isEmpty(props.prepTime) ? { name: i18n.t("recipe.prep-time"), value: props.prepTime } : null;
|
||||
});
|
||||
|
||||
const validatePrepTime = computed(() => {
|
||||
return !isEmpty(props.prepTime) ? { name: i18n.t("recipe.prep-time"), value: props.prepTime } : null;
|
||||
});
|
||||
const validatePerformTime = computed(() => {
|
||||
return !isEmpty(props.performTime) ? { name: i18n.t("recipe.perform-time"), value: props.performTime } : null;
|
||||
});
|
||||
|
||||
const validatePerformTime = computed(() => {
|
||||
return !isEmpty(props.performTime) ? { name: i18n.t("recipe.perform-time"), value: props.performTime } : null;
|
||||
});
|
||||
const fontSize = computed(() => {
|
||||
return props.small ? { fontSize: "smaller" } : { fontSize: "larger" };
|
||||
});
|
||||
|
||||
const fontSize = computed(() => {
|
||||
return props.small ? { fontSize: "smaller" } : { fontSize: "larger" };
|
||||
return {
|
||||
showCards,
|
||||
validateTotalTime,
|
||||
validatePrepTime,
|
||||
validatePerformTime,
|
||||
fontSize,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
nudge-bottom="3"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-badge
|
||||
:content="filterBadgeCount"
|
||||
:model-value="filterBadgeCount > 0"
|
||||
@@ -21,7 +21,7 @@
|
||||
class="rounded-circle"
|
||||
size="small"
|
||||
color="info"
|
||||
v-bind="activatorProps"
|
||||
v-bind="props"
|
||||
icon
|
||||
>
|
||||
<v-icon> {{ $globals.icons.filter }} </v-icon>
|
||||
@@ -105,7 +105,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { useThrottleFn, whenever } from "@vueuse/core";
|
||||
import RecipeTimelineItem from "./RecipeTimelineItem.vue";
|
||||
import { useTimelinePreferences } from "~/composables/use-users/preferences";
|
||||
@@ -115,208 +115,252 @@ import { alert } from "~/composables/use-toast";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe";
|
||||
|
||||
interface Props {
|
||||
modelValue?: boolean;
|
||||
queryFilter: string;
|
||||
maxHeight?: number | string;
|
||||
showRecipeCards?: boolean;
|
||||
}
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeTimelineItem },
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
maxHeight: undefined,
|
||||
showRecipeCards: 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 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;
|
||||
|
||||
whenever(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initializeTimelineEvents();
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
queryFilter: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
maxHeight: {
|
||||
type: [Number, String],
|
||||
default: undefined,
|
||||
},
|
||||
showRecipeCards: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Preferences
|
||||
function reverseSort() {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
const i18n = useI18n();
|
||||
const preferences = useTimelinePreferences();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
const loading = ref(true);
|
||||
const ready = ref(false);
|
||||
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
const page = ref(1);
|
||||
const perPage = 32;
|
||||
const hasMore = ref(true);
|
||||
|
||||
function toggleEventTypeOption(option: TimelineEventType) {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
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 index = preferences.value.types.indexOf(option);
|
||||
if (index === -1) {
|
||||
preferences.value.types.push(option);
|
||||
}
|
||||
else {
|
||||
preferences.value.types.splice(index, 1);
|
||||
}
|
||||
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
|
||||
// Timeline Actions
|
||||
async function updateTimelineEvent(index: number) {
|
||||
const event = timelineEvents.value[index];
|
||||
const payload: RecipeTimelineEventUpdate = {
|
||||
subject: event.subject,
|
||||
eventMessage: event.eventMessage,
|
||||
image: event.image,
|
||||
};
|
||||
|
||||
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
|
||||
if (response?.status !== 200) {
|
||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
alert.success(i18n.t("events.event-updated") as string);
|
||||
}
|
||||
|
||||
async function deleteTimelineEvent(index: number) {
|
||||
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
|
||||
if (response?.status !== 200) {
|
||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
timelineEvents.value.splice(index, 1);
|
||||
alert.success(i18n.t("events.event-deleted") as string);
|
||||
}
|
||||
|
||||
async function 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 recipeIds: string[] = [];
|
||||
events.forEach((event) => {
|
||||
if (recipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
|
||||
return;
|
||||
interface ScrollEvent extends Event {
|
||||
target: HTMLInputElement;
|
||||
}
|
||||
|
||||
recipeIds.push(event.recipeId);
|
||||
});
|
||||
const screenBuffer = 4;
|
||||
const onScroll = (event: ScrollEvent) => {
|
||||
if (!event.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await getRecipes(recipeIds);
|
||||
results.forEach((result) => {
|
||||
if (!result?.id) {
|
||||
return;
|
||||
}
|
||||
recipes.set(result.id, result);
|
||||
});
|
||||
}
|
||||
const { scrollTop, offsetHeight, scrollHeight } = event.target;
|
||||
|
||||
async function scrollTimelineEvents() {
|
||||
const orderBy = "timestamp";
|
||||
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
|
||||
// trigger when the user is getting close to the bottom
|
||||
const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight * screenBuffer);
|
||||
if (bottomOfElement) {
|
||||
infiniteScroll();
|
||||
}
|
||||
};
|
||||
|
||||
const eventTypeValue = `["${preferences.value.types.join("\", \"")}"]`;
|
||||
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`;
|
||||
whenever(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initializeTimelineEvents();
|
||||
},
|
||||
);
|
||||
|
||||
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter });
|
||||
page.value += 1;
|
||||
if (!response?.data) {
|
||||
return;
|
||||
}
|
||||
// Preferences
|
||||
function reverseSort() {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const events = response.data.items;
|
||||
if (events.length < perPage) {
|
||||
hasMore.value = false;
|
||||
if (!events.length) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// fetch recipes
|
||||
if (props.showRecipeCards) {
|
||||
await updateRecipes(events);
|
||||
}
|
||||
|
||||
// this is set last so Vue knows to re-render
|
||||
timelineEvents.value.push(...events);
|
||||
}
|
||||
|
||||
async function initializeTimelineEvents() {
|
||||
loading.value = true;
|
||||
ready.value = false;
|
||||
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
timelineEvents.value = [];
|
||||
await scrollTimelineEvents();
|
||||
|
||||
ready.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const infiniteScroll = useThrottleFn(() => {
|
||||
useAsyncData(useAsyncKey(), async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
await scrollTimelineEvents();
|
||||
loading.value = false;
|
||||
});
|
||||
}, 500);
|
||||
function toggleEventTypeOption(option: TimelineEventType) {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// preload events
|
||||
initializeTimelineEvents();
|
||||
const index = preferences.value.types.indexOf(option);
|
||||
if (index === -1) {
|
||||
preferences.value.types.push(option);
|
||||
}
|
||||
else {
|
||||
preferences.value.types.splice(index, 1);
|
||||
}
|
||||
|
||||
onMounted(
|
||||
() => {
|
||||
document.onscroll = () => {
|
||||
// if the inner element is scrollable, let its scroll event handle the infiniteScroll
|
||||
const timelineContainerElement = document.getElementById("timeline-container");
|
||||
if (timelineContainerElement) {
|
||||
const { clientHeight, scrollHeight } = timelineContainerElement;
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
|
||||
// if scrollHeight == clientHeight, the element is not scrollable, so we need to look at the global position
|
||||
// if scrollHeight > clientHeight, it is scrollable and we don't need to do anything here
|
||||
if (scrollHeight > clientHeight) {
|
||||
// Timeline Actions
|
||||
async function updateTimelineEvent(index: number) {
|
||||
const event = timelineEvents.value[index];
|
||||
const payload: RecipeTimelineEventUpdate = {
|
||||
subject: event.subject,
|
||||
eventMessage: event.eventMessage,
|
||||
image: event.image,
|
||||
};
|
||||
|
||||
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
|
||||
if (response?.status !== 200) {
|
||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
alert.success(i18n.t("events.event-updated") as string);
|
||||
};
|
||||
|
||||
async function deleteTimelineEvent(index: number) {
|
||||
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
|
||||
if (response?.status !== 200) {
|
||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
timelineEvents.value.splice(index, 1);
|
||||
alert.success(i18n.t("events.event-deleted") as string);
|
||||
};
|
||||
|
||||
async function getRecipe(recipeId: string): Promise<Recipe | null> {
|
||||
const { data } = await api.recipes.getOne(recipeId);
|
||||
return data;
|
||||
};
|
||||
|
||||
async function updateRecipes(events: RecipeTimelineEventOut[]) {
|
||||
const recipePromises: Promise<Recipe | null>[] = [];
|
||||
const seenRecipeIds: string[] = [];
|
||||
events.forEach((event) => {
|
||||
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seenRecipeIds.push(event.recipeId);
|
||||
recipePromises.push(getRecipe(event.recipeId));
|
||||
});
|
||||
|
||||
const results = await Promise.all(recipePromises);
|
||||
results.forEach((result) => {
|
||||
if (result && result.id) {
|
||||
recipes.set(result.id, result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollTimelineEvents() {
|
||||
const orderBy = "timestamp";
|
||||
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
|
||||
|
||||
const eventTypeValue = `["${preferences.value.types.join("\", \"")}"]`;
|
||||
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`;
|
||||
|
||||
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter });
|
||||
page.value += 1;
|
||||
if (!response?.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const events = response.data.items;
|
||||
if (events.length < perPage) {
|
||||
hasMore.value = false;
|
||||
if (!events.length) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight * screenBuffer);
|
||||
if (bottomOfWindow) {
|
||||
infiniteScroll();
|
||||
// fetch recipes
|
||||
if (props.showRecipeCards) {
|
||||
await updateRecipes(events);
|
||||
}
|
||||
|
||||
// this is set last so Vue knows to re-render
|
||||
timelineEvents.value.push(...events);
|
||||
};
|
||||
|
||||
async function initializeTimelineEvents() {
|
||||
loading.value = true;
|
||||
ready.value = false;
|
||||
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
timelineEvents.value = [];
|
||||
await scrollTimelineEvents();
|
||||
|
||||
ready.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const infiniteScroll = useThrottleFn(() => {
|
||||
useAsyncData(useAsyncKey(), async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
await scrollTimelineEvents();
|
||||
loading.value = false;
|
||||
});
|
||||
}, 500);
|
||||
|
||||
// preload events
|
||||
initializeTimelineEvents();
|
||||
|
||||
onMounted(
|
||||
() => {
|
||||
document.onscroll = () => {
|
||||
// if the inner element is scrollable, let its scroll event handle the infiniteScroll
|
||||
const timelineContainerElement = document.getElementById("timeline-container");
|
||||
if (timelineContainerElement) {
|
||||
const { clientHeight, scrollHeight } = timelineContainerElement;
|
||||
|
||||
// if scrollHeight == clientHeight, the element is not scrollable, so we need to look at the global position
|
||||
// if scrollHeight > clientHeight, it is scrollable and we don't need to do anything here
|
||||
if (scrollHeight > clientHeight) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight * screenBuffer);
|
||||
if (bottomOfWindow) {
|
||||
infiniteScroll();
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
deleteTimelineEvent,
|
||||
filterBadgeCount,
|
||||
loading,
|
||||
onScroll,
|
||||
preferences,
|
||||
eventTypeFilterState,
|
||||
recipes,
|
||||
reverseSort,
|
||||
toggleEventTypeOption,
|
||||
timelineEvents,
|
||||
updateTimelineEvent,
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<v-tooltip
|
||||
location="bottom"
|
||||
bottom
|
||||
nudge-right="50"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon
|
||||
:variant="buttonStyle ? 'flat' : undefined"
|
||||
@@ -12,7 +12,7 @@
|
||||
size="small"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
:fab="buttonStyle"
|
||||
v-bind="{ ...activatorProps, ...$attrs }"
|
||||
v-bind="{ ...props, ...$attrs }"
|
||||
@click.prevent="toggleTimeline"
|
||||
>
|
||||
<v-icon
|
||||
@@ -39,37 +39,48 @@
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import RecipeTimeline from "./RecipeTimeline.vue";
|
||||
|
||||
interface Props {
|
||||
buttonStyle?: boolean;
|
||||
slug?: string;
|
||||
recipeName?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
buttonStyle: false,
|
||||
slug: "",
|
||||
recipeName: "",
|
||||
});
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeTimeline },
|
||||
|
||||
const i18n = useI18n();
|
||||
const { smAndDown } = useDisplay();
|
||||
const showTimeline = ref(false);
|
||||
props: {
|
||||
buttonStyle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
recipeName: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
|
||||
function toggleTimeline() {
|
||||
showTimeline.value = !showTimeline.value;
|
||||
}
|
||||
setup(props) {
|
||||
const i18n = useI18n();
|
||||
const { smAndDown } = useDisplay();
|
||||
const showTimeline = ref(false);
|
||||
function toggleTimeline() {
|
||||
showTimeline.value = !showTimeline.value;
|
||||
}
|
||||
|
||||
const timelineAttrs = computed(() => {
|
||||
let title = i18n.t("recipe.timeline");
|
||||
if (smAndDown.value) {
|
||||
title += ` – ${props.recipeName}`;
|
||||
}
|
||||
const timelineAttrs = computed(() => {
|
||||
let title = i18n.t("recipe.timeline");
|
||||
if (smAndDown.value) {
|
||||
title += ` – ${props.recipeName}`;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
queryFilter: `recipe.slug="${props.slug}"`,
|
||||
};
|
||||
return {
|
||||
title,
|
||||
queryFilter: `recipe.slug="${props.slug}"`,
|
||||
};
|
||||
});
|
||||
|
||||
return { showTimeline, timelineAttrs, toggleTimeline };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
<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"
|
||||
@@ -91,7 +90,7 @@
|
||||
</v-timeline-item>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
@@ -100,79 +99,96 @@ import type { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
|
||||
|
||||
interface Props {
|
||||
event: RecipeTimelineEventOut;
|
||||
recipe?: Recipe;
|
||||
showRecipeCards?: boolean;
|
||||
}
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar, SafeMarkdown },
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipe: undefined,
|
||||
showRecipeCards: false,
|
||||
});
|
||||
props: {
|
||||
event: {
|
||||
type: Object as () => RecipeTimelineEventOut,
|
||||
required: true,
|
||||
},
|
||||
recipe: {
|
||||
type: Object as () => Recipe,
|
||||
default: undefined,
|
||||
},
|
||||
showRecipeCards: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["selected", "update", "delete"],
|
||||
|
||||
defineEmits<{
|
||||
selected: [];
|
||||
update: [];
|
||||
delete: [];
|
||||
}>();
|
||||
setup(props) {
|
||||
const { $vuetify, $globals } = useNuxtApp();
|
||||
const { recipeTimelineEventImage } = useStaticRoutes();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
|
||||
|
||||
const { $vuetify, $globals } = useNuxtApp();
|
||||
const { recipeTimelineEventImage } = useStaticRoutes();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
const { user: currentUser } = useMealieAuth();
|
||||
|
||||
const { user: currentUser } = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
|
||||
const useMobileFormat = computed(() => {
|
||||
return $vuetify.display.smAndDown.value;
|
||||
});
|
||||
|
||||
const useMobileFormat = computed(() => {
|
||||
return $vuetify.display.smAndDown.value;
|
||||
});
|
||||
const attrs = computed(() => {
|
||||
if (useMobileFormat.value) {
|
||||
return {
|
||||
class: "px-0",
|
||||
small: false,
|
||||
avatar: {
|
||||
size: "30px",
|
||||
class: "pr-0",
|
||||
},
|
||||
image: {
|
||||
maxHeight: "250",
|
||||
class: "my-3",
|
||||
},
|
||||
};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
class: "px-3",
|
||||
small: false,
|
||||
avatar: {
|
||||
size: "42px",
|
||||
class: "",
|
||||
},
|
||||
image: {
|
||||
maxHeight: "300",
|
||||
class: "mb-5",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
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>(() => {
|
||||
if (props.event.image !== "has image") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
|
||||
});
|
||||
|
||||
const attrs = computed(() => {
|
||||
if (useMobileFormat.value) {
|
||||
return {
|
||||
class: "px-0",
|
||||
small: false,
|
||||
avatar: {
|
||||
size: "30px",
|
||||
class: "pr-0",
|
||||
},
|
||||
image: {
|
||||
maxHeight: "250",
|
||||
class: "my-3",
|
||||
},
|
||||
attrs,
|
||||
groupSlug,
|
||||
icon,
|
||||
eventImageUrl,
|
||||
hideImage,
|
||||
timelineEvents,
|
||||
useMobileFormat,
|
||||
currentUser,
|
||||
};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
class: "px-3",
|
||||
small: false,
|
||||
avatar: {
|
||||
size: "42px",
|
||||
class: "",
|
||||
},
|
||||
image: {
|
||||
maxHeight: "300",
|
||||
class: "mb-5",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
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>(() => {
|
||||
if (props.event.image !== "has image") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="yieldDisplay"
|
||||
v-if="scaledAmount"
|
||||
class="d-flex align-center"
|
||||
>
|
||||
<v-row
|
||||
@@ -18,49 +18,53 @@
|
||||
<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="yieldDisplay" />
|
||||
<span v-html="scaledAmount" /> {{ text }}
|
||||
</p>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import DOMPurify from "dompurify";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
interface Props {
|
||||
yieldQuantity?: number;
|
||||
yieldText?: string;
|
||||
scale?: number;
|
||||
color?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
yieldQuantity: 0,
|
||||
yieldText: "",
|
||||
scale: 1,
|
||||
color: "accent custom-transparent",
|
||||
});
|
||||
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) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
const scaledAmount = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
|
||||
return scaledAmountDisplay;
|
||||
});
|
||||
const text = sanitizeHTML(props.yield);
|
||||
|
||||
const yieldDisplay = computed<string>(() => {
|
||||
const components: string[] = [];
|
||||
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
|
||||
if (scaledAmountDisplay) {
|
||||
components.push(scaledAmountDisplay);
|
||||
}
|
||||
|
||||
const text = props.yieldText;
|
||||
if (text) {
|
||||
components.push(text);
|
||||
}
|
||||
|
||||
return sanitizeHTML(components.join(" "));
|
||||
return {
|
||||
scaledAmount,
|
||||
text,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
v-if="requireAll != undefined"
|
||||
v-model="requireAllValue"
|
||||
density="compact"
|
||||
size="small"
|
||||
hide-details
|
||||
class="my-auto"
|
||||
color="primary"
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2 handle"
|
||||
icon
|
||||
v-bind="props"
|
||||
|
||||
@@ -8,25 +8,36 @@
|
||||
class="flex-nowrap align-center"
|
||||
>
|
||||
<v-col :cols="itemLabelCols">
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-checkbox
|
||||
v-model="listItem.checked"
|
||||
hide-details
|
||||
density="compact"
|
||||
class="mt-0 flex-shrink-0"
|
||||
color="null"
|
||||
@change="$emit('checked', listItem)"
|
||||
/>
|
||||
<div
|
||||
class="ml-2 text-truncate"
|
||||
:class="listItem.checked ? 'strike-through' : ''"
|
||||
style="min-width: 0;"
|
||||
>
|
||||
<RecipeIngredientListItem :ingredient="listItem" />
|
||||
</div>
|
||||
</div>
|
||||
<v-checkbox
|
||||
v-model="listItem.checked"
|
||||
class="mt-0"
|
||||
color="null"
|
||||
hide-details
|
||||
density="compact"
|
||||
:label="listItem.note!"
|
||||
@change="$emit('checked', listItem)"
|
||||
>
|
||||
<template #label>
|
||||
<div :class="listItem.checked ? 'strike-through' : ''">
|
||||
<RecipeIngredientListItem
|
||||
:ingredient="listItem"
|
||||
:disable-amount="!(listItem.isFood || listItem.quantity !== 1)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
<v-col
|
||||
v-if="label && showLabel"
|
||||
cols="3"
|
||||
class="text-right"
|
||||
>
|
||||
<MultiPurposeLabel
|
||||
:label="label"
|
||||
size="small"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="auto"
|
||||
class="text-right"
|
||||
@@ -46,7 +57,7 @@
|
||||
open-delay="200"
|
||||
transition="slide-x-reverse-transition"
|
||||
density="compact"
|
||||
location="end"
|
||||
right
|
||||
content-class="text-caption"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
@@ -65,6 +76,27 @@
|
||||
</template>
|
||||
<span>Toggle Recipes</span>
|
||||
</v-tooltip>
|
||||
<!-- Dummy button so the spacing is consistent when labels are enabled -->
|
||||
<v-btn
|
||||
v-else
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
icon
|
||||
disabled
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="ml-2 handle"
|
||||
icon
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
@@ -76,17 +108,6 @@
|
||||
{{ $globals.icons.edit }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="handle"
|
||||
icon
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
@@ -149,6 +170,7 @@
|
||||
@save="save"
|
||||
@cancel="toggleEdit(false)"
|
||||
@delete="$emit('delete')"
|
||||
@toggle-foods="localListItem.isFood = !localListItem.isFood"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -157,6 +179,7 @@
|
||||
import { useOnline } from "@vueuse/core";
|
||||
import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue";
|
||||
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
|
||||
import MultiPurposeLabel from "./MultiPurposeLabel.vue";
|
||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels";
|
||||
import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe";
|
||||
@@ -168,12 +191,16 @@ interface actions {
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { ShoppingListItemEditor, RecipeList, RecipeIngredientListItem },
|
||||
components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeList, RecipeIngredientListItem },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object as () => ShoppingListItemOut,
|
||||
required: true,
|
||||
},
|
||||
showLabel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
labels: {
|
||||
type: Array as () => MultiPurposeLabelOut[],
|
||||
required: true,
|
||||
@@ -195,7 +222,7 @@ export default defineNuxtComponent({
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
const displayRecipeRefs = ref(false);
|
||||
const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : "6");
|
||||
const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : props.showLabel ? "4" : "6");
|
||||
const isOffline = computed(() => useOnline().value === false);
|
||||
|
||||
const contextMenu: actions[] = [
|
||||
@@ -280,7 +307,7 @@ export default defineNuxtComponent({
|
||||
}
|
||||
|
||||
listItem.value.recipeReferences.forEach((ref) => {
|
||||
const recipe = props.recipes?.get(ref.recipeId);
|
||||
const recipe = props.recipes.get(ref.recipeId);
|
||||
if (recipe) {
|
||||
recipeList.push(recipe);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<v-card variant="outlined">
|
||||
<v-card-text class="pb-3 pt-1">
|
||||
<div class="d-md-flex align-center mb-2" style="gap: 20px">
|
||||
<div v-if="listItem.isFood" class="d-md-flex align-center mb-2" style="gap: 20px">
|
||||
<div>
|
||||
<InputQuantity v-model="listItem.quantity" />
|
||||
</div>
|
||||
@@ -26,6 +26,9 @@
|
||||
/>
|
||||
</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
|
||||
@@ -69,7 +72,7 @@
|
||||
</div>
|
||||
<BaseButton
|
||||
v-if="listItem.labelId && listItem.food && listItem.labelId !== listItem.food.labelId"
|
||||
small
|
||||
size="small"
|
||||
color="info"
|
||||
:icon="$globals.icons.tagArrowRight"
|
||||
:text="$t('shopping-list.save-label')"
|
||||
@@ -96,6 +99,11 @@
|
||||
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'),
|
||||
@@ -105,6 +113,7 @@
|
||||
@save="$emit('save')"
|
||||
@cancel="$emit('cancel')"
|
||||
@delete="$emit('delete')"
|
||||
@toggle-foods="listItem.isFood = !listItem.isFood"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<v-tooltip
|
||||
v-if="userId"
|
||||
:disabled="!user || !tooltip"
|
||||
location="end"
|
||||
right
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-avatar
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<v-btn
|
||||
color="white"
|
||||
icon
|
||||
href="https://github.com/mealie-recipes/mealie"
|
||||
href="https://github.com/hay-kot/mealie"
|
||||
target="_blank"
|
||||
>
|
||||
<v-icon>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<v-tooltip
|
||||
ref="copyToolTip"
|
||||
v-model="show"
|
||||
location="top"
|
||||
top
|
||||
:open-on-hover="false"
|
||||
:open-on-click="true"
|
||||
close-delay="500"
|
||||
|
||||
@@ -37,16 +37,15 @@
|
||||
:name="inputField.varName"
|
||||
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate) || (disabledFields && disabledFields.includes(inputField.varName))"
|
||||
:hint="inputField.hint"
|
||||
:hide-details="!inputField.hint"
|
||||
:persistent-hint="!!inputField.hint"
|
||||
hide-details="auto"
|
||||
density="comfortable"
|
||||
@change="emitBlur">
|
||||
<template #label>
|
||||
<span class="ml-4">
|
||||
{{ inputField.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
|
||||
<!-- Text Field -->
|
||||
<v-text-field
|
||||
@@ -98,15 +97,22 @@
|
||||
:label="inputField.label"
|
||||
:name="inputField.varName"
|
||||
:items="inputField.options"
|
||||
item-title="text"
|
||||
item-value="text"
|
||||
:item-title="inputField.itemText"
|
||||
:item-value="inputField.itemValue"
|
||||
:return-object="false"
|
||||
:hint="inputField.hint"
|
||||
density="comfortable"
|
||||
persistent-hint
|
||||
lazy-validation
|
||||
@blur="emitBlur"
|
||||
/>
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div>
|
||||
<v-list-item-title>{{ item.raw.text }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ item.raw.description }}</v-list-item-subtitle>
|
||||
</div>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
open-delay="200"
|
||||
transition="slide-y-reverse-transition"
|
||||
density="compact"
|
||||
location="bottom"
|
||||
bottom
|
||||
content-class="text-caption"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<BaseButton
|
||||
v-if="canSubmit"
|
||||
type="submit"
|
||||
:disabled="submitDisabled || loading"
|
||||
:disabled="submitDisabled"
|
||||
@click="submitEvent"
|
||||
>
|
||||
{{ submitText }}
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-checkbox
|
||||
v-for="itemValue in localHeaders"
|
||||
v-for="itemValue in headers"
|
||||
:key="itemValue.text + itemValue.show"
|
||||
v-model="itemValue.show"
|
||||
v-model="filteredHeaders"
|
||||
:value="itemValue.value"
|
||||
density="compact"
|
||||
flat
|
||||
inset
|
||||
@@ -41,7 +42,7 @@
|
||||
color="info"
|
||||
variant="elevated"
|
||||
:items="bulkActions"
|
||||
v-on="bulkActionListener"
|
||||
v-bind="bulkActionListener"
|
||||
/>
|
||||
<slot name="button-row" />
|
||||
</v-card-actions>
|
||||
@@ -54,7 +55,7 @@
|
||||
</div>
|
||||
<v-data-table
|
||||
v-model="selected"
|
||||
return-object
|
||||
item-key="id"
|
||||
:headers="activeHeaders"
|
||||
:show-select="bulkActions.length > 0"
|
||||
:sort-by="sortBy"
|
||||
@@ -171,20 +172,12 @@ 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 localHeaders.value.filter(header => header.show).map(header => header.value);
|
||||
return props.headers.filter(header => header.show).map(header => header.value);
|
||||
});
|
||||
|
||||
const headersWithoutActions = computed(() =>
|
||||
localHeaders.value
|
||||
props.headers
|
||||
.filter(header => filteredHeaders.value.includes(header.value))
|
||||
.map(header => ({
|
||||
...header,
|
||||
@@ -221,7 +214,6 @@ export default defineNuxtComponent({
|
||||
return {
|
||||
sortBy,
|
||||
selected,
|
||||
localHeaders,
|
||||
filteredHeaders,
|
||||
headersWithoutActions,
|
||||
activeHeaders,
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
:min="min"
|
||||
:max="max"
|
||||
type="number"
|
||||
size="small"
|
||||
variant="plain"
|
||||
density="compact"
|
||||
style="width: 60px;"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,14 +7,13 @@
|
||||
<v-card-text>
|
||||
{{ $t("language-dialog.select-description") }}
|
||||
<v-autocomplete
|
||||
v-model="selectedLocale"
|
||||
v-model="locale"
|
||||
:items="locales"
|
||||
item-title="name"
|
||||
item-value="value"
|
||||
class="my-3"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
@update:model-value="onLocaleSelect"
|
||||
offset
|
||||
>
|
||||
<template #item="{ item, props }">
|
||||
<div
|
||||
@@ -60,14 +59,6 @@ export default defineNuxtComponent({
|
||||
});
|
||||
|
||||
const { locales: LOCALES, locale, i18n } = useLocales();
|
||||
|
||||
const selectedLocale = ref(locale.value);
|
||||
const onLocaleSelect = (value: string) => {
|
||||
if (value && locales.some(l => l.value === value)) {
|
||||
locale.value = value as any;
|
||||
}
|
||||
};
|
||||
|
||||
watch(locale, () => {
|
||||
dialog.value = false; // Close dialog when locale changes
|
||||
});
|
||||
@@ -81,8 +72,6 @@ export default defineNuxtComponent({
|
||||
i18n,
|
||||
locales,
|
||||
locale,
|
||||
selectedLocale,
|
||||
onLocaleSelect,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,10 +26,10 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
setup(_, { emit }) {
|
||||
function parseEvent(event: any): object {
|
||||
if (!event) {
|
||||
return props.modelValue || {};
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
if (event.json) {
|
||||
@@ -43,14 +43,11 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return props.modelValue || {};
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function onChange(event: any) {
|
||||
const parsed = parseEvent(event);
|
||||
if (parsed !== props.modelValue) {
|
||||
emit("update:modelValue", parsed);
|
||||
}
|
||||
emit("update:modelValue", parseEvent(event));
|
||||
}
|
||||
return {
|
||||
onChange,
|
||||
|
||||
@@ -4,39 +4,43 @@ function UnknownToString(ukn: string | unknown) {
|
||||
|
||||
export const useStaticRoutes = () => {
|
||||
const { $config } = useNuxtApp();
|
||||
const serverBase = useRequestURL().origin;
|
||||
|
||||
const prefix = `${$config.public.SUB_PATH}/api`.replace("//", "/");
|
||||
|
||||
const fullBase = serverBase + prefix;
|
||||
|
||||
// Methods to Generate reference urls for assets/images *
|
||||
function recipeImage(recipeId: string, version: string | unknown = "", key: string | number = 1) {
|
||||
return `${prefix}/media/recipes/${recipeId}/images/original.webp?rnd=${key}&version=${UnknownToString(version)}`;
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/original.webp?rnd=${key}&version=${UnknownToString(version)}`;
|
||||
}
|
||||
|
||||
function recipeSmallImage(recipeId: string, version: string | unknown = "", key: string | number = 1) {
|
||||
return `${prefix}/media/recipes/${recipeId}/images/min-original.webp?rnd=${key}&version=${UnknownToString(
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/min-original.webp?rnd=${key}&version=${UnknownToString(
|
||||
version,
|
||||
)}`;
|
||||
}
|
||||
|
||||
function recipeTinyImage(recipeId: string, version: string | unknown = "", key: string | number = 1) {
|
||||
return `${prefix}/media/recipes/${recipeId}/images/tiny-original.webp?rnd=${key}&version=${UnknownToString(
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/tiny-original.webp?rnd=${key}&version=${UnknownToString(
|
||||
version,
|
||||
)}`;
|
||||
}
|
||||
|
||||
function recipeTimelineEventImage(recipeId: string, timelineEventId: string) {
|
||||
return `${prefix}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/original.webp`;
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/original.webp`;
|
||||
}
|
||||
|
||||
function recipeTimelineEventSmallImage(recipeId: string, timelineEventId: string) {
|
||||
return `${prefix}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/min-original.webp`;
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/min-original.webp`;
|
||||
}
|
||||
|
||||
function recipeTimelineEventTinyImage(recipeId: string, timelineEventId: string) {
|
||||
return `${prefix}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/tiny-original.webp`;
|
||||
return `${fullBase}/media/recipes/${recipeId}/images/timeline/${timelineEventId}/tiny-original.webp`;
|
||||
}
|
||||
|
||||
function recipeAssetPath(recipeId: string, assetName: string) {
|
||||
return `${prefix}/media/recipes/${recipeId}/assets/${assetName}`;
|
||||
return `${fullBase}/media/recipes/${recipeId}/assets/${assetName}`;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,8 +18,8 @@ function removeStartingPunctuation(word: string): string {
|
||||
return word.replace(punctuationAtBeginning, "");
|
||||
}
|
||||
|
||||
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string) {
|
||||
const searchText = parseIngredientText(ingredient);
|
||||
function ingredientMatchesWord(ingredient: RecipeIngredient, word: string, recipeIngredientAmountsDisabled: boolean) {
|
||||
const searchText = parseIngredientText(ingredient, recipeIngredientAmountsDisabled);
|
||||
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): Set<string> {
|
||||
export function useExtractIngredientReferences(recipeIngredients: RecipeIngredient[], activeRefs: string[], text: string, recipeIngredientAmountsDisabled: boolean): 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)))
|
||||
.flatMap(word => availableIngredients.filter(ingredient => ingredientMatchesWord(ingredient, word, recipeIngredientAmountsDisabled)))
|
||||
.map(ingredient => ingredient.referenceId as string);
|
||||
// deduplicate
|
||||
|
||||
|
||||
94
frontend/composables/recipe-page/use-recipe-list-state.ts
Normal file
94
frontend/composables/recipe-page/use-recipe-list-state.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
|
||||
interface RecipeListState {
|
||||
recipes: Recipe[];
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
scrollPosition: number;
|
||||
query: RecipeSearchQuery | null;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
const recipeListStates = new Map<string, RecipeListState>();
|
||||
|
||||
function generateStateKey(query: RecipeSearchQuery | null): string {
|
||||
if (!query) return "default";
|
||||
|
||||
const keyParts = [
|
||||
query.search || "",
|
||||
query.orderBy || "",
|
||||
query.orderDirection || "",
|
||||
query.queryFilter || "",
|
||||
JSON.stringify(query.categories || []),
|
||||
JSON.stringify(query.tags || []),
|
||||
JSON.stringify(query.tools || []),
|
||||
JSON.stringify(query.foods || []),
|
||||
JSON.stringify(query.households || []),
|
||||
];
|
||||
|
||||
return keyParts.join("|");
|
||||
}
|
||||
|
||||
export function useRecipeListState(query: RecipeSearchQuery | null) {
|
||||
const stateKey = generateStateKey(query);
|
||||
|
||||
// Initialize state if it doesn't exist
|
||||
if (!recipeListStates.has(stateKey)) {
|
||||
recipeListStates.set(stateKey, {
|
||||
recipes: [],
|
||||
page: 1,
|
||||
hasMore: true,
|
||||
scrollPosition: 0,
|
||||
query,
|
||||
ready: false,
|
||||
});
|
||||
}
|
||||
|
||||
const state = recipeListStates.get(stateKey)!;
|
||||
|
||||
function saveState(newState: Partial<RecipeListState>) {
|
||||
Object.assign(state, newState);
|
||||
}
|
||||
|
||||
function saveScrollPosition() {
|
||||
state.scrollPosition = window.scrollY || document.documentElement.scrollTop || 0;
|
||||
}
|
||||
|
||||
function restoreScrollPosition() {
|
||||
if (state.scrollPosition > 0) {
|
||||
// Use nextTick to ensure DOM is updated before scrolling
|
||||
nextTick(() => {
|
||||
window.scrollTo(0, state.scrollPosition);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearState() {
|
||||
recipeListStates.delete(stateKey);
|
||||
}
|
||||
|
||||
function hasValidState(): boolean {
|
||||
return state.recipes.length > 0 && state.ready;
|
||||
}
|
||||
|
||||
function isQueryMatch(newQuery: RecipeSearchQuery | null): boolean {
|
||||
const newKey = generateStateKey(newQuery);
|
||||
return newKey === stateKey;
|
||||
}
|
||||
|
||||
return {
|
||||
state: readonly(state),
|
||||
saveState,
|
||||
saveScrollPosition,
|
||||
restoreScrollPosition,
|
||||
clearState,
|
||||
hasValidState,
|
||||
isQueryMatch,
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up old states when navigating away from recipe sections
|
||||
export function cleanupRecipeListStates() {
|
||||
recipeListStates.clear();
|
||||
}
|
||||
@@ -16,27 +16,33 @@ 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)).toContain("custom note");
|
||||
expect(parseIngredientText(ingredient, false)).toContain("custom note");
|
||||
});
|
||||
|
||||
test("ingredient text with fraction", () => {
|
||||
const ingredient = createRecipeIngredient({ quantity: 1.5, unit: { fraction: true, id: "1", name: "cup" } });
|
||||
|
||||
expect(parseIngredientText(ingredient, 1, true)).contain("1<sup>1</sup>").and.to.contain("<sub>2</sub>");
|
||||
expect(parseIngredientText(ingredient, false, 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, 1, true)).contain("1<sup>1</sup>").and.to.contain("<sub>2</sub>");
|
||||
expect(parseIngredientText(ingredient, false, 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, 1, false);
|
||||
const result = parseIngredientText(ingredient, false, 1, false);
|
||||
|
||||
expect(result).not.contain("<");
|
||||
expect(result).not.contain(">");
|
||||
@@ -46,7 +52,7 @@ describe(parseIngredientText.name, () => {
|
||||
test("sanitizes html", () => {
|
||||
const ingredient = createRecipeIngredient({ note: "<script>alert('foo')</script>" });
|
||||
|
||||
expect(parseIngredientText(ingredient)).not.toContain("<script>");
|
||||
expect(parseIngredientText(ingredient, false)).not.toContain("<script>");
|
||||
});
|
||||
|
||||
test("plural test : plural qty : use abbreviation", () => {
|
||||
@@ -56,7 +62,7 @@ describe(parseIngredientText.name, () => {
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tbsps diced onions");
|
||||
expect(parseIngredientText(ingredient, false)).toEqual("2 tbsps diced onions");
|
||||
});
|
||||
|
||||
test("plural test : plural qty : not abbreviation", () => {
|
||||
@@ -66,7 +72,7 @@ describe(parseIngredientText.name, () => {
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("2 tablespoons diced onions");
|
||||
expect(parseIngredientText(ingredient, false)).toEqual("2 tablespoons diced onions");
|
||||
});
|
||||
|
||||
test("plural test : single qty : use abbreviation", () => {
|
||||
@@ -76,7 +82,7 @@ describe(parseIngredientText.name, () => {
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("1 tbsp diced onion");
|
||||
expect(parseIngredientText(ingredient, false)).toEqual("1 tbsp diced onion");
|
||||
});
|
||||
|
||||
test("plural test : single qty : not abbreviation", () => {
|
||||
@@ -86,7 +92,7 @@ describe(parseIngredientText.name, () => {
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("1 tablespoon diced onion");
|
||||
expect(parseIngredientText(ingredient, false)).toEqual("1 tablespoon diced onion");
|
||||
});
|
||||
|
||||
test("plural test : small qty : use abbreviation", () => {
|
||||
@@ -96,7 +102,7 @@ describe(parseIngredientText.name, () => {
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("0.5 tbsp diced onion");
|
||||
expect(parseIngredientText(ingredient, false)).toEqual("0.5 tbsp diced onion");
|
||||
});
|
||||
|
||||
test("plural test : small qty : not abbreviation", () => {
|
||||
@@ -106,7 +112,7 @@ describe(parseIngredientText.name, () => {
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("0.5 tablespoon diced onion");
|
||||
expect(parseIngredientText(ingredient, false)).toEqual("0.5 tablespoon diced onion");
|
||||
});
|
||||
|
||||
test("plural test : zero qty", () => {
|
||||
@@ -116,7 +122,7 @@ describe(parseIngredientText.name, () => {
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient)).toEqual("diced onions");
|
||||
expect(parseIngredientText(ingredient, false)).toEqual("diced onions");
|
||||
});
|
||||
|
||||
test("plural test : single qty, scaled", () => {
|
||||
@@ -126,6 +132,6 @@ describe(parseIngredientText.name, () => {
|
||||
food: { id: "1", name: "diced onion", pluralName: "diced onions" },
|
||||
});
|
||||
|
||||
expect(parseIngredientText(ingredient, 2)).toEqual("2 tablespoons diced onions");
|
||||
expect(parseIngredientText(ingredient, false, 2)).toEqual("2 tablespoons diced onions");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +36,16 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const { quantity, food, unit, note } = ingredient;
|
||||
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
|
||||
const usePluralFood = (!quantity) || quantity * scale > 1;
|
||||
@@ -73,8 +82,8 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true): string {
|
||||
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, scale, includeFormating);
|
||||
export function parseIngredientText(ingredient: RecipeIngredient, disableAmount: boolean, scale = 1, includeFormating = true): string {
|
||||
const { quantity, unit, name, note } = useParsedIngredientText(ingredient, disableAmount, scale, includeFormating);
|
||||
|
||||
const text = `${quantity || ""} ${unit || ""} ${name || ""} ${note || ""}`.replace(/ {2,}/g, " ").trim();
|
||||
return sanitizeIngredientHTML(text);
|
||||
|
||||
@@ -90,8 +90,6 @@ export const useLazyRecipes = function (publicGroupSlug: string | null = null) {
|
||||
}
|
||||
|
||||
async function getRandom(query: RecipeSearchQuery | null = null, queryFilter: string | null = null) {
|
||||
query = query || {};
|
||||
query._searchSeed = query._searchSeed || Date.now().toString();
|
||||
const { data } = await api.recipes.getAll(1, 1, getParams("random", "desc", null, query, queryFilter));
|
||||
if (data?.items.length) {
|
||||
return data.items[0];
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import { useCopyList } from "~/composables/use-copy";
|
||||
|
||||
type CopyTypes = "plain" | "markdown";
|
||||
|
||||
/**
|
||||
* Composable for managing shopping list copy functionality
|
||||
*/
|
||||
export function useShoppingListCopy() {
|
||||
const copy = useCopyList();
|
||||
|
||||
function copyListItems(itemsByLabel: { [key: string]: ShoppingListItemOut[] }, copyType: CopyTypes) {
|
||||
const text: string[] = [];
|
||||
Object.entries(itemsByLabel).forEach(([label, items], idx) => {
|
||||
if (idx) {
|
||||
text.push("");
|
||||
}
|
||||
|
||||
text.push(formatCopiedLabelHeading(copyType, label));
|
||||
items.forEach(item => text.push(formatCopiedListItem(copyType, item)));
|
||||
});
|
||||
|
||||
copy.copyPlain(text);
|
||||
}
|
||||
|
||||
function formatCopiedListItem(copyType: CopyTypes, item: ShoppingListItemOut): string {
|
||||
const display = item.display || "";
|
||||
switch (copyType) {
|
||||
case "markdown":
|
||||
return `- [ ] ${display}`;
|
||||
default:
|
||||
return display;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCopiedLabelHeading(copyType: CopyTypes, label: string): string {
|
||||
switch (copyType) {
|
||||
case "markdown":
|
||||
return `# ${label}`;
|
||||
default:
|
||||
return `[${label}]`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
copyListItems,
|
||||
formatCopiedListItem,
|
||||
formatCopiedLabelHeading,
|
||||
};
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import type { ShoppingListOut, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { uuid4 } from "~/composables/use-utils";
|
||||
|
||||
/**
|
||||
* Composable for managing shopping list item CRUD operations
|
||||
*/
|
||||
export function useShoppingListCrud(
|
||||
shoppingList: Ref<ShoppingListOut | null>,
|
||||
loadingCounter: Ref<number>,
|
||||
listItems: { unchecked: ShoppingListItemOut[]; checked: ShoppingListItemOut[] },
|
||||
shoppingListItemActions: any,
|
||||
refresh: () => void,
|
||||
sortCheckedItems: (a: ShoppingListItemOut, b: ShoppingListItemOut) => number,
|
||||
updateListItemOrder: () => void,
|
||||
) {
|
||||
const { t } = useI18n();
|
||||
const userApi = useUserApi();
|
||||
|
||||
const createListItemData = ref<ShoppingListItemOut>(listItemFactory());
|
||||
const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>();
|
||||
|
||||
function listItemFactory(): ShoppingListItemOut {
|
||||
return {
|
||||
id: uuid4(),
|
||||
shoppingListId: shoppingList.value?.id || "",
|
||||
checked: false,
|
||||
position: shoppingList.value?.listItems?.length || 1,
|
||||
quantity: 0,
|
||||
note: "",
|
||||
labelId: undefined,
|
||||
unitId: undefined,
|
||||
foodId: undefined,
|
||||
} as ShoppingListItemOut;
|
||||
}
|
||||
|
||||
// Check/Uncheck All operations
|
||||
function checkAllItems() {
|
||||
let hasChanged = false;
|
||||
shoppingList.value?.listItems?.forEach((item) => {
|
||||
if (!item.checked) {
|
||||
hasChanged = true;
|
||||
item.checked = true;
|
||||
}
|
||||
});
|
||||
if (hasChanged) {
|
||||
updateUncheckedListItems();
|
||||
}
|
||||
}
|
||||
|
||||
function uncheckAllItems() {
|
||||
let hasChanged = false;
|
||||
shoppingList.value?.listItems?.forEach((item) => {
|
||||
if (item.checked) {
|
||||
hasChanged = true;
|
||||
item.checked = false;
|
||||
}
|
||||
});
|
||||
if (hasChanged) {
|
||||
listItems.unchecked = [...listItems.unchecked, ...listItems.checked];
|
||||
listItems.checked = [];
|
||||
updateUncheckedListItems();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCheckedItems() {
|
||||
const checked = shoppingList.value?.listItems?.filter(item => item.checked);
|
||||
|
||||
if (!checked || checked?.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingCounter.value += 1;
|
||||
deleteListItems(checked);
|
||||
loadingCounter.value -= 1;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function saveListItem(item: ShoppingListItemOut) {
|
||||
if (!shoppingList.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// set a temporary updatedAt timestamp prior to refresh so it appears at the top of the checked items
|
||||
item.updatedAt = new Date().toISOString();
|
||||
|
||||
// make updates reflect immediately
|
||||
if (shoppingList.value.listItems) {
|
||||
shoppingList.value.listItems.forEach((oldListItem: ShoppingListItemOut, idx: number) => {
|
||||
if (oldListItem.id === item.id && shoppingList.value?.listItems) {
|
||||
shoppingList.value.listItems[idx] = item;
|
||||
}
|
||||
});
|
||||
// Immediately update checked/unchecked arrays for UI
|
||||
listItems.unchecked = shoppingList.value.listItems.filter(i => !i.checked);
|
||||
listItems.checked = shoppingList.value.listItems.filter(i => i.checked)
|
||||
.sort(sortCheckedItems);
|
||||
}
|
||||
|
||||
// Update the item if it's checked, otherwise updateUncheckedListItems will handle it
|
||||
if (item.checked) {
|
||||
shoppingListItemActions.updateItem(item);
|
||||
}
|
||||
|
||||
updateListItemOrder();
|
||||
updateUncheckedListItems();
|
||||
}
|
||||
|
||||
function deleteListItem(item: ShoppingListItemOut) {
|
||||
if (!shoppingList.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
shoppingListItemActions.deleteItem(item);
|
||||
|
||||
// remove the item from the list immediately so the user sees the change
|
||||
if (shoppingList.value.listItems) {
|
||||
shoppingList.value.listItems = shoppingList.value.listItems.filter(itm => itm.id !== item.id);
|
||||
}
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
function deleteListItems(items: ShoppingListItemOut[]) {
|
||||
if (!shoppingList.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item) => {
|
||||
shoppingListItemActions.deleteItem(item);
|
||||
});
|
||||
// remove the items from the list immediately so the user sees the change
|
||||
if (shoppingList.value?.listItems) {
|
||||
const deletedItems = new Set(items.map(item => item.id));
|
||||
shoppingList.value.listItems = shoppingList.value.listItems.filter(itm => !deletedItems.has(itm.id));
|
||||
}
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
function createListItem() {
|
||||
if (!shoppingList.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createListItemData.value.foodId && !createListItemData.value.note) {
|
||||
// don't create an empty item
|
||||
return;
|
||||
}
|
||||
|
||||
loadingCounter.value += 1;
|
||||
|
||||
// ensure list id is set to the current list, which may not have loaded yet in the factory
|
||||
createListItemData.value.shoppingListId = shoppingList.value.id;
|
||||
|
||||
// ensure item is inserted into the end of the list, which may have been updated
|
||||
createListItemData.value.position = shoppingList.value?.listItems?.length
|
||||
? (shoppingList.value.listItems.reduce((a, b) => (a.position || 0) > (b.position || 0) ? a : b).position || 0) + 1
|
||||
: 0;
|
||||
|
||||
createListItemData.value.createdAt = new Date().toISOString();
|
||||
createListItemData.value.updatedAt = createListItemData.value.createdAt;
|
||||
|
||||
updateListItemOrder();
|
||||
|
||||
shoppingListItemActions.createItem(createListItemData.value);
|
||||
loadingCounter.value -= 1;
|
||||
|
||||
if (shoppingList.value.listItems) {
|
||||
// add the item to the list immediately so the user sees the change
|
||||
shoppingList.value.listItems.push(createListItemData.value);
|
||||
updateListItemOrder();
|
||||
}
|
||||
createListItemData.value = listItemFactory();
|
||||
refresh();
|
||||
}
|
||||
|
||||
function updateUncheckedListItems() {
|
||||
if (!shoppingList.value?.listItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set position for unchecked items
|
||||
listItems.unchecked.forEach((item: ShoppingListItemOut, idx: number) => {
|
||||
item.position = idx;
|
||||
shoppingListItemActions.updateItem(item);
|
||||
});
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
// Label management
|
||||
function updateLabelOrder(labelSettings: ShoppingListMultiPurposeLabelOut[]) {
|
||||
if (!shoppingList.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
labelSettings.forEach((labelSetting, index) => {
|
||||
labelSetting.position = index;
|
||||
return labelSetting;
|
||||
});
|
||||
|
||||
localLabels.value = labelSettings;
|
||||
}
|
||||
|
||||
function cancelLabelOrder() {
|
||||
loadingCounter.value -= 1;
|
||||
if (!shoppingList.value) {
|
||||
return;
|
||||
}
|
||||
// restore original state
|
||||
localLabels.value = shoppingList.value.labelSettings;
|
||||
}
|
||||
|
||||
async function saveLabelOrder(updateItemsByLabel: () => void) {
|
||||
if (!shoppingList.value || !localLabels.value || (localLabels.value === shoppingList.value.labelSettings)) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingCounter.value += 1;
|
||||
const { data } = await userApi.shopping.lists.updateLabelSettings(shoppingList.value.id, localLabels.value);
|
||||
loadingCounter.value -= 1;
|
||||
|
||||
if (data) {
|
||||
// update shoppingList labels using the API response
|
||||
shoppingList.value.labelSettings = (data as ShoppingListOut).labelSettings;
|
||||
updateItemsByLabel();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleReorderLabelsDialog(reorderLabelsDialog: Ref<boolean>) {
|
||||
// stop polling and populate localLabels
|
||||
loadingCounter.value += 1;
|
||||
reorderLabelsDialog.value = !reorderLabelsDialog.value;
|
||||
localLabels.value = shoppingList.value?.labelSettings;
|
||||
}
|
||||
|
||||
// Context menu actions
|
||||
const contextActions = {
|
||||
delete: "delete",
|
||||
};
|
||||
|
||||
const contextMenu = [
|
||||
{ title: t("general.delete"), action: contextActions.delete },
|
||||
];
|
||||
|
||||
return {
|
||||
createListItemData,
|
||||
localLabels,
|
||||
listItemFactory,
|
||||
checkAllItems,
|
||||
uncheckAllItems,
|
||||
deleteCheckedItems,
|
||||
saveListItem,
|
||||
deleteListItem,
|
||||
deleteListItems,
|
||||
createListItem,
|
||||
updateUncheckedListItems,
|
||||
updateLabelOrder,
|
||||
cancelLabelOrder,
|
||||
saveLabelOrder,
|
||||
toggleReorderLabelsDialog,
|
||||
contextActions,
|
||||
contextMenu,
|
||||
};
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useOnline, useIdle } from "@vueuse/core";
|
||||
import type { ShoppingListOut } from "~/lib/api/types/household";
|
||||
import { useShoppingListItemActions } from "~/composables/use-shopping-list-item-actions";
|
||||
|
||||
/**
|
||||
* Composable for managing shopping list data fetching and polling
|
||||
*/
|
||||
export function useShoppingListData(listId: string, shoppingList: Ref<ShoppingListOut | null>, loadingCounter: Ref<number>) {
|
||||
const isOffline = computed(() => useOnline().value === false);
|
||||
const { idle } = useIdle(5 * 60 * 1000); // 5 minutes
|
||||
const shoppingListItemActions = useShoppingListItemActions(listId);
|
||||
|
||||
async function fetchShoppingList() {
|
||||
const data = await shoppingListItemActions.getList();
|
||||
return data;
|
||||
}
|
||||
|
||||
async function refresh(updateListItemOrder: () => void) {
|
||||
loadingCounter.value += 1;
|
||||
try {
|
||||
await shoppingListItemActions.process();
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
let newListValue: typeof shoppingList.value = null;
|
||||
try {
|
||||
newListValue = await fetchShoppingList();
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
loadingCounter.value -= 1;
|
||||
|
||||
// only update the list with the new value if we're not loading, to prevent UI jitter
|
||||
if (loadingCounter.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent overwriting local changes with stale backend data when offline
|
||||
if (isOffline.value) {
|
||||
// Do not update shoppingList.value from backend when offline
|
||||
updateListItemOrder();
|
||||
return;
|
||||
}
|
||||
|
||||
// if we're not connected to the network, this will be null, so we don't want to clear the list
|
||||
if (newListValue) {
|
||||
shoppingList.value = newListValue;
|
||||
}
|
||||
|
||||
updateListItemOrder();
|
||||
}
|
||||
|
||||
// constantly polls for changes
|
||||
async function pollForChanges(updateListItemOrder: () => void) {
|
||||
// pause polling if the user isn't active or we're busy
|
||||
if (idle.value || loadingCounter.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await refresh(updateListItemOrder);
|
||||
|
||||
if (shoppingList.value) {
|
||||
attempts = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// if the refresh was unsuccessful, the shopping list will be null, so we increment the attempt counter
|
||||
attempts++;
|
||||
}
|
||||
catch {
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// if we hit too many errors, stop polling
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
// start polling
|
||||
loadingCounter.value -= 1;
|
||||
|
||||
// max poll time = pollFrequency * maxAttempts = 24 hours
|
||||
// we use a long max poll time since polling stops when the user is idle anyway
|
||||
const pollFrequency = 5000;
|
||||
const maxAttempts = 17280;
|
||||
let attempts = 0;
|
||||
let pollTimer: ReturnType<typeof setInterval>;
|
||||
|
||||
function startPolling(updateListItemOrder: () => void) {
|
||||
pollForChanges(updateListItemOrder); // populate initial list
|
||||
|
||||
pollTimer = setInterval(() => {
|
||||
pollForChanges(updateListItemOrder);
|
||||
}, pollFrequency);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isOffline,
|
||||
fetchShoppingList,
|
||||
refresh,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
shoppingListItemActions,
|
||||
};
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useToggle } from "@vueuse/core";
|
||||
import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
|
||||
/**
|
||||
* Composable for managing shopping list label state and operations
|
||||
*/
|
||||
export function useShoppingListLabels(shoppingList: Ref<ShoppingListOut | null>) {
|
||||
const { t } = useI18n();
|
||||
const labelOpenState = ref<{ [key: string]: boolean }>({});
|
||||
const [showChecked, toggleShowChecked] = useToggle(false);
|
||||
|
||||
const initializeLabelOpenStates = () => {
|
||||
if (!shoppingList.value?.listItems) return;
|
||||
|
||||
const existingLabels = new Set(Object.keys(labelOpenState.value));
|
||||
let hasChanges = false;
|
||||
|
||||
for (const item of shoppingList.value.listItems) {
|
||||
const labelName = item.label?.name || t("shopping-list.no-label");
|
||||
if (!existingLabels.has(labelName) && !(labelName in labelOpenState.value)) {
|
||||
labelOpenState.value[labelName] = true;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
labelOpenState.value = { ...labelOpenState.value };
|
||||
}
|
||||
};
|
||||
|
||||
const labelNames = computed(() => {
|
||||
return new Set(
|
||||
shoppingList.value?.listItems
|
||||
?.map(item => item.label?.name || t("shopping-list.no-label"))
|
||||
.filter(Boolean) ?? [],
|
||||
);
|
||||
});
|
||||
|
||||
watch(labelNames, initializeLabelOpenStates, { immediate: true });
|
||||
|
||||
function toggleShowLabel(key: string) {
|
||||
labelOpenState.value[key] = !labelOpenState.value[key];
|
||||
}
|
||||
|
||||
function getLabelColor(item: ShoppingListItemOut | null) {
|
||||
return item?.label?.color;
|
||||
}
|
||||
|
||||
const presentLabels = computed(() => {
|
||||
const labels: Array<{ id: string; name: string }> = [];
|
||||
|
||||
shoppingList.value?.listItems?.forEach((item) => {
|
||||
if (item.labelId && item.label) {
|
||||
labels.push({
|
||||
name: item.label.name,
|
||||
id: item.labelId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return labels;
|
||||
});
|
||||
|
||||
return {
|
||||
labelOpenState,
|
||||
showChecked,
|
||||
toggleShowChecked,
|
||||
toggleShowLabel,
|
||||
getLabelColor,
|
||||
presentLabels,
|
||||
initializeLabelOpenStates,
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { ShoppingListOut } from "~/lib/api/types/household";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
/**
|
||||
* Composable for managing shopping list recipe references
|
||||
*/
|
||||
export function useShoppingListRecipes(
|
||||
shoppingList: Ref<ShoppingListOut | null>,
|
||||
loadingCounter: Ref<number>,
|
||||
recipeReferenceLoading: Ref<boolean>,
|
||||
refresh: () => void,
|
||||
) {
|
||||
const userApi = useUserApi();
|
||||
|
||||
async function addRecipeReferenceToList(recipeId: string) {
|
||||
if (!shoppingList.value || recipeReferenceLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingCounter.value += 1;
|
||||
recipeReferenceLoading.value = true;
|
||||
const { data } = await userApi.shopping.lists.addRecipes(shoppingList.value.id, [{ recipeId }]);
|
||||
recipeReferenceLoading.value = false;
|
||||
loadingCounter.value -= 1;
|
||||
|
||||
if (data) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRecipeReferenceToList(recipeId: string) {
|
||||
if (!shoppingList.value || recipeReferenceLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingCounter.value += 1;
|
||||
recipeReferenceLoading.value = true;
|
||||
const { data } = await userApi.shopping.lists.removeRecipe(shoppingList.value.id, recipeId);
|
||||
recipeReferenceLoading.value = false;
|
||||
loadingCounter.value -= 1;
|
||||
|
||||
if (data) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
addRecipeReferenceToList,
|
||||
removeRecipeReferenceToList,
|
||||
};
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
|
||||
interface ListItemGroup {
|
||||
position: number;
|
||||
createdAt: string;
|
||||
items: ShoppingListItemOut[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing shopping list item sorting and organization
|
||||
*/
|
||||
export function useShoppingListSorting() {
|
||||
const { t } = useI18n();
|
||||
|
||||
function sortItems(a: ShoppingListItemOut | ListItemGroup, b: ShoppingListItemOut | ListItemGroup) {
|
||||
// Sort by position ASC, then by createdAt ASC
|
||||
const posA = a.position ?? 0;
|
||||
const posB = b.position ?? 0;
|
||||
if (posA !== posB) {
|
||||
return posA - posB;
|
||||
}
|
||||
const createdA = a.createdAt ?? "";
|
||||
const createdB = b.createdAt ?? "";
|
||||
if (createdA !== createdB) {
|
||||
return createdA < createdB ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function groupAndSortListItemsByFood(shoppingList: ShoppingListOut) {
|
||||
if (!shoppingList?.listItems?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkedItemKey = "__checkedItem";
|
||||
const listItemGroupsMap = new Map<string, ListItemGroup>();
|
||||
listItemGroupsMap.set(checkedItemKey, { position: Number.MAX_SAFE_INTEGER, createdAt: "", items: [] });
|
||||
|
||||
// group items by checked status, food, or note
|
||||
shoppingList.listItems.forEach((item) => {
|
||||
const key = item.checked
|
||||
? checkedItemKey
|
||||
: item.food?.name
|
||||
? item.food.name
|
||||
: item.note || "";
|
||||
|
||||
const group = listItemGroupsMap.get(key);
|
||||
if (!group) {
|
||||
listItemGroupsMap.set(key, { position: item.position || 0, createdAt: item.createdAt || "", items: [item] });
|
||||
}
|
||||
else {
|
||||
group.items.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const listItemGroups = Array.from(listItemGroupsMap.values());
|
||||
listItemGroups.sort(sortItems);
|
||||
|
||||
// sort group items, then aggregate them
|
||||
const sortedItems: ShoppingListItemOut[] = [];
|
||||
let nextPosition = 0;
|
||||
listItemGroups.forEach((listItemGroup) => {
|
||||
listItemGroup.items.sort(sortItems);
|
||||
listItemGroup.items.forEach((item) => {
|
||||
item.position = nextPosition;
|
||||
nextPosition += 1;
|
||||
sortedItems.push(item);
|
||||
});
|
||||
});
|
||||
|
||||
shoppingList.listItems = sortedItems;
|
||||
}
|
||||
|
||||
function sortListItems(shoppingList: ShoppingListOut) {
|
||||
if (!shoppingList?.listItems?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
shoppingList.listItems.sort(sortItems);
|
||||
}
|
||||
|
||||
function updateItemsByLabel(shoppingList: ShoppingListOut) {
|
||||
const items: { [prop: string]: ShoppingListItemOut[] } = {};
|
||||
const noLabelText = t("shopping-list.no-label");
|
||||
const noLabel = [] as ShoppingListItemOut[];
|
||||
|
||||
shoppingList?.listItems?.forEach((item) => {
|
||||
if (item.checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.labelId) {
|
||||
if (item.label && item.label.name in items) {
|
||||
items[item.label.name].push(item);
|
||||
}
|
||||
else if (item.label) {
|
||||
items[item.label.name] = [item];
|
||||
}
|
||||
}
|
||||
else {
|
||||
noLabel.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
if (noLabel.length > 0) {
|
||||
items[noLabelText] = noLabel;
|
||||
}
|
||||
|
||||
// sort the map by label order
|
||||
const orderedLabelNames = shoppingList?.labelSettings?.map(labelSetting => labelSetting.label.name);
|
||||
if (!orderedLabelNames) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const itemsSorted: { [prop: string]: ShoppingListItemOut[] } = {};
|
||||
if (noLabelText in items) {
|
||||
itemsSorted[noLabelText] = items[noLabelText];
|
||||
}
|
||||
|
||||
orderedLabelNames.forEach((labelName) => {
|
||||
if (labelName in items) {
|
||||
itemsSorted[labelName] = items[labelName];
|
||||
}
|
||||
});
|
||||
|
||||
return itemsSorted;
|
||||
}
|
||||
|
||||
return {
|
||||
sortItems,
|
||||
groupAndSortListItemsByFood,
|
||||
sortListItems,
|
||||
updateItemsByLabel,
|
||||
};
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
|
||||
/**
|
||||
* Composable for managing shopping list state and reactive data
|
||||
*/
|
||||
export function useShoppingListState() {
|
||||
const shoppingList = ref<ShoppingListOut | null>(null);
|
||||
const loadingCounter = ref(1);
|
||||
const recipeReferenceLoading = ref(false);
|
||||
const preserveItemOrder = ref(false);
|
||||
|
||||
// UI state
|
||||
const edit = ref(false);
|
||||
const threeDot = ref(false);
|
||||
const reorderLabelsDialog = ref(false);
|
||||
const createEditorOpen = ref(false);
|
||||
|
||||
// Dialog states
|
||||
const state = reactive({
|
||||
checkAllDialog: false,
|
||||
uncheckAllDialog: false,
|
||||
deleteCheckedDialog: false,
|
||||
});
|
||||
|
||||
// Hydrate listItems from shoppingList.value?.listItems
|
||||
const listItems = reactive({
|
||||
unchecked: [] as ShoppingListItemOut[],
|
||||
checked: [] as ShoppingListItemOut[],
|
||||
});
|
||||
|
||||
function sortCheckedItems(a: ShoppingListItemOut, b: ShoppingListItemOut) {
|
||||
if (a.updatedAt! === b.updatedAt!) {
|
||||
return ((a.position || 0) > (b.position || 0)) ? -1 : 1;
|
||||
}
|
||||
return a.updatedAt! < b.updatedAt! ? 1 : -1;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => shoppingList.value?.listItems,
|
||||
(items) => {
|
||||
listItems.unchecked = (items?.filter(item => !item.checked) ?? []);
|
||||
listItems.checked = (items?.filter(item => item.checked)
|
||||
.sort(sortCheckedItems) ?? []);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const recipeMap = computed(() => new Map(
|
||||
(shoppingList.value?.recipeReferences?.map(ref => ref.recipe) ?? [])
|
||||
.map(recipe => [recipe.id || "", recipe])),
|
||||
);
|
||||
|
||||
const recipeList = computed(() => Array.from(recipeMap.value.values()));
|
||||
|
||||
return {
|
||||
shoppingList,
|
||||
loadingCounter,
|
||||
recipeReferenceLoading,
|
||||
preserveItemOrder,
|
||||
edit,
|
||||
threeDot,
|
||||
reorderLabelsDialog,
|
||||
createEditorOpen,
|
||||
state,
|
||||
listItems,
|
||||
recipeMap,
|
||||
recipeList,
|
||||
sortCheckedItems,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user