This commit is contained in:
Hayden 2020-12-24 16:37:38 -09:00
commit beed8576c2
137 changed files with 40218 additions and 0 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
*/node_modules
*/dist

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.css linguist-detectable=false

139
.gitignore vendored Normal file
View File

@ -0,0 +1,139 @@
# Byte-compiled / optimized / DLL files
.env
__pycache__/
*.py[cod]
*$py.class
frontend/.env.development
docs/site/
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
.env.development
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# dotenv
# .env
# virtualenv
.venv
venv/
ENV/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
# IDE settings
# .vscode/
# Node Modules
node_modules/
/*.env.development

26
Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM node:alpine as build-stage
WORKDIR /app
COPY ./frontend/package*.json ./
RUN npm install
COPY ./frontend/ .
RUN npm run build
FROM python:3
RUN apt-get update -y && \
apt-get install -y python-pip python-dev
# We copy just the requirements.txt first to leverage Docker cache
COPY ./requirements.txt /app/requirements.txt
WORKDIR /app
RUN pip install -r requirements.txt
COPY ./mealie /app
COPY --from=build-stage /app/dist /app/dist
ENTRYPOINT [ "python" ]
# TODO Reconfigure Command to start a Gunicorn Server that managed the Uvicorn Server. Also Learn how to do that :-/
CMD [ "app.py" ]

20
Dockerfile.dev Normal file
View File

@ -0,0 +1,20 @@
FROM python:3
RUN apt-get update -y && \
apt-get install -y python-pip python-dev
# We copy just the requirements.txt first to leverage Docker cache
COPY ./requirements.txt /app/requirements.txt
WORKDIR /app
RUN pip install -r requirements.txt
# COPY ./mealie /app
ENTRYPOINT [ "python" ]
# TODO Reconfigure Command to start a Gunicorn Server that managed the Uvicorn Server. Also Learn how to do that :-/
CMD [ "app.py" ]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020, Hayden Kotelman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

121
README.md Normal file
View File

@ -0,0 +1,121 @@
[![Contributors][contributors-shield]][contributors-url]
[![Forks][forks-shield]][forks-url]
[![Stargazers][stars-shield]][stars-url]
[![Issues][issues-shield]][issues-url]
[![MIT License][license-shield]][license-url]
[![LinkedIn][linkedin-shield]][linkedin-url]
<!-- PROJECT LOGO -->
<br />
<p align="center">
<a href="https://github.com/hay-kot/mealie">
<img src="images/logo.png" alt="Logo" width="80" height="80">
</a>
<h3 align="center">Mealie</h3>
<p align="center">
A Place for All Your Recipes
<br />
<a href="https://hay-kot.github.io/mealie/"><strong>Explore the docs »</strong></a>
<br />
<br />
<a href="https://github.com/hay-kot/mealie"><s>View Demo</s></a>
·
<a href="https://github.com/hay-kot/mealie/issues">Report Bug</a>
·
<a href="https://github.com/hay-kot/mealie/issues">Request Feature</a>
</p>
</p>
<!-- ABOUT THE PROJECT -->
## About The Project
[![Product Name Screen Shot][product-screenshot]](https://example.com)
**Mealie** is a self hosted recipe manager and meal planner with a RestAPI backend and a reactive frontend application built in Vue for a pleasant user experience for the whole family. Easily add recipes into your database by providing the url and mealie will automatically import the relavent data or add a family recipe with the UI editor.
Melaie also provides a secure API for interactions from 3rd party applications. **Why does my recipe manager need an API?** An API allows integration into applications like [Home Assistant]() that can act as notification engines to provide custom notifications based of Meal Plan data to remind you to defrost the chicken, marinade the steak, or start the CrockPot. See the section on [Meal Plan hooks](#hooks) for more information. Additionally, you can access any avaiable API from the backend server. To explore the API spin up your server and navigate to http://yourserver.com/docs for interactive API documentation.
### Main Features
#### Recipes
- Automatic web scrapping for common recipe platforms
- Interactive API Documentation thanks to [FastAPI](https://fastapi.tiangolo.com/) and [Swagger](https://petstore.swagger.io/)
- UI Recipe Editor
- JSON Recipe Editor in browser
- Custom tags and categories
- Rate recipes
- Add notes to recipes
#### Meal Planner
- Random Meal plan generation based off categories
- Expose notes in the API to allow external applications to access relavent information for meal plans
#### Database Import / Export
- Easily Import / Export your recipes from the UI
- Export recipes in markdown format for universal access
- Use the default or a custom jinja2 template
### Built With
* [Vue.js](https://vuejs.org/)
* [Vuetify](https://vuetifyjs.com/en/)
* [FastAPI](https://fastapi.tiangolo.com/)
* [MongoDB](https://www.mongodb.com/)
* [Docker](https://www.docker.com/)
### Built With
* [Vue.js](https://vuejs.org/)
* [Vuetify](https://vuetifyjs.com/en/)
* [FastAPI](https://fastapi.tiangolo.com/)
* [Docker](https://www.docker.com/)
<!-- CONTRIBUTING -->
## Contributing
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**.
<!-- LICENSE -->
## License
Distributed under the MIT License. See `LICENSE` for more information.
<!-- CONTACT -->
## Contact
Project Link: [https://github.com/hay-kot/mealie](https://github.com/hay-kot/mealie)
<!-- ACKNOWLEDGEMENTS -->
## Acknowledgements
* [Talk Python Training for helping me learn python](https://training.talkpython.fm/)
* [Academind for helping me learn Javascript and Vue.js](https://academind.com/)
<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[contributors-shield]: https://img.shields.io/github/contributors/hay-kot/mealie.svg?style=flat-square
[contributors-url]: https://github.com/hay-kot/mealie/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/hay-kot/mealie.svg?style=flat-square
[forks-url]: https://github.com/hay-kot/mealie/network/members
[stars-shield]: https://img.shields.io/github/stars/hay-kot/mealie.svg?style=flat-square
[stars-url]: https://github.com/hay-kot/mealie/stargazers
[issues-shield]: https://img.shields.io/github/issues/hay-kot/mealie.svg?style=flat-square
[issues-url]: https://github.com/hay-kot/mealie/issues
[license-shield]: https://img.shields.io/github/license/hay-kot/mealie.svg?style=flat-square
[license-url]: https://github.com/hay-kot/mealie/blob/master/LICENSE.txt
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555
[linkedin-url]: https://linkedin.com/in/hay-kot
[product-screenshot]: docs/docs/img/home_screenshot.png

78
dev/README.md Normal file
View File

@ -0,0 +1,78 @@
# Mealie Development Notes
[toc]
## Feature List (TODOs)
### Frontend Tasks
- [x] Fix Menu Links
- [ ] 404 Page
- [x] Refactor API / Split Code
- [ ] Form Validation
- [x] Admin
- [x] Backups
- [x] Themes
- [ ] Recipe Viewer
- [ ] notes Hidden/Not Hidden
- [ ] Total Time Indicator
- [ ] BakeTime
- [x] Proper Response Handling
- [x] Recipe Created URL Feedback
- [x] Recipe Deleted
- [x] Backup Creation
- [x] Backup Deleted
- [x] Meal Plan
- [x] Empty Response Bug
- [x] Breakup Vue Componenets for Reusability
- [x] Meal Cards
- [x] Editor Button
- [x] Recipe Editor
- [x] New Recipe File Upload
- [x] Bulk Import for Ingredients / Instructions
- [x] Meal Plan
- [x] Creator
- [x] UI
- [x] Requests / Response
- [x] Timeline
- [x] View
- [x] Delete
- [x] Edit Existing
- [x] Random Meal Generator
- [x] Whats For Dinner Page
- [x] Current Meal Plan
- [ ] Include Lunch / Dinner / Breaksfast Categories Option
- [x] Admin Settings
- [x] Site Settings
- [x] Webhooks
- [x] Dark Mode - Cookies
- [x] Color Themes - Cookies
### Backend Tasks
- [x] Proper Response Handling
- [ ] Backup Options
- [ ] Force Update
- [ ] Rebuild
- [ ] Meal Planner
- [x] Scheduler
- [x] Webhooks
- [ ] Recipe Data
- [ ] Better Scraper
- [ ] Image Minification
- [ ] Scraper Data Validation
- [ ] Category Management
- Lunch / Dinner / Breakfast <- Meal Generation
- Dessert / Side / Appetizer / Bread / Drinks /
## v1.0 Roadmad
Frontend
- [ ] Login / Logout Navigation
- [ ] Initial Page
- [ ] Logic / Function Calls
Backend
- [ ] User Setup
- [ ] Authentication
- [ ] Default Admin/Superuser Account
- [ ] User Accounts
- [ ] Edit / Delete Lock

32
dev/build.py Normal file
View File

@ -0,0 +1,32 @@
import requests
import json
POST_URL = "http://localhost:9921/api/recipe/create-url"
URL_LIST = [
"https://www.bonappetit.com/recipe/hallacas"
"https://www.bonappetit.com/recipe/oat-and-pecan-brittle-cookies",
"https://www.bonappetit.com/recipe/tequila-beer-and-citrus-cocktail",
"https://www.bonappetit.com/recipe/corn-and-crab-beignets-with-yaji-aioli",
"https://www.bonappetit.com/recipe/nan-e-berenji",
"https://www.bonappetit.com/recipe/ginger-citrus-cookies",
"https://www.bonappetit.com/recipe/chocolate-pizzettes-cookies",
"https://www.bonappetit.com/recipe/swedish-glogg",
"https://www.bonappetit.com/recipe/roasted-beets-with-dukkah-and-sage",
"https://www.bonappetit.com/recipe/collard-greens-salad-with-pickled-fennel-and-coconut"
"https://www.bonappetit.com/recipe/sparkling-wine-cocktail",
"https://www.bonappetit.com/recipe/pretzel-and-potato-chip-moon-pies",
"https://www.bonappetit.com/recipe/coffee-hazlenut-biscotti",
"https://www.bonappetit.com/recipe/curry-cauliflower-rice",
"https://www.bonappetit.com/recipe/life-of-the-party-layer-cake",
"https://www.bonappetit.com/recipe/marranitos-enfiestados",
]
if __name__ == "__main__":
for url in URL_LIST:
data = {"url": url}
data = json.dumps(data)
try:
response = requests.post(POST_URL, data)
except:
continue

View File

@ -0,0 +1 @@
docker-compose -f docker-compose.dev.yml build && docker-compose -f docker-compose.dev.yml -p dev-mealie up -d

1
dev/scripts/docker-compose.sh Executable file
View File

@ -0,0 +1 @@
docker-compose build && docker-compose -p mealie up -d

View File

@ -0,0 +1,17 @@
$CWD = Get-Location
$pyFolder = Join-Path -Path $CWD -ChildPath "mealie"
$pyVenv = Join-Path -Path $CWD -ChildPath "/venv/Scripts/python.exe"
$pyScript = Join-Path -Path $CWD -ChildPath "/mealie/app.py"
$pythonCommand = "powershell.exe -NoExit -Command $pyVenv $pyScript"
$vuePath = Join-Path -Path $CWD -ChildPath "/frontend"
$npmCommand = "powershell.exe -NoExit -Command npm run serve"
wt -d $pyFolder "powershell.exe" $pythonCommand `; split-pane -d $vuePath "powershell.exe" $npmCommand
Start-Process chrome "http://127.0.0.1:8000/docs"
Start-Process chrome "http://127.0.0.1:8080
"

40
dev/write_settings.py Normal file
View File

@ -0,0 +1,40 @@
import json
import requests
POST_URL = "http://localhost:9921/api/site-settings/themes/create/"
GET_URL = "http://localhost:9921/api/site-settings/themes/"
SITE_SETTINGS = [
{
"name": "default",
"colors": {
"primary": "#E58325",
"accent": "#00457A",
"secondary": "#973542",
"success": "#5AB1BB",
"info": "#FFFD99",
"warning": "#FF4081",
"error": "#EF5350",
},
},
{
"name": "purple",
"colors": {
"accent": "#4527A0",
"primary": "#FF4081",
"secondary": "#26C6DA",
"success": "#4CAF50",
"info": "#2196F3",
"warning": "#FB8C00",
"error": "#FF5252",
},
},
]
if __name__ == "__main__":
for theme in SITE_SETTINGS:
data = json.dumps(theme)
response = requests.post(POST_URL, data)
response = requests.get(GET_URL)
print(response.text)

53
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,53 @@
# Use root/example as user/password credentials
version: "3.1"
services:
# Vue Frontend
mealie:
build:
context: ./frontend
dockerfile: frontend.Dockerfile
container_name: mealie_frontend
restart: always
ports:
- 9920:8080
volumes:
- ./frontend:/app
# Fast API
mealie-api:
build:
context: ./
dockerfile: Dockerfile.dev
container_name: mealie-api
restart: always
ports:
- 9921:9000
environment:
TZ: America/Anchorage # Specify Correct Timezone for Date/Time to line up correctly.
db_username: root
db_password: example
db_host: mongo
db_port: 27017
volumes:
- ./mealie:/app
# Database
mongo:
image: mongo
restart: always
ports:
- 9923:27017
environment:
TZ: America/Anchorage # Specify Correct Timezone for Date/Time to line up correctly.
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
# Database UI
mongo-express:
image: mongo-express
restart: always
ports:
- 9922:8081
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: root
ME_CONFIG_MONGODB_ADMINPASSWORD: example

34
docker-compose.yml Normal file
View File

@ -0,0 +1,34 @@
# Use root/example as user/password credentials
# Frontend/Backend Served via the same Uvicorn Server
version: "3.1"
services:
mealie:
build:
context: ./
dockerfile: Dockerfile
container_name: mealie
restart: always
ports:
- 9000:9000
environment:
db_username: root
db_password: example
db_host: mongo
db_port: 27017
volumes:
- ./mealie/data/img:/app/data/img
- ./mealie/data/backups:/app/data/backups
mongo:
image: mongo
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
mongo-express: # Optional Mongo GUI
image: mongo-express
restart: always
ports:
- 9091:8081
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: root
ME_CONFIG_MONGODB_ADMINPASSWORD: example

View File

@ -0,0 +1,46 @@
# Getting Started
To deploy docker on your local network it is highly recommended to use docker to deploy the image straight from dockerhub. Using the docker-compose below you should be able to get a stack up and running easily by changing a few default values and deploying.
Alternatively, this project is run in python. If you are deadset on deploying on a linux machine you can run this in an python enviromnet with a dedicated MongoDatabase. Provided that you know thats how you want to host the application, I'll assume you know how to do that.
[Get Docker](https://docs.docker.com/get-docker/)
### Installation - Docker
```yaml
# docker-compose.yml
version: "3.1"
services:
mealie:
build:
context: ./
dockerfile: Dockerfile
container_name: mealie
restart: always
ports:
- 9000:9000
environment:
db_username: root # Your Mongo DB Username - Please Change
db_password: example # Your Mongo DB Password - Please Change
db_host: mongo
db_port: 27017 # The Default port for Mongo DB
volumes:
- ./mealie/data/img:/app/data/img
- ./mealie/data/backups:/app/data/backups
mongo:
image: mongo
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: root # Change!
MONGO_INITDB_ROOT_PASSWORD: example # Change!
mongo-express: # Optional Mongo GUI
image: mongo-express
restart: always
ports:
- 9091:8081
environment:
ME_CONFIG_MONGODB_ADMINUSERNAME: root
ME_CONFIG_MONGODB_ADMINPASSWORD: example
```

View File

@ -0,0 +1,95 @@
# Recipes
## URL Import
Adding a recipe can be as easy as copying the recipe URL into mealie and letting the web scrapper try to pull down the information. Currently this scraper is implemented with [scrape-schema-recipe package](https://pypi.org/project/scrape-schema-recipe/). You may have mixed results on some websites, especially with blogs or non specific recipe websites. See the bulk import Option below for another a convient way to add blog style recipes into Mealie.
![](gifs/url-demo.gif)
## Recipe Editor
Recipes can be edited and created via the UI. This is done with both a form based approach where you have a UI to work with as well as with a in browser JSON Editor. The JSON editor allows you to easily copy and paste data from other sources.
You can also add a custom recipe with the UI editor built into the web view. After logging in as a user you'll have access to the editor to make changes to all the content in the recipe.
![](gifs/editor-demo.gif)
## Bulk Import
Mealie also supports bulk import of recipe instructions and ingredients. Select "Bulk Add" in the editor and paste in your plain text data to be parsed. Each line is treated as one entry and will be appended to the existing ingredients or instructions if they exist. Empty lines will be stripped from the text.
![](gifs/bulk-add-demo.gif)
## Schema
Recipes are stored in the json-like format in mongoDB and then sent and edited in json format on the frontend. Each recipes uses [Recipe Schema](https://schema.org/Recipe) as a general guide with some additional properties specific to Mealie.
### Example
```json
{
_id: ObjectId('5fcdc3d715f131e8b191f642'),
name: 'Oat and Pecan Brittle Cookies',
description: 'A gorgeously textured cookie with crispy-edges, a chewy center, toasty pecans, and tiny crispy pecan brittle bits throughout.',
image: 'oat-and-pecan-brittle-cookies.jpg',
recipeYield: 'Makes about 18',
recipeIngredient: [
'1¼ cups (142 g) coarsely chopped pecans',
'¾ cup (150 g) granulated sugar',
'4 Tbsp. (½ stick) unsalted butter',
'½ tsp. baking soda',
'½ tsp. Diamond Crystal or ¼ tsp. Morton kosher salt',
'1 cup (2 sticks) unsalted butter, cut into 16 pieces, divided',
'1⅓ cups (173 g) all-purpose flour',
'2 tsp. Diamond Crystal or 1 tsp. Morton kosher salt',
'1 tsp. baking soda',
'2 cups (200 g) old-fashioned oats, divided',
'¾ cup (packed; 150 g) dark brown sugar',
'½ cup (100 g) granulated sugar',
'2 large eggs',
'1 Tbsp. vanilla extract'
],
recipeInstructions: [
{
'@type': 'HowToStep',
text: 'Place a rack in middle of oven; preheat to 350°. Toast pecans on a small rimmed baking sheet, tossing halfway through, until slightly darkened and fragrant, 810 minutes. Let cool.'
},
{
'@type': 'HowToStep',
text: 'Line another small rimmed baking sheet with a Silpat baking mat. Cook granulated sugar, butter, and 2 Tbsp. water in a small saucepan over medium-low heat, stirring gently with a heatproof rubber spatula, until sugar is dissolved. Increase heat to medium and bring syrup to a rapid simmer. Cook, without stirring, swirling pan often, until syrup turns a deep amber color, 810 minutes. Immediately remove saucepan from heat and stir in pecans. Once pecans are well coated, add baking soda and salt and stir to incorporate (mixture will foam and sputter as baking soda aerates caramel). Working quickly (it will harden fast), scrape mixture onto prepared baking sheet and spread into a thin layer. Let cool completely, 510 minutes. Chop into pea-size pieces; set aside.'
},
{
'@type': 'HowToStep',
text: 'Place half of butter (½ cup) in the bowl of a stand mixer. Bring remaining butter to a boil in a small saucepan over medium-low heat, stirring often with a heatproof rubber spatula. Cook, scraping bottom and sides of pan constantly, until butter sputters, foams, and, eventually, you see browned bits floating on the surface, 57 minutes. Pour brown butter over butter in stand mixer bowl, making sure to scrape in all the browned bits. Let sit until butter begins to resolidify, about 30 minutes.'
},
{
'@type': 'HowToStep',
text: 'Pulse flour, salt, and baking soda in a food processor to combine. Add half of reserved pecan brittle and 1 cup oats; process in long pulses until oats and brittle are finely ground.'
},
{
'@type': 'HowToStep',
text: 'Add brown sugar and granulated sugar to butter and beat with paddle attachment on medium speed until light and smooth but not fluffy, about 2 minutes. Scrape down sides of bowl and add eggs and vanilla. Beat until very light and satiny, about 1 minute. Scrape down sides of bowl and add flour mixture; beat on low speed until no dry spots remain and you have a soft, evenly mixed dough. Add remaining half of brittle and remaining 1 cup oats; mix on low speed just to distribute. Fold batter several times with a spatula to ensure everything is evenly mixed.'
},
{
'@type': 'HowToStep',
text: 'Using a 2-oz. scoop or ¼-cup measuring cup, scoop level portions of dough to make 18 cookies. Place on a parchment-lined baking sheet, spacing as close together as possible (youll space them out before baking). Cover tightly with plastic wrap and chill at least 12 hours and up to 2 days. (If youre pressed for time, a couple hours will do; cookies just wont be as chewy.)'
},
{
'@type': 'HowToStep',
text: 'When ready to bake, place racks in upper and lower thirds of oven; preheat to 350°. Line 2 large rimmed baking sheets with parchment paper. Arrange 6 cookies on each prepared baking sheet, spacing at least 3" apart.'
},
{
'@type': 'HowToStep',
text: 'Bake cookies, rotating baking sheets top to bottom and front to back after 12 minutes, until dark golden brown around the edges, 1620 minutes. Let cookies cool 5 minutes on baking sheets, then transfer cookies to a wire rack with a spatula and let cool completely.'
},
{
'@type': 'HowToStep',
text: 'Carefully move a rack to middle of oven. Arrange remaining dough on one of the baking sheets (its okay if its still warm). Bake as before (this batch might go a bit faster).\nDo ahead: Dough can be formed 2 months ahead; chill dough balls at least 2 hours before transferring to freezer. Once frozen solid, store in resealable plastic freezer bags and keep frozen. No need to thaw before baking, but you may need to add a minute or two to the baking time. Cookies can be baked 5 days ahead; store airtight at room temperature.'
}
],
slug: 'oat-and-pecan-brittle-cookies',
tags: [],
categories: [],
dateAdded: ISODate('2020-12-07T05:55:35.434Z'),
notes: [],
orgURL: 'https://www.bonappetit.com/recipe/oat-and-pecan-brittle-cookies',
rating: 3
}
```

View File

@ -0,0 +1,11 @@
# Meal Planner
## Working with Meal Plans
In Mealie you can create a mealplan based off the calendar inputs on the meal planner page. There is no limit to how long or how short a meal plan is. You may also create duplicate meal plans for the same date range. After selecting your date range, click on the card for each day and seach through recipes to find your choice. After selecting a recipe for all meals save the plan.
To edit the meal in a meal plan simply select the edit button on the card in the timeline. Similiarly, to delete a mealplan click the delete button on the card in the timeline. Currently there is no support to change the date range in a meal plan.
!!! warning
In coming a future release recipes for meals will be restricted to specific categories.
![](gifs/meal-plan-demo.gif)

View File

@ -0,0 +1,26 @@
# Admin Panel
!!! danger
As this is still a **BETA** It is reccomended that you backup your data often and store in more than one place. Adhear to backup best practies with the [3-2-1 Backup Rule](https://en.wikipedia.org/wiki/Backup)
### Theme Settings
![](img/admin-theme.png)
## Backup and Export
All recipe data can be imported and exported as necessary from the UI. Under the admin page you'll find the section for using Backups and Exports.
To create an export simple add the tag and the markdown template and click Backup Recipes and your backup will be created on the server. The backup is a standard zipfile containing all the images, json files, and rendered markdown files for each recipe. Markdown files are rendered from jinja2 templates. Adding your own markdown file into the templates folder will automatically show up as an option to select when creating a backup.
To import a backup it must be in your backups folder. If it is in the backup folder it will automatically show up as an source to restore from. Selected the desired backup and import the backup file.
![](img/admin-backup.png)
## Meal Planner Webhooks
In the webhooks section you can find a list of all your endpoint URLs that are used as part of the meal planner
![](img/admin-webhooks.png)
## SFTP Settings - Target Release 1.0
## User Settings - Target Release 1.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

BIN
docs/docs/gifs/url-demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

110
docs/docs/index.md Normal file
View File

@ -0,0 +1,110 @@
# About The Project
<p align="center">
<a href="https://github.com/hay-kot/mealie">
</a>
<p align="center">
<a href="https://github.com/hay-kot/mealie/issues">Report Bug</a>
·
<a href="https://github.com/hay-kot/mealie/issues">Request Feature</a>
</p>
</p>
<!-- ABOUT THE PROJECT -->
![Product Name Screen Shot][product-screenshot]
**Mealie** is a self hosted recipe manager and meal planner with a RestAPI backend and a reactive frontend application built in Vue for a pleasant user experience for the whole family. Easily add recipes into your database by providing the url and mealie will automatically import the relavent data or add a family recipe with the UI editor.
Melaie also provides a secure API for interactions from 3rd party applications. **Why does my recipe manager need an API?** An API allows integration into applications like [Home Assistant](https://www.home-assistant.io/) that can act as notification engines to provide custom notifications based of Meal Plan data to remind you to defrost the chicken, marinade the steak, or start the CrockPot. Additionally, you can access any avaiable API from the backend server. To explore the API spin up your server and navigate to http://yourserver.com/docs for interactive API documentation.
!!! note
In some of the demo gifs the styling may be different than the finale application. demos were done during development prior to finale styling.
!!! warning
Note that this is a **BETA** release and that means things may break and or change down the line. I'll do my best to make sure that any API changes are thoughtful and necessary in order not to break things. Additionally, I'll do my best to provide a migration path if the database schema ever changes. That said, one of the nice things about MongoDB is that it's flexible!
### Main Features
#### Recipes
- Automatic web scrapping for common recipe platforms
- Interactive API Documentation thanks to [FastAPI](https://fastapi.tiangolo.com/) and [Swagger](https://petstore.swagger.io/)
- UI Recipe Editor
- JSON Recipe Editor in browser
- Custom tags and categories
- Rate recipes
- Add notes to recipes
#### Meal Planner
- Random Meal plan generation based off categories
- Expose notes in the API to allow external applications to access relavent information for meal plans
#### Database Import / Export
- Easily Import / Export your recipes from the UI
- Export recipes in markdown format for universal access
- Use the default or a custom jinja2 template
### Built With
* [Vue.js](https://vuejs.org/)
* [Vuetify](https://vuetifyjs.com/en/)
* [FastAPI](https://fastapi.tiangolo.com/)
* [MongoDB](https://www.mongodb.com/)
* [Docker](https://www.docker.com/)
<!-- ROADMAP -->
## Roadmap
#### Authentication - Target 1.0
- User Login
- Token Based API Access/Auth
#### Recipe Sharing
- Share Button / Email
- Export to PDF
- Print Button
- Git Repo Based Sharing (Import / Export / Search)
<!-- CONTRIBUTING -->
## Contributing
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. Especially test. Literally any tests.
<!-- LICENSE -->
## License
Distributed under the MIT License. See `LICENSE` for more information.
<!-- CONTACT -->
## Contact
Project Link: [https://github.com/hay-kot/mealie](https://github.com/hay-kot/mealie)
<!-- ACKNOWLEDGEMENTS -->
## Acknowledgements
* [Talk Python Training for helping me learn python](https://training.talkpython.fm/)
* [Academind for helping me learn Javascript and Vue.js](https://academind.com/)
<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[contributors-shield]: https://img.shields.io/github/contributors/hay-kot/mealie.svg?style=flat-square
[contributors-url]: https://github.com/hay-kot/mealie/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/hay-kot/mealie.svg?style=flat-square
[forks-url]: https://github.com/hay-kot/mealie/network/members
[stars-shield]: https://img.shields.io/github/stars/hay-kot/mealie.svg?style=flat-square
[stars-url]: https://github.com/hay-kot/mealie/stargazers
[issues-shield]: https://img.shields.io/github/issues/hay-kot/mealie.svg?style=flat-square
[issues-url]: https://github.com/hay-kot/mealie/issues
[license-shield]: https://img.shields.io/github/license/hay-kot/mealie.svg?style=flat-square
[license-url]: https://github.com/hay-kot/mealie/blob/master/LICENSE.txt
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555
[linkedin-url]: https://linkedin.com/in/hay-kot
[product-screenshot]: img/home_screenshot.png

View File

@ -0,0 +1,8 @@
:root {
--md-primary-fg-color: #e58325;
--md-primary-fg-color--light: #e58325;
--md-primary-fg-color--dark: #e58325;
--md-accent-fg-color: #e58325;
--md-accent-fg-color--light: #e58325;
--md-accent-fg-color--dark: #e58325;
}

15
docs/mkdocs.yml Normal file
View File

@ -0,0 +1,15 @@
site_name: Mealie
theme:
name: material
icon:
logo: material/silverware-variant
features:
- navigation.instant
markdown_extensions:
- pymdownx.highlight
- pymdownx.superfences
- admonition
extra_css:
- stylesheets/custom.css
repo_url: https://github.com/hay-kot/mealie
repo_name: hay-kot/mealie

24
frontend/README.md Normal file
View File

@ -0,0 +1,24 @@
# frontend
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
frontend/babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@ -0,0 +1,22 @@
FROM node:lts-alpine
# # install simple http server for serving static content
# RUN npm install -g http-server
# make the 'app' folder the current working directory
WORKDIR /app
# copy both 'package.json' and 'package-lock.json' (if available)
COPY package*.json ./
# install project dependencies
RUN npm install
# copy project files and folders to the current working directory (i.e. 'app' folder)
# COPY . .
# build app for production with minification
# RUN npm run build
EXPOSE 8080
CMD [ "npm", "run", "serve" ]

12138
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
frontend/package.json Normal file
View File

@ -0,0 +1,54 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.21.0",
"core-js": "^3.8.1",
"qs": "^6.9.4",
"v-jsoneditor": "^1.4.2",
"vue": "^2.6.11",
"vue-cookies": "^1.7.4",
"vue-html-to-paper": "^1.3.1",
"vue-router": "^3.4.9",
"vuetify": "^2.3.21",
"vuex": "^3.6.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"sass": "^1.30.0",
"sass-loader": "^8.0.0",
"vue-cli-plugin-vuetify": "^2.0.8",
"vue-template-compiler": "^2.6.11",
"vuetify-loader": "^1.3.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

89
frontend/src/App.vue Normal file
View File

@ -0,0 +1,89 @@
<template>
<v-app>
<v-app-bar dense app color="primary" dark class="d-print-none">
<div class="d-flex align-center">
<v-icon size="40" @click="$router.push('/')">
mdi-silverware-variant
</v-icon>
</div>
<div btn class="pl-2" @click="$router.push('/')">
<v-toolbar-title>Mealie</v-toolbar-title>
</div>
<v-spacer></v-spacer>
<v-btn icon @click="toggleSearch">
<v-icon>mdi-magnify</v-icon>
</v-btn>
<Menu />
</v-app-bar>
<v-main>
<v-container>
<AddRecipe />
<SnackBar />
<v-expand-transition>
<SearchHeader v-show="search" />
</v-expand-transition>
<router-view></router-view>
</v-container>
</v-main>
</v-app>
</template>
<script>
import Menu from "./components/UI/Menu";
import SearchHeader from "./components/UI/SearchHeader";
import AddRecipe from "./components/AddRecipe";
import SnackBar from "./components/UI/SnackBar";
export default {
name: "App",
components: {
Menu,
AddRecipe,
SearchHeader,
SnackBar,
},
watch: {
$route() {
this.search = false;
},
},
mounted() {
this.$store.dispatch("initCookies");
this.$store.dispatch("requestRecentRecipes");
},
data: () => ({
search: false,
}),
methods: {
toggleSearch() {
if (this.search === true) {
this.search = false;
} else {
this.search = true;
}
},
},
};
</script>
<style>
/* Scroll Bar PageSettings */
body::-webkit-scrollbar {
width: 0.25rem;
}
body::-webkit-scrollbar-track {
background: grey;
}
body::-webkit-scrollbar-thumb {
background: black;
}
</style>

