meal-planner rewrite

This commit is contained in:
hay-kot 2021-05-22 21:32:24 -07:00
commit 26a3b95e10
37 changed files with 1150 additions and 308 deletions

22
.gitignore vendored
View file

@ -146,3 +146,25 @@ scratch.py
dev/data/backups/dev_sample_data*.zip dev/data/backups/dev_sample_data*.zip
!dev/data/backups/test*.zip !dev/data/backups/test*.zip
dev/data/recipes/* dev/data/recipes/*
dev/scripts/output/app_routes.py
dev/scripts/output/javascriptAPI/apiRoutes.js
dev/scripts/output/javascriptAPI/appEvents.js
dev/scripts/output/javascriptAPI/authentication.js
dev/scripts/output/javascriptAPI/backups.js
dev/scripts/output/javascriptAPI/debug.js
dev/scripts/output/javascriptAPI/groups.js
dev/scripts/output/javascriptAPI/index.js
dev/scripts/output/javascriptAPI/mealPlan.js
dev/scripts/output/javascriptAPI/migration.js
dev/scripts/output/javascriptAPI/queryAllRecipes.js
dev/scripts/output/javascriptAPI/recipeCategories.js
dev/scripts/output/javascriptAPI/recipeCRUD.js
dev/scripts/output/javascriptAPI/recipeTags.js
dev/scripts/output/javascriptAPI/settings.js
dev/scripts/output/javascriptAPI/shoppingLists.js
dev/scripts/output/javascriptAPI/siteMedia.js
dev/scripts/output/javascriptAPI/themes.js
dev/scripts/output/javascriptAPI/userAPITokens.js
dev/scripts/output/javascriptAPI/users.js
dev/scripts/output/javascriptAPI/userSignup.js
dev/scripts/output/javascriptAPI/utils.js

View file

@ -0,0 +1,144 @@
import json
import re
from enum import Enum
from itertools import groupby
from pathlib import Path
import slugify
from fastapi import FastAPI
from humps import camelize
from jinja2 import Template
from mealie.app import app
from pydantic import BaseModel
CWD = Path(__file__).parent
OUT_DIR = CWD / "output"
OUT_FILE = OUT_DIR / "app_routes.py"
JS_DIR = OUT_DIR / "javascriptAPI"
JS_OUT_FILE = JS_DIR / "apiRoutes.js"
TEMPLATES_DIR = CWD / "templates"
PYTEST_TEMPLATE = TEMPLATES_DIR / "pytest_routes.j2"
JS_REQUESTS = TEMPLATES_DIR / "js_requests.j2"
JS_ROUTES = TEMPLATES_DIR / "js_routes.j2"
JS_INDEX = TEMPLATES_DIR / "js_index.j2"
JS_DIR.mkdir(exist_ok=True, parents=True)
class RouteObject:
def __init__(self, route_string) -> None:
self.prefix = "/" + route_string.split("/")[1]
self.route = route_string.replace(self.prefix, "")
self.js_route = self.route.replace("{", "${")
self.parts = route_string.split("/")[1:]
self.var = re.findall(r"\{(.*?)\}", route_string)
self.is_function = "{" in self.route
self.router_slug = slugify.slugify("_".join(self.parts[1:]), separator="_")
self.router_camel = camelize(self.router_slug)
def __repr__(self) -> str:
return f"""Route: {self.route}
Parts: {self.parts}
Function: {self.is_function}
Var: {self.var}
Slug: {self.router_slug}
"""
class RequestType(str, Enum):
get = "get"
put = "put"
post = "post"
patch = "patch"
delete = "delete"
class HTTPRequest(BaseModel):
request_type: RequestType
description: str = ""
summary: str
tags: list[str]
@property
def summary_camel(self):
return camelize(self.summary)
@property
def js_docs(self):
return self.description.replace("\n", " \n * ")
class PathObject(BaseModel):
route_object: RouteObject
http_verbs: list[HTTPRequest]
class Config:
arbitrary_types_allowed = True
def get_path_objects(app: FastAPI):
paths = []
with open("scratch.json", "w") as f:
f.write(json.dumps(app.openapi()))
for key, value in app.openapi().items():
if key == "paths":
for key, value in value.items():
paths.append(
PathObject(
route_object=RouteObject(key),
http_verbs=[HTTPRequest(request_type=k, **v) for k, v in value.items()],
)
)
return paths
def read_template(file: Path):
with open(file, "r") as f:
return f.read()
def generate_template(app):
paths = get_path_objects(app)
static_paths = [x.route_object for x in paths if not x.route_object.is_function]
function_paths = [x.route_object for x in paths if x.route_object.is_function]
static_paths.sort(key=lambda x: x.router_slug)
function_paths.sort(key=lambda x: x.router_slug)
template = Template(read_template(PYTEST_TEMPLATE))
content = template.render(paths={"prefix": "/api", "static_paths": static_paths, "function_paths": function_paths})
with open(OUT_FILE, "w") as f:
f.write(content)
template = Template(read_template(JS_ROUTES))
content = template.render(
paths={"prefix": "/api", "static_paths": static_paths, "function_paths": function_paths, "all_paths": paths}
)
with open(JS_OUT_FILE, "w") as f:
f.write(content)
all_tags = []
for k, g in groupby(paths, lambda x: x.http_verbs[0].tags[0]):
template = Template(read_template(JS_REQUESTS))
content = template.render(paths={"all_paths": list(g), "export_name": camelize(k)})
all_tags.append(camelize(k))
with open(JS_DIR.joinpath(camelize(k) + ".js"), "w") as f:
f.write(content)
template = Template(read_template(JS_INDEX))
content = template.render(files={"files": all_tags})
with open(JS_DIR.joinpath("index.js"), "w") as f:
f.write(content)
if __name__ == "__main__":
generate_template(app)

View file

@ -1,81 +0,0 @@
import json
import re
from pathlib import Path
import slugify
from jinja2 import Template
from mealie.app import app
CWD = Path(__file__).parent
OUT_FILE = CWD.joinpath("output", "app_routes.py")
code_template = """
class AppRoutes:
def __init__(self) -> None:
self.prefix = '{{paths.prefix}}'
{% for path in paths.static_paths %}
self.{{ path.router_slug }} = "{{path.prefix}}{{ path.route }}"{% endfor %}
{% for path in paths.function_paths %}
def {{path.router_slug}}(self, {{path.var|join(", ")}}):
return f"{self.prefix}{{ path.route }}"
{% endfor %}
"""
def get_variables(path):
path = path.replace("/", " ")
print(path)
var = re.findall(r" \{.*\}", path)
print(var)
if var:
return [v.replace("{", "").replace("}", "") for v in var]
else:
return None
class RouteObject:
def __init__(self, route_string) -> None:
self.prefix = "/" + route_string.split("/")[1]
self.route = route_string.replace(self.prefix, "")
self.parts = route_string.split("/")[1:]
self.var = re.findall(r"\{(.*?)\}", route_string)
self.is_function = "{" in self.route
self.router_slug = slugify.slugify("_".join(self.parts[1:]), separator="_")
def __repr__(self) -> str:
return f"""Route: {self.route}
Parts: {self.parts}
Function: {self.is_function}
Var: {self.var}
Slug: {self.router_slug}
"""
def get_paths(app):
paths = []
print(json.dumps(app.openapi()))
for key, value in app.openapi().items():
if key == "paths":
for key, value in value.items():
paths.append(key)
return paths
def generate_template(app):
paths = get_paths(app)
new_paths = [RouteObject(path) for path in paths]
static_paths = [p for p in new_paths if not p.is_function]
function_paths = [p for p in new_paths if p.is_function]
template = Template(code_template)
content = template.render(paths={"prefix": "/api", "static_paths": static_paths, "function_paths": function_paths})
with open(OUT_FILE, "w") as f:
f.write(content)
if __name__ == "__main__":
generate_template(app)

View file

@ -1,105 +1,63 @@
# This Content is Auto Generated for Pytest
class AppRoutes: class AppRoutes:
def __init__(self) -> None: def __init__(self) -> None:
self.prefix = "/api" self.prefix = '/api'
self.about_events = "/api/about/events"
self.about_events_notifications = "/api/about/events/notifications"
self.about_events_notifications_test = "/api/about/events/notifications/test"
self.auth_refresh = "/api/auth/refresh"
self.auth_token = "/api/auth/token" self.auth_token = "/api/auth/token"
self.auth_token_long = "/api/auth/token/long" self.auth_token_long = "/api/auth/token/long"
self.auth_refresh = "/api/auth/refresh" self.backups_available = "/api/backups/available"
self.users_sign_ups = "/api/users/sign-ups" self.backups_export_database = "/api/backups/export/database"
self.users = "/api/users" self.backups_upload = "/api/backups/upload"
self.users_self = "/api/users/self"
self.users_api_tokens = "/api/users-tokens"
self.groups = "/api/groups"
self.groups_self = "/api/groups/self"
self.recipes_summary = "/api/recipes/summary"
self.recipes_summary_untagged = "/api/recipes/summary/untagged"
self.recipes_summary_uncategorized = "/api/recipes/summary/uncategorized"
self.recipes_category = "/api/recipes/category"
self.recipes_tag = "/api/recipes/tag"
self.recipes_create = "/api/recipes/create"
self.recipes_create_url = "/api/recipes/create-url"
self.categories = "/api/categories" self.categories = "/api/categories"
self.categories_empty = "/api/categories/empty" self.categories_empty = "/api/categories/empty"
self.tags = "/api/tags" self.debug = "/api/debug"
self.tags_empty = "/api/tags/empty" self.debug_last_recipe_json = "/api/debug/last-recipe-json"
self.about_events = "/api/about/events" self.debug_log = "/api/debug/log"
self.debug_statistics = "/api/debug/statistics"
self.debug_version = "/api/debug/version"
self.groups = "/api/groups"
self.groups_self = "/api/groups/self"
self.meal_plans_all = "/api/meal-plans/all" self.meal_plans_all = "/api/meal-plans/all"
self.meal_plans_create = "/api/meal-plans/create" self.meal_plans_create = "/api/meal-plans/create"
self.meal_plans_this_week = "/api/meal-plans/this-week" self.meal_plans_this_week = "/api/meal-plans/this-week"
self.meal_plans_today = "/api/meal-plans/today" self.meal_plans_today = "/api/meal-plans/today"
self.meal_plans_today_image = "/api/meal-plans/today/image" self.meal_plans_today_image = "/api/meal-plans/today/image"
self.site_settings_custom_pages = "/api/site-settings/custom-pages" self.migrations = "/api/migrations"
self.recipes_category = "/api/recipes/category"
self.recipes_create = "/api/recipes/create"
self.recipes_create_url = "/api/recipes/create-url"
self.recipes_summary = "/api/recipes/summary"
self.recipes_summary_uncategorized = "/api/recipes/summary/uncategorized"
self.recipes_summary_untagged = "/api/recipes/summary/untagged"
self.recipes_tag = "/api/recipes/tag"
self.shopping_lists = "/api/shopping-lists"
self.site_settings = "/api/site-settings" self.site_settings = "/api/site-settings"
self.site_settings_custom_pages = "/api/site-settings/custom-pages"
self.site_settings_webhooks_test = "/api/site-settings/webhooks/test" self.site_settings_webhooks_test = "/api/site-settings/webhooks/test"
self.tags = "/api/tags"
self.tags_empty = "/api/tags/empty"
self.themes = "/api/themes" self.themes = "/api/themes"
self.themes_create = "/api/themes/create" self.themes_create = "/api/themes/create"
self.backups_available = "/api/backups/available" self.users = "/api/users"
self.backups_export_database = "/api/backups/export/database" self.users_api_tokens = "/api/users-tokens"
self.backups_upload = "/api/backups/upload" self.users_self = "/api/users/self"
self.migrations = "/api/migrations" self.users_sign_ups = "/api/users/sign-ups"
self.debug = "/api/debug"
self.debug_statistics = "/api/debug/statistics"
self.debug_version = "/api/debug/version"
self.debug_last_recipe_json = "/api/debug/last-recipe-json"
self.debug_log = "/api/debug/log"
self.utils_download = "/api/utils/download" self.utils_download = "/api/utils/download"
def users_sign_ups_token(self, token):
return f"{self.prefix}/users/sign-ups/{token}"
def users_id(self, id):
return f"{self.prefix}/users/{id}"
def users_id_reset_password(self, id):
return f"{self.prefix}/users/{id}/reset-password"
def users_id_image(self, id):
return f"{self.prefix}/users/{id}/image"
def users_id_password(self, id):
return f"{self.prefix}/users/{id}/password"
def users_api_tokens_token_id(self, token_id):
return f"{self.prefix}/users-tokens/{token_id}"
def groups_id(self, id):
return f"{self.prefix}/groups/{id}"
def recipes_recipe_slug(self, recipe_slug):
return f"{self.prefix}/recipes/{recipe_slug}"
def recipes_recipe_slug_image(self, recipe_slug):
return f"{self.prefix}/recipes/{recipe_slug}/image"
def recipes_recipe_slug_assets(self, recipe_slug):
return f"{self.prefix}/recipes/{recipe_slug}/assets"
def categories_category(self, category):
return f"{self.prefix}/categories/{category}"
def tags_tag(self, tag):
return f"{self.prefix}/tags/{tag}"
def media_recipes_recipe_slug_images_file_name(self, recipe_slug, file_name):
return f"{self.prefix}/media/recipes/{recipe_slug}/images/{file_name}"
def media_recipes_recipe_slug_assets_file_name(self, recipe_slug, file_name):
return f"{self.prefix}/media/recipes/{recipe_slug}/assets/{file_name}"
def about_events_id(self, id): def about_events_id(self, id):
return f"{self.prefix}/about/events/{id}" return f"{self.prefix}/about/events/{id}"
def meal_plans_plan_id(self, plan_id): def about_events_notifications_id(self, id):
return f"{self.prefix}/meal-plans/{plan_id}" return f"{self.prefix}/about/events/notifications/{id}"
def meal_plans_id_shopping_list(self, id): def backups_file_name_delete(self, file_name):
return f"{self.prefix}/meal-plans/{id}/shopping-list" return f"{self.prefix}/backups/{file_name}/delete"
def site_settings_custom_pages_id(self, id):
return f"{self.prefix}/site-settings/custom-pages/{id}"
def themes_id(self, id):
return f"{self.prefix}/themes/{id}"
def backups_file_name_download(self, file_name): def backups_file_name_download(self, file_name):
return f"{self.prefix}/backups/{file_name}/download" return f"{self.prefix}/backups/{file_name}/download"
@ -107,17 +65,71 @@ class AppRoutes:
def backups_file_name_import(self, file_name): def backups_file_name_import(self, file_name):
return f"{self.prefix}/backups/{file_name}/import" return f"{self.prefix}/backups/{file_name}/import"
def backups_file_name_delete(self, file_name): def categories_category(self, category):
return f"{self.prefix}/backups/{file_name}/delete" return f"{self.prefix}/categories/{category}"
def migrations_import_type_file_name_import(self, import_type, file_name): def debug_log_num(self, num):
return f"{self.prefix}/migrations/{import_type}/{file_name}/import" return f"{self.prefix}/debug/log/{num}"
def groups_id(self, id):
return f"{self.prefix}/groups/{id}"
def meal_plans_id_shopping_list(self, id):
return f"{self.prefix}/meal-plans/{id}/shopping-list"
def meal_plans_plan_id(self, plan_id):
return f"{self.prefix}/meal-plans/{plan_id}"
def media_recipes_recipe_slug_assets_file_name(self, recipe_slug, file_name):
return f"{self.prefix}/media/recipes/{recipe_slug}/assets/{file_name}"
def media_recipes_recipe_slug_images_file_name(self, recipe_slug, file_name):
return f"{self.prefix}/media/recipes/{recipe_slug}/images/{file_name}"
def migrations_import_type_file_name_delete(self, import_type, file_name): def migrations_import_type_file_name_delete(self, import_type, file_name):
return f"{self.prefix}/migrations/{import_type}/{file_name}/delete" return f"{self.prefix}/migrations/{import_type}/{file_name}/delete"
def migrations_import_type_file_name_import(self, import_type, file_name):
return f"{self.prefix}/migrations/{import_type}/{file_name}/import"
def migrations_import_type_upload(self, import_type): def migrations_import_type_upload(self, import_type):
return f"{self.prefix}/migrations/{import_type}/upload" return f"{self.prefix}/migrations/{import_type}/upload"
def debug_log_num(self, num): def recipes_recipe_slug(self, recipe_slug):
return f"{self.prefix}/debug/log/{num}" return f"{self.prefix}/recipes/{recipe_slug}"
def recipes_recipe_slug_assets(self, recipe_slug):
return f"{self.prefix}/recipes/{recipe_slug}/assets"
def recipes_recipe_slug_image(self, recipe_slug):
return f"{self.prefix}/recipes/{recipe_slug}/image"
def shopping_lists_id(self, id):
return f"{self.prefix}/shopping-lists/{id}"
def site_settings_custom_pages_id(self, id):
return f"{self.prefix}/site-settings/custom-pages/{id}"
def tags_tag(self, tag):
return f"{self.prefix}/tags/{tag}"
def themes_id(self, id):
return f"{self.prefix}/themes/{id}"
def users_api_tokens_token_id(self, token_id):
return f"{self.prefix}/users-tokens/{token_id}"
def users_id(self, id):
return f"{self.prefix}/users/{id}"
def users_id_image(self, id):
return f"{self.prefix}/users/{id}/image"
def users_id_password(self, id):
return f"{self.prefix}/users/{id}/password"
def users_id_reset_password(self, id):
return f"{self.prefix}/users/{id}/reset-password"
def users_sign_ups_token(self, token):
return f"{self.prefix}/users/sign-ups/{token}"

View file

@ -0,0 +1,77 @@
// This Content is Auto Generated
const prefix = '/api'
export const API_ROUTES = {
aboutEvents: "/api/about/events",
aboutEventsNotifications: "/api/about/events/notifications",
aboutEventsNotificationsTest: "/api/about/events/notifications/test",
authRefresh: "/api/auth/refresh",
authToken: "/api/auth/token",
authTokenLong: "/api/auth/token/long",
backupsAvailable: "/api/backups/available",
backupsExportDatabase: "/api/backups/export/database",
backupsUpload: "/api/backups/upload",
categories: "/api/categories",
categoriesEmpty: "/api/categories/empty",
debug: "/api/debug",
debugLastRecipeJson: "/api/debug/last-recipe-json",
debugLog: "/api/debug/log",
debugStatistics: "/api/debug/statistics",
debugVersion: "/api/debug/version",
groups: "/api/groups",
groupsSelf: "/api/groups/self",
mealPlansAll: "/api/meal-plans/all",
mealPlansCreate: "/api/meal-plans/create",
mealPlansThisWeek: "/api/meal-plans/this-week",
mealPlansToday: "/api/meal-plans/today",
mealPlansTodayImage: "/api/meal-plans/today/image",
migrations: "/api/migrations",
recipesCategory: "/api/recipes/category",
recipesCreate: "/api/recipes/create",
recipesCreateUrl: "/api/recipes/create-url",
recipesSummary: "/api/recipes/summary",
recipesSummaryUncategorized: "/api/recipes/summary/uncategorized",
recipesSummaryUntagged: "/api/recipes/summary/untagged",
recipesTag: "/api/recipes/tag",
shoppingLists: "/api/shopping-lists",
siteSettings: "/api/site-settings",
siteSettingsCustomPages: "/api/site-settings/custom-pages",
siteSettingsWebhooksTest: "/api/site-settings/webhooks/test",
tags: "/api/tags",
tagsEmpty: "/api/tags/empty",
themes: "/api/themes",
themesCreate: "/api/themes/create",
users: "/api/users",
usersApiTokens: "/api/users-tokens",
usersSelf: "/api/users/self",
usersSignUps: "/api/users/sign-ups",
utilsDownload: "/api/utils/download",
aboutEventsId: (id) => `${prefix}/about/events/${id}`,
aboutEventsNotificationsId: (id) => `${prefix}/about/events/notifications/${id}`,
backupsFileNameDelete: (file_name) => `${prefix}/backups/${file_name}/delete`,
backupsFileNameDownload: (file_name) => `${prefix}/backups/${file_name}/download`,
backupsFileNameImport: (file_name) => `${prefix}/backups/${file_name}/import`,
categoriesCategory: (category) => `${prefix}/categories/${category}`,
debugLogNum: (num) => `${prefix}/debug/log/${num}`,
groupsId: (id) => `${prefix}/groups/${id}`,
mealPlansIdShoppingList: (id) => `${prefix}/meal-plans/${id}/shopping-list`,
mealPlansPlanId: (plan_id) => `${prefix}/meal-plans/${plan_id}`,
mediaRecipesRecipeSlugAssetsFileName: (recipe_slug, file_name) => `${prefix}/media/recipes/${recipe_slug}/assets/${file_name}`,
mediaRecipesRecipeSlugImagesFileName: (recipe_slug, file_name) => `${prefix}/media/recipes/${recipe_slug}/images/${file_name}`,
migrationsImportTypeFileNameDelete: (import_type, file_name) => `${prefix}/migrations/${import_type}/${file_name}/delete`,
migrationsImportTypeFileNameImport: (import_type, file_name) => `${prefix}/migrations/${import_type}/${file_name}/import`,
migrationsImportTypeUpload: (import_type) => `${prefix}/migrations/${import_type}/upload`,
recipesRecipeSlug: (recipe_slug) => `${prefix}/recipes/${recipe_slug}`,
recipesRecipeSlugAssets: (recipe_slug) => `${prefix}/recipes/${recipe_slug}/assets`,
recipesRecipeSlugImage: (recipe_slug) => `${prefix}/recipes/${recipe_slug}/image`,
shoppingListsId: (id) => `${prefix}/shopping-lists/${id}`,
siteSettingsCustomPagesId: (id) => `${prefix}/site-settings/custom-pages/${id}`,
tagsTag: (tag) => `${prefix}/tags/${tag}`,
themesId: (id) => `${prefix}/themes/${id}`,
usersApiTokensTokenId: (token_id) => `${prefix}/users-tokens/${token_id}`,
usersId: (id) => `${prefix}/users/${id}`,
usersIdImage: (id) => `${prefix}/users/${id}/image`,
usersIdPassword: (id) => `${prefix}/users/${id}/password`,
usersIdResetPassword: (id) => `${prefix}/users/${id}/reset-password`,
usersSignUpsToken: (token) => `${prefix}/users/sign-ups/${token}`,
}

View file

@ -12,6 +12,7 @@ import { signupAPI } from "./signUps";
import { groupAPI } from "./groups"; import { groupAPI } from "./groups";
import { siteSettingsAPI } from "./siteSettings"; import { siteSettingsAPI } from "./siteSettings";
import { aboutAPI } from "./about"; import { aboutAPI } from "./about";
import { shoppingListsAPI } from "./shoppingLists";
/** /**
* The main object namespace for interacting with the backend database * The main object namespace for interacting with the backend database
@ -32,4 +33,5 @@ export const api = {
signUps: signupAPI, signUps: signupAPI,
groups: groupAPI, groups: groupAPI,
about: aboutAPI, about: aboutAPI,
shoppingLists: shoppingListsAPI,
}; };

View file

@ -0,0 +1,33 @@
// This Content is Auto Generated
import { API_ROUTES } from "./apiRoutes";
import { apiReq } from "./api-utils";
export const shoppingListsAPI = {
/** Create Shopping List in the Database
*/
async createShoppingList(data) {
const response = await apiReq.post(API_ROUTES.shoppingLists, data);
return response.data;
},
/** Get Shopping List from the Database
* @param id
*/
async getShoppingList(id) {
const response = await apiReq.get(API_ROUTES.shoppingListsId(id));
return response.data;
},
/** Update Shopping List in the Database
* @param id
*/
async updateShoppingList(id, data) {
const response = await apiReq.put(API_ROUTES.shoppingListsId(id), data);
return response.data;
},
/** Delete Shopping List from the Database
* @param id
*/
async deleteShoppingList(id) {
const response = await apiReq.delete(API_ROUTES.shoppingListsId(id));
return response.data;
},
};

