init
This commit is contained in:
@@ -39,7 +39,12 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
|
# Custom settings
|
||||||
'webpack_loader',
|
'webpack_loader',
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework_nested',
|
||||||
|
'recipe_book',
|
||||||
|
'planning',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -14,11 +14,29 @@ Including another URLconf
|
|||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import path, include
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
from recipe_book import views
|
||||||
|
from rest_framework_nested import routers
|
||||||
|
|
||||||
|
router = routers.DefaultRouter()
|
||||||
|
router.register(r'recips', views.RecipesViewSet)
|
||||||
|
router.register(r'ingdts', views.IngredientViewSet)
|
||||||
|
|
||||||
|
amounts_router = routers.NestedDefaultRouter(
|
||||||
|
router, r'recips', lookup="recipe"
|
||||||
|
)
|
||||||
|
amounts_router.register(
|
||||||
|
r'amounts', views.IngredientWAmountViewSet,
|
||||||
|
base_name="recipe-ingdt-amounts"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/', include(router.urls)),
|
||||||
|
path('api/', include(amounts_router.urls)),
|
||||||
path('',
|
path('',
|
||||||
TemplateView.as_view(
|
TemplateView.as_view(
|
||||||
template_name="application.html"
|
template_name="application.html"
|
||||||
|
|||||||
657
frontend/package-lock.json
generated
657
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,12 +10,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^2.6.5",
|
"core-js": "^2.6.5",
|
||||||
"vue": "^2.6.6",
|
"vue": "^2.6.6",
|
||||||
|
"vue-router": "^3.0.2",
|
||||||
"vuetify": "^1.5.5"
|
"vuetify": "^1.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vue/cli-plugin-babel": "^3.5.0",
|
"@vue/cli-plugin-babel": "^3.5.0",
|
||||||
"@vue/cli-plugin-eslint": "^3.5.0",
|
"@vue/cli-plugin-eslint": "^3.5.0",
|
||||||
"@vue/cli-service": "^3.5.0",
|
"@vue/cli-service": "^3.6.0",
|
||||||
"babel-eslint": "^10.0.1",
|
"babel-eslint": "^10.0.1",
|
||||||
"eslint": "^5.8.0",
|
"eslint": "^5.8.0",
|
||||||
"eslint-plugin-vue": "^5.0.0",
|
"eslint-plugin-vue": "^5.0.0",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<title>frontend</title>
|
<title>frontend</title>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript>
|
||||||
|
|||||||
@@ -1,27 +1,119 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app>
|
<v-app>
|
||||||
<ToolBar/>
|
<v-toolbar app tabs>
|
||||||
|
<v-toolbar-side-icon
|
||||||
|
@click.stop="showNav = !showNav"
|
||||||
|
></v-toolbar-side-icon>
|
||||||
|
<v-toolbar-title>{{ title }}</v-toolbar-title>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
|
<v-btn icon
|
||||||
|
v-for="(tool, idx) in tools"
|
||||||
|
:key="`tool-${idx}`"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
:color="tool.color ? tool.color : 'dark'"
|
||||||
|
v-html="tool.icon"
|
||||||
|
></v-icon>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<template v-slot:extension>
|
||||||
|
<router-view name="extension"></router-view>
|
||||||
|
</template>
|
||||||
|
</v-toolbar>
|
||||||
|
<v-navigation-drawer
|
||||||
|
fixed
|
||||||
|
app
|
||||||
|
clipped
|
||||||
|
v-model="showNav"
|
||||||
|
>
|
||||||
|
<v-list dense>
|
||||||
|
<v-list-tile
|
||||||
|
v-for="link in links"
|
||||||
|
:key="link.path"
|
||||||
|
:to="link.path"
|
||||||
|
>
|
||||||
|
<v-list-tile-avatar>
|
||||||
|
<v-icon v-html="link.icon"
|
||||||
|
></v-icon>
|
||||||
|
</v-list-tile-avatar>
|
||||||
|
<v-list-tile-title v-html="link.text"
|
||||||
|
></v-list-tile-title>
|
||||||
|
</v-list-tile>
|
||||||
|
</v-list>
|
||||||
|
</v-navigation-drawer>
|
||||||
<v-content>
|
<v-content>
|
||||||
<HelloWorld/>
|
<router-view></router-view>
|
||||||
</v-content>
|
</v-content>
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import HelloWorld from './components/HelloWorld'
|
|
||||||
import ToolBar from './components/ToolBar'
|
import NotFound from './routes/NotFound'
|
||||||
|
|
||||||
|
export const RecipeCategories = {
|
||||||
|
0: "Petit-déjeuner",
|
||||||
|
1: "Entrée",
|
||||||
|
2: "Plat",
|
||||||
|
3: "Dessert"
|
||||||
|
}
|
||||||
|
// *** TOOLS ***
|
||||||
|
//
|
||||||
|
// * create_recipe: go to creation page
|
||||||
|
// * import_recipe: go to import page
|
||||||
|
// ---- Recipe list
|
||||||
|
// * category_tabs: navigate through categories
|
||||||
|
// * search: show input inside extension
|
||||||
|
// ---- Recipe details
|
||||||
|
// * go_back: go back to previous page
|
||||||
|
// * edit: edit fields of model details
|
||||||
|
// * add_to_menu: show menu slot picker in bottom sheet
|
||||||
|
// ---- Planning
|
||||||
|
// * complete_menu: ask for automatic completion of menu
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
components: {
|
|
||||||
HelloWorld,
|
|
||||||
ToolBar
|
|
||||||
},
|
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
//
|
links: [],
|
||||||
|
categories: RecipeCategories,
|
||||||
|
showNav: null,//
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
// Build navigation links from router.js config
|
||||||
|
for (var idx in this.$router.options.routes) {
|
||||||
|
var route = this.$router.options.routes[idx];
|
||||||
|
this.links.push({
|
||||||
|
path: route.path,
|
||||||
|
text: route.meta.title,
|
||||||
|
icon: route.meta.icon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
tools: function() {
|
||||||
|
return [{icon: 'add'},]
|
||||||
|
},
|
||||||
|
// TODO: move this inside routing events
|
||||||
|
title: function() {
|
||||||
|
var title = "CookAssistant"
|
||||||
|
for (var key in this.$route.matched) {
|
||||||
|
var route = this.$route.matched[key];
|
||||||
|
if (route.hasOwnProperty('meta')) {
|
||||||
|
if (route.meta.title != undefined) {
|
||||||
|
title = route.meta.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html { overflow-y: auto }
|
||||||
|
</style>
|
||||||
|
|||||||
153
frontend/src/api.js
Normal file
153
frontend/src/api.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/*
|
||||||
|
|
||||||
|
TODO:
|
||||||
|
Implement 'create'/'edit'/'read' modes with generic
|
||||||
|
configuration retrieved from OPTIONS request (using api.js)
|
||||||
|
|
||||||
|
'read' would use GET methods
|
||||||
|
'edit' would use PUT actions
|
||||||
|
'create' would use POST action
|
||||||
|
|
||||||
|
*/
|
||||||
|
const baseUrl = "http://localhost:8000/api"
|
||||||
|
|
||||||
|
/*
|
||||||
|
Check that the response status is what's expected.
|
||||||
|
Returns an object with :
|
||||||
|
* success: bool
|
||||||
|
* data: response data
|
||||||
|
*/
|
||||||
|
const processStatusCode = function(expected_code) {
|
||||||
|
return function(response) {
|
||||||
|
const status_code = response.status;
|
||||||
|
let data;
|
||||||
|
if (expected_code != 204) {
|
||||||
|
data = response.json();
|
||||||
|
} else {
|
||||||
|
data = null
|
||||||
|
}
|
||||||
|
return Promise.all([status_code, data])
|
||||||
|
.then(ret => ({
|
||||||
|
success: ret[0] == expected_code,
|
||||||
|
data: ret[1]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Returns data on success or throw an error with data.
|
||||||
|
*/
|
||||||
|
const processSuccess = function(result) {
|
||||||
|
if (!result.success) {
|
||||||
|
throw result.data;
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _headers = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
const _request = {
|
||||||
|
get (url) {
|
||||||
|
return fetch(
|
||||||
|
url,
|
||||||
|
{ method: 'GET',
|
||||||
|
})
|
||||||
|
.then(processStatusCode(200))
|
||||||
|
.then(processSuccess);
|
||||||
|
},
|
||||||
|
post (url, data) {
|
||||||
|
return fetch(
|
||||||
|
url,
|
||||||
|
{ method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: _headers,
|
||||||
|
})
|
||||||
|
.then(processStatusCode(201))
|
||||||
|
.then(processSuccess)
|
||||||
|
},
|
||||||
|
put (url, data) {
|
||||||
|
return fetch(
|
||||||
|
url,
|
||||||
|
{ method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
headers: _headers,
|
||||||
|
})
|
||||||
|
.then(processStatusCode(200))
|
||||||
|
.then(processSuccess)
|
||||||
|
},
|
||||||
|
delete(url) {
|
||||||
|
return fetch(
|
||||||
|
url,
|
||||||
|
{ method: 'DELETE',
|
||||||
|
headers: _headers, })
|
||||||
|
.then(processStatusCode(204))
|
||||||
|
.then(processSuccess)
|
||||||
|
},
|
||||||
|
options (url) {
|
||||||
|
return fetch(
|
||||||
|
url,
|
||||||
|
{ method: 'OPTIONS',
|
||||||
|
})
|
||||||
|
.then(processStatusCode(200))
|
||||||
|
.then(processSuccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountEndPoint = function(url) {
|
||||||
|
return {
|
||||||
|
read (id) {
|
||||||
|
return _request.get(url + id + '/')
|
||||||
|
},
|
||||||
|
create (data) {
|
||||||
|
return _request.post(url, data)
|
||||||
|
},
|
||||||
|
edit (id, data) {
|
||||||
|
return _request.put(url + id + '/', data)
|
||||||
|
},
|
||||||
|
delete (id) {
|
||||||
|
return _request.delete(url + id + '/')
|
||||||
|
},
|
||||||
|
options() { return _request.options(url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountNestedEndPoint = function(url) {
|
||||||
|
const getUrl = function(parent_id) {
|
||||||
|
// TODO: Make it safe against url injection !
|
||||||
|
return url.replace('*', parent_id)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
read (parent_id, id) {
|
||||||
|
return _request.get(getUrl(parent_id) + id + '/')
|
||||||
|
},
|
||||||
|
create (parent_id, data) {
|
||||||
|
return _request.post(
|
||||||
|
getUrl(parent_id),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
edit (parent_id, id, data) {
|
||||||
|
return _request.put(
|
||||||
|
getUrl(parent_id) + id + '/',
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
delete (parent_id, id) {
|
||||||
|
return _request.delete( getUrl(parent_id) + id + '/' )
|
||||||
|
},
|
||||||
|
options () {
|
||||||
|
return _request.options( getUrl(1) )
|
||||||
|
},
|
||||||
|
list (parent_id) {
|
||||||
|
return _request.get( getUrl(parent_id) )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
recipes: mountEndPoint(`${baseUrl}/recips/`),
|
||||||
|
ingredients: mountNestedEndPoint(
|
||||||
|
`${baseUrl}/recips/*/amounts/`
|
||||||
|
),
|
||||||
|
}
|
||||||
40
frontend/src/components/CategoryTabs.vue
Normal file
40
frontend/src/components/CategoryTabs.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Categories list -->
|
||||||
|
<span>
|
||||||
|
<v-tabs v-if="showTabs" :mandatory="false">
|
||||||
|
<v-tab v-for="key in Object.keys(categories)"
|
||||||
|
:key="`cat-${key}`"
|
||||||
|
:to="`/recipes/category/${key}`">
|
||||||
|
{{ categories[key] }}
|
||||||
|
</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
<template v-else>
|
||||||
|
<v-toolbar-title>
|
||||||
|
Détails
|
||||||
|
</v-toolbar-title>
|
||||||
|
<v-btn v-show="!showTabs"
|
||||||
|
icon
|
||||||
|
@click="$router.go(-1)"
|
||||||
|
><v-icon>arrow_back</v-icon></v-btn>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import { RecipeCategories } from '../App'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: () => ({
|
||||||
|
categories: RecipeCategories,
|
||||||
|
}),
|
||||||
|
computed: {
|
||||||
|
showTabs: function() {
|
||||||
|
return !(this.$route.path.startsWith("/recipes/id"))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-container>
|
|
||||||
<v-layout
|
|
||||||
text-xs-center
|
|
||||||
wrap
|
|
||||||
>
|
|
||||||
<v-flex xs12>
|
|
||||||
<v-img
|
|
||||||
:src="require('../assets/logo.svg')"
|
|
||||||
class="my-3"
|
|
||||||
contain
|
|
||||||
height="200"
|
|
||||||
></v-img>
|
|
||||||
</v-flex>
|
|
||||||
|
|
||||||
<v-flex mb-4>
|
|
||||||
<h1 class="display-2 font-weight-bold mb-3">
|
|
||||||
Welcome to Vuetify
|
|
||||||
</h1>
|
|
||||||
<p class="subheading font-weight-regular">
|
|
||||||
For help and collaboration with other Vuetify developers,
|
|
||||||
<br>please join our online
|
|
||||||
<a href="https://community.vuetifyjs.com" target="_blank">Discord Community</a>
|
|
||||||
</p>
|
|
||||||
</v-flex>
|
|
||||||
|
|
||||||
<v-flex
|
|
||||||
mb-5
|
|
||||||
xs12
|
|
||||||
>
|
|
||||||
<h2 class="headline font-weight-bold mb-3">What's next?</h2>
|
|
||||||
|
|
||||||
<v-layout justify-center>
|
|
||||||
<a
|
|
||||||
v-for="(next, i) in whatsNext"
|
|
||||||
:key="i"
|
|
||||||
:href="next.href"
|
|
||||||
class="subheading mx-3"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
{{ next.text }}
|
|
||||||
</a>
|
|
||||||
</v-layout>
|
|
||||||
</v-flex>
|
|
||||||
|
|
||||||
<v-flex
|
|
||||||
xs12
|
|
||||||
mb-5
|
|
||||||
>
|
|
||||||
<h2 class="headline font-weight-bold mb-3">Important Links</h2>
|
|
||||||
|
|
||||||
<v-layout justify-center>
|
|
||||||
<a
|
|
||||||
v-for="(link, i) in importantLinks"
|
|
||||||
:key="i"
|
|
||||||
:href="link.href"
|
|
||||||
class="subheading mx-3"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
{{ link.text }}
|
|
||||||
</a>
|
|
||||||
</v-layout>
|
|
||||||
</v-flex>
|
|
||||||
|
|
||||||
<v-flex
|
|
||||||
xs12
|
|
||||||
mb-5
|
|
||||||
>
|
|
||||||
<h2 class="headline font-weight-bold mb-3">Ecosystem</h2>
|
|
||||||
|
|
||||||
<v-layout justify-center>
|
|
||||||
<a
|
|
||||||
v-for="(eco, i) in ecosystem"
|
|
||||||
:key="i"
|
|
||||||
:href="eco.href"
|
|
||||||
class="subheading mx-3"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
{{ eco.text }}
|
|
||||||
</a>
|
|
||||||
</v-layout>
|
|
||||||
</v-flex>
|
|
||||||
</v-layout>
|
|
||||||
</v-container>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data: () => ({
|
|
||||||
ecosystem: [
|
|
||||||
{
|
|
||||||
text: 'vuetify-loader',
|
|
||||||
href: 'https://github.com/vuetifyjs/vuetify-loader'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'github',
|
|
||||||
href: 'https://github.com/vuetifyjs/vuetify'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'awesome-vuetify',
|
|
||||||
href: 'https://github.com/vuetifyjs/awesome-vuetify'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
importantLinks: [
|
|
||||||
{
|
|
||||||
text: 'Documentation',
|
|
||||||
href: 'https://vuetifyjs.com'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Chat',
|
|
||||||
href: 'https://community.vuetifyjs.com'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Made with Vuetify',
|
|
||||||
href: 'https://madewithvuetifyjs.com'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Twitter',
|
|
||||||
href: 'https://twitter.com/vuetifyjs'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Articles',
|
|
||||||
href: 'https://medium.com/vuetify'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
whatsNext: [
|
|
||||||
{
|
|
||||||
text: 'Explore components',
|
|
||||||
href: 'https://vuetifyjs.com/components/api-explorer'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Select a layout',
|
|
||||||
href: 'https://vuetifyjs.com/layout/pre-defined'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Frequently Asked Questions',
|
|
||||||
href: 'https://vuetifyjs.com/getting-started/frequently-asked-questions'
|
|
||||||
}
|
|
||||||
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
26
frontend/src/components/RessourceField.vue
Normal file
26
frontend/src/components/RessourceField.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<v-select v-if="type == 'choice'"
|
||||||
|
:label="label"
|
||||||
|
placeholder="----"
|
||||||
|
:items="choices"
|
||||||
|
item-text="display_name"
|
||||||
|
:value="value"
|
||||||
|
@change="val => $emit('input', val)"
|
||||||
|
/>
|
||||||
|
<v-text-field v-else
|
||||||
|
:label="label"
|
||||||
|
:type="type"
|
||||||
|
:value="value"
|
||||||
|
@input="val => $emit('input', val)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
//TODO: work with nested object fields...
|
||||||
|
export default {
|
||||||
|
props: ['value', 'label', 'read_only', 'required', 'type', 'choices'],
|
||||||
|
data () {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-toolbar dark color="primary">
|
|
||||||
<!-- <v-toolbar-side-icon></v-toolbar-side-icon> -->
|
|
||||||
|
|
||||||
<v-toolbar-title class="white--text">
|
|
||||||
Cook Assistant
|
|
||||||
</v-toolbar-title>
|
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
|
|
||||||
<v-btn icon>
|
|
||||||
<v-icon>search</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn icon>
|
|
||||||
<v-icon>apps</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn icon>
|
|
||||||
<v-icon>refresh</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn icon>
|
|
||||||
<v-icon>more_vert</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</v-toolbar>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data: () => ({})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
87
frontend/src/components/recipes/Ingredient.vue
Normal file
87
frontend/src/components/recipes/Ingredient.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<v-card-text class="py-1" v-if="!editing">
|
||||||
|
{{ item.display }}
|
||||||
|
</v-card-text>
|
||||||
|
<v-input v-else
|
||||||
|
:append-icon="append_icon"
|
||||||
|
@click:append="appendIconAction"
|
||||||
|
:messages="errors"
|
||||||
|
>
|
||||||
|
<v-container fluid class="pa-0 ma-0">
|
||||||
|
<v-layout>
|
||||||
|
<v-flex xs3>
|
||||||
|
<RField
|
||||||
|
v-bind="fields.amount"
|
||||||
|
v-model="item.amount"
|
||||||
|
@input="markUpdated"
|
||||||
|
/>
|
||||||
|
</v-flex>
|
||||||
|
<v-flex xs3>
|
||||||
|
<RField
|
||||||
|
v-bind="fields.unit"
|
||||||
|
v-model="item.unit"
|
||||||
|
@input="markUpdated"
|
||||||
|
/>
|
||||||
|
</v-flex>
|
||||||
|
<v-flex xs6>
|
||||||
|
<RField
|
||||||
|
v-bind="fields.ingredient.children.name"
|
||||||
|
v-model="item.ingredient.name"
|
||||||
|
@input="markUpdated"
|
||||||
|
/>
|
||||||
|
</v-flex>
|
||||||
|
</v-layout>
|
||||||
|
</v-container>
|
||||||
|
</v-input>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import api from '../../api.js'
|
||||||
|
import RField from '../RessourceField'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { RField },
|
||||||
|
props: ["item", "fields", "editing"],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
errors: [],
|
||||||
|
updated: false,
|
||||||
|
append_icon: "delete"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isNew () {
|
||||||
|
return !this.item.ingredient.id
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
if (this.isNew) {
|
||||||
|
this.append_icon = "add"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
appendIconAction () {
|
||||||
|
if (this.isNew) {
|
||||||
|
this.$emit('create', this)
|
||||||
|
} else {
|
||||||
|
// TODO: should ask for confirmation
|
||||||
|
this.$emit('delete', this)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
markUpdated () {
|
||||||
|
this.updated = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
editing(newValue) {
|
||||||
|
if (newValue == false && this.updated) {
|
||||||
|
// TODO: check if there has been updates
|
||||||
|
this.$emit('save', this)
|
||||||
|
this.updated = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
103
frontend/src/components/recipes/IngredientList.vue
Normal file
103
frontend/src/components/recipes/IngredientList.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="pa-1">
|
||||||
|
<v-card-title>
|
||||||
|
<strong>Ingrédients</strong>
|
||||||
|
</v-card-title>
|
||||||
|
<ingredient-details
|
||||||
|
v-for="ingdt in edited_ingdts"
|
||||||
|
:item="ingdt"
|
||||||
|
:fields="fields"
|
||||||
|
:editing="editing"
|
||||||
|
@delete="deleteItem"
|
||||||
|
@save="saveItem"
|
||||||
|
/>
|
||||||
|
<!-- New ingredient in editing mode -->
|
||||||
|
<p v-if="editing">
|
||||||
|
(+) <ingredient-details
|
||||||
|
:item="newItem()"
|
||||||
|
:fields="fields"
|
||||||
|
:editing="editing"
|
||||||
|
@create="createItem"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import api from '../../api.js'
|
||||||
|
import Ingredient from './Ingredient'
|
||||||
|
function NullIngredient (recipe_id) {
|
||||||
|
this.recipe = recipe_id
|
||||||
|
this.ingredient = { name: "" }
|
||||||
|
this.amount = 0
|
||||||
|
this.unit = ""
|
||||||
|
this.display = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
'ingredient-details': Ingredient,
|
||||||
|
},
|
||||||
|
props: ['ingredients', 'editing', 'recipe'],
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
fields: {},
|
||||||
|
edited_ingdts: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
// Copy the property to mutate it
|
||||||
|
api.ingredients.options()
|
||||||
|
.then(opts => {
|
||||||
|
this.fields = opts.actions.POST
|
||||||
|
})
|
||||||
|
console.log("get list:", this.recipe)
|
||||||
|
api.ingredients.list(this.recipe)
|
||||||
|
.then(data => this.edited_ingdts = data)
|
||||||
|
.catch(err => console.error("error reading ingdt:", err))
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
newItem () {
|
||||||
|
return new NullIngredient(this.recipe)
|
||||||
|
},
|
||||||
|
createItem (comp) {
|
||||||
|
let item = comp.item
|
||||||
|
item.recipe = this.recipe
|
||||||
|
api.ingredients.create(
|
||||||
|
this.recipe,
|
||||||
|
item
|
||||||
|
)
|
||||||
|
.then(data => {
|
||||||
|
this.edited_ingdts.push(data)
|
||||||
|
//TODO: reset additionnal Ingredient
|
||||||
|
})
|
||||||
|
.catch(err => comp.errors.push(JSON.stringify(err)))
|
||||||
|
},
|
||||||
|
saveItem(comp) {
|
||||||
|
let item = comp.item
|
||||||
|
item.recipe = this.recipe // Inject recipe's id into data
|
||||||
|
api.ingredients.edit(
|
||||||
|
item.recipe,
|
||||||
|
item.ingredient.id,
|
||||||
|
item,
|
||||||
|
)
|
||||||
|
.then(data => {
|
||||||
|
// TODO: better identification for ingredients
|
||||||
|
// BUG: will not work if ingredient changes!!
|
||||||
|
let idx = this.edited_ingdts.indexOf(it => it.ingredient == data.ingredient)
|
||||||
|
this.edited_ingdts[idx] = data
|
||||||
|
})
|
||||||
|
.catch(err => comp.errors.push(JSON.stringify(err)))
|
||||||
|
},
|
||||||
|
deleteItem(comp) {
|
||||||
|
let item = comp.item
|
||||||
|
api.ingredients.delete(this.recipe, item.ingredient.id)
|
||||||
|
.then(() => {
|
||||||
|
var idx = this.edited_ingdts.indexOf(item)
|
||||||
|
this.edited_ingdts.splice(idx, 1)
|
||||||
|
})
|
||||||
|
.catch(err => comp.errors.push(JSON.stringify(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
194
frontend/src/components/recipes/RecipeDetails.vue
Normal file
194
frontend/src/components/recipes/RecipeDetails.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<!-- Details view of a Recipe
|
||||||
|
|
||||||
|
Has read/edit modes
|
||||||
|
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<v-container fluid>
|
||||||
|
<v-layout wrap>
|
||||||
|
<v-flex xs10> <!-- Heading -->
|
||||||
|
<v-alert v-for="(error,idx) in errors"
|
||||||
|
:key="`alert-${idx}`"
|
||||||
|
:value="true">
|
||||||
|
Error: {{ error }}
|
||||||
|
</v-alert>
|
||||||
|
<v-card-text v-if="!editing">
|
||||||
|
<h6 class="title pb-3">
|
||||||
|
{{item.name}}
|
||||||
|
</h6>
|
||||||
|
<p class="subheading pb-3">
|
||||||
|
{{categories[item.category]}}
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
<template v-else>
|
||||||
|
<RField
|
||||||
|
v-bind="fields.name"
|
||||||
|
v-model="item.name"
|
||||||
|
/>
|
||||||
|
<RField
|
||||||
|
v-bind="fields.category"
|
||||||
|
v-model="item.category"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-flex>
|
||||||
|
<v-flex xs2> <!-- Buttons -->
|
||||||
|
<!-- Add to planning -->
|
||||||
|
<v-bottom-sheet v-show="!editing"
|
||||||
|
v-model="sheetSelectSlot"
|
||||||
|
>
|
||||||
|
<template v-slot:activator>
|
||||||
|
<v-btn
|
||||||
|
fab dark
|
||||||
|
color="success"
|
||||||
|
><v-icon>add</v-icon></v-btn>
|
||||||
|
</template>
|
||||||
|
<!-- Put a meal picker here... -->
|
||||||
|
<v-card elevation="10" style="min-height: 300px;">
|
||||||
|
<v-card-text>
|
||||||
|
Content of bottom sheet
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-bottom-sheet>
|
||||||
|
<!-- Edit mode -->
|
||||||
|
<v-fab-transition>
|
||||||
|
<v-btn
|
||||||
|
fab dark
|
||||||
|
:small="!editing"
|
||||||
|
color="error"
|
||||||
|
@click="switchEdit"
|
||||||
|
>
|
||||||
|
<v-icon v-if="editing">save</v-icon>
|
||||||
|
<v-icon v-else>edit</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-fab-transition>
|
||||||
|
</v-flex>
|
||||||
|
<v-flex xs12 md4>
|
||||||
|
<IngredientList v-if="!isLoading"
|
||||||
|
:editing="editing"
|
||||||
|
:recipe="item.id"
|
||||||
|
:ingredients="item.ingredients"
|
||||||
|
/>
|
||||||
|
</v-flex>
|
||||||
|
</v-layout>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import RField from '../RessourceField'
|
||||||
|
import { RecipeCategories } from '../../App'
|
||||||
|
import api from '../../api.js'
|
||||||
|
import NotFound from '../../routes/NotFound'
|
||||||
|
const IngredientList = import('./IngredientList')
|
||||||
|
|
||||||
|
function NullRecipe() {
|
||||||
|
this.name = String();
|
||||||
|
this.category = Number();
|
||||||
|
this.ingredients = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
IngredientList: () => ({
|
||||||
|
component: IngredientList,
|
||||||
|
loading: NotFound, //TODO: actual loading view
|
||||||
|
error: NotFound,
|
||||||
|
delay: 100,
|
||||||
|
timeout: 3000,
|
||||||
|
}),
|
||||||
|
RField,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
// Transform categories for v-select component
|
||||||
|
var select_items = []
|
||||||
|
Object.keys(RecipeCategories)
|
||||||
|
.map(function(key) {
|
||||||
|
select_items.push({
|
||||||
|
text: RecipeCategories[key],
|
||||||
|
value: key,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading: true,
|
||||||
|
sheetSelectSlot: false,
|
||||||
|
editing: false,
|
||||||
|
select_items,
|
||||||
|
categories: RecipeCategories,
|
||||||
|
errors: [],
|
||||||
|
// Will be populated by initRecipeItem method
|
||||||
|
item: new NullRecipe,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
switchEdit: function() {
|
||||||
|
if (this.editing) {
|
||||||
|
var data = {
|
||||||
|
name: this.item.name,
|
||||||
|
category: this.item.category,
|
||||||
|
}
|
||||||
|
if (this.item.id) {
|
||||||
|
// Edit recipe
|
||||||
|
api.recipes.edit(this.item.id, data)
|
||||||
|
.then(data => {
|
||||||
|
this.item = data
|
||||||
|
this.editing = false
|
||||||
|
})
|
||||||
|
.catch(err => this.errors.push(err))
|
||||||
|
} else {
|
||||||
|
// Create recipe
|
||||||
|
api.recipes.create(data)
|
||||||
|
.then(data => {
|
||||||
|
this.item = data
|
||||||
|
this.editing = false
|
||||||
|
})
|
||||||
|
.catch(err => this.errors.push(err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.editing = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initRecipeItem: function(id) {
|
||||||
|
// An id of 0 means we want a creation page
|
||||||
|
if (id == 0) {
|
||||||
|
this.item = new NullRecipe
|
||||||
|
this.editing = true
|
||||||
|
} else {
|
||||||
|
api.recipes.read(id)
|
||||||
|
.then(data => {
|
||||||
|
this.errors = []
|
||||||
|
this.item = data
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.errors.push(err)
|
||||||
|
this.item = new NullRecipe
|
||||||
|
})
|
||||||
|
.then(() => this.isLoading = false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateRecip: function(form_data) {
|
||||||
|
console.log(form_data);
|
||||||
|
var messages = [];
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
api.recipes.options()
|
||||||
|
.then(data => this.fields = data.actions.POST)
|
||||||
|
.then(() => console.log(this.fields))
|
||||||
|
},
|
||||||
|
beforeRouteEnter (to, from, next) {
|
||||||
|
next(vm => {
|
||||||
|
const id = to.params.id;
|
||||||
|
vm.initRecipeItem(id);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
beforeRouteUpdate (to, from, next) {
|
||||||
|
const id = to.params.id;
|
||||||
|
this.initRecipeItem(id);
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
48
frontend/src/components/recipes/RecipeList.vue
Normal file
48
frontend/src/components/recipes/RecipeList.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/* List view of recipes */
|
||||||
|
<template>
|
||||||
|
<v-list>
|
||||||
|
<template v-for="(item, idx) in items">
|
||||||
|
<v-list-tile
|
||||||
|
:key="item.title"
|
||||||
|
:to="`/recipes/id/${item.id}`">
|
||||||
|
<v-list-tile-content>
|
||||||
|
{{item.name}}
|
||||||
|
</v-list-tile-content>
|
||||||
|
</v-list-tile>
|
||||||
|
<v-divider v-if="idx < (items.length - 1)"
|
||||||
|
:key="idx"></v-divider>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// Recipes from db grouped by categories id
|
||||||
|
content: {
|
||||||
|
0: [], 1: [], 2: [], 3: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
items: function() {
|
||||||
|
return this.content[this.$route.params.cat];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function() {
|
||||||
|
const vm = this;
|
||||||
|
fetch('http://localhost:8000/api/recips/')
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then(function(data) {
|
||||||
|
for (var idx in data) {
|
||||||
|
var obj = data[idx];
|
||||||
|
vm.content[parseInt(obj.category)].push(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
import './plugins/vuetify'
|
import './plugins/vuetify'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
Vue.config.productionTip = false
|
Vue.config.productionTip = false
|
||||||
|
|
||||||
new Vue({
|
new Vue({
|
||||||
|
router,
|
||||||
render: h => h(App),
|
render: h => h(App),
|
||||||
}).$mount('#app')
|
}).$mount('#app')
|
||||||
|
|||||||
46
frontend/src/router.js
Normal file
46
frontend/src/router.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import Router from 'vue-router'
|
||||||
|
|
||||||
|
import Index from './routes/Index'
|
||||||
|
import NotFound from './routes/NotFound'
|
||||||
|
import Recipes from './routes/Recipes'
|
||||||
|
import Planning from './routes/Planning'
|
||||||
|
|
||||||
|
import RecipeDetails from './components/recipes/RecipeDetails'
|
||||||
|
import RecipeList from './components/recipes/RecipeList'
|
||||||
|
import CategoryTabs from './components/CategoryTabs'
|
||||||
|
|
||||||
|
Vue.use(Router)
|
||||||
|
|
||||||
|
const router = new Router({
|
||||||
|
routes: [
|
||||||
|
{ path: '/',
|
||||||
|
component: Index,
|
||||||
|
meta: { title: "Home", icon: "home" }
|
||||||
|
},
|
||||||
|
|
||||||
|
{ path: '/recipes',
|
||||||
|
components: { default: Recipes,
|
||||||
|
extension: CategoryTabs, },
|
||||||
|
meta: { title: "Recettes",
|
||||||
|
icon:"book" },
|
||||||
|
children: [
|
||||||
|
{ path: 'id/:id',
|
||||||
|
component: RecipeDetails,
|
||||||
|
meta: { subtitle: "Détails", } },
|
||||||
|
{ path: 'category/:cat',
|
||||||
|
component: RecipeList,
|
||||||
|
meta: { subtitle: "Liste", } },
|
||||||
|
{ path: '*',
|
||||||
|
component: NotFound }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
{ path: '/planning',
|
||||||
|
component: Planning,
|
||||||
|
meta: { title: "Menu", icon: "calendar_today" },
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
21
frontend/src/routes/Index.vue
Normal file
21
frontend/src/routes/Index.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<v-img
|
||||||
|
:src="require('../assets/logo.svg')"
|
||||||
|
class="ma-3"
|
||||||
|
contain
|
||||||
|
height="200"
|
||||||
|
></v-img>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data: () => ({
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
|
||||||
|
</style>
|
||||||
9
frontend/src/routes/NotFound.vue
Normal file
9
frontend/src/routes/NotFound.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<v-alert value="true">Not found</v-alert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data: () => ({})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
91
frontend/src/routes/Planning.vue
Normal file
91
frontend/src/routes/Planning.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<v-list two-line overflow-y>
|
||||||
|
<template v-for="(item, index) in items">
|
||||||
|
<v-subheader v-if="item.header"
|
||||||
|
:key="item.header"
|
||||||
|
>{{item.header}}</v-subheader>
|
||||||
|
<v-divider v-else-if="item.divider"></v-divider>
|
||||||
|
<v-list-tile v-else
|
||||||
|
:key="index">
|
||||||
|
<v-list-tile-content>
|
||||||
|
<v-list-tile-sub-title
|
||||||
|
>{{ item.subtitle }}</v-list-tile-sub-title>
|
||||||
|
<v-list-tile-title
|
||||||
|
>{{ item.title }}</v-list-tile-title>
|
||||||
|
</v-list-tile-content>
|
||||||
|
<v-list-tile-action v-show="item.title">
|
||||||
|
<v-btn icon color="primary" small
|
||||||
|
:to="`/recipes/id/${item.id}`"
|
||||||
|
><v-icon>remove_red_eye</v-icon></v-btn>
|
||||||
|
</v-list-tile-action>
|
||||||
|
<v-list-tile-action>
|
||||||
|
<v-btn icon color="warning" small>
|
||||||
|
<v-icon>
|
||||||
|
{{ item.title ? 'delete' : 'add' }}
|
||||||
|
</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-list-tile-action>
|
||||||
|
</v-list-tile>
|
||||||
|
</template>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
const MockPlanning = {
|
||||||
|
"Lundi": {
|
||||||
|
"Midi": "Raclette",
|
||||||
|
"Soir": "Soupe",
|
||||||
|
},
|
||||||
|
"Mardi": {
|
||||||
|
"Midi": null,
|
||||||
|
"Soir": null,
|
||||||
|
},
|
||||||
|
"Mercredi": {
|
||||||
|
"Midi": null,
|
||||||
|
"Soir": null,
|
||||||
|
},
|
||||||
|
"Jeudi": {
|
||||||
|
"Midi": null,
|
||||||
|
"Soir": null,
|
||||||
|
},
|
||||||
|
"Vendredi": {
|
||||||
|
"Midi": null,
|
||||||
|
"Soir": null,
|
||||||
|
},
|
||||||
|
"Samedi": {
|
||||||
|
"Midi": null,
|
||||||
|
"Soir": null,
|
||||||
|
},
|
||||||
|
"Dimanche": {
|
||||||
|
"Midi": null,
|
||||||
|
"Soir": null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
var items = [];
|
||||||
|
|
||||||
|
for (var day in MockPlanning) {
|
||||||
|
items.push({ divider: true })
|
||||||
|
items.push({ header: day });
|
||||||
|
for (var meal in MockPlanning[day]) {
|
||||||
|
items.push({ id: 0, subtitle: meal, title: MockPlanning[day][meal] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
toolActions: [
|
||||||
|
{ icon: "find_replace", color: "success" },
|
||||||
|
],
|
||||||
|
planning: MockPlanning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
14
frontend/src/routes/Recipes.vue
Normal file
14
frontend/src/routes/Recipes.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<router-view></router-view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data: () => ({
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
13
frontend/src/views/Home.vue
Normal file
13
frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<HelloWorld />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import HelloWorld from '../components/HelloWorld'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
HelloWorld
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const BundleTracker = require("webpack-bundle-tracker");
|
const BundleTracker = require("webpack-bundle-tracker");
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
baseUrl: "http://127.0.0.1:8080/",
|
publicPath: "http://localhost:8080/",
|
||||||
outputDir: './dist/',
|
outputDir: './dist/',
|
||||||
|
|
||||||
chainWebpack: config => {
|
chainWebpack: config => {
|
||||||
@@ -17,8 +17,8 @@ module.exports = {
|
|||||||
.set('__STATIC__', 'static')
|
.set('__STATIC__', 'static')
|
||||||
|
|
||||||
config.devServer
|
config.devServer
|
||||||
.public('http://0.0.0.0:8080')
|
.public('http://localhost:8080')
|
||||||
.host('0.0.0.0')
|
.host('localhost')
|
||||||
.port(8080)
|
.port(8080)
|
||||||
.hotOnly(true)
|
.hotOnly(true)
|
||||||
.watchOptions({poll: 1000})
|
.watchOptions({poll: 1000})
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"status":"done","publicPath":"http://127.0.0.1:8080/","chunks":{"app":[{"name":"app.js","publicPath":"http://127.0.0.1:8080/app.js","path":"C:\\Users\\lecor\\Documents\\Dev\\Python\\cookAssistant\\frontend\\dist\\app.js"},{"name":"app.a2bf8f46c2ce5a8648c5.hot-update.js","publicPath":"http://127.0.0.1:8080/app.a2bf8f46c2ce5a8648c5.hot-update.js","path":"C:\\Users\\lecor\\Documents\\Dev\\Python\\cookAssistant\\frontend\\dist\\app.a2bf8f46c2ce5a8648c5.hot-update.js"}]},"error":"ModuleError","message":"Module Error (from ./node_modules/vue-loader/lib/loaders/templateLoader.js):\n(Emitted value instead of an instance of Error) \n\n Errors compiling template:\n\n tag <v-toolbar-items> has no matching end tag.\n\n 7 | </v-toolbar-title>\n 8 | <v-spacer></v-spacer>\n 9 | <v-toolbar-items>\n | ^^^^^^^^^^^^^^^^^\n 10 | <v-btn\n 11 | flat\n"}
|
{"status":"done","publicPath":"http://localhost:8080/","chunks":{"null":[{"name":"0.js","publicPath":"http://localhost:8080/0.js","path":"/home/artus/Documents/cookAssistant/frontend/dist/0.js"},{"name":"0.880af34e5f0e1c2d5443.hot-update.js","publicPath":"http://localhost:8080/0.880af34e5f0e1c2d5443.hot-update.js","path":"/home/artus/Documents/cookAssistant/frontend/dist/0.880af34e5f0e1c2d5443.hot-update.js"}],"app":[{"name":"app.js","publicPath":"http://localhost:8080/app.js","path":"/home/artus/Documents/cookAssistant/frontend/dist/app.js"}]},"error":"ModuleBuildError","message":"Module build failed (from ./node_modules/babel-loader/lib/index.js):\nSyntaxError: /home/artus/Documents/cookAssistant/frontend/src/components/recipes/IngredientList.vue: Unexpected token (85:70)\n\n 83 | )\n 84 | .then(data => {\n> 85 | var idx = this.edited_ingdts.indexOf(i => i.ingredient.id ==)\n | ^\n 86 | })\n 87 | .catch(err => comp.errors.push(JSON.stringify(err)))\n 88 | },\n at Object.raise (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:3851:17)\n at Object.unexpected (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5167:16)\n at Object.parseExprAtom (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:6328:20)\n at Object.parseExprAtom (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:3570:20)\n at Object.parseExprSubscripts (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5914:23)\n at Object.parseMaybeUnary (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5894:21)\n at Object.parseExprOpBaseRightExpr (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5854:34)\n at Object.parseExprOpRightExpr (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5847:21)\n at Object.parseExprOp (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5826:27)\n at Object.parseExprOps (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5791:17)\n at Object.parseMaybeConditional (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5754:23)\n at Object.parseMaybeAssign (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:5701:21)\n at Object.parseFunctionBody (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:6891:24)\n at Object.parseArrowExpression (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:6851:10)\n at Object.parseExprAtom (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:6213:18)\n at Object.parseExprAtom (/home/artus/Documents/cookAssistant/frontend/node_modules/@babel/parser/lib/index.js:3570:20)"}
|
||||||
0
planning/__init__.py
Normal file
0
planning/__init__.py
Normal file
3
planning/admin.py
Normal file
3
planning/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
planning/apps.py
Normal file
5
planning/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningConfig(AppConfig):
|
||||||
|
name = 'planning'
|
||||||
0
planning/migrations/__init__.py
Normal file
0
planning/migrations/__init__.py
Normal file
12
planning/models.py
Normal file
12
planning/models.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from django.db import models
|
||||||
|
from recipe_book.models import Recipe
|
||||||
|
# Create your models here.
|
||||||
|
|
||||||
|
class WeekPlanning(models.Model):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DayMeals(models.Model):
|
||||||
|
midi = models.ForeignKey(
|
||||||
|
Recipe,
|
||||||
|
on_delete=models.CASCADE )
|
||||||
3
planning/tests.py
Normal file
3
planning/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
planning/views.py
Normal file
3
planning/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
0
recipe_book/__init__.py
Normal file
0
recipe_book/__init__.py
Normal file
13
recipe_book/admin.py
Normal file
13
recipe_book/admin.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Recipe,IngredientWithAmount, Ingredient
|
||||||
|
# Register your models here.
|
||||||
|
|
||||||
|
class IngredientWithAmountInline(admin.TabularInline):
|
||||||
|
model=IngredientWithAmount
|
||||||
|
|
||||||
|
class RecipeAdmin(admin.ModelAdmin):
|
||||||
|
inlines = [ IngredientWithAmountInline, ]
|
||||||
|
admin.site.register(Recipe, RecipeAdmin)
|
||||||
|
|
||||||
|
admin.site.register(Ingredient)
|
||||||
5
recipe_book/apps.py
Normal file
5
recipe_book/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeBookConfig(AppConfig):
|
||||||
|
name = 'recipe_book'
|
||||||
45
recipe_book/migrations/0001_initial.py
Normal file
45
recipe_book/migrations/0001_initial.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 2.2 on 2019-04-11 13:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Ingredient',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=256)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='IngredientWithAmount',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=6)),
|
||||||
|
('unit', models.CharField(choices=[('gr', 'Grammes'), ('u', 'Units'), ('l', 'Litres')], max_length=2)),
|
||||||
|
('ingredients', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipe_book.Ingredient')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Recipe',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=512)),
|
||||||
|
('category', models.CharField(choices=[('0', 'Petit-déjeuner'), ('1', 'Entrée'), ('2', 'Plat'), ('3', 'Dessert')], max_length=2)),
|
||||||
|
('ingredients', models.ManyToManyField(through='recipe_book.IngredientWithAmount', to='recipe_book.Ingredient')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ingredientwithamount',
|
||||||
|
name='recipe',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='recipe_book.Recipe'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
recipe_book/migrations/0002_auto_20190411_1556.py
Normal file
18
recipe_book/migrations/0002_auto_20190411_1556.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 2.2 on 2019-04-11 13:56
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recipe_book', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='ingredientwithamount',
|
||||||
|
old_name='ingredients',
|
||||||
|
new_name='ingredient',
|
||||||
|
),
|
||||||
|
]
|
||||||
0
recipe_book/migrations/__init__.py
Normal file
0
recipe_book/migrations/__init__.py
Normal file
103
recipe_book/models.py
Normal file
103
recipe_book/models.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Recipe's creation workflow :
|
||||||
|
|
||||||
|
1. Create recipe with required info, optional preparation
|
||||||
|
2. Create recipe's list of ingredients
|
||||||
|
a. Create new ingredients instances
|
||||||
|
b. Create IngredientWithAmount instances
|
||||||
|
3. Add preparation steps if necessary
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Recipe(models.Model):
|
||||||
|
name = models.CharField(max_length=512)
|
||||||
|
category = models.CharField(
|
||||||
|
max_length=2,
|
||||||
|
choices=(
|
||||||
|
('0', 'Petit-déjeuner'),
|
||||||
|
('1', 'Entrée'),
|
||||||
|
('2', 'Plat'),
|
||||||
|
('3', 'Dessert'),
|
||||||
|
))
|
||||||
|
ingredients = models.ManyToManyField(
|
||||||
|
'Ingredient',
|
||||||
|
through='IngredientWithAmount'
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "{}".format(self.name)
|
||||||
|
|
||||||
|
|
||||||
|
class Ingredient(models.Model):
|
||||||
|
name = models.CharField(max_length=256)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "{}".format(self.name)
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientWithAmount(models.Model):
|
||||||
|
""" A recipe-ingredient relation associated with amounts
|
||||||
|
"""
|
||||||
|
|
||||||
|
recipe = models.ForeignKey(
|
||||||
|
'Recipe',
|
||||||
|
models.CASCADE)
|
||||||
|
ingredient = models.ForeignKey(
|
||||||
|
'Ingredient',
|
||||||
|
models.CASCADE )
|
||||||
|
amount = models.DecimalField(
|
||||||
|
max_digits=6,
|
||||||
|
decimal_places=2,
|
||||||
|
)
|
||||||
|
unit = models.CharField(
|
||||||
|
max_length=2,
|
||||||
|
choices=(
|
||||||
|
('gr', 'Grammes'),
|
||||||
|
('u', 'Units'),
|
||||||
|
('l', 'Litres'),
|
||||||
|
))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "<{}>".format(self.display())
|
||||||
|
|
||||||
|
def display(self):
|
||||||
|
""" Print info in a human friendly way.
|
||||||
|
|
||||||
|
* Correct grammar
|
||||||
|
* Apropriate units according to amount
|
||||||
|
"""
|
||||||
|
# Note: amount has a .is_integer() method :)
|
||||||
|
amount = self.amount
|
||||||
|
name = self.ingredient.name.lower()
|
||||||
|
particule = ""
|
||||||
|
unit = ""
|
||||||
|
if self.unit == 'u':
|
||||||
|
integer = int(amount)
|
||||||
|
fraction = amount - integer
|
||||||
|
# Print subfractions of one as ratio
|
||||||
|
if fraction: #TODO: if is not integer...
|
||||||
|
fraction = "{}/{}".format(
|
||||||
|
*amount.as_integer_ratio()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fraction = ""
|
||||||
|
if integer:
|
||||||
|
# Plural
|
||||||
|
if integer >= 2:
|
||||||
|
name += "s"
|
||||||
|
integer = str(integer)
|
||||||
|
else:
|
||||||
|
integer = ""
|
||||||
|
amount = "{}{}".format(integer, fraction)
|
||||||
|
else:
|
||||||
|
particule = "de "
|
||||||
|
unit = self.unit
|
||||||
|
|
||||||
|
return "{}{} {}{}".format(
|
||||||
|
amount, unit, particule, name
|
||||||
|
)
|
||||||
56
recipe_book/serializers.py
Normal file
56
recipe_book/serializers.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from rest_framework import serializers as ser
|
||||||
|
from .models import Recipe, Ingredient, IngredientWithAmount
|
||||||
|
|
||||||
|
class RecipeSerializer(ser.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Recipe
|
||||||
|
fields = ('id', 'name', 'category', 'ingredients')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientSerializer(ser.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Ingredient
|
||||||
|
fields = ('id', 'name')
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientWithAmountSerializer(ser.ModelSerializer):
|
||||||
|
ingredient = IngredientSerializer()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = IngredientWithAmount
|
||||||
|
fields = ('recipe', 'ingredient', 'amount', 'unit', 'display')
|
||||||
|
extra_kwargs = {
|
||||||
|
'recipe': { 'write_only': True },
|
||||||
|
}
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
# TODO: better management of ingredient finding
|
||||||
|
ingredient, created = Ingredient.objects.get_or_create(
|
||||||
|
**validated_data.pop('ingredient')
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
ingredient.save()
|
||||||
|
validated_data['ingredient'] = ingredient
|
||||||
|
return IngredientWithAmount.objects.create(
|
||||||
|
**validated_data
|
||||||
|
)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
for attr in ('recipe', 'amount', 'unit'):
|
||||||
|
setattr(
|
||||||
|
instance,
|
||||||
|
attr,
|
||||||
|
validated_data.get(attr,
|
||||||
|
getattr(instance, attr))
|
||||||
|
)
|
||||||
|
if 'ingredient' in validated_data:
|
||||||
|
ingdt, created = Ingredient.objects.get_or_create(
|
||||||
|
**validated_data['ingredient']
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
ingdt.save()
|
||||||
|
instance.ingredient = ingdt
|
||||||
|
instance.save()
|
||||||
|
|
||||||
|
return instance
|
||||||
3
recipe_book/tests.py
Normal file
3
recipe_book/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
33
recipe_book/views.py
Normal file
33
recipe_book/views.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from recipe_book.serializers import (
|
||||||
|
RecipeSerializer,
|
||||||
|
IngredientSerializer,
|
||||||
|
IngredientWithAmountSerializer,
|
||||||
|
)
|
||||||
|
from recipe_book.models import (
|
||||||
|
Recipe,
|
||||||
|
Ingredient,
|
||||||
|
IngredientWithAmount,
|
||||||
|
)
|
||||||
|
|
||||||
|
class RecipesViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Recipe.objects.all()
|
||||||
|
serializer_class = RecipeSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Ingredient.objects.all()
|
||||||
|
serializer_class = IngredientSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class IngredientWAmountViewSet(viewsets.ModelViewSet):
|
||||||
|
serializer_class = IngredientWithAmountSerializer
|
||||||
|
lookup_field = 'ingredient'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return IngredientWithAmount.objects.filter(
|
||||||
|
recipe=self.kwargs['recipe_pk']
|
||||||
|
)
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
django==2.2
|
django==2.2
|
||||||
django-webpack-loader==0.6
|
django-webpack-loader==0.6
|
||||||
|
djangorestframework
|
||||||
|
drf-nested-routers
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="overflow-hidden">
|
||||||
<noscript>
|
<noscript>
|
||||||
<strong>We're sorry but frontend doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
<strong>We're sorry but frontend doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|||||||
Reference in New Issue
Block a user