15
frontend/src/api.js Normal file
View File

@ -0,0 +1,15 @@
import backup from "./api/backup";
import recipe from "./api/recipe";
import mealplan from "./api/mealplan";
import settings from "./api/settings";
import themes from "./api/themes";
// import api from "../api";
export default {
recipes: recipe,
backups: backup,
mealPlans: mealplan,
settings: settings,
themes: themes,
};

View File

@ -0,0 +1,51 @@
const baseURL = "/api/";
import axios from "axios";
import store from "../store/store";
function processResponse(response) {
if (("data" in response) & ("snackbar" in response.data)) {
store.commit("setSnackBar", {
text: response.data.snackbar.text,
type: response.data.snackbar.type,
});
} else return;
}
const apiReq = {
post: async function(url, data) {
let response = await axios.post(url, data).catch(function(error) {
if (error.response) {
console.log("Error");
processResponse(error.response);
return;
}
});
processResponse(response);
return response;
},
get: async function(url, data) {
let response = await axios.get(url, data).catch(function(error) {
if (error.response) {
processResponse(error.response);
return;
} else return;
});
// processResponse(response);
return response;
},
delete: async function(url, data) {
let response = await axios.delete(url, data).catch(function(error) {
if (error.response) {
processResponse(error.response);
return;
}
});
processResponse(response);
return response;
},
};
export { apiReq };
export { baseURL };