View file

@ -0,0 +1,17 @@
<template>
<div>
<The404>
<h1 class="mx-auto">No Recipe Found</h1>
</The404>
</div>
</template>
<script>
import The404 from "./The404.vue";
export default {
components: { The404 },
};
</script>

View file

@ -0,0 +1,51 @@
<template>
<div>
<v-card-title>
<slot>
<h1 class="mx-auto">{{ $t("404.page-not-found") }}</h1>
</slot>
</v-card-title>
<div class="d-flex justify-space-around">
<div class="d-flex">
<p>4</p>
<v-icon color="primary" class="mx-auto" size="200">
mdi-silverware-variant
</v-icon>
<p>4</p>
</div>
</div>
<v-card-actions>
<v-spacer></v-spacer>
<slot name="actions">
<v-btn v-for="(button, index) in buttons" :key="index" :to="button.to" color="primary">
<v-icon left> {{ button.icon }} </v-icon>
{{ button.text }}
</v-btn>
</slot>
<v-spacer></v-spacer>
</v-card-actions>
</div>
</template>
<script>
export default {
data() {
return {
buttons: [
{ icon: "mdi-home", to: "/", text: "Home" },
{ icon: "mdi-silverware-variant", to: "/recipes/all", text: "All Recipes" },
{ icon: "mdi-magnify", to: "/search", text: "Search" },
],
};
},
};
</script>
<style scoped>
p {
padding-bottom: 0 !important;
margin-bottom: 0 !important;
color: var(--v-primary-base);
font-size: 200px;
}
</style>

