Commit 3a8be3b8 authored by Floréal Cabanettes's avatar Floréal Cabanettes

Add profile page + add client side page navigation + improved design

+ improve navigation bar behavior + add rollback like behavior on register
+ minor improvements + reformat code
parent 1d5d83c8
......@@ -2,9 +2,9 @@
import json
import hashlib
from flask import Flask, render_template, request, flash, redirect, url_for, session
from flask import Flask, render_template, request, flash, redirect, url_for, session, jsonify
from flask_mongoengine import MongoEngine
from flask_bcrypt import check_password_hash, generate_password_hash
from flask_bcrypt import check_password_hash
from settings import DB_NAME, SITE_NAME, TIMEZONE, LOCALE
from werkzeug.exceptions import NotFound
from flask_babel import Babel
......@@ -16,7 +16,7 @@ from view.register import page as register
from view.panel import page as panel
from exceptions import InvalidPassword, NotActiveUser
from functions import is_authenticated
from functions import is_authenticated, get_title
from settings import SECRET_KEY
......@@ -43,9 +43,11 @@ def inject_default_data():
data = {
"locale": LOCALE,
"locales": [LOCALE],
"email": None,
"dark": True,
"show_search": True
"show_search": True,
"request": request,
"main_title": SITE_NAME,
"title": get_title(request.path)
}
if is_authenticated():
data["email_hash"] = hashlib.md5(session["email"].encode()).hexdigest()
......@@ -68,11 +70,13 @@ def home():
# categories=[{"name": "Plat principal"}, {"name": "Entrée"}],
# author={"name": "Floréal", "id": "1"})
# recipe.save()
return render_template("web/basisnav.html", title=_("Panel") + " | " + SITE_NAME)
return render_template("web/basisnav.html")
@app.route('/login', methods=["GET", "POST"])
def login():
if is_authenticated():
return redirect(url_for("home"))
if request.method == 'GET':
email = ""
after = "/"
......@@ -80,8 +84,7 @@ def login():
email = request.args.get("email")
if "after" in request.args and request.args.get("after") is not None:
after = request.args.get("after")
return render_template("web/login.html", email=email, after=after, title=_("Login") + " | " + SITE_NAME,
show_search=False)
return render_template("web/login.html", email=email, after=after, show_search=False)
email = request.form['email']
password = request.form['password']
after = None
......@@ -122,6 +125,11 @@ def logout():
return redirect(url_for("home"))
@app.route("/data/page_title")
def page_title():
return jsonify(title=get_title(request.args.get("endpoint")))
@app.errorhandler(NotFound)
def error_handle_not_found(e):
return render_template("404.html", title=SITE_NAME)
......
from flask import session
import random
import string
from flask import session
from flask_babel import gettext as _
from settings import SITE_NAME
def random_string(string_length=50):
"""Generate a random string of letters and digits """
......@@ -11,3 +15,19 @@ def random_string(string_length=50):
def is_authenticated():
return "is_authenticated" in session and session["is_authenticated"] is True
def get_title(endpoint, title=None):
if title is not None:
title = title + " | " + SITE_NAME
elif endpoint == "/panel/profile":
title = _("User profile") + " | " + SITE_NAME
elif endpoint.startswith("/panel"):
title = _("Panel") + " | " + SITE_NAME
elif endpoint == "/login":
title = _("Login") + " | " + SITE_NAME
elif endpoint == "/register":
title = _("Register") + " | " + SITE_NAME
else:
title = SITE_NAME
return title
[v-cloak] > * { display:none; }
[v-cloak] > * {
display: none;
}
[v-cloak]::before {
content: " ";
display: block;
position: absolute;
width: 80px;
height: 80px;
background-image: url(/static/img/loader.svg);
background-size: cover;
left: 50%;
top: 50%;
content: " ";
display: block;
position: absolute;
width: 80px;
height: 80px;
background-image: url(/static/img/loader.svg);
background-size: cover;
left: 50%;
top: 50%;
}
html {
background-color: black;
color: white;
scroll-behavior: auto;
overflow-y: auto;
background-color: #303030;
color: white;
scroll-behavior: auto;
overflow-y: auto;
}
.main-content {
margin-left: 260px;
margin-top: 10px;
}
/*.main-content {*/
/* margin-left: 260px;*/
/* margin-top: 10px;*/
/*}*/
@media screen and (max-width: 1300px) {
.main-content {
margin-left: 85px;
}
}
/*@media screen and (max-width: 1300px) {*/
/* .main-content {*/
/* margin-left: 85px;*/
/* }*/
/*}*/
#loading {
margin-top: 10px;
position: fixed;
bottom: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
margin-top: 10px;
position: fixed;
bottom: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
#loading .loader {
width: 240px;
height: 240px;
background-image: url(/static/img/loader.svg);
background-size: cover;
position: fixed;
bottom: 0;
right: 0;
width: 240px;
height: 240px;
background-image: url(/static/img/loader.svg);
background-size: cover;
position: fixed;
bottom: 0;
right: 0;
}
header {
z-index: 1001 !important;
z-index: 1001 !important;
}
.v-menu__content.menuable__content__active {
top: 8px !important;
top: 8px !important;
}
.recipen-navbar {
width: 0;
z-index: 0;
display: table-cell;
vertical-align: top;
height: 100%;
padding-top: 10px;
}
.recipen-navbar.hide {
display: none;
}
.recipen-main-content {
display: table-cell;
vertical-align: top;
padding-left: 10px;
padding-top: 10px;
}
.menu-selected {
background: #3d9843;
}
.v-toolbar__title a {
color: white !important;
text-decoration: none;
}
.recipen-main-container {
padding-top: 37px;
display: table !important;
padding-left: 0;
margin-left: 0;
margin-right: 0;
max-width: none;
padding-bottom: 0;
}
.recipen-main-container.web {
padding-top: 117px;
}
\ No newline at end of file
fr = {
"Admin": "Admin",
"At least 12 characters": "Au moins 12 caractères",
"Clear": "Effacer",
"Connexion": "",
"Connexion": "Connexion",
"Current password": "Mot de passe actuel",
"E-mail": "Mail",
"E-mail (confirmation)": "Mail (confirmation)",
"E-mail must be less than 50 characters": "Le mail doit faire moins de 50 caractères",
"E-mail must be valid": "Le mail n'est pas valide",
"E-mails does not match": "Les mails ne correspondent pas",
"Editor": "Éditeur",
"Field required": "Champs requis",
"Forgotten password": "Mot de passe oublié",
"Login|||title": "Se connecter",
"Min 12 characters": "Min 12 caractères",
"Moderator": "Modérateur",
"Name": "Nom",
"Name must be less than 50 characters": "Le nom doit faire moins de 50 caractères",
"New password": "Nouveau mot de passe",
"New password (confirmation)": "Nouveau mot de passe (confirmation)",
"No account? Please": "Pas encore de compte ? Veuillez",
"Password": "Mot de passe",
"Password (confirmation)": "Mot de passe (confirmation)",
......@@ -20,5 +26,8 @@ fr = {
"Register": "Créer un compte",
"Request fails. Please contact the support.": "La requête a échouée. Veuillez contacter le support.",
"Send": "Envoyer",
"Send changes": "Envoyer les modifications",
"Simple user": "Simple utilisateur",
"User profile": "Profil utilisateur",
"register|||action": "vous enregistrer"
};
\ No newline at end of file
session = {};
session.get = function(url, then, error_message=null, except=null, final=null) {
session.get = function(url, data, then, error_message=null, except=null, final=null) {
eventBus.$emit("startLoading");
ax = axios.get(url);
ax = axios.get(url, {params: data});
ax.then(function(response) {
then(response);
});
......
......@@ -23,7 +23,7 @@ Vue.directive('blur', {
});
})(jQuery);
init = function(locale, email_hash=null, name=null, dark=true, show_search=true, role="basic") {
init = function(locale, email_hash=null, name=null, dark=true, show_search=true, role="basic", page=null) {
LANG = locale;
eventBus = new Vue();
dark = dark !== null && dark !== undefined && dark !== "false" && dark !== "False";
......@@ -45,6 +45,8 @@ init = function(locale, email_hash=null, name=null, dark=true, show_search=true,
logged: email_hash && email_hash !== "None" && email_hash !== "null" && email_hash !== "none",
message_alert: null,
name: name,
navForceOpen: null,
navOpen: true,
show_search: show_search,
search: false,
searchFieldColor: dark ? "white": "black",
......@@ -60,6 +62,7 @@ init = function(locale, email_hash=null, name=null, dark=true, show_search=true,
y: 0
},
menu: false,
page: page
},
computed: {
user_role_name() {
......@@ -129,12 +132,39 @@ init = function(locale, email_hash=null, name=null, dark=true, show_search=true,
launchSearch() {
console.log("Searching for " + this.searchText + "...");
},
showHideMenu() {
if (this.navForceOpen === null) {
this.navForceOpen = false;
this.navOpen = false;
}
else {
this.navForceOpen = !this.navForceOpen;
this.navOpen = this.navForceOpen;
}
},
onResize () {
this.windowSize = { x: window.innerWidth, y: window.innerHeight }
},
isPage(page) {
return page === this.page || page === this.page + "/" || page + "/" === this.page
},
setPage(page, keep_history=false) {
console.log("set page");
this.page = page;
session.get("/data/page_title", {endpoint: page}, response => {
let title = response.data.title;
document.title = title;
if (keep_history) {
window.history.replaceState("", title, page);
} else {
window.history.pushState("", title, page);
}
});
}
},
mounted() {
let vm = this;
let $this = this;
window.addEventListener('keydown', function(e) {
// If down arrow was pressed...
if (e.ctrlKey && e.code === "KeyF") {
......@@ -147,45 +177,51 @@ init = function(locale, email_hash=null, name=null, dark=true, show_search=true,
}
}
});
$(window).on('popstate', function() {
$this.setPage(window.location.pathname, true);
});
this.onResize();
},
created() {
let $this = this;
// Alert events:
eventBus.$on('alertInfo', function (message) {
eventBus.$on('alertInfo', (message) => {
this.alertInfo(message);
}.bind(this));
eventBus.$on('alertError', function (message) {
});
eventBus.$on('alertError', (message) => {
this.alertError(message);
}.bind(this));
eventBus.$on('alertWarn', function (message) {
});
eventBus.$on('alertWarn', (message) => {
this.alertWarn(message);
}.bind(this));
eventBus.$on('alertSuccess', function (message) {
});
eventBus.$on('alertSuccess', (message) => {
this.alertSuccess(message);
}.bind(this));
});
// Notification events:
eventBus.$on('notifInfo', function (message) {
eventBus.$on('notifInfo', (message) => {
this.notifInfo(message);
}.bind(this));
eventBus.$on('notifError', function (message) {
console.log("NOTIF ERROR", message);
});
eventBus.$on('notifError', (message) => {
this.notifError(message);
}.bind(this));
eventBus.$on('notifWarn', function (message) {
});
eventBus.$on('notifWarn', (message) => {
this.notifWarn(message);
}.bind(this));
eventBus.$on('notifSuccess', function (message) {
});
eventBus.$on('notifSuccess', (message) => {
this.notifSuccess(message);
}.bind(this));
eventBus.$on('startLoading', function () {
console.log("start");
$this.loadings += 1;
});
eventBus.$on('stopLoading', function () {
console.log("stop");
$this.loadings -= 1;
eventBus.$on('startLoading', () => {
console.debug("Start loading");
this.loadings += 1;
});
eventBus.$on('stopLoading', () => {
console.debug("Stop loading");
this.loadings -= 1;
});
eventBus.$on("userChange", (user) => {
console.log("User change");
this.name = user.name;
})
},
});
};
\ No newline at end of file
Vue.component("main-profile", {
extends: Basic,
template: `
<v-container fill-height>
<v-layout row wrap>
<v-flex class="text-xs-center">
<v-card column class="mx-auto">
<v-card-title>
{{ tr("User profile") }}
</v-card-title>
<v-card-text>
<v-form
ref="form"
v-model="valid"
>
<v-text-field
v-model="user.name"
:counter="50"
:rules="[rules.required, rules.nameLength]"
:label="tr('Name')"
required
></v-text-field>
<v-text-field
v-model="user.email"
:counter="100"
:rules="[rules.required, rules.mailLength, rules.mailValid]"
:label="tr('E-mail')"
disabled
></v-text-field>
<v-text-field
v-model="oldPassword"
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'"
:rules="[rules.requiredIfPwd, rules.passwordLength]"
:type="show1 ? 'text' : 'password'"
name="input-10-1"
:label="tr('Current password')"
:hint="tr('At least 12 characters')"
counter
@click:append="show1 = !show1"
></v-text-field>
<v-text-field
v-model="password"
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'"
:rules="[rules.passwordLength, rules.passwordChange]"
:type="show1 ? 'text' : 'password'"
name="input-10-1"
:label="tr('New password')"
:hint="tr('At least 12 characters')"
counter
@click:append="show1 = !show1"
></v-text-field>
<v-text-field
v-model="passwordConf"
:append-icon="show2 ? 'mdi-eye' : 'mdi-eye-off'"
:rules="[rules.requiredIfPwd, rules.passwordMatch, rules.passwordLength]"
:type="show2 ? 'text' : 'password'"
name="input-10-1"
:label="tr('New password (confirmation)')"
:hint="tr('At least 12 characters')"
counter
@click:append="show2 = !show2"
></v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn v-blur
small
style="margin-left: 10px; margin-right: 10px; margin-bottom: 10px;"
@click="send"
>
{{ tr('Send changes') }}
</v-btn>
</v-card-actions>
</v-card>
</v-flex>
</v-layout>
</v-container>
`,
data() { return {
oldPassword: "",
password: "",
passwordConf: "",
show1: false,
show2: false,
user: {
"email": "",
"name": "",
},
rules: {
required: v => !!v || tr('Field required'),
requiredIfPwd: v => ((!this.password || !!v) || tr('Field required')),
mailValid: v => (/.+@.+\..+/.test(v) || tr("E-mail must be valid")),
nameLength: v => ((v && v.length <= 50) || tr('Name must be less than 50 characters')),
mailLength: v => ((v && v.length <= 100) || tr('E-mail must be less than 50 characters')),
passwordLength: v => (!v || v.length >= 12 || tr('Min 12 characters')),
passwordMatch: v => (v === this.password || tr("Passwords does not match")),
passwordChange: v => ((!v ||v !== this.oldPassword) || tr("New password must be different from the old one"))
},
valid: false,
}},
methods: {
send() {
this.$refs.form.validate();
window.setTimeout(() => { // To ensure validation is done before
if (this.valid) {
session.post("/panel/profile", {
name: this.user.name,
email: this.user.email,
password: this.oldPassword,
new_password: this.password
},
response => {
eventBus.$emit("notifSuccess", response.data.message);
eventBus.$emit("userChange", {
name: this.user.name
})
});
}
}, 0);
}
},
mounted() {
session.get("/panel/data/userinfo", {}, response => {
this.user = response.data;
}, tr("Unable to get user infos. Please try again later"))
}
});
\ No newline at end of file
{% extends "empty.html" %}
{% block script %}
{{ super() }}
<script src="/static/js/vue.js" defer></script>
<script src="/static/js/vuetify-v2.0.18.min.js" defer></script>
<script src="/static/js/jquery-3.4.1.min.js" defer></script>
<script src="/static/js/axios.min.js" defer></script>
<script src="/static/js/translate.js" defer></script>
{% if locales %}
{% for my_locale in locales %}
<script src="/static/js/locale/{{ my_locale }}.js" defer></script>
{% endfor %}
{% endif %}
<script src="/static/js/session.js" defer></script>
<script src="/static/vue/main.js" defer></script>
<script src="/static/vue/basic.js" defer></script>
{{ super() }}
<script src="/static/js/vue.js" defer></script>
<script src="/static/js/vuetify-v2.0.18.min.js" defer></script>
<script src="/static/js/jquery-3.4.1.min.js" defer></script>
<script src="/static/js/axios.min.js" defer></script>
<script src="/static/js/translate.js" defer></script>
{% if locales %}
{% for my_locale in locales %}
<script src="/static/js/locale/{{ my_locale }}.js" defer></script>
{% endfor %}
{% endif %}
<script src="/static/js/session.js" defer></script>
<script src="/static/js/ajax.js" defer></script>
<script src="/static/vue/main.js" defer></script>
<script src="/static/vue/basic.js" defer></script>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="/static/css/materialdesignicons.min.css">
<link rel="stylesheet" type="text/css" href="/static/css/vuetify-v2.0.18.min.css">
<link rel="stylesheet" type="text/css" href="/static/css/recipen.css">
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="/static/css/materialdesignicons.min.css">
<link rel="stylesheet" type="text/css" href="/static/css/vuetify-v2.0.18.min.css">
<link rel="stylesheet" type="text/css" href="/static/css/recipen.css">
{% endblock %}
{% block body_attr %}
onload="init('{{ locale }}', '{{ email_hash }}', '{{ name }}', '{{ dark }}', '{{ show_search }}', '{{ role }}');"
onload="init('{{ locale }}', '{{ email_hash }}', '{{ name }}', '{{ dark }}', '{{ show_search }}', '{{ role }}',
'{{ request.path }}');"
{% endblock %}
{% block body %}
<div id="app">
<div v-cloak>
<v-app :dark="dark" v-resize="onResize">
{% block appbody %}
{% endblock %}
<div class="text-center">
<v-snackbar
v-model="snackbar"
:multi-line="snackbar_multiline"
:top="true"
:color="snackbar_color"
style="z-index: 1002"
>
(( snackbar_message ))
<v-btn
color="black"
text
@click="snackbar = false"
>
{{ _("Close") }}
</v-btn>
</v-snackbar>
</div>
</v-app>
<div id="app">
<div v-cloak>
<v-app :dark="dark" v-resize="onResize">
{% block appbody %}
{% endblock %}
<div class="text-center">
<v-snackbar
v-model="snackbar"
:multi-line="snackbar_multiline"
:top="true"
:color="snackbar_color"
style="z-index: 1002"
>
(( snackbar_message ))
<v-btn
color="black"
text
@click="snackbar = false"
>
{{ _("Close") }}
</v-btn>
</v-snackbar>
</div>
</v-app>
</div>
</div>
</div>
{% endblock %}
\ No newline at end of file
{% extends "basis.html" %}
{% block script %}
{{ super() }}
<script src="/static/vue/profile.js" defer></script>
{% endblock %}
{% block appbody %}
<v-app-bar
absolute
color="#CA2C0A"
color="#CE472B"
dense