View File

@ -0,0 +1,37 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
import { store } from "../store/store";
const backupBase = baseURL + "backups/";
const backupURLs = {
// Backup
avaiable: `${backupBase}avaiable/`,
createBackup: `${backupBase}export/database/`,
importBackup: (fileName) => `${backupBase}${fileName}/import/`,
deleteBackup: (fileName) => `${backupBase}${fileName}/delete/`,
};
export default {
async requestAvailable() {
let response = await apiReq.get(backupURLs.avaiable);
return response.data;
},
async import(fileName) {
apiReq.post(backupURLs.importBackup(fileName));
store.dispatch("requestRecentRecipes");
},
async delete(fileName) {
await apiReq.delete(backupURLs.deleteBackup(fileName));
},
async create(tag, template) {
let response = apiReq.post(backupURLs.createBackup, {
tag: tag,
template: template,
});
return response;
},
};

View File

@ -0,0 +1,46 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
const mealplanBase = baseURL + "meal-plan/";
const mealPlanURLs = {
// Meals
create: `${mealplanBase}create/`,
today: `${mealplanBase}today/`,
thisWeek: `${mealplanBase}this-week/`,
all: `${mealplanBase}all/`,
delete: (planID) => `${mealplanBase}${planID}/delete/`,
update: (planID) => `${mealplanBase}${planID}/update/`,
};
export default {
async create(postBody) {
let response = await apiReq.post(mealPlanURLs.create, postBody);
return response;
},
async all() {
let response = await apiReq.get(mealPlanURLs.all);
return response;
},
async thisWeek() {
let response = await apiReq.get(mealPlanURLs.thisWeek);
return response.data;
},
async today() {
let response = await apiReq.get(mealPlanURLs.today);
return response;
},
async delete(id) {
let response = await apiReq.delete(mealPlanURLs.delete(id));
return response;
},
async update(id, body) {
let response = await apiReq.post(mealPlanURLs.update(id), body);
return response;
},
};

View File

@ -0,0 +1,75 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
import { store } from "../store/store";
import { router } from "../main";
import qs from "qs";
const recipeBase = baseURL + "recipe/";
const recipeURLs = {
// Recipes
allRecipes: baseURL + "all-recipes/",
recipe: (slug) => recipeBase + slug + "/",
recipeImage: (slug) => recipeBase + "image/" + slug + "/",
createByURL: recipeBase + "create-url/",
create: recipeBase + "create/",
updateImage: (slug) => `${recipeBase}${slug}/update/image/`,
update: (slug) => `${recipeBase}${slug}/update/`,
delete: (slug) => `${recipeBase}${slug}/delete/`,
};
export default {
async createByURL(recipeURL) {
let response = await apiReq.post(recipeURLs.createByURL, { url: recipeURL });
let recipeSlug = response.data;
store.dispatch("requestRecentRecipes");
router.push(`/recipe/${recipeSlug}`);
},
async create(recipeData) {
let response = await apiReq.post(recipeURLs.create, recipeData);
return response.data;
},
async requestDetails(recipeSlug) {
let response = await apiReq.get(recipeURLs.recipe(recipeSlug));
return response.data;
},
async updateImage(recipeSlug, fileObject) {
const fd = new FormData();
fd.append("image", fileObject);
fd.append("extension", fileObject.name.split(".").pop());
let response = apiReq.post(recipeURLs.updateImage(recipeSlug), fd);
return response;
},
async update(data) {
const recipeSlug = data.slug;
apiReq.post(recipeURLs.update(recipeSlug), data);
store.dispatch("requestRecentRecipes");
},
async delete(recipeSlug) {
apiReq.delete(recipeURLs.delete(recipeSlug));
store.dispatch("requestRecentRecipes");
router.push(`/`);
},
async allByKeys(recipeKeys, num = 100) {
const response = await apiReq.get(recipeURLs.allRecipes, {
params: {
keys: recipeKeys,
num: num,
},
paramsSerializer: (params) => {
return qs.stringify(params, { arrayFormat: "repeat" });
},
});
return response.data;
},
};

View File

@ -0,0 +1,27 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
const settingsBase = baseURL + "site-settings/";
const settingsURLs = {
siteSettings: `${settingsBase}`,
updateSiteSettings: `${settingsBase}update/`,
testWebhooks: `${settingsBase}webhooks/test/`,
};
export default {
async requestAll() {
let response = await apiReq.get(settingsURLs.siteSettings);
return response.data;
},
async testWebhooks() {
let response = await apiReq.post(settingsURLs.testWebhooks);
return response.data;
},
async update(body) {
let response = await apiReq.post(settingsURLs.updateSiteSettings, body);
return response.data;
},
};

View File