View file

@ -1,18 +1,48 @@
<template> <template>
<v-row> <v-row>
<SearchDialog ref="mealselect" @select="setSlug" /> <SearchDialog ref="mealselect" @select="setSlug" />
<BaseDialog
title="Custom Meal"
title-icon="mdi-silverware-variant"
submit-text="Save"
:top="true"
ref="customMealDialog"
@submit="pushCustomMeal"
>
<v-card-text>
<v-text-field autofocus v-model="customMeal.name" label="Name"> </v-text-field>
<v-textarea v-model="customMeal.description" label="Description"> </v-textarea>
</v-card-text>
</BaseDialog>
<v-col cols="12" sm="12" md="6" lg="4" xl="3" v-for="(planDay, index) in value" :key="index"> <v-col cols="12" sm="12" md="6" lg="4" xl="3" v-for="(planDay, index) in value" :key="index">
<v-hover v-slot="{ hover }" :open-delay="50"> <v-hover v-slot="{ hover }" :open-delay="50">
<v-card :class="{ 'on-hover': hover }" :elevation="hover ? 12 : 2"> <v-card :class="{ 'on-hover': hover }" :elevation="hover ? 12 : 2">
<CardImage large :slug="planDay.meals[0].slug" icon-size="200" @click="openSearch(index, modes.primary)"> <CardImage large :slug="planDay.meals[0].slug" icon-size="200" @click="openSearch(index, modes.primary)">
<v-fade-transition>
<v-btn v-if="hover" small color="info" class="ma-1" @click.stop="addCustomItem(index, modes.primary)">
<v-icon left>
mdi-square-edit-outline
</v-icon>
No Recipe
</v-btn>
</v-fade-transition>
</CardImage> </CardImage>
<v-card-title class="my-n3 mb-n6"> <v-card-title class="my-n3 mb-n6">
{{ $d(new Date(planDay.date.split("-")), "short") }} {{ $d(new Date(planDay.date.split("-")), "short") }}
</v-card-title> </v-card-title>
<v-card-subtitle class="mb-0 pb-0"> {{ planDay.meals[0].name }}</v-card-subtitle> <v-card-subtitle class="mb-0 pb-0"> {{ planDay.meals[0].name }}</v-card-subtitle>
<v-hover v-slot="{ hover }">
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-fade-transition>
<v-btn v-if="hover" small color="info" text @click.stop="addCustomItem(index, modes.sides)">
<v-icon left>
mdi-square-edit-outline
</v-icon>
No Recipe
</v-btn>
</v-fade-transition>
<v-btn color="info" outlined small @click="openSearch(index, modes.sides)"> <v-btn color="info" outlined small @click="openSearch(index, modes.sides)">
<v-icon small class="mr-1"> <v-icon small class="mr-1">
mdi-plus mdi-plus
@ -20,10 +50,11 @@
Side Side
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-hover>
<v-divider class="mx-2"></v-divider> <v-divider class="mx-2"></v-divider>
<v-list dense> <v-list dense>
<v-list-item v-for="(recipe, i) in planDay.meals.slice(1)" :key="i"> <v-list-item v-for="(recipe, i) in planDay.meals.slice(1)" :key="i">
<v-list-item-avatar> <v-list-item-avatar color="accent">
<v-img :alt="recipe.slug" :src="getImage(recipe.slug)"></v-img> <v-img :alt="recipe.slug" :src="getImage(recipe.slug)"></v-img>
</v-list-item-avatar> </v-list-item-avatar>
@ -48,12 +79,14 @@
<script> <script>
import SearchDialog from "../UI/Search/SearchDialog"; import SearchDialog from "../UI/Search/SearchDialog";
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import { api } from "@/api"; import { api } from "@/api";
import CardImage from "../Recipe/CardImage.vue"; import CardImage from "../Recipe/CardImage.vue";
export default { export default {
components: { components: {
SearchDialog, SearchDialog,
CardImage, CardImage,
BaseDialog,
}, },
props: { props: {
value: Array, value: Array,
@ -66,6 +99,11 @@ export default {
primary: "PRIMARY", primary: "PRIMARY",
sides: "SIDES", sides: "SIDES",
}, },
customMeal: {
slug: null,
name: "",
description: "",
},
}; };
}, },
watch: { watch: {
@ -82,13 +120,14 @@ export default {
return api.recipes.recipeSmallImage(slug); return api.recipes.recipeSmallImage(slug);
} }
}, },
setSide(name, slug) { setSide(name, slug = null, description = "") {
const meal = { name: name, slug: slug }; const meal = { name: name, slug: slug, description: description };
this.value[this.activeIndex]["meals"].push(meal); this.value[this.activeIndex]["meals"].push(meal);
}, },
setPrimary(name, slug) { setPrimary(name, slug, description = "") {
this.value[this.activeIndex]["meals"][0]["slug"] = slug; this.value[this.activeIndex]["meals"][0]["slug"] = slug;
this.value[this.activeIndex]["meals"][0]["name"] = name; this.value[this.activeIndex]["meals"][0]["name"] = name;
this.value[this.activeIndex]["meals"][0]["description"] = description;
}, },
setSlug(name, slug) { setSlug(name, slug) {
switch (this.mode) { switch (this.mode) {
@ -108,6 +147,23 @@ export default {
removeSide(dayIndex, sideIndex) { removeSide(dayIndex, sideIndex) {
this.value[dayIndex]["meals"].splice(sideIndex, 1); this.value[dayIndex]["meals"].splice(sideIndex, 1);
}, },
addCustomItem(index, mode) {
this.mode = mode;
this.activeIndex = index;
this.$refs.customMealDialog.open();
},
pushCustomMeal() {
switch (this.mode) {
case this.modes.primary:
this.setPrimary(this.customMeal.name, this.customMeal.slug, this.customMeal.description);
break;
default:
this.setSide(this.customMeal.name, this.customMeal.slug, this.customMeal.description);
break;
}
console.log("Hello World");
this.customMeal = { name: "", slug: null, description: "" };
},
}, },
}; };
</script> </script>
@ -116,4 +172,8 @@ export default {
.relative-card { .relative-card {
position: relative; position: relative;
} }
.custom-button {
z-index: -1;
}
</style> </style>

View file

@ -9,10 +9,15 @@
> >
<slot> </slot> <slot> </slot>
</v-img> </v-img>
<v-icon v-else color="primary" class="icon-position" :size="iconSize"> <div class="icon-slot" v-else>
<div>
<slot> </slot>
</div>
<v-icon color="primary" class="icon-position" :size="iconSize">
mdi-silverware-variant mdi-silverware-variant
</v-icon> </v-icon>
</div> </div>
</div>
</template> </template>
<script> <script>
@ -75,6 +80,15 @@ export default {
</script> </script>
<style scoped> <style scoped>
.icon-slot {
position: relative;
}
.icon-slot > div {
position: absolute;
z-index: 1;
}
.icon-position { .icon-position {
opacity: 0.8; opacity: 0.8;
display: flex !important; display: flex !important;

View file

@ -7,10 +7,7 @@
@click="$emit('click')" @click="$emit('click')"
min-height="275" min-height="275"
> >
<v-img height="200" class="d-flex" :src="getImage(slug)" @error="fallBackImage = true"> <CardImage icon-size="200" :slug="slug">
<v-icon v-if="fallBackImage" color="primary" class="icon-position" size="200">
mdi-silverware-variant
</v-icon>
<v-expand-transition v-if="description"> <v-expand-transition v-if="description">
<div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal " style="height: 100%;"> <div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal " style="height: 100%;">
<v-card-text class="v-card--text-show white--text"> <v-card-text class="v-card--text-show white--text">
@ -18,7 +15,7 @@
</v-card-text> </v-card-text>
</div> </div>
</v-expand-transition> </v-expand-transition>
</v-img> </CardImage>
<v-card-title class="my-n3 mb-n6 "> <v-card-title class="my-n3 mb-n6 ">
<div class="headerClass"> <div class="headerClass">
{{ name }} {{ name }}
@ -38,6 +35,7 @@
<script> <script>
import RecipeChips from "@/components/Recipe/RecipeViewer/RecipeChips"; import RecipeChips from "@/components/Recipe/RecipeViewer/RecipeChips";
import ContextMenu from "@/components/Recipe/ContextMenu"; import ContextMenu from "@/components/Recipe/ContextMenu";
import CardImage from "@/components/Recipe/CardImage";
import Rating from "@/components/Recipe/Parts/Rating"; import Rating from "@/components/Recipe/Parts/Rating";
import { api } from "@/api"; import { api } from "@/api";
export default { export default {
@ -45,6 +43,7 @@ export default {
RecipeChips, RecipeChips,
ContextMenu, ContextMenu,
Rating, Rating,
CardImage,
}, },
props: { props: {
name: String, name: String,
@ -91,6 +90,4 @@ export default {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
</style> </style>

View file

@ -3,7 +3,7 @@
ref="copyToolTip" ref="copyToolTip"
v-model="show" v-model="show"
color="success lighten-1" color="success lighten-1"
right top
:open-on-hover="false" :open-on-hover="false"
:open-on-click="true" :open-on-click="true"
close-delay="500" close-delay="500"
@ -12,7 +12,7 @@
<template v-slot:activator="{ on }"> <template v-slot:activator="{ on }">
<v-btn <v-btn
icon icon
color="primary" :color="color"
@click=" @click="
on.click; on.click;
textToClipboard(); textToClipboard();
@ -27,8 +27,7 @@
<v-icon left dark> <v-icon left dark>
mdi-clipboard-check mdi-clipboard-check
</v-icon> </v-icon>
{{ $t('general.coppied')}}! {{ $t("general.coppied") }}!
</span> </span>
</v-tooltip> </v-tooltip>
</template> </template>
@ -39,6 +38,9 @@ export default {
copyText: { copyText: {
default: "Default Copy Text", default: "Default Copy Text",
}, },
color: {
default: "primary",
},
}, },
data() { data() {
return { return {

View file

@ -64,7 +64,7 @@ export default {
default: false, default: false,
}, },
top: { top: {
default: false, default: null,
}, },
submitText: { submitText: {
default: () => i18n.t("general.create"), default: () => i18n.t("general.create"),

View file

@ -74,8 +74,6 @@ export default {
}, },
open() { open() {
this.dialog = true; this.dialog = true;
this.$refs.mealSearchBar.resetSearch();
this.$router.push("#search");
}, },
toggleDialog(open) { toggleDialog(open) {
if (open) { if (open) {

View file

@ -63,6 +63,12 @@ export default {
nav: "/meal-plan/planner", nav: "/meal-plan/planner",
restricted: true, restricted: true,
}, },
{
icon: "mdi-format-list-checks",
title: "Shopping Lists",
nav: "/shopping-list",
restricted: true,
},
{ {
icon: "mdi-logout", icon: "mdi-logout",
title: this.$t("user.logout"), title: this.$t("user.logout"),

View file

@ -1,22 +1,13 @@
<template> <template>
<v-container class="text-center"> <v-container class="text-center">
<v-row> <The404 />
<v-col cols="2"></v-col>
<v-col>
<v-card height="">
<v-card-text>
<h1>{{ $t("404.page-not-found") }}</h1>
</v-card-text>
<v-btn text block @click="$router.push('/')"> {{ $t("404.take-me-home") }} </v-btn>
</v-card>
</v-col>
<v-col cols="2"></v-col>
</v-row>
</v-container> </v-container>
</template> </template>
<script> <script>
export default {}; import The404 from "@/components/Fallbacks/The404";
export default {
components: { The404 },
};
</script> </script>
<style lang="scss" scoped></style>

View file

@ -2,7 +2,6 @@
<v-container> <v-container>
<EditPlan v-if="editMealPlan" :meal-plan="editMealPlan" @updated="planUpdated" /> <EditPlan v-if="editMealPlan" :meal-plan="editMealPlan" @updated="planUpdated" />
<NewMeal v-else @created="requestMeals" class="mb-5" /> <NewMeal v-else @created="requestMeals" class="mb-5" />
<ShoppingListDialog ref="shoppingList" />
<v-card class="my-2"> <v-card class="my-2">
<v-card-title class="headline"> <v-card-title class="headline">
@ -13,12 +12,34 @@
<v-row dense> <v-row dense>
<v-col :sm="6" :md="6" :lg="4" :xl="3" v-for="(mealplan, i) in plannedMeals" :key="i"> <v-col :sm="6" :md="6" :lg="4" :xl="3" v-for="(mealplan, i) in plannedMeals" :key="i">
<v-card class="mt-1"> <v-card class="mt-1">
<v-card-title> <v-card-title class="mb-0 pb-0">
{{ $d(new Date(mealplan.startDate.split("-")), "short") }} - {{ $d(new Date(mealplan.startDate.split("-")), "short") }} -
{{ $d(new Date(mealplan.endDate.split("-")), "short") }} {{ $d(new Date(mealplan.endDate.split("-")), "short") }}
</v-card-title> </v-card-title>
<v-divider class="mx-2 pa-1"></v-divider>
<v-card-actions class="mb-0 px-2 py-0">
<v-btn text small v-if="!mealplan.shoppingList" color="info" @click="createShoppingList(mealplan.uid)">
<v-icon left small>
mdi-cart-check
</v-icon>
Create Shopping List
</v-btn>
<v-btn
text
small
v-else
color="info"
class="mx-0"
:to="{ path: '/shopping-list', query: { list: mealplan.shoppingList } }"
>
<v-icon left small>
mdi-cart-check
</v-icon>
Shopping List
</v-btn>
</v-card-actions>
<v-list> <v-list class="mt-0 pt-0">
<v-list-group v-for="(planDay, pdi) in mealplan.planDays" :key="`planDays-${pdi}`"> <v-list-group v-for="(planDay, pdi) in mealplan.planDays" :key="`planDays-${pdi}`">
<template v-slot:activator> <template v-slot:activator>
<v-list-item-avatar color="primary" class="headline font-weight-light white--text"> <v-list-item-avatar color="primary" class="headline font-weight-light white--text">
@ -47,17 +68,14 @@
</v-list-group> </v-list-group>
</v-list> </v-list>
<v-card-actions class="mt-n5"> <v-card-actions class="mt-n3">
<v-btn color="accent lighten-2" class="mx-0" text @click="openShoppingList(mealplan.uid)"> <v-btn color="error lighten-2" small outlined @click="deletePlan(mealplan.uid)">
{{ $t("meal-plan.shopping-list") }} {{ $t("general.delete") }}
</v-btn> </v-btn>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="accent lighten-2" class="mx-0" text @click="editPlan(mealplan.uid)"> <v-btn color="info" small @click="editPlan(mealplan.uid)">
{{ $t("general.edit") }} {{ $t("general.edit") }}
</v-btn> </v-btn>
<v-btn color="error lighten-2" class="mx-2" text @click="deletePlan(mealplan.uid)">
{{ $t("general.delete") }}
</v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-col> </v-col>
@ -70,13 +88,11 @@ import { api } from "@/api";
import { utils } from "@/utils"; import { utils } from "@/utils";
import NewMeal from "@/components/MealPlan/MealPlanNew"; import NewMeal from "@/components/MealPlan/MealPlanNew";
import EditPlan from "@/components/MealPlan/MealPlanEditor"; import EditPlan from "@/components/MealPlan/MealPlanEditor";
import ShoppingListDialog from "@/components/MealPlan/ShoppingListDialog";
export default { export default {
components: { components: {
NewMeal, NewMeal,
EditPlan, EditPlan,
ShoppingListDialog,
}, },
data: () => ({ data: () => ({
plannedMeals: [], plannedMeals: [],
@ -114,8 +130,13 @@ export default {
this.requestMeals(); this.requestMeals();
} }
}, },
openShoppingList(id) { async createShoppingList(id) {
this.$refs.shoppingList.openDialog(id); await api.mealPlans.shoppingList(id);
this.requestMeals();
this.$store.dispatch("requestCurrentGroup");
},
redirectToList(id) {
this.$router.push(id);
}, },
}, },
}; };

View file

@ -1,43 +1,44 @@
<template> <template>
<v-container fill-height> <v-container>
<v-row> <div v-for="(planDay, index) in mealPlan.planDays" :key="index" class="mb-5">
<v-col sm="12"> <v-card-title class="headline">
<v-card v-for="(meal, index) in mealPlan.meals" :key="index" class="my-2"> {{ $d(new Date(planDay.date), "short") }}
<v-row dense no-gutters align="center" justify="center">
<v-col order="1" md="6" sm="12">
<v-card flat class="align-center justify-center" align="center" justify="center">
<v-card-title class="justify-center">
{{ meal.name }}
</v-card-title> </v-card-title>
<v-card-subtitle> {{ $d(new Date(meal.date), "short") }}</v-card-subtitle> <v-divider class="mx-2"></v-divider>
<v-row>
<v-card-text> {{ meal.description }} </v-card-text> <v-col cols="12" md="5" sm="12">
<v-card-title class="headline">Main</v-card-title>
<v-card-actions> <RecipeCard
<v-spacer></v-spacer> :name="planDay.meals[0].name"
<v-btn align="center" color="secondary" text @click="$router.push(`/recipe/${meal.slug}`)"> :slug="planDay.meals[0].slug"
{{ $t("recipe.view-recipe") }} :description="planDay.meals[0].description"
</v-btn> />
<v-spacer></v-spacer>
</v-card-actions>
</v-card>
</v-col> </v-col>
<v-col order-sm="0" :order-md="getOrder(index)" md="6" sm="12"> <v-col cols="12" lg="6" md="6" sm="12">
<v-card flat> <v-card-title class="headline">Sides</v-card-title>
<v-img :src="getImage(meal.slug)" max-height="300"> </v-img> <MobileRecipeCard
</v-card> v-for="(side, index) in planDay.meals.slice(1)"
</v-col> :key="`side-${index}`"
</v-row> :name="side.name"
</v-card> :slug="side.slug"
:description="side.description"
/>
</v-col> </v-col>
</v-row> </v-row>
</div>
</v-container> </v-container>
</template> </template>
<script> <script>
import { api } from "@/api"; import { api } from "@/api";
import { utils } from "@/utils"; import { utils } from "@/utils";
import RecipeCard from "@/components/Recipe/RecipeCard";
import MobileRecipeCard from "@/components/Recipe/MobileRecipeCard";
export default { export default {
components: {
RecipeCard,
MobileRecipeCard,
},
data() { data() {
return { return {
mealPlan: {}, mealPlan: {},
@ -48,6 +49,7 @@ export default {
if (!this.mealPlan) { if (!this.mealPlan) {
utils.notify.warning(this.$t("meal-plan.no-meal-plan-defined-yet")); utils.notify.warning(this.$t("meal-plan.no-meal-plan-defined-yet"));
} }
console.log(this.mealPlan);
}, },
methods: { methods: {
getOrder(index) { getOrder(index) {

View file

@ -3,7 +3,8 @@
<v-card v-if="skeleton" :color="`white ${theme.isDark ? 'darken-2' : 'lighten-4'}`" class="pa-3"> <v-card v-if="skeleton" :color="`white ${theme.isDark ? 'darken-2' : 'lighten-4'}`" class="pa-3">
<v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader> <v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader>
</v-card> </v-card>
<v-card v-else id="myRecipe" class="d-print-none"> <NoRecipe v-else-if="loadFailed" />
<v-card v-else-if="!loadFailed" id="myRecipe" class="d-print-none">
<v-img height="400" :src="getImage(recipeDetails.slug)" class="d-print-none" :key="imageKey"> <v-img height="400" :src="getImage(recipeDetails.slug)" class="d-print-none" :key="imageKey">
<RecipeTimeCard <RecipeTimeCard
:class="isMobile ? undefined : 'force-bottom'" :class="isMobile ? undefined : 'force-bottom'"
@ -48,6 +49,7 @@ import PrintView from "@/components/Recipe/PrintView";
import RecipeEditor from "@/components/Recipe/RecipeEditor"; import RecipeEditor from "@/components/Recipe/RecipeEditor";
import RecipeTimeCard from "@/components/Recipe/RecipeTimeCard.vue"; import RecipeTimeCard from "@/components/Recipe/RecipeTimeCard.vue";
import EditorButtonRow from "@/components/Recipe/EditorButtonRow"; import EditorButtonRow from "@/components/Recipe/EditorButtonRow";
import NoRecipe from "@/components/Fallbacks/NoRecipe";
import { user } from "@/mixins/user"; import { user } from "@/mixins/user";
import { router } from "@/routes"; import { router } from "@/routes";
@ -59,6 +61,7 @@ export default {
EditorButtonRow, EditorButtonRow,
RecipeTimeCard, RecipeTimeCard,
PrintView, PrintView,
NoRecipe,
}, },
mixins: [user], mixins: [user],
inject: { inject: {
@ -68,6 +71,7 @@ export default {
}, },
data() { data() {
return { return {
loadFailed: false,
skeleton: true, skeleton: true,
form: false, form: false,
jsonEditor: false, jsonEditor: false,
@ -99,6 +103,7 @@ export default {
async mounted() { async mounted() {
await this.getRecipeDetails(); await this.getRecipeDetails();
this.jsonEditor = false; this.jsonEditor = false;
this.form = this.$route.query.edit === "true" && this.loggedIn; this.form = this.$route.query.edit === "true" && this.loggedIn;
@ -141,6 +146,12 @@ export default {
this.saveImage(); this.saveImage();
}, },
async getRecipeDetails() { async getRecipeDetails() {
if (this.currentRecipe === "null") {
this.skeleton = false;
this.loadFailed = true;
return;
}
this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe); this.recipeDetails = await api.recipes.requestDetails(this.currentRecipe);
this.skeleton = false; this.skeleton = false;
}, },

View file

@ -0,0 +1,280 @@
<template>
<v-container>
<v-app-bar color="transparent" flat class="mt-n1 rounded">
<v-btn v-if="list" color="info" @click="list = null">
<v-icon left>
mdi-arrow-left-bold
</v-icon>
All Lists
</v-btn>
<v-icon v-if="!list" large left>
mdi-format-list-checks
</v-icon>
<v-toolbar-title v-if="!list" class="headline"> Shopping Lists </v-toolbar-title>
<v-spacer></v-spacer>
<BaseDialog title="New List" title-icon="mdi-format-list-checks" submit-text="Create" @submit="createNewList">
<template v-slot:open="{ open }">
<v-btn color="info" @click="open">
<v-icon left>
mdi-plus
</v-icon>
New List
</v-btn>
</template>
<v-card-text>
<v-text-field autofocus v-model="newList.name" label="List Name"> </v-text-field>
</v-card-text>
</BaseDialog>
</v-app-bar>
<v-slide-x-transition hide-on-leave>
<v-row v-if="list == null">
<v-col cols="12" :sm="6" :md="6" :lg="4" :xl="3" v-for="(item, index) in group.shoppingLists" :key="index">
<v-card>
<v-card-title class="headline">
{{ item.name }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-actions>
<v-btn text color="error" @click="deleteList(item.id)">
<v-icon left>
mdi-delete
</v-icon>
Delete
</v-btn>
<v-spacer></v-spacer>
<v-btn color="info" @click="list = item.id">
<v-icon left>
mdi-cart-check
</v-icon>
View
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-card v-else-if="activeList">
<v-card-title class="headline">
<TheCopyButton v-if="!edit" :copy-text="listAsText" color="info" />
<v-text-field label="Name" single-line dense v-if="edit" v-model="activeList.name"> </v-text-field>
<div v-else>
{{ activeList.name }}
</div>
<v-spacer></v-spacer>
<v-btn v-if="edit" color="success" @click="saveList">
Save
</v-btn>
<v-btn v-else color="info" @click="edit = true">
Edit
</v-btn>
</v-card-title>
<v-divider class="mx-2 mb-1"></v-divider>
<SearchDialog ref="searchRecipe" @select="importIngredients" />
<v-card-text>
<v-row dense v-for="(item, index) in activeList.items" :key="index">
<v-col v-if="edit" cols="12" class="d-flex no-wrap align-center">
<p class="mb-0">Quantity: {{ item.quantity }}</p>
<div v-if="edit">
<v-btn x-small text class="ml-1" @click="activeList.items[index].quantity -= 1">
<v-icon>
mdi-minus
</v-icon>
</v-btn>
<v-btn x-small text class="mr-1" @click="activeList.items[index].quantity += 1">
<v-icon>
mdi-plus
</v-icon>
</v-btn>
</div>
<v-spacer></v-spacer>
<v-btn v-if="edit" icon @click="removeItemByIndex(index)" color="error">
<v-icon>mdi-delete</v-icon>
</v-btn>
</v-col>
<v-col cols="12" class="d-flex no-wrap align-center">
<v-checkbox
v-if="!edit"
hide-details
v-model="activeList.items[index].checked"
class="pt-0 my-auto py-auto"
color="secondary"
@change="saveList"
></v-checkbox>
<p v-if="!edit" class="mb-0">{{ item.quantity }}</p>
<v-icon v-if="!edit" small class="mx-3">
mdi-window-close
</v-icon>
<vue-markdown v-if="!edit" class="dense-markdown" :source="item.text"> </vue-markdown>
<v-textarea
single-line
rows="1"
auto-grow
class="mb-n2 pa-0"
dense
v-else
v-model="activeList.items[index].text"
></v-textarea>
</v-col>
<v-divider class="ma-1"></v-divider>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn v-if="edit" color="success" @click="openSearch">
<v-icon left>
mdi-silverware-variant
</v-icon>
From Recipe
</v-btn>
<v-btn v-if="edit" color="success" @click="newItem">
<v-icon left>
mdi-plus
</v-icon>
New
</v-btn>
</v-card-actions>
</v-card>
</v-slide-x-transition>
</v-container>
</template>
<script>
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
import SearchDialog from "@/components/UI/Search/SearchDialog";
import TheCopyButton from "@/components/UI/Buttons/TheCopyButton";
import VueMarkdown from "@adapttive/vue-markdown";
import { api } from "@/api";
export default {
components: {
BaseDialog,
SearchDialog,
TheCopyButton,
VueMarkdown,
},
data() {
return {
newList: {
name: "",
group: "",
items: [],
},
activeList: null,
edit: false,
};
},
computed: {
group() {
return this.$store.getters.getCurrentGroup;
},
list: {
set(list) {
this.$router.replace({ query: { ...this.$route.query, list } });
},
get() {
return this.$route.query.list;
},
},
listAsText() {
const formatList = this.activeList.items.map(x => {
return `${x.quantity} - ${x.text}`;
});
return formatList.join("\n");
},
},
watch: {
group: {
immediate: true,
handler: "setActiveList",
},
list: {
immediate: true,
handler: "setActiveList",
},
},
methods: {
openSearch() {
this.$refs.searchRecipe.open();
},
async importIngredients(_, slug) {
const recipe = await api.recipes.requestDetails(slug);
const ingredients = recipe.recipeIngredient.map(x => ({
title: "",
text: x,
quantity: 1,
checked: false,
}));
this.activeList.items = [...this.activeList.items, ...ingredients];
this.consolidateList();
},
consolidateList() {
const allText = this.activeList.items.map(x => x.text);
const uniqueText = allText.filter((item, index) => {
return allText.indexOf(item) === index;
});
const newItems = uniqueText.map(x => {
let matchingItems = this.activeList.items.filter(y => y.text === x);
matchingItems[0].quantity = this.sumQuantiy(matchingItems);
return matchingItems[0];
});
this.activeList.items = newItems;
},
sumQuantiy(itemList) {
let quantity = 0;
itemList.forEach(element => {
quantity += element.quantity;
});
return quantity;
},
setActiveList() {
if (!this.list) return null;
if (!this.group.shoppingLists) return null;
this.activeList = this.group.shoppingLists.find(x => x.id == this.list);
},
async createNewList() {
this.newList.group = this.group.name;
await api.shoppingLists.createShoppingList(this.newList);
this.$store.dispatch("requestCurrentGroup");
},
async deleteList(id) {
await api.shoppingLists.deleteShoppingList(id);
this.$store.dispatch("requestCurrentGroup");
},
removeItemByIndex(index) {
this.activeList.items.splice(index, 1);
},
newItem() {
this.activeList.items.push({
title: null,
text: "",
quantity: 1,
checked: false,
});
},
async saveList() {
await this.consolidateList();
await api.shoppingLists.updateShoppingList(this.activeList.id, this.activeList);
this.edit = false;
},
},
};
</script>
<style >
</style>

View file

@ -1,9 +1,11 @@
import SearchPage from "@/pages/SearchPage"; import SearchPage from "@/pages/SearchPage";
import HomePage from "@/pages/HomePage"; import HomePage from "@/pages/HomePage";
import ShoppingList from "@/pages/ShoppingList";
export const generalRoutes = [ export const generalRoutes = [
{ path: "/", name: "home", component: HomePage }, { path: "/", name: "home", component: HomePage },
{ path: "/mealie", component: HomePage }, { path: "/mealie", component: HomePage },
{ path: "/shopping-list", component: ShoppingList },
{ {
path: "/search", path: "/search",
component: SearchPage, component: SearchPage,

View file

@ -5,18 +5,14 @@ import { store } from "@/store";
export const utils = { export const utils = {
recipe: recipe, recipe: recipe,
getImageURL(image) {
return `/api/recipes/${image}/image?image_type=small`;
},
generateUniqueKey(item, index) { generateUniqueKey(item, index) {
const uniqueKey = `${item}-${index}`; const uniqueKey = `${item}-${index}`;
return uniqueKey; return uniqueKey;
}, },
getDateAsPythonDate(dateObject) { getDateAsPythonDate(dateObject) {
const month = dateObject.getUTCMonth() + 1; const month = dateObject.getMonth() + 1;
const day = dateObject.getUTCDate(); const day = dateObject.getDate();
const year = dateObject.getFullYear(); const year = dateObject.getFullYear();
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
}, },
notify: { notify: {

View file

@ -9,6 +9,7 @@ from mealie.routes.groups import groups_router
from mealie.routes.mealplans import meal_plan_router from mealie.routes.mealplans import meal_plan_router
from mealie.routes.media import media_router from mealie.routes.media import media_router
from mealie.routes.recipe import recipe_router from mealie.routes.recipe import recipe_router
from mealie.routes.shopping_list import shopping_list_router
from mealie.routes.site_settings import settings_router from mealie.routes.site_settings import settings_router
from mealie.routes.users import user_router from mealie.routes.users import user_router
from mealie.services.events import create_general_event from mealie.services.events import create_general_event
@ -32,6 +33,7 @@ def api_routers():
# Authentication # Authentication
app.include_router(user_router) app.include_router(user_router)
app.include_router(groups_router) app.include_router(groups_router)
app.include_router(shopping_list_router)
# Recipes # Recipes
app.include_router(recipe_router) app.include_router(recipe_router)
app.include_router(media_router) app.include_router(media_router)

View file

@ -6,6 +6,7 @@ from mealie.db.models.group import Group
from mealie.db.models.mealplan import MealPlan from mealie.db.models.mealplan import MealPlan
from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag
from mealie.db.models.settings import CustomPage, SiteSettings from mealie.db.models.settings import CustomPage, SiteSettings
from mealie.db.models.shopping_list import ShoppingList
from mealie.db.models.sign_up import SignUp from mealie.db.models.sign_up import SignUp
from mealie.db.models.theme import SiteThemeModel from mealie.db.models.theme import SiteThemeModel
from mealie.db.models.users import LongLiveToken, User from mealie.db.models.users import LongLiveToken, User
@ -16,6 +17,7 @@ from mealie.schema.meal import MealPlanOut
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.settings import CustomPageOut from mealie.schema.settings import CustomPageOut
from mealie.schema.settings import SiteSettings as SiteSettingsSchema from mealie.schema.settings import SiteSettings as SiteSettingsSchema
from mealie.schema.shopping_list import ShoppingListOut
from mealie.schema.sign_up import SignUpOut from mealie.schema.sign_up import SignUpOut
from mealie.schema.theme import SiteTheme from mealie.schema.theme import SiteTheme
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, UserInDB from mealie.schema.user import GroupInDB, LongLiveTokenInDB, UserInDB
@ -136,6 +138,13 @@ class _Groups(BaseDocument):
return group.mealplans return group.mealplans
class _ShoppingList(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = ShoppingList
self.schema = ShoppingListOut
class _SignUps(BaseDocument): class _SignUps(BaseDocument):
def __init__(self) -> None: def __init__(self) -> None:
self.primary_key = "token" self.primary_key = "token"
@ -179,6 +188,7 @@ class Database:
self.custom_pages = _CustomPages() self.custom_pages = _CustomPages()
self.events = _Events() self.events = _Events()
self.event_notifications = _EventNotification() self.event_notifications = _EventNotification()
self.shopping_lists = _ShoppingList()
db = Database() db = Database()

View file

@ -3,6 +3,7 @@ from mealie.db.models.group import *
from mealie.db.models.mealplan import * from mealie.db.models.mealplan import *
from mealie.db.models.recipe.recipe import * from mealie.db.models.recipe.recipe import *
from mealie.db.models.settings import * from mealie.db.models.settings import *
from mealie.db.models.shopping_list import *
from mealie.db.models.sign_up import * from mealie.db.models.sign_up import *
from mealie.db.models.theme import * from mealie.db.models.theme import *
from mealie.db.models.users import * from mealie.db.models.users import *

View file

@ -24,6 +24,13 @@ class Group(SqlAlchemyBase, BaseMixins):
single_parent=True, single_parent=True,
order_by="MealPlan.start_date", order_by="MealPlan.start_date",
) )
shopping_lists = orm.relationship(
"ShoppingList",
back_populates="group",
single_parent=True,
)
categories = orm.relationship("Category", secondary=group2categories, single_parent=True) categories = orm.relationship("Category", secondary=group2categories, single_parent=True)
# Webhook Settings # Webhook Settings
@ -32,16 +39,7 @@ class Group(SqlAlchemyBase, BaseMixins):
webhook_urls = orm.relationship("WebhookURLModel", uselist=True, cascade="all, delete-orphan") webhook_urls = orm.relationship("WebhookURLModel", uselist=True, cascade="all, delete-orphan")
def __init__( def __init__(
self, self, name, categories=[], session=None, webhook_enable=False, webhook_time="00:00", webhook_urls=[], **_
name,
id=None,
users=None,
mealplans=None,
categories=[],
session=None,
webhook_enable=False,
webhook_time="00:00",
webhook_urls=[],
) -> None: ) -> None:
self.name = name self.name = name
self.categories = [Category.get_ref(session=session, slug=cat.get("slug")) for cat in categories] self.categories = [Category.get_ref(session=session, slug=cat.get("slug")) for cat in categories]

View file

@ -2,6 +2,7 @@ import sqlalchemy.orm as orm
from mealie.db.models.group import Group from mealie.db.models.group import Group
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.recipe.recipe import RecipeModel from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.shopping_list import ShoppingList
from sqlalchemy import Column, Date, ForeignKey, Integer, String from sqlalchemy import Column, Date, ForeignKey, Integer, String
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
@ -56,8 +57,24 @@ class MealPlan(SqlAlchemyBase, BaseMixins):
group_id = Column(Integer, ForeignKey("groups.id")) group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="mealplans") group = orm.relationship("Group", back_populates="mealplans")
def __init__(self, start_date, end_date, plan_days, group: str, uid=None, session=None) -> None: shopping_list_id = Column(Integer, ForeignKey("shopping_lists.id"))
shopping_list: ShoppingList = orm.relationship("ShoppingList", single_parent=True)
def __init__(
self,
start_date,
end_date,
plan_days,
group: str,
shopping_list: int = None,
session=None,
**_,
) -> None:
self.start_date = start_date self.start_date = start_date
self.end_date = end_date self.end_date = end_date
self.group = Group.get_ref(session, group) self.group = Group.get_ref(session, group)
if shopping_list:
self.shopping_list = ShoppingList.get_ref(session, shopping_list)
self.plan_days = [MealDay(**day, session=session) for day in plan_days] self.plan_days = [MealDay(**day, session=session) for day in plan_days]

View file

@ -0,0 +1,49 @@
import sqlalchemy.orm as orm
from mealie.db.models.group import Group
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from requests import Session
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String
from sqlalchemy.ext.orderinglist import ordering_list
class ShoppingListItem(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_list_items"
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey("shopping_lists.id"))
position = Column(Integer, nullable=False)
title = Column(String)
text = Column(String)
quantity = Column(Integer)
checked = Column(Boolean)
def __init__(self, title, text, quantity, checked, **_) -> None:
self.title = title
self.text = text
self.quantity = quantity
self.checked = checked
class ShoppingList(SqlAlchemyBase, BaseMixins):
__tablename__ = "shopping_lists"
id = Column(Integer, primary_key=True)
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="shopping_lists")
name = Column(String)
items: list[ShoppingListItem] = orm.relationship(
ShoppingListItem,
cascade="all, delete, delete-orphan",
order_by="ShoppingListItem.position",
collection_class=ordering_list("position"),
)
def __init__(self, name, group, items, session=None, **_) -> None:
self.name = name
self.group = Group.get_ref(session, group)
self.items = [ShoppingListItem(**i) for i in items]
@staticmethod
def get_ref(session: Session, id: int):
return session.query(ShoppingList).filter(ShoppingList.id == id).one_or_none()

View file

@ -1,11 +1,16 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from mealie.core.root_logger import get_logger
from mealie.db.database import db from mealie.db.database import db
from mealie.db.db_setup import generate_session from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user from mealie.routes.deps import get_current_user
from mealie.schema.meal import MealPlanOut from mealie.schema.meal import MealPlanOut
from mealie.schema.recipe import Recipe from mealie.schema.recipe import Recipe
from mealie.schema.shopping_list import ListItem, ShoppingListIn, ShoppingListOut
from mealie.schema.user import UserInDB
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
logger = get_logger()
router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"]) router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
@ -13,12 +18,32 @@ router = APIRouter(prefix="/api/meal-plans", tags=["Meal Plan"])
def get_shopping_list( def get_shopping_list(
id: str, id: str,
session: Session = Depends(generate_session), session: Session = Depends(generate_session),
current_user=Depends(get_current_user), current_user: UserInDB = Depends(get_current_user),
): ):
# ! Refactor into Single Database Call mealplan: MealPlanOut = db.meals.get(session, id)
mealplan = db.meals.get(session, id)
mealplan: MealPlanOut all_ingredients = []
slugs = [x.slug for x in mealplan.meals]
recipes: list[Recipe] = [db.recipes.get(session, x) for x in slugs] for plan_day in mealplan.plan_days:
return [{"name": x.name, "recipe_ingredient": x.recipe_ingredient} for x in recipes if x] for meal in plan_day.meals:
if not meal.slug:
continue
try:
recipe: Recipe = db.recipes.get(session, meal.slug)
all_ingredients += recipe.recipe_ingredient
except Exception:
logger.error("Recipe Not Found")
new_list = ShoppingListIn(
name="MealPlan Shopping List", group=current_user.group, items=[ListItem(text=t) for t in all_ingredients]
)
created_list: ShoppingListOut = db.shopping_lists.create(session, new_list)
mealplan.shopping_list = created_list.id
db.meals.update(session, mealplan.uid, mealplan)
return created_list

View file

@ -5,10 +5,7 @@ from mealie.routes.deps import get_current_user
from mealie.schema.category import CategoryIn, RecipeCategoryResponse from mealie.schema.category import CategoryIn, RecipeCategoryResponse
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
router = APIRouter( router = APIRouter(prefix="/api/categories", tags=["Recipe Categories"])
prefix="/api/categories",
tags=["Recipe Categories"],
)
@router.get("") @router.get("")

View file

@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.shopping_list import ShoppingListIn, ShoppingListOut
from mealie.schema.user import UserInDB
from sqlalchemy.orm.session import Session
shopping_list_router = APIRouter(prefix="/api/shopping-lists", tags=["Shopping Lists"])
@shopping_list_router.post("", response_model=ShoppingListOut)
async def create_shopping_list(
list_in: ShoppingListIn,
current_user: UserInDB = Depends(get_current_user),
session: Session = Depends(generate_session),
):
""" Create Shopping List in the Database """
list_in.group = current_user.group
return db.shopping_lists.create(session, list_in)
@shopping_list_router.get("/{id}", response_model=ShoppingListOut)
async def get_shopping_list(id: int, session: Session = Depends(generate_session)):
""" Get Shopping List from the Database """
return db.shopping_lists.get(session, id)
@shopping_list_router.put("/{id}", dependencies=[Depends(get_current_user)], response_model=ShoppingListOut)
async def update_shopping_list(id: int, new_data: ShoppingListIn, session: Session = Depends(generate_session)):
""" Update Shopping List in the Database """
return db.shopping_lists.update(session, id, new_data)
@shopping_list_router.delete("/{id}", dependencies=[Depends(get_current_user)])
async def delete_shopping_list(id: int, session: Session = Depends(generate_session)):
""" Delete Shopping List from the Database """
return db.shopping_lists.delete(session, id)

View file

@ -49,13 +49,22 @@ class MealPlanIn(CamelModel):
class MealPlanOut(MealPlanIn): class MealPlanOut(MealPlanIn):
uid: int uid: int
shopping_list: Optional[int]
class Config: class Config:
orm_mode = True orm_mode = True
@classmethod @classmethod
def getter_dict(_cls, name_orm: MealPlan): def getter_dict(_cls, name_orm: MealPlan):
try:
return { return {
**GetterDict(name_orm), **GetterDict(name_orm),
"group": name_orm.group.name, "group": name_orm.group.name,
"shopping_list": name_orm.shopping_list.id,
}
except Exception:
return {
**GetterDict(name_orm),
"group": name_orm.group.name,
"shopping_list": None,
} }

View file

@ -0,0 +1,35 @@
from typing import Optional
from fastapi_camelcase import CamelModel
from mealie.db.models.shopping_list import ShoppingList
from pydantic.utils import GetterDict
class ListItem(CamelModel):
title: Optional[str]
text: str = ""
quantity: int = 1
checked: bool = False
class Config:
orm_mode = True
class ShoppingListIn(CamelModel):
name: str
group: Optional[str]
items: list[ListItem]
class ShoppingListOut(ShoppingListIn):
id: int
class Config:
orm_mode = True
@classmethod
def getter_dict(cls, ormModel: ShoppingList):
return {
**GetterDict(ormModel),
"group": ormModel.group.name,
}

View file

@ -6,6 +6,7 @@ from mealie.db.models.group import Group
from mealie.db.models.users import User from mealie.db.models.users import User
from mealie.schema.category import CategoryBase from mealie.schema.category import CategoryBase
from mealie.schema.meal import MealPlanOut from mealie.schema.meal import MealPlanOut
from mealie.schema.shopping_list import ShoppingListOut
from pydantic.types import constr from pydantic.types import constr
from pydantic.utils import GetterDict from pydantic.utils import GetterDict
@ -106,6 +107,7 @@ class UpdateGroup(GroupBase):
class GroupInDB(UpdateGroup): class GroupInDB(UpdateGroup):
users: Optional[list[UserOut]] users: Optional[list[UserOut]]
mealplans: Optional[list[MealPlanOut]] mealplans: Optional[list[MealPlanOut]]
shopping_lists: Optional[list[ShoppingListOut]]
class Config: class Config:
orm_mode = True orm_mode = True

View file

@ -12,7 +12,6 @@ from sqlalchemy.orm.session import Session
def set_mealplan_dates(meal_plan_base: MealPlanIn) -> MealPlanIn: def set_mealplan_dates(meal_plan_base: MealPlanIn) -> MealPlanIn:
for x, plan_days in enumerate(meal_plan_base.plan_days): for x, plan_days in enumerate(meal_plan_base.plan_days):
plan_days: MealDayIn plan_days: MealDayIn
plan_days.date = meal_plan_base.start_date + timedelta(days=x) plan_days.date = meal_plan_base.start_date + timedelta(days=x)
@ -29,22 +28,22 @@ def get_todays_meal(session: Session, group: Union[int, GroupInDB]) -> Recipe:
Recipe: Pydantic Recipe Object Recipe: Pydantic Recipe Object
""" """
return session = session or create_session()
# session = session or create_session() if isinstance(group, int):
group: GroupInDB = db.groups.get(session, group)
# if isinstance(group, int): today_slug = None
# group: GroupInDB = db.groups.get(session, group)
# today_slug = None for mealplan in group.mealplans:
for plan_day in mealplan.plan_days:
if plan_day.date == date.today():
if plan_day.meals[0].slug and plan_day.meals[0].slug != "":
today_slug = plan_day.meals[0].slug
else:
return plan_day.meals[0]
# for mealplan in group.mealplans: if today_slug:
# for meal in mealplan.meals: return db.recipes.get(session, today_slug)
# if meal.date == date.today(): else:
# today_slug = meal.slug return None
# break
# if today_slug:
# return db.recipes.get(session, today_slug)
# else:
# return None

1
scratch.json Normal file

File diff suppressed because one or more lines are too long