@ -0,0 +1,44 @@
import { baseURL } from "./api-utils";
import { apiReq } from "./api-utils";
const themesBase = baseURL + "site-settings/";
const settingsURLs = {
allThemes: `${themesBase}themes/`,
specificTheme: (themeName) => `${themesBase}themes/${themeName}/`,
createTheme: `${themesBase}themes/create/`,
updateTheme: (themeName) => `${themesBase}themes/${themeName}/update/`,
deleteTheme: (themeName) => `${themesBase}themes/${themeName}/delete/`,
};
export default {
async requestAll() {
let response = await apiReq.get(settingsURLs.allThemes);
return response.data;
},
async requestByName(name) {
let response = await apiReq.get(settingsURLs.specificTheme(name));
console.log(response);
return response.data;
},
async create(postBody) {
let response = await apiReq.post(settingsURLs.createTheme, postBody);
return response.data;
},
async update(themeName, colors) {
const body = {
name: themeName,
colors: colors,
};
let response = await apiReq.post(settingsURLs.updateTheme(themeName), body);
return response.data;
},
async delete(themeName) {
let response = await apiReq.delete(settingsURLs.deleteTheme(themeName));
return response.data;
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.5 100"><defs><style>.cls-1{fill:#1697f6;}.cls-2{fill:#7bc6ff;}.cls-3{fill:#1867c0;}.cls-4{fill:#aeddff;}</style></defs><title>Artboard 46</title><polyline class="cls-1" points="43.75 0 23.31 0 43.75 48.32"/><polygon class="cls-2" points="43.75 62.5 43.75 100 0 14.58 22.92 14.58 43.75 62.5"/><polyline class="cls-3" points="43.75 0 64.19 0 43.75 48.32"/><polygon class="cls-4" points="64.58 14.58 87.5 14.58 43.75 100 43.75 62.5 64.58 14.58"/></svg>

After

Width:  |  Height:  |  Size: 539 B

View File

@ -0,0 +1,70 @@
<template>
<div class="text-center">
<v-dialog v-model="addRecipe" width="650" @click:outside="reset">
<v-card :loading="processing">
<v-card-title class="headline"> From URL </v-card-title>
<v-card-text>
<v-form>
<v-text-field v-model="recipeURL" label="Recipe URL"></v-text-field>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="createRecipe"> Submit </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-speed-dial v-model="fab" fixed right bottom open-on-hover>
<template v-slot:activator>
<v-btn v-model="fab" color="secondary" dark fab @click="navCreate">
<v-icon> mdi-plus </v-icon>
</v-btn>
</template>
<v-btn fab dark small color="success" @click="addRecipe = true">
<v-icon>mdi-link</v-icon>
</v-btn>
</v-speed-dial>
</div>
</template>
<script>
import api from "../api";
export default {
data() {
return {
fab: false,
addRecipe: false,
recipeURL: "",
processing: false,
};
},
methods: {
async createRecipe() {
this.processing = true;
await api.recipes.createByURL(this.recipeURL);
this.addRecipe = false;
this.processing = false;
},
navCreate() {
this.$router.push("/new");
},
reset() {
(this.fab = false),
(this.addRecipe = false),
(this.recipeURL = ""),
(this.processing = false);
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,23 @@
<template>
<v-container>
<Theme />
<Backup />
<Webhooks />
</v-container>
</template>
<script>
import Backup from "./Backup";
import Webhooks from "./Webhooks";
import Theme from "./Theme";
export default {
components: {
Backup,
Webhooks,
Theme,
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,121 @@
<template>
<v-card :loading="backupLoading" class="mt-3">
<v-card-title class="card-title"> Backup and Exports </v-card-title>
<v-card-text>
<p>
Backups are exported in standard JSON format along with all the images
stored on the file system. In your backup folder you'll find a .zip file
that contains all of the recipe JSON and images from the database.
Additionally, if you selected a markdown file, those will also be stored
in the .zip file. To import a backup, it must be located in your backups
folder. Automated backups are done each day at 3:00 AM.
</p>
<v-row dense align="center">
<v-col dense cols="12" sm="12" md="4">
<v-text-field v-model="backupTag" label="Backup Tag"></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="3">
<v-combobox
auto-select-first
label="Markdown Template"
:items="availableTemplates"
v-model="selectedTemplate"
></v-combobox>
</v-col>
<v-col dense cols="12" sm="12" md="2">
<v-btn block color="accent" @click="createBackup" width="165">
Backup Recipes
</v-btn>
</v-col>
</v-row>
<v-row dense align="center">
<v-col dense cols="12" sm="12" md="4">
<v-form ref="form">
<v-combobox
auto-select-first
label="Select a Backup for Import"
:items="availableBackups"
v-model="selectedBackup"
:rules="[(v) => !!v || 'Backup Selection is Required']"
required
></v-combobox>
</v-form>
</v-col>
<v-col dense cols="12" sm="12" md="3" lg="2">
<v-btn block color="accent" @click="importBackup">
Import Backup
</v-btn>
</v-col>
<v-col dense cols="12" sm="12" md="2" lg="2">
<v-btn block color="error" @click="deleteBackup">
Delete Backup
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
import api from "../../api";
export default {
data() {
return {
backupLoading: false,
backupTag: null,
selectedBackup: null,
selectedTemplate: null,
availableBackups: [],
availableTemplates: [],
};
},
mounted() {
this.getAvailableBackups();
},
methods: {
async getAvailableBackups() {
let response = await api.backups.requestAvailable();
this.availableBackups = response.imports;
this.availableTemplates = response.templates;
},
importBackup() {
if (this.$refs.form.validate()) {
this.backupLoading = true;
api.backups.import(this.selectedBackup);
this.backupLoading = false;
}
},
deleteBackup() {
if (this.$refs.form.validate()) {
this.backupLoading = true;
api.backups.delete(this.selectedBackup);
this.getAvailableBackups();
this.selectedBackup = null;
this.backupLoading = false;
}
},
async createBackup() {
this.backupLoading = true;
let response = await api.backups.create(
this.backupTag,
this.selectedTemplate
);
if (response.status == 201) {
this.selectedBackup = null;
this.getAvailableBackups();
this.backupLoading = false;
}
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,12 @@
<template>
<v-card>
<v-card-title class="card-title mt-1"> SFTP Settings </v-card-title>
</v-card>
</template>
<script>
export default {};
</script>
<style>
</style>

View File

@ -0,0 +1,159 @@
<template>
<v-card>
<v-card-title class="card-title mt-1"> Theme Settings </v-card-title>
<v-card-text>
<p>
Select a theme from the dropdown or create a new theme. Note that the
default theme will be served to all users who have not set a theme
preference.
</p>
<v-row dense align="center">
<v-col cols="12" md="2" sm="5">
<v-switch
v-model="darkMode"
inset
label="Dark Mode"
class="my-n3"
@change="toggleDarkMode"
></v-switch>
</v-col>
<v-col cols="12" md="4" sm="3">
<v-form ref="form" lazy-validation>
<v-select
label="Saved Color Schemes"
:items="avaiableThemes"
item-text="name"
item-value="colors"
return-object
v-model="selectedScheme"
@change="themeSelected"
:rules="[(v) => !!v || 'Theme is required']"
required
>
</v-select>
</v-form>
</v-col>
<v-col cols="12" sm="1">
<NewTheme @new-theme="appendTheme" />
</v-col>
<v-col cols="12" sm="1">
<v-btn text color="error" @click="deleteSelected"> Delete </v-btn>
</v-col>
</v-row>
<v-row dense align-content="center" v-if="activeTheme">
<v-col>
<ColorPicker button-text="Primary" v-model="activeTheme.primary" />
</v-col>
<v-col>
<ColorPicker button-text="Accent" v-model="activeTheme.accent" />
</v-col>
<v-col>
<ColorPicker
button-text="Secondary"
v-model="activeTheme.secondary"
/>
</v-col>
<v-col>
<ColorPicker button-text="Success" v-model="activeTheme.success" />
</v-col>
<v-col>
<ColorPicker button-text="Info" v-model="activeTheme.info" />
</v-col>
<v-col>
<ColorPicker button-text="Warning" v-model="activeTheme.warning" />
</v-col>
<v-col>
<ColorPicker button-text="Error" v-model="activeTheme.error" />
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-row>
<v-col> </v-col>
<v-col></v-col>
<v-col align="end">
<v-btn text color="success" @click="saveThemes"> Save Theme </v-btn>
</v-col>
</v-row>
</v-card-actions>
</v-card>
</template>
<script>
import api from "../../api";
import ColorPicker from "./ThemeUI/ColorPicker";
import NewTheme from "./ThemeUI/NewTheme";
export default {
components: {
ColorPicker,
NewTheme,
},
data() {
return {
themes: null,
activeTheme: {},
darkMode: false,
avaiableThemes: [],
selectedScheme: "",
selectedLight: "",
};
},
async mounted() {
this.avaiableThemes = await api.themes.requestAll();
this.darkMode = this.$store.getters.getDarkMode;
this.themes = this.$store.getters.getThemes;
this.setThemeEditor();
},
methods: {
async deleteSelected() {
if (this.$refs.form.validate()) {
if (this.selectedScheme === "default") {
// Notify User Can't Delete Default
} else if (this.selectedScheme !== "") {
api.themes.delete(this.selectedScheme.name);
}
this.avaiableThemes = await api.themes.requestAll();
}
},
async appendTheme(newTheme) {
api.themes.create(newTheme);
this.avaiableThemes.push(newTheme);
},
themeSelected() {
this.activeTheme = this.selectedScheme.colors;
},
setThemeEditor() {
if (this.darkMode) {
this.activeTheme = this.themes.dark;
} else {
this.activeTheme = this.themes.light;
}
},
toggleDarkMode() {
this.$store.commit("setDarkMode", this.darkMode);
this.selectedScheme = "";
this.setThemeEditor();
},
saveThemes() {
if (this.$refs.form.validate()) {
if (this.darkMode) {
this.themes.dark = this.activeTheme;
} else {
this.themes.light = this.activeTheme;
}
this.$store.commit("setThemes", this.themes);
this.$store.dispatch("initCookies");
api.themes.update(this.selectedScheme.name, this.activeTheme);
} else;
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,70 @@
<template>
<div>
<v-btn block :color="value" @click="dialog = true">
{{ buttonText }}
</v-btn>
<v-dialog v-model="dialog" width="400">
<v-card>
<v-card-title> {{ buttonText }} Color </v-card-title>
<v-card-text>
<v-text-field v-model="color"> </v-text-field>
<v-row>
<v-col></v-col>
<v-col>
<v-color-picker
dot-size="28"
hide-inputs
hide-mode-switch
mode="hexa"
:show-swatches="swatches"
swatches-max-height="300"
v-model="color"
@change="updateColor"
></v-color-picker>
</v-col>
<v-col></v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-btn text @click="toggleSwatches"> Swatches </v-btn>
<v-btn text @click="dialog = false"> Select </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
export default {
props: {
buttonText: String,
value: String,
},
data() {
return {
dialog: false,
swatches: false,
color: "#FF00FF",
};
},
watch: {
color() {
this.updateColor();
},
},
methods: {
toggleSwatches() {
if (this.swatches) {
this.swatches = false;
} else this.swatches = true;
},
updateColor() {
this.$emit("input", this.color);
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,62 @@
<template>
<div>
<v-btn text color="success" @click="dialog = true"> New </v-btn>
<v-dialog v-model="dialog" width="400">
<v-card>
<v-card-title> Add a New Theme </v-card-title>
<v-card-text>
<v-text-field label="Theme Name" v-model="themeName"></v-text-field>
</v-card-text>
<v-card-actions>
<v-btn color="success" text @click="Select"> Create </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
export default {
props: {
buttonText: String,
value: String,
},
data() {
return {
dialog: false,
themeName: "",
};
},
watch: {
color() {
this.updateColor();
},
},
methods: {
randomColor() {
return "#" + Math.floor(Math.random() * 16777215).toString(16);
},
Select() {
const newTheme = {
name: this.themeName,
colors: {
primary: this.randomColor(),
accent: this.randomColor(),
secondary: this.randomColor(),
success: this.randomColor(),
info: this.randomColor(),
warning: this.randomColor(),
error: this.randomColor(),
},
};
this.$emit("new-theme", newTheme);
this.dialog = false;
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,12 @@
<template>
<v-card>
<v-card-title class="card-title mt-1"> User Settings </v-card-title>
</v-card>
</template>
<script>
export default {};
</script>
<style>
</style>

View File

@ -0,0 +1,114 @@
<template>
<v-card>
<v-card-title class="card-title mt-1"> Meal Planner Webhooks </v-card-title>
<v-card-text>
<p>
The URLs listed below will recieve webhooks containing the recipe data
for the meal plan on it's scheduled day. Currently Webhooks will execute
at <strong>{{ time }}</strong>
</p>
<v-row dense align="center">
<v-col cols="12" md="2" sm="5">
<v-switch
v-model="enabled"
inset
label="Enabled"
class="my-n3"
></v-switch>
</v-col>
<v-col cols="12" md="3" sm="5">
<TimePicker @save-time="saveTime" />
</v-col>
<v-col cols="12" md="4" sm="5">
<v-btn text color="info" @click="testWebhooks"> Test Webhooks </v-btn>
</v-col>
</v-row>
<v-row v-for="(url, index) in webhooks" :key="index" align="center" dense>
<v-col cols="1">
<v-btn icon color="error" @click="removeWebhook(index)">
<v-icon>mdi-minus</v-icon>
</v-btn>
</v-col>
<v-col>
<v-text-field
v-model="webhooks[index]"
label="Webhook URL"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-row>
<v-col>
<v-btn icon color="success" @click="addWebhook">
<v-icon>mdi-plus</v-icon>
</v-btn>
</v-col>
<v-col> </v-col>
<v-col align="end">
<v-btn text color="success" @click="saveWebhooks">
Save Webhooks
</v-btn>
</v-col>
</v-row>
</v-card-actions>
</v-card>
</template>
<script>
import api from "../../api";
import TimePicker from "./Webhooks/TimePicker";
export default {
components: {
TimePicker,
},
data() {
return {
name: "main",
webhooks: [],
enabled: false,
time: "",
};
},
mounted() {
this.getSiteSettings();
},
methods: {
saveTime(value) {
this.time = value;
},
async getSiteSettings() {
let settings = await api.settings.requestAll();
this.webhooks = settings.webhooks.webhookURLs;
this.name = settings.name;
this.time = settings.webhooks.webhookTime;
this.enabled = settings.webhooks.enabled;
},
addWebhook() {
this.webhooks.push(" ");
},
removeWebhook(index) {
this.webhooks.splice(index, 1);
},
saveWebhooks() {
const body = {
name: this.name,
webhooks: {
webhookURLs: this.webhooks,
webhookTime: this.time,
enabled: this.enabled,
},
};
api.settings.update(body);
},
testWebhooks() {
api.settings.testWebhooks();
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,45 @@
<template>
<v-dialog
ref="dialog"
v-model="modal2"
:return-value.sync="time"
persistent
width="290px"
>
<template v-slot:activator="{ on, attrs }">
<v-text-field
v-model="time"
label="Set New Time"
prepend-icon="mdi-clock-time-four-outline"
readonly
v-bind="attrs"
v-on="on"
></v-text-field>
</template>
<v-time-picker v-if="modal2" v-model="time" full-width>
<v-spacer></v-spacer>
<v-btn text color="primary" @click="modal2 = false"> Cancel </v-btn>
<v-btn text color="primary" @click="saveTime"> OK </v-btn>
</v-time-picker>
</v-dialog>
</template>
<script>
export default {
data() {
return {
time: null,
modal2: false,
};
},
methods: {
saveTime() {
this.$refs.dialog.save(this.time);
this.$emit("save-time", this.time);
},
},
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,18 @@
<template>
<div>
<RecentRecipes />
</div>
</template>
<script>
import RecentRecipes from "./RecentRecipes";
export default {
components: {
RecentRecipes,
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,44 @@
<template>
<v-card>
<v-card-title class="accent white--text"> Edit Meal Plan </v-card-title>
<v-card-text> </v-card-text>
<v-card-text>
<MealPlanCard v-model="mealPlan.meals" />
<v-row align="center" justify="end">
<v-card-actions>
<v-btn color="success" text @click="update"> Update </v-btn>
<v-spacer></v-spacer>
</v-card-actions>
</v-row>
</v-card-text>
</v-card>
</template>
<script>
import api from "../../api";
import utils from "../../utils";
import MealPlanCard from "./MealPlanCard";
export default {
components: {
MealPlanCard,
},
props: {
mealPlan: Object,
},
methods: {
formatDate(timestamp) {
let dateObject = new Date(timestamp);
return utils.getDateAsPythonDate(dateObject);
},
async update() {
this.process();
await api.mealPlans.update(this.mealPlan.uid, this.mealPlan);
this.$emit("updated");
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,76 @@
<template>
<v-row>
<MealSelect
:forceDialog="dialog"
@close="dialog = false"
@select="setSlug($event)"
/>
<v-col
cols="12"
sm="12"
md="6"
lg="4"
xl="3"
v-for="(meal, index) in value"
:key="index"
>
<v-hover v-slot="{ hover }" :open-delay="50">
<v-card :class="{ 'on-hover': hover }" :elevation="hover ? 12 : 2">
<v-img
height="200"
:src="getImage(meal.slug)"
@click="selectRecipe(index)"
></v-img>
<v-card-title class="my-n3 mb-n6">{{ meal.dateText }}</v-card-title>
<v-card-subtitle> {{ meal.slug }}</v-card-subtitle>
</v-card>
</v-hover>
</v-col>
</v-row>
</template>
<script>
import utils from "../../utils";
import MealSelect from "./MealSelect";
export default {
components: {
MealSelect,
},
props: {
value: Array,
},
data() {
return {
recipeData: [],
cardData: [],
activeIndex: 0,
dialog: false,
};
},
methods: {
getImage(slug) {
if (slug) {
return utils.getImageURL(slug);
}
},
setSlug(slug) {
let index = this.activeIndex;
this.value[index]["slug"] = slug;
},
selectRecipe(index) {
this.activeIndex = index;
this.dialog = true;
},
getProperty(index, property) {
try {
return this.recipeData[index][property];
} catch {
return null;
}
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,124 @@
<template>
<div>
<EditPlan
v-if="editMealPlan"
:meal-plan="editMealPlan"
@updated="planUpdated"
/>
<NewMeal v-else @created="requestMeals" />
<v-card class="my-1">
<v-card-title class="accent white--text"> Meal Plans </v-card-title>
<v-timeline align-top :dense="$vuetify.breakpoint.smAndDown">
<v-timeline-item
class="px-1"
v-for="(mealplan, i) in plannedMeals"
:key="i"
color="accent lighten-2"
icon="mdi-silverware-variant"
fill-dot
>
<v-card color="accent lighten-2" dark>
<v-card-title class="title">
{{ formatDate(mealplan.startDate) }} -
{{ formatDate(mealplan.endDate) }}
</v-card-title>
<v-card-text class="white text--primary">
<v-row dense align="center">
<v-col></v-col>
<v-col
v-for="(meal, index) in mealplan.meals"
:key="generateKey(meal.slug, index)"
>
<v-img
class="rounded-lg"
:src="getImage(meal.image)"
height="80"
width="80"
>
</v-img>
</v-col>
<v-col></v-col>
</v-row>
<v-btn
color="accent lighten-2"
class="mx-0"
outlined
@click="editPlan(mealplan.uid)"
>
Edit
</v-btn>
<v-btn
color="error lighten-2"
class="mx-2"
outlined
@click="deletePlan(mealplan.uid)"
>
Delete
</v-btn>
</v-card-text>
</v-card>
</v-timeline-item>
</v-timeline>
</v-card>
</div>
</template>
<script>
import api from "../../api";
import utils from "../../utils";
import NewMeal from "./NewMeal";
import EditPlan from "./EditPlan";
export default {
components: {
NewMeal,
EditPlan,
},
data: () => ({
plannedMeals: [],
editMealPlan: null,
}),
async mounted() {
this.requestMeals();
},
methods: {
async requestMeals() {
const response = await api.mealPlans.all();
this.plannedMeals = response.data;
},
generateKey(name, index) {
return utils.generateUniqueKey(name, index);
},
formatDate(timestamp) {
let dateObject = new Date(timestamp);
return utils.getDateAsTextAlt(dateObject);
},
getImage(image) {
return utils.getImageURL(image);
},
editPlan(id) {
this.plannedMeals.forEach((element) => {
if (element.uid === id) {
console.log(element);
this.editMealPlan = element;
}
});
},
planUpdated() {
this.editMealPlan = null;
this.requestMeals();
},
deletePlan(id) {
api.mealPlans.delete(id);
this.requestMeals();
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,102 @@
<template>
<v-row justify="center">
<v-dialog v-model="dialog" persistent max-width="800">
<v-card>
<v-card-title class="headline"> Choose a Recipe </v-card-title>
<v-card-text>
<v-autocomplete
:items="avaiableRecipes"
v-model="selected"
clearable
return
dense
hide-details
hide-selected
item-text="slug"
label="Search for a Recipe"
single-line
>
<template v-slot:no-data>
<v-list-item>
<v-list-item-title>
Search for your Favorite
<strong>Recipe</strong>
</v-list-item-title>
</v-list-item>
</template>
<template v-slot:item="{ item }">
<v-row align="center" @click="dialog = false">
<v-col sm="2">
<v-img
max-height="100"
max-width="100"
:src="getImage(item.image)"
></v-img>
</v-col>
<v-col sm="10">
<h3>
{{ item.name }}
</h3>
</v-col>
</v-row>
</template>
</v-autocomplete>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="secondary" text @click="dialog = false"> Close </v-btn>
<v-btn color="secondary" text @click="dialog = false"> Select </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
</template>
<script>
import utils from "../../utils";
export default {
props: {
forceDialog: Boolean,
},
data() {
return {
dialog: false,
selected: "",
};
},
watch: {
forceDialog() {
this.dialog = this.forceDialog;
},
selected() {
if (this.selected) {
this.$emit("select", this.selected);
}
},
dialog() {
if (this.dialog === false) {
this.$emit("close");
} else {
this.selected = "";
}
},
},
computed: {
avaiableRecipes() {
return this.$store.getters.getRecentRecipes;
},
},
methods: {
getImage(slug) {
return utils.getImageURL(slug);
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,207 @@
<template>
<v-card>
<v-card-title class="accent white--text">
Create a New Meal Plan
</v-card-title>
<v-card-text>
<v-row dense>
<v-col cols="12" lg="6" md="6" sm="12">
<v-menu
ref="menu1"
v-model="menu1"
:close-on-content-click="true"
transition="scale-transition"
offset-y
max-width="290px"
min-width="290px"
>
<template v-slot:activator="{ on, attrs }">
<v-text-field
v-model="startComputedDateFormatted"
label="Start Date"
persistent-hint
prepend-icon="mdi-calendar"
readonly
v-bind="attrs"
v-on="on"
></v-text-field>
</template>
<v-date-picker
v-model="startDate"
no-title
@input="menu2 = false"
></v-date-picker>
</v-menu>
</v-col>
<v-col cols="12" lg="6" md="6" sm="12">
<v-menu
ref="menu2"
v-model="menu2"
:close-on-content-click="true"
transition="scale-transition"
offset-y
max-width="290px"
min-width="290px"
>
<template v-slot:activator="{ on, attrs }">
<v-text-field
v-model="endComputedDateFormatted"
label="End Date"
persistent-hint
prepend-icon="mdi-calendar"
readonly
v-bind="attrs"
v-on="on"
></v-text-field>
</template>
<v-date-picker
v-model="endDate"
no-title
@input="menu2 = false"
></v-date-picker>
</v-menu>
</v-col>
</v-row>
</v-card-text>
<v-card-text>
<MealPlanCard v-model="meals" />
</v-card-text>
<v-row align="center" justify="end">
<v-card-actions>
<v-btn color="success" @click="random" v-if="meals[1]" text>
Random
</v-btn>
<v-btn color="success" @click="save" text> Save </v-btn>
<v-spacer></v-spacer>
<v-btn icon @click="show = !show"> </v-btn>
</v-card-actions>
</v-row>
</v-card>
</template>
<script>
import api from "../../api";
import utils from "../../utils";
import MealPlanCard from "./MealPlanCard";
export default {
components: {
MealPlanCard,
},
data() {
return {
isLoading: false,
meals: [],
// Dates
startDate: null,
endDate: null,
menu1: false,
menu2: false,
};
},
watch: {
dateDif() {
this.meals = [];
for (let i = 0; i < this.dateDif; i++) {
this.meals.push({
slug: "",
date: this.getDate(i),
dateText: this.getDayText(i),
});
}
},
},
computed: {
items() {
return this.$store.getters.getRecentRecipes;
},
actualStartDate() {
return Date.parse(this.startDate);
},
actualEndDate() {
return Date.parse(this.endDate);
},
dateDif() {
let startDate = new Date(this.startDate);
let endDate = new Date(this.endDate);
let dateDif = (endDate - startDate) / (1000 * 3600 * 24) + 1;
if (dateDif <= 1) {
return null;
}
return dateDif;
},
startComputedDateFormatted() {
return this.formatDate(this.startDate);
},
endComputedDateFormatted() {
return this.formatDate(this.endDate);
},
},
methods: {
get_random(list) {
const object = list[Math.floor(Math.random() * list.length)];
return object.slug;
},
random() {
this.meals.forEach((element, index) => {
this.meals[index]["slug"] = this.get_random(this.items);
});
},
processTime(index) {
let dateText = new Date(
this.actualStartDate.valueOf() + 1000 * 3600 * 24 * index
);
return dateText;
},
getDayText(index) {
const dateObj = this.processTime(index);
return utils.getDateAsText(dateObj);
},
getDate(index) {
const dateObj = this.processTime(index);
return utils.getDateAsPythonDate(dateObj);
},
async save() {
const mealBody = {
startDate: this.startDate,
endDate: this.endDate,
meals: this.meals,
};
await api.mealPlans.create(mealBody);
this.$emit("created");
this.startDate = null;
this.endDate = null;
this.meals = [];
},
getImage(image) {
return utils.getImageURL(image);
},
formatDate(date) {
if (!date) return null;
const [year, month, day] = date.split("-");
return `${month}/${day}/${year}`;
},
parseDate(date) {
if (!date) return null;
const [month, day, year] = date.split("/");
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,67 @@
<template>
<v-container fill-height>
<v-row justify="center" align="center">
<v-col sm="12">
<v-card
v-for="(meal, index) in mealPlan.meals"
:key="index"
class="my-2"
>
<v-row dense no-gutters align="center" justify="center">
<v-col order="1" md="6" sm="12">
<v-card flat>
<v-card-title> {{ meal.name }} </v-card-title>
<v-card-subtitle> {{ meal.dateText }}</v-card-subtitle>
<v-card-text> {{ meal.description }} </v-card-text>
<v-card-actions>
<v-btn
color="accent"
text
@click="$router.push(`/recipe/${meal.slug}`)"
>
View Recipe
</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col order-sm="0" :order-md="getOrder(index)" md="6" sm="12">
<v-card>
<v-img :src="getImage(meal.image)" max-height="300"> </v-img>
</v-card>
</v-col>
</v-row>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script>
import api from "../../api";
import utils from "../../utils";
export default {
data() {
return {
mealPlan: {},
};
},
async mounted() {
this.mealPlan = await api.mealPlans.thisWeek();
console.log(this.mealPlan);
},
methods: {
getOrder(index) {
if (index % 2 == 0) return 2;
else return 0;
},
getImage(image) {
return utils.getImageURL(image);
},
},
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,104 @@
<template>
<v-card :loading="isLoading">
<v-img v-if="image" height="400" :src="image">
<template v-slot:placeholder>
<v-row class="fill-height ma-0" align="center" justify="center">
<v-progress-circular
indeterminate
color="grey lighten-5"
></v-progress-circular>
</v-row>
</template>
</v-img>
<br v-else />
<ButtonRow
@json="jsonEditor = true"
@editor="jsonEditor = false"
@save="createRecipe"
/>
<VJsoneditor
v-if="jsonEditor"
v-model="recipeDetails"
height="1500px"
:options="jsonEditorOptions"
/>
<EditRecipe v-else v-model="recipeDetails" @upload="getImage" />
</v-card>
</template>
<script>
import api from "../api";
import EditRecipe from "./RecipeEditor/EditRecipe";
import VJsoneditor from "v-jsoneditor";
import ButtonRow from "./UI/ButtonRow";
export default {
components: {
VJsoneditor,
EditRecipe,
ButtonRow,
},
data() {
return {
isLoading: false,
fileObject: null,
selectedFile: null,
image: null,
jsonEditor: false,
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
recipeDetails: {
name: "",
description: "",
image: "",
recipeYield: "",
recipeIngredient: [],
recipeInstructions: [],
slug: "",
filePath: "",
tags: [],
categories: [],
// dateAdded: "",
notes: [],
extras: [],
},
};
},
methods: {
getImage(fileObject) {
console.log(fileObject);
this.fileObject = fileObject;
this.onFileChange();
},
onFileChange() {
this.image = URL.createObjectURL(this.fileObject);
},
async createRecipe() {
this.isLoading = true;
this.recipeDetails.image = this.fileObject.name;
console.log(this.recipeDetails);
let slug = await api.recipes.create(this.recipeDetails);
let response = await api.recipes.updateImage(slug, this.fileObject);
console.log(response);
this.isLoading = false;
this.$router.push(`/recipe/${slug}`);
},
},
};
</script>
<style>
.img-input {
position: absolute;
bottom: 0;
}
</style>

View File

@ -0,0 +1,23 @@
<template>
<div class="text-center">
<v-row>
<v-col cols="2"></v-col>
<v-col>
<v-card height="">
<v-card-text>
<h1>404 No Page Found</h1>
</v-card-text>
<v-btn text block @click="$router.push('/')"> Take me Home </v-btn>
</v-card>
</v-col>
<v-col cols="2"></v-col>
</v-row>
</div>
</template>
<script>
export default {};
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,40 @@
<template>
<v-row>
<v-col
:sm="6"
:md="6"
:lg="4"
:xl="3"
v-for="recipe in recipes"
:key="recipe.name"
>
<RecipeCard
:name="recipe.name"
:description="recipe.description"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
/>
</v-col>
</v-row>
</template>
<script>
import RecipeCard from "./UI/RecipeCard";
export default {
components: {
RecipeCard,
},
data: () => ({}),
mounted() {},
computed: {
recipes() {
return this.$store.getters.getRecentRecipes;
},
},
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,157 @@
<template>
<v-card id="myRecipe">
<v-img
height="400"
:src="getImage(recipeDetails.image)"
class="d-print-none"
:key="imageKey"
>
</v-img>
<ButtonRow
:open="showIcons"
@json="jsonEditor = true"
@editor="
jsonEditor = false;
form = true;
"
@save="saveRecipe"
@delete="deleteRecipe"
/>
<ViewRecipe
v-if="!form"
:name="recipeDetails.name"
:ingredients="recipeDetails.recipeIngredient"
:description="recipeDetails.description"
:instructions="recipeDetails.recipeInstructions"
:tags="recipeDetails.tags"
:categories="recipeDetails.categories"
:notes="recipeDetails.notes"
:rating="recipeDetails.rating"
:yields="recipeDetails.recipeYield"
:orgURL="recipeDetails.orgURL"
/>
<VJsoneditor
class="mt-10"
v-else-if="showJsonEditor"
v-model="recipeDetails"
height="1500px"
:options="jsonEditorOptions"
/>
<EditRecipe v-else v-model="recipeDetails" @upload="getImageFile" />
</v-card>
</template>
<script>
import api from "../api";
import utils from "../utils";
import VJsoneditor from "v-jsoneditor";
import ViewRecipe from "./RecipeEditor/ViewRecipe";
import EditRecipe from "./RecipeEditor/EditRecipe";
import ButtonRow from "./UI/ButtonRow";
export default {
components: {
VJsoneditor,
ViewRecipe,
EditRecipe,
ButtonRow,
},
data() {
return {
// CurrentRecipe: this.$route.params.recipe,
form: false,
jsonEditor: false,
jsonEditorOptions: {
mode: "code",
search: false,
mainMenuBar: false,
},
// Recipe Details //
recipeDetails: {
name: "",
description: "",
image: "",
recipeYield: "",
recipeIngredient: [],
recipeInstructions: [],
slug: "",
filePath: "",
url: "",
tags: [],
categories: [],
dateAdded: "",
notes: [],
rating: 0,
},
imageKey: 1,
};
},
mounted() {
this.getRecipeDetails();
},
watch: {
$route: function () {
this.getRecipeDetails();
},
},
computed: {
CurrentRecipe() {
return this.$route.params.recipe;
},
showIcons() {
return this.form;
},
showJsonEditor() {
if ((this.form === true) & (this.jsonEditor === true)) {
return true;
} else {
return false;
}
},
},
methods: {
getImageFile(fileObject) {
this.fileObject = fileObject;
},
async getRecipeDetails() {
this.recipeDetails = await api.recipes.requestDetails(this.CurrentRecipe);
this.form = false;
},
getImage(image) {
if (image) {
return utils.getImageURL(image) + "?rnd=" + this.imageKey;
}
},
deleteRecipe() {
api.recipes.delete(this.recipeDetails.slug);
},
async saveRecipe() {
console.log(this.recipeDetails);
await api.recipes.update(this.recipeDetails);
if (this.fileObject) {
await api.recipes.updateImage(this.recipeDetails.slug, this.fileObject);
}
this.form = false;
this.imageKey += 1;
},
showForm() {
this.form = true;
this.jsonEditor = false;
},
},
};
</script>
<style>
.card-btn {
margin-top: -10px;
}
.disabled-card {
opacity: 50%;
}
</style>

View File

@ -0,0 +1,65 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="600">
<template v-slot:activator="{ on, attrs }">
<v-btn
text
color="secondary lighten-2"
dark
v-bind="attrs"
v-on="on"
@click="inputText = ''"
>
Bulk Add
</v-btn>
</template>
<v-card>
<v-card-title class="headline"> Bulk Add </v-card-title>
<v-card-text>
<p>
Paste in your recipe data. Each line will be treated as an item in a
list
</p>
<v-textarea v-model="inputText"> </v-textarea>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="success" text @click="save"> Save </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
export default {
data() {
return {
dialog: false,
inputText: "",
};
},
methods: {
splitText() {
let split = this.inputText.split("\n");
split.forEach((element, index) => {
if ((element === "\n") | (element == false)) {
split.splice(index, 1);
}
});
return split;
},
save() {
this.$emit("bulk-data", this.splitText());
this.dialog = false;
},
},
};
</script>

View File

@ -0,0 +1,128 @@
<template>
<v-card-text>
<v-row>
<v-col cols="4">
<h2 class="mb-4">Ingredients</h2>
<div v-for="ingredient in ingredients" :key="ingredient">
<v-row align="center">
<v-checkbox hide-details class="shrink mr-2 mt-0"></v-checkbox>
<v-text-field :value="ingredient"></v-text-field>
</v-row>
</div>
<v-btn
class="ml-n5"
color="primary"
fab`
dark
small
@click="addIngredient"
>
<v-icon>mdi-plus</v-icon>
</v-btn>
<h2 class="mt-6">Categories</h2>
<v-combobox
dense
multiple
chips
item-color="primary"
deletable-chips
:value="categories"
>
<template v-slot:selection="data">
<v-chip :selected="data.selected" close color="primary" dark>
{{ data.item }}
</v-chip>
</template>
</v-combobox>
<h2 class="mt-4">Tags</h2>
<v-combobox dense multiple chips deletable-chips :value="tags">
<template v-slot:selection="data">
<v-chip :selected="data.selected" close color="primary" dark>
{{ data.item }}
</v-chip>
</template>
</v-combobox>
</v-col>
<v-divider :vertical="true"></v-divider>
<v-col>
<h2 class="mb-4">Instructions</h2>
<div v-for="(step, index) in instructions" :key="step.text">
<v-hover v-slot="{ hover }">
<v-card
class="ma-1"
:class="[{ 'on-hover': hover }]"
:elevation="hover ? 12 : 2"
>
<v-card-title>Step: {{ index + 1 }}</v-card-title>
<v-card-text>
<v-textarea dense :value="step.text"></v-textarea>
</v-card-text>
</v-card>
</v-hover>
</div>
<v-btn color="primary" fab dark small @click="addStep">
<v-icon>mdi-plus</v-icon>
</v-btn>
</v-col>
</v-row>
</v-card-text>
</template>
<script>
export default {
props: {
form: Boolean,
ingredients: Array,
instructions: Array,
categories: Array,
tags: Array,
},
data() {
return {
disabledSteps: [],
};
},
methods: {
toggleDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) {
let index = this.disabledSteps.indexOf(stepIndex);
if (index !== -1) {
this.disabledSteps.splice(index, 1);
}
} else {
this.disabledSteps.push(stepIndex);
}
},
isDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) {
return "disabled-card";
} else {
return;
}
},
saveRecipe() {
this.$emit("save");
},
deleteRecipe() {
this.$emit("delete");
},
addIngredient() {
this.$emit("addingredient");
},
addStep() {
this.$emit("addstep");
},
},
};
</script>
<style>
.disabled-card {
opacity: 50%;
}
</style>

View File

@ -0,0 +1,270 @@
<template>
<div>
<v-card-text>
<v-row dense>
<v-col cols="3"></v-col>
<v-col>
<v-file-input
v-model="fileObject"
label="Image File"
truncate-length="30"
@change="uploadImage"
></v-file-input>
</v-col>
<v-col cols="3"></v-col>
</v-row>
<v-text-field class="my-3" label="Recipe Name" v-model="value.name">
</v-text-field>
<v-textarea height="100" label="Description" v-model="value.description">
</v-textarea>
<div class="my-2"></div>
<v-row dense disabled>
<v-col sm="5">
<v-text-field
label="Servings"
color="accent darken-1"
v-model="value.recipeYield"
>
</v-text-field>
</v-col>
<v-col></v-col>
<v-rating
class="mr-2 align-end"
color="accent darken-1"
background-color="accent lighten-3"
length="5"
v-model="value.rating"
></v-rating>
</v-row>
<v-row>
<v-col cols="12" sm="12" md="4" lg="4">
<h2 class="mb-4">Ingredients</h2>
<div
v-for="(ingredient, index) in value.recipeIngredient"
:key="generateKey('ingredient', index)"
>
<v-row align="center">
<v-btn
fab
x-small
color="white"
class="mr-2"
elevation="0"
@click="removeIngredient(index)"
>
<v-icon color="error">mdi-delete</v-icon>
</v-btn>
<v-text-field
label="Ingredient"
v-model="value.recipeIngredient[index]"
></v-text-field>
</v-row>
</div>
<v-btn color="secondary" fab dark small @click="addIngredient">
<v-icon>mdi-plus</v-icon>
</v-btn>
<BulkAdd @bulk-data="appendIngredients" />
<h2 class="mt-6">Categories</h2>
<v-combobox
dense
multiple
chips
item-color="secondary"
deletable-chips
v-model="value.categories"
>
<template v-slot:selection="data">
<v-chip :input-value="data.selected" close color="secondary" dark>
{{ data.item }}
</v-chip>
</template>
</v-combobox>
<h2 class="mt-4">Tags</h2>
<v-combobox dense multiple chips deletable-chips v-model="value.tags">
<template v-slot:selection="data">
<v-chip :input-value="data.selected" close color="secondary" dark>
{{ data.item }}
</v-chip>
</template>
</v-combobox>
<h2 class="my-4">Notes</h2>
<v-card
class="mt-1"
v-for="(note, index) in value.notes"
:key="generateKey('note', index)"
>
<v-card-text>
<v-row align="center">
<v-btn
fab
x-small
color="white"
class="mr-2"
elevation="0"
@click="removeNote(index)"
>
<v-icon color="error">mdi-delete</v-icon>
</v-btn>
<v-text-field
label="Title"
v-model="value.notes[index]['title']"
></v-text-field>
</v-row>
<v-textarea label="Note" v-model="value.notes[index]['text']">
</v-textarea>
</v-card-text>
</v-card>
<v-btn class="mt-1" color="secondary" fab dark small @click="addNote">
<v-icon>mdi-plus</v-icon>
</v-btn>
</v-col>
<v-divider class="my-divider" :vertical="true"></v-divider>
<v-col cols="12" sm="12" md="8" lg="8">
<h2 class="mb-4">Instructions</h2>
<div v-for="(step, index) in value.recipeInstructions" :key="index">
<v-hover v-slot="{ hover }">
<v-card
class="ma-1"
:class="[{ 'on-hover': hover }]"
:elevation="hover ? 12 : 2"
>
<v-card-title>
<v-btn
fab
x-small
color="white"
class="mr-2"
elevation="0"
@click="removeStep(index)"
>
<v-icon color="error">mdi-delete</v-icon> </v-btn
>Step: {{ index + 1 }}</v-card-title
>
<v-card-text>
<v-textarea
dense
v-model="value.recipeInstructions[index]['text']"
:key="generateKey('instructions', index)"
></v-textarea>
</v-card-text>
</v-card>
</v-hover>
</div>
<v-btn color="secondary" fab dark small @click="addStep">
<v-icon>mdi-plus</v-icon>
</v-btn>
<BulkAdd @bulk-data="appendSteps" />
</v-col>
</v-row>
</v-card-text>
</div>
</template>
<script>
import api from "../../api";
import utils from "../../utils";
import BulkAdd from "./BulkAdd";
export default {
components: {
BulkAdd,
},
props: {
value: Object,
},
data() {
return {
fileObject: null,
content: this.value,
disabledSteps: [],
description: String,
ingredients: Array,
instructions: Array,
categories: Array,
tags: Array,
};
},
methods: {
uploadImage() {
this.$emit("upload", this.fileObject);
},
async updateImage() {
let slug = this.value.slug;
api.recipes.updateImage(slug, this.fileObject);
},
toggleDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) {
let index = this.disabledSteps.indexOf(stepIndex);
if (index !== -1) {
this.disabledSteps.splice(index, 1);
}
} else {
this.disabledSteps.push(stepIndex);
}
},
isDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) {
return "disabled-card";
} else {
return;
}
},
generateKey(item, index) {
return utils.generateUniqueKey(item, index);
},
deleteRecipe() {
this.$emit("delete");
},
appendIngredients(ingredients) {
this.value.recipeIngredient.push(...ingredients);
},
addIngredient() {
let list = this.value.recipeIngredient;
list.push("");
},
removeIngredient(index) {
this.value.recipeIngredient.splice(index, 1);
},
appendSteps(steps) {
let processSteps = [];
steps.forEach((element) => {
processSteps.push({ text: element });
});
this.value.recipeInstructions.push(...processSteps);
},
addStep() {
let list = this.value.recipeInstructions;
list.push({ text: "" });
},
removeStep(index) {
this.value.recipeInstructions.splice(index, 1);
},
addNote() {
let list = this.value.notes;
list.push({ text: "" });
},
removeNote(index) {
this.value.notes.splice(index, 1);
},
},
};
</script>
<style>
.disabled-card {
opacity: 50%;
}
.my-divider {
margin: 0 -1px;
}
</style>

View File

@ -0,0 +1,183 @@
<template>
<div>
<v-card-title class="headline">
{{ name }}
</v-card-title>
<v-card-text>
{{ description }}
<div class="my-2"></div>
<v-row dense disabled>
<v-col>
<v-btn
v-if="yields"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
color="accent darken-1"
class="rounded-sm static"
>
{{ yields }}
</v-btn>
</v-col>
<v-rating
class="mr-2 align-end static"
color="accent darken-1"
background-color="accent lighten-3"
length="5"
:value="rating"
></v-rating>
</v-row>
<v-row>
<v-col cols="12" sm="12" md="4" lg="4">
<h2 class="mb-4">Ingredients</h2>
<div
v-for="(ingredient, index) in ingredients"
:key="generateKey('ingredient', index)"
>
<v-checkbox
hide-details
class="ingredients"
:label="ingredient"
color="accent"
>
</v-checkbox>
</div>
<div v-if="categories[0]">
<h2 class="mt-4">Categories</h2>
<v-chip
class="ma-1"
color="primary"
dark
v-for="category in categories"
:key="category"
>
{{ category }}
</v-chip>
</div>
<div v-if="tags[0]">
<h2 class="mt-4">Tags</h2>
<v-chip
class="ma-1"
color="primary"
dark
v-for="tag in tags"
:key="tag"
>
{{ tag }}
</v-chip>
</div>
<h2 v-if="notes[0]" class="my-4">Notes</h2>
<v-card
class="mt-1"
v-for="(note, index) in notes"
:key="generateKey('note', index)"
>
<v-card-title> {{ note.title }}</v-card-title>
<v-card-text>
{{ note.text }}
</v-card-text>
</v-card>
</v-col>
<v-divider class="my-divider" :vertical="true"></v-divider>
<v-col cols="12" sm="12" md="8" lg="8">
<h2 class="mb-4">Instructions</h2>
<v-hover
v-for="(step, index) in instructions"
:key="generateKey('step', index)"
v-slot="{ hover }"
>
<v-card
class="ma-1"
:class="[{ 'on-hover': hover }, isDisabled(index)]"
:elevation="hover ? 12 : 2"
@click="toggleDisabled(index)"
>
<v-card-title>Step: {{ index + 1 }}</v-card-title>
<v-card-text>{{ step.text }}</v-card-text>
</v-card>
</v-hover>
</v-col>
</v-row>
<v-row>
<v-col></v-col>
<v-btn
v-if="orgURL"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
:href="orgURL"
color="accent darken-1"
target="_blank"
class="rounded-sm mr-4"
>
Original Recipe
</v-btn>
</v-row>
</v-card-text>
</div>
</template>
<script>
import utils from "../../utils";
export default {
props: {
name: String,
description: String,
ingredients: Array,
instructions: Array,
categories: Array,
tags: Array,
notes: Array,
rating: Number,
yields: String,
orgURL: String,
},
data() {
return {
disabledSteps: [],
};
},
methods: {
toggleDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) {
let index = this.disabledSteps.indexOf(stepIndex);
if (index !== -1) {
this.disabledSteps.splice(index, 1);
}
} else {
this.disabledSteps.push(stepIndex);
}
},
isDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) {
return "disabled-card";
} else {
return;
}
},
generateKey(item, index) {
return utils.generateUniqueKey(item, index);
},
},
};
</script>
<style>
.static {
pointer-events: none;
}
.my-divider {
margin: 0 -1px;
}
</style>

View File

@ -0,0 +1,48 @@
<template>
<v-toolbar class="card-btn" flat height="0" extension-height="0">
<template v-slot:extension>
<v-col></v-col>
<div v-if="open">
<v-btn class="mr-2" fab dark small color="error" @click="deleteRecipe">
<v-icon>mdi-delete</v-icon>
</v-btn>
<v-btn class="mr-2" fab dark small color="success" @click="save">
<v-icon>mdi-content-save</v-icon>
</v-btn>
<v-btn class="mr-5" fab dark small color="accent" @click="json">
<v-icon>mdi-code-braces</v-icon>
</v-btn>
</div>
<v-btn color="secondary" fab dark small @click="editor">
<v-icon>mdi-square-edit-outline</v-icon>
</v-btn>
</template>
</v-toolbar>
</template>
<script>
export default {
props: {
open: {
default: true,
},
},
methods: {
editor() {
this.$emit("editor");
},
save() {
this.$emit("save");
},
deleteRecipe() {
this.$emit("delete");
},
json() {
this.$emit("json");
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,99 @@
<template>
<div class="text-center">
<v-btn icon @click="showLogin = true">
<v-icon>mdi-account</v-icon>
</v-btn>
<v-dialog v-model="showLogin" width="500">
<v-flex class="login-form text-xs-center">
<v-card>
<v-card-text>
<v-form>
<v-text-field
v-if="!options.isLoggingIn"
v-model="user.name"
light="light"
prepend-icon="person"
label="Name"
></v-text-field>
<v-text-field
v-model="user.email"
light="light"
prepend-icon="mdi-email"
label="Email"
type="email"
></v-text-field>
<v-text-field
v-model="user.password"
light="light"
prepend-icon="mdi-lock"
label="Password"
type="password"
></v-text-field>
<v-checkbox
class="mb-2 mt-0"
v-if="options.isLoggingIn"
v-model="options.shouldStayLoggedIn"
light="light"
label="Stay logged in?"
hide-details="hide-details"
></v-checkbox>
<v-btn
v-if="options.isLoggingIn"
@click.prevent="login"
dark
color="primary"
block="block"
type="submit"
>Sign in</v-btn
>
<v-btn
v-else
block="block"
type="submit"
@click.prevent="options.isLoggingIn = true"
>Sign up</v-btn
>
</v-form>
</v-card-text>
<!-- <v-card-actions v-if="options.isLoggingIn" class="card-actions">
Don't have an account?
<v-btn
color="primary"
light="light"
@click="options.isLoggingIn = false"
>
Sign up
</v-btn>
</v-card-actions> -->
</v-card>
</v-flex>
</v-dialog>
</div>
</template>
<script>
import api from "../../api";
export default {
props: {},
data() {
return {
showLogin: false,
user: {
email: "",
password: "",
},
options: {
isLoggingIn: true,
},
};
},
methods: {
async login() {
let key = await api.login(this.user.email, this.user.password);
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,66 @@
<template>
<div class="text-center">
<v-menu
transition="slide-x-transition"
bottom
right
offset-y
open-on-hover
close-delay="200"
>
<template v-slot:activator="{ on, attrs }">
<v-btn v-bind="attrs" v-on="on" icon>
<v-icon>mdi-menu</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item v-for="(item, i) in items" :key="i" link>
<v-list-item-icon @click="navRouter(item.nav)">
<v-icon>{{ item.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-content @click="navRouter(item.nav)">
<v-list-item-title>
{{ item.title }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<script>
export default {
data: () => ({
items: [
{
icon: "mdi-calendar-week",
title: "Dinner This Week",
nav: "/meal-plan/this-week",
},
{
icon: "mdi-calendar-today",
title: "Dinner Today",
nav: "/meal-plan/today",
},
{
icon: "mdi-calendar-multiselect",
title: "Planner",
nav: "/meal-plan/planner",
},
{ icon: "mdi-cog", title: "Settings", nav: "/settings/site" },
],
}),
methods: {
navRouter(route) {
this.$router.push(route);
},
},
};
</script>
<style>
.menu-text {
text-align: left !important;
}
</style>

View File

@ -0,0 +1,63 @@
<template>
<v-hover v-slot="{ hover }" :open-delay="50">
<v-card
:class="{ 'on-hover': hover }"
:elevation="hover ? 12 : 2"
@click="moreInfo(slug)"
>
<v-img height="200" :src="getImage(image)"></v-img>
<v-card-title class="my-n3 mb-n6">{{ name | truncate(30) }}</v-card-title>
<v-card-actions class="">
<v-row dense align="center">
<v-col>
<v-rating
class="mr-2"
color="accent"
background-color="accent lighten-3"
dense
length="5"
size="15"
:value="rating"
></v-rating>
</v-col>
<v-col></v-col>
<v-col align="end">
<v-tooltip top color="secondary" max-width="400" open-delay="50">
<template v-slot:activator="{ on, attrs }">
<v-btn color="secondary" v-on="on" v-bind="attrs" text
>Description</v-btn
>
</template>
<span>{{ description }}</span>
</v-tooltip>
</v-col>
</v-row>
</v-card-actions>
</v-card>
</v-hover>
</template>
<script>
import utils from "../../utils";
export default {
props: {
name: String,
slug: String,
description: String,
rating: Number,
image: String,
},
methods: {
moreInfo(recipeSlug) {
this.$router.push(`/recipe/${recipeSlug}`);
},
getImage(image) {
return utils.getImageURL(image);
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,65 @@
<template>
<v-autocomplete
:items="items"
:loading="isLoading"
v-model="selected"
clearable
return
dense
hide-details
hide-selected
item-text="slug"
label="Search for a Recipe"
single-line
@keyup.enter.native="moreInfo(selected)"
>
<template v-slot:no-data>
<v-list-item>
<v-list-item-title>
Search for your Favorite
<strong>Recipe</strong>
</v-list-item-title>
</v-list-item>
</template>
<template v-slot:item="{ item }">
<v-list-item-avatar
color="primary"
class="headline font-weight-light white--text"
>
<v-img :src="getImage(item.image)"></v-img>
</v-list-item-avatar>
<v-list-item-content @click="moreInfo(item.slug)">
<v-list-item-title v-text="item.name"></v-list-item-title>
</v-list-item-content>
</template>
</v-autocomplete>
</template>
<script>
import utils from "../../utils";
export default {
data: () => ({
selected: null,
isLoading: false,
}),
computed: {
items() {
return this.$store.getters.getRecentRecipes;
},
},
methods: {
moreInfo(recipeSlug) {
this.$router.push(`/recipe/${recipeSlug}`);
},
getImage(image) {
return utils.getImageURL(image);
},
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,27 @@
<template>
<v-row>
<v-col cols="2"> </v-col>
<v-col>
<v-expand-transition>
<Search class="search-bar" />
</v-expand-transition>
</v-col>
<v-col cols="2">
<v-btn icon>
<v-icon> mdi-filter </v-icon>
</v-btn>
</v-col>
</v-row>
</template>
<script>
import Search from "./Search";
export default {
components: {
Search,
},
};
</script>
<style>
</style>

View File

@ -0,0 +1,41 @@
<template>
<div class="text-center">
<v-snackbar :value="active" :timeout="timeout" :color="type">
{{ text }}
<template v-slot:action="{ attrs }">
<v-btn color="white" text v-bind="attrs" @click="close(false)">
Close
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script>
export default {
data: () => ({
snackbar: false,
timeout: -1,
}),
computed: {
text() {
return this.$store.getters.getSnackText;
},
active() {
return this.$store.getters.getSnackActive;
},
type() {
return this.$store.getters.getSnackType;
},
},
methods: {
close(value) {
this.$store.commit("setSnackActive", value);
},
},
};
</script>
<style>
</style>

38
frontend/src/main.js Normal file
View File

@ -0,0 +1,38 @@
import Vue from "vue";
import App from "./App.vue";
import vuetify from "./plugins/vuetify";
import store from "./store/store";
import VueRouter from "vue-router";
import { routes } from "./routes";
import VueCookies from "vue-cookies";
Vue.config.productionTip = false;
Vue.use(VueRouter);
Vue.use(VueCookies);
const router = new VueRouter({
routes,
mode: process.env.NODE_ENV === "production" ? "history" : "hash",
});
new Vue({
vuetify,
store,
router,
render: (h) => h(App),
}).$mount("#app");
// Truncate
let filter = function(text, length, clamp) {
clamp = clamp || "...";
let node = document.createElement("div");
node.innerHTML = text;
let content = node.textContent;
return content.length > length ? content.slice(0, length) + clamp : content;
};
Vue.filter("truncate", filter);
export { router };

View File

@ -0,0 +1,33 @@
import Vue from "vue";
import Vuetify from "vuetify/lib";
Vue.use(Vuetify);
const vuetify = new Vuetify({
theme: {
dark: false,
themes: {
light: {
primary: "#E58325",
accent: "#00457A",
secondary: "#973542",
success: "#43A047",
info: "#FFFD99",
warning: "#FF4081",
error: "#EF5350",
},
dark: {
primary: "#4527A0",
accent: "#FF4081",
secondary: "#26C6DA",
success: "#43A047",
info: "#2196F3",
warning: "#FB8C00",
error: "#FF5252",
},
},
},
});
export default vuetify;
export { vuetify };

32
frontend/src/routes.js Normal file
View File

@ -0,0 +1,32 @@
import Home from "./components/Home";
import Page404 from "./components/Page404";
import Recipe from "./components/Recipe";
import NewRecipe from "./components/NewRecipe";
import Admin from "./components/Admin/Admin";
import MealPlanner from "./components/MealPlan/MealPlanner";
import ThisWeek from "./components/MealPlan/ThisWeek";
import api from "./api";
export const routes = [
{ path: "/", component: Home },
{ path: "/mealie", component: Home },
{ path: "/recipe/:recipe", component: Recipe },
{ path: "/new/", component: NewRecipe },
{ path: "/settings/site", component: Admin },
{ path: "/meal-plan/planner", component: MealPlanner },
{ path: "/meal-plan/this-week", component: ThisWeek },
{
path: "/meal-plan/today",
beforeEnter: async (_to, _from, next) => {
await todaysMealRoute().then((redirect) => {
next(redirect);
});
},
},
{ path: "*", component: Page404 },
];
async function todaysMealRoute() {
const response = await api.mealPlans.today();
return "/recipe/" + response.data;
}

119
frontend/src/store/store.js Normal file
View File

@ -0,0 +1,119 @@
import Vue from "vue";
import Vuex from "vuex";
import api from "../api";
import Vuetify from "../plugins/vuetify";
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
// Snackbar
snackActive: false,
snackText: "",
snackType: "warning",
// All Recipe Data Store
recentRecipes: [],
allRecipes: [],
// Site Settings
darkMode: false,
themes: {
light: {
primary: "#E58325",
accent: "#00457A",
secondary: "#973542",
success: "#43A047",
info: "#FFFD99",
warning: "#FF4081",
error: "#EF5350",
},
dark: {
primary: "#4527A0",
accent: "#FF4081",
secondary: "#26C6DA",
success: "#43A047",
info: "#2196F3",
warning: "#FB8C00",
error: "#FF5252",
},
},
},
mutations: {
setSnackBar(state, payload) {
state.snackText = payload.text;
state.snackType = payload.type;
state.snackActive = true;
},
setSnackActive(state, payload) {
state.snackActive = payload;
},
setRecentRecipes(state, payload) {
state.recentRecipes = payload;
},
setDarkMode(state, payload) {
state.darkMode = payload;
Vue.$cookies.set("darkMode", payload);
Vuetify.framework.theme.dark = payload;
},
setThemes(state, payload) {
state.themes = payload;
Vue.$cookies.set("themes", payload);
Vuetify.framework.theme.themes = payload;
},
},
actions: {
async initCookies() {
if (!Vue.$cookies.isKey("themes")) {
const DEFAULT_THEME = await api.themes.requestByName("default");
Vue.$cookies.set("themes", {
light: DEFAULT_THEME.colors,
dark: DEFAULT_THEME.colors,
});
}
this.commit("setThemes", Vue.$cookies.get("themes"));
// Dark Mode
if (!Vue.$cookies.isKey("darkMode")) {
Vue.$cookies.set("darkMode", false);
}
this.commit("setDarkMode", JSON.parse(Vue.$cookies.get("darkMode")));
},
async requestRecentRecipes() {
const keys = [
"name",
"slug",
"image",
"description",
"dateAdded",
"rating",
];
const payload = await api.recipes.allByKeys(keys);
this.commit("setRecentRecipes", payload);
},
},
getters: {
//
getSnackText: (state) => state.snackText,
getSnackActive: (state) => state.snackActive,
getSnackType: (state) => state.snackType,
getRecentRecipes: (state) => state.recentRecipes,
// Site Settings
getDarkMode: (state) => state.darkMode,
getThemes: (state) => state.themes,
},
});
export default store;
export { store };

75
frontend/src/utils.js Normal file
View File

@ -0,0 +1,75 @@
// import utils from "../../utils";
// import Vue from "vue";
// import Vuetify from "./plugins/vuetify";
const days = [
"Sunday",
"Monday",
"Tueday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const monthsShort = [
"Jan",
"Feb",
"March",
"April",
"May",
"June",
"July",
"Aug",
"Sept",
"Oct",
"Nov",
"Dec",
];
export default {
getImageURL(image) {
return `/api/recipe/image/${image}/`;
},
generateUniqueKey(item, index) {
const uniqueKey = `${item}-${index}`;
return uniqueKey;
},
getDateAsText(dateObject) {
const dow = days[dateObject.getUTCDay()];
const month = months[dateObject.getUTCMonth()];
const day = dateObject.getUTCDate();
const year = dateObject.getFullYear();
return `${dow}, ${month} ${day}, ${year}`;
},
getDateAsTextAlt(dateObject) {
const dow = days[dateObject.getUTCDay()];
const month = monthsShort[dateObject.getUTCMonth()];
const day = dateObject.getUTCDate();
const year = dateObject.getFullYear();
return `${dow}, ${month} ${day}, ${year}`;
},
getDateAsPythonDate(dateObject) {
const month = dateObject.getMonth() + 1;
const day = dateObject.getDate();
const year = dateObject.getFullYear();
return `${year}-${month}-${day}`;
},
};

13
frontend/vue.config.js Normal file
View File

@ -0,0 +1,13 @@
module.exports = {
transpileDependencies: ["vuetify"],
publicPath: process.env.NODE_ENV === "production" ? "/static/" : "/",
outputDir: process.env.NODE_ENV === "production" ? "./dist" : "../mealie/web",
devServer: {
proxy: {
"/api": {
target: process.env.VUE_APP_API_BASE_URL,
secure: false,
},
},
},
};

55
mealie/app.py Normal file
View File

@ -0,0 +1,55 @@
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from routes import (
backup_routes,
meal_routes,
recipe_routes,
setting_routes,
static_routes,
user_routes,
)
from routes.setting_routes import scheduler
from settings import PORT
from utils.logger import logger
CWD = Path(__file__).parent
WEB_PATH = CWD.joinpath("dist")
app = FastAPI()
# Mount Vue Frontend
app.mount("/static", StaticFiles(directory=WEB_PATH, html=True))
# API Routes
app.include_router(recipe_routes.router)
app.include_router(meal_routes.router)
app.include_router(setting_routes.router)
app.include_router(backup_routes.router)
app.include_router(user_routes.router)
# API 404 Catch all CALL AFTER ROUTERS
@app.get("/api/{full_path:path}", status_code=404, include_in_schema=False)
def invalid_api():
return None
app.include_router(static_routes.router)
if __name__ == "__main__":
logger.info("-----SYSTEM STARTUP-----")
uvicorn.run(
"app:app",
host="0.0.0.0",
port=PORT,
reload=True,
debug=True,
workers=1,
forwarded_allow_ips="*",
)

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View File

@ -0,0 +1,25 @@
![Recipe Image](../images/{{ recipe.image }})
# {{ recipe.name }}
## Ingredients
{% for ingredient in recipe.recipeIngredient %}
- [ ] {{ ingredient }}
{% endfor %}
## Instructions
{% for step in recipe.recipeInstructions %}
- [ ] {{ step.text }}
{% endfor %}
{% for note in recipe.notes %}
**{{ note.title }}:** {{ note.text }}
{% endfor %}
---
Tags: {{ recipe.tags }}
Categories: {{ recipe.categories }}
Original URL: {{ recipe.orgURL }}

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