* Create a minimalistic toast

* Profile editing (WIP)
This commit is contained in:
Greg Burri 2024-12-04 17:39:56 +01:00
parent 327b2d0a5b
commit 1c79cc890d
25 changed files with 1133 additions and 575 deletions

604
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,7 @@ fn main() {
fn run_sass(command: &mut Command) -> Output { fn run_sass(command: &mut Command) -> Output {
command command
.arg("style.scss") .arg("scss/style.scss")
.arg("static/style.css") .arg("static/style.css")
.output() .output()
.expect("Unable to compile SASS file, install SASS, see https://sass-lang.com/") .expect("Unable to compile SASS file, install SASS, see https://sass-lang.com/")

159
backend/scss/style.scss Normal file
View file

@ -0,0 +1,159 @@
@use 'toast.scss';
@font-face {
font-family: Fira Code;
font-weight: 200;
src: url(FiraCode-Light.woff2) format("woff2");
}
@font-face {
font-family: Fira Code;
font-weight: 400;
src: url(FiraCode-Regular.woff2) format("woff2");
}
@font-face {
font-family: Fira Code;
font-weight: 600;
src: url(FiraCode-SemiBold.woff2) format("woff2");
}
@font-face {
font-family: Fira Code;
font-weight: 700;
src: url(FiraCode-Bold.woff2) format("woff2");
}
$primary: #182430;
$background: darken($primary, 5%);
$background-container: lighten($primary, 10%);
* {
margin: 5px;
padding: 0px;
}
html {
font-size: 80%
}
a {
color: lighten($primary, 40%);
text-decoration: none;
&:hover {
color: lighten($primary, 60%);
}
}
body {
display: flex;
flex-direction: column;
font-family: Fira Code, Helvetica Neue, Helvetica, Arial, sans-serif;
text-shadow: 2px 2px 2px rgb(0, 0, 0);
// line-height: 18px;
color: rgb(255, 255, 255);
background-color: $background;
margin: 0px;
.recipe-item {
padding: 4px;
}
.recipe-item-current {
padding: 3px;
border: 1px solid white;
}
.header-container {
align-self: center;
}
.footer-container {
align-self: center;
font-size: 0.5em;
}
.main-container {
display: flex;
flex-direction: row;
// .recipes-list {
// text-align: left;
// }
.content {
flex-grow: 1;
background-color: $background-container;
border: 0.1em solid white;
padding: 0.5em;
h1 {
text-align: center;
}
}
form {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px;
input,
button {
background-color: rgb(52, 40, 85);
border-width: 1px;
border-color: white;
color: white;
}
}
// #user-edit {
// .label-name {
// grid-column: 1;
// grid-row: 1;
// }
// .input-name {
// grid-column: 2;
// grid-row: 1;
// }
// .label-password-1 {
// grid-column: 1;
// grid-row: 2;
// }
// .input-password-1 {
// grid-column: 2;
// grid-row: 2;
// }
// .label-password-2 {
// grid-column: 1;
// grid-row: 3;
// }
// .input-password-2 {
// grid-column: 2;
// grid-row: 3;
// }
// .button-save {
// grid-column: 2;
// grid-row: 4;
// width: fit-content;
// justify-self: flex-end;
// }
// }
// #sign-in {
// }
}
img {
border: 0px;
}
}

44
backend/scss/toast.scss Normal file
View file

@ -0,0 +1,44 @@
#toast {
visibility: hidden;
max-width: 300px;
margin-left: -125px;
background-color: black;
text-align: center;
border-radius: 2px;
padding: 16px; // TODO: 'rem' better?
position: fixed;
z-index: 1;
left: 50%;
bottom: 30px;
box-shadow: -1px 1px 10px rgba(0, 0, 0, 0.3);
}
#toast.show {
visibility: visible;
animation: fadein 0.5s, fadeout 0.5s 3.6s;
animation-iteration-count: 1;
}
@keyframes fadein {
from {
bottom: 0;
opacity: 0;
}
to {
bottom: 30px;
opacity: 1;
}
}
@keyframes fadeout {
from {
bottom: 30px;
opacity: 1;
}
to {
bottom: 0;
opacity: 0;
}
}

View file

@ -0,0 +1,70 @@
use askama::Template;
use crate::model;
#[derive(Template)]
#[template(path = "home.html")]
pub struct HomeTemplate {
pub user: Option<model::User>,
pub recipes: Vec<(i64, String)>,
pub current_recipe_id: Option<i64>,
}
#[derive(Template)]
#[template(path = "view_recipe.html")]
pub struct ViewRecipeTemplate {
pub user: Option<model::User>,
pub recipes: Vec<(i64, String)>,
pub current_recipe_id: Option<i64>,
pub current_recipe: model::Recipe,
}
#[derive(Template)]
#[template(path = "message.html")]
pub struct MessageTemplate<'a> {
pub user: Option<model::User>,
pub message: &'a str,
pub as_code: bool, // Display the message in <pre> markup.
}
#[derive(Template)]
#[template(path = "sign_up_form.html")]
pub struct SignUpFormTemplate {
pub user: Option<model::User>,
pub email: String,
pub message: String,
pub message_email: String,
pub message_password: String,
}
#[derive(Template)]
#[template(path = "sign_in_form.html")]
pub struct SignInFormTemplate {
pub user: Option<model::User>,
pub email: String,
pub message: String,
}
#[derive(Template)]
#[template(path = "ask_reset_password.html")]
pub struct AskResetPasswordTemplate {
pub user: Option<model::User>,
pub email: String,
pub message: String,
pub message_email: String,
}
#[derive(Template)]
#[template(path = "reset_password.html")]
pub struct ResetPasswordTemplate {
pub user: Option<model::User>,
pub reset_token: String,
pub message: String,
pub message_password: String,
}
#[derive(Template)]
#[template(path = "profile.html")]
pub struct ProfileTemplate {
pub user: Option<model::User>,
}

View file

@ -2,9 +2,10 @@ use std::{net::SocketAddr, path::Path};
use axum::{ use axum::{
extract::{ConnectInfo, FromRef, Request, State}, extract::{ConnectInfo, FromRef, Request, State},
http::StatusCode,
middleware::{self, Next}, middleware::{self, Next},
response::{Response, Result}, response::{Response, Result},
routing::{get, post, put}, routing::{get, put},
Router, Router,
}; };
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
@ -21,8 +22,10 @@ mod consts;
mod data; mod data;
mod email; mod email;
mod hash; mod hash;
mod html_templates;
mod model; mod model;
mod ron_extractor; mod ron_extractor;
mod ron_utils;
mod services; mod services;
mod utils; mod utils;
@ -44,6 +47,12 @@ impl FromRef<AppState> for db::Connection {
} }
} }
impl axum::response::IntoResponse for db::DBError {
fn into_response(self) -> Response {
ron_utils::ron_error(StatusCode::INTERNAL_SERVER_ERROR, &self.to_string()).into_response()
}
}
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
const TRACING_LEVEL: tracing::Level = tracing::Level::DEBUG; const TRACING_LEVEL: tracing::Level = tracing::Level::DEBUG;
@ -75,7 +84,12 @@ async fn main() {
db_connection, db_connection,
}; };
let app = Router::new() // TODO: Add fallback fo ron_api_routes.
let ron_api_routes = Router::new()
.route("/user/update", put(services::ron::update_user))
.fallback(services::ron::not_found);
let html_routes = Router::new()
.route("/", get(services::home_page)) .route("/", get(services::home_page))
.route( .route(
"/signup", "/signup",
@ -99,15 +113,19 @@ async fn main() {
.route("/recipe/view/:id", get(services::view_recipe)) .route("/recipe/view/:id", get(services::view_recipe))
// User. // User.
.route("/user/edit", get(services::edit_user)) .route("/user/edit", get(services::edit_user))
// RON API. .route_layer(middleware::from_fn(services::ron_error_to_html));
.route("/user/set_name", put(services::ron::update_user))
let app = Router::new()
.merge(html_routes)
.nest("/ron-api", ron_api_routes)
.fallback(services::not_found)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.route_layer(middleware::from_fn_with_state( // FIXME: Should be 'route_layer' but it doesn't work for 'fallback(..)'.
.layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
user_authentication, user_authentication,
)) ))
.nest_service("/static", ServeDir::new("static")) .nest_service("/static", ServeDir::new("static"))
.fallback(services::not_found)
.with_state(state) .with_state(state)
.into_make_service_with_connect_info::<SocketAddr>(); .into_make_service_with_connect_info::<SocketAddr>();

View file

@ -5,63 +5,9 @@ use axum::{
http::{header, StatusCode}, http::{header, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use ron::{ use serde::de::DeserializeOwned;
de::from_bytes,
ser::{to_string_pretty, PrettyConfig},
};
use serde::{de::DeserializeOwned, Serialize};
const RON_CONTENT_TYPE: &'static str = "application/ron"; use crate::ron_utils;
#[derive(Debug, Serialize, Clone)]
pub struct RonError {
pub error: String,
}
impl axum::response::IntoResponse for RonError {
fn into_response(self) -> Response {
let ron_as_str = to_string_pretty(&self, PrettyConfig::new()).unwrap();
ron_as_str.into_response()
}
}
impl From<RonError> for Response {
fn from(value: RonError) -> Self {
value.into_response()
}
}
pub fn ron_error(status: StatusCode, message: &str) -> impl IntoResponse {
(
status,
[(header::CONTENT_TYPE, RON_CONTENT_TYPE)],
RonError {
error: message.to_string(),
},
)
}
pub fn ron_response<T>(ron: T) -> impl IntoResponse
where
T: Serialize,
{
let ron_as_str = to_string_pretty(&ron, PrettyConfig::new()).unwrap();
([(header::CONTENT_TYPE, RON_CONTENT_TYPE)], ron_as_str)
}
fn parse_body<T>(body: Bytes) -> Result<T, RonError>
where
T: DeserializeOwned,
{
match from_bytes::<T>(&body) {
Ok(ron) => Ok(ron),
Err(error) => {
return Err(RonError {
error: format!("Ron parsing error: {}", error),
});
}
}
}
pub struct ExtractRon<T: DeserializeOwned>(pub T); pub struct ExtractRon<T: DeserializeOwned>(pub T);
@ -71,22 +17,26 @@ where
S: Send + Sync, S: Send + Sync,
T: DeserializeOwned, T: DeserializeOwned,
{ {
type Rejection = Response; // axum::Error::ErrorResponse; type Rejection = Response;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
match req.headers().get(header::CONTENT_TYPE) { match req.headers().get(header::CONTENT_TYPE) {
Some(content_type) => { Some(content_type) => {
if content_type != RON_CONTENT_TYPE { if content_type != ron_utils::RON_CONTENT_TYPE {
return Err(ron_error( return Err(ron_utils::ron_error(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
&format!("Invalid content type, must be {}", RON_CONTENT_TYPE), &format!(
"Invalid content type, must be {:?}",
ron_utils::RON_CONTENT_TYPE
),
) )
.into_response()); .into_response());
} }
} }
None => { None => {
return Err( return Err(
ron_error(StatusCode::BAD_REQUEST, "No content type given").into_response() ron_utils::ron_error(StatusCode::BAD_REQUEST, "No content type given")
.into_response(),
) )
} }
} }
@ -95,7 +45,7 @@ where
.await .await
.map_err(IntoResponse::into_response)?; .map_err(IntoResponse::into_response)?;
let ron = parse_body(body)?; let ron = ron_utils::parse_body(body)?;
Ok(Self(ron)) Ok(Self(ron))
} }

71
backend/src/ron_utils.rs Normal file
View file

@ -0,0 +1,71 @@
use axum::{
async_trait,
body::Bytes,
extract::{FromRequest, Request},
http::{header, HeaderValue, StatusCode},
response::{IntoResponse, Response},
};
use common::ron_api;
use ron::de::from_bytes;
use serde::{de::DeserializeOwned, Serialize};
pub const RON_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/ron");
#[derive(Debug, Serialize, Clone)]
pub struct RonError {
pub error: String,
}
impl axum::response::IntoResponse for RonError {
fn into_response(self) -> Response {
let ron_as_str = ron_api::to_string(&self);
(
StatusCode::BAD_REQUEST,
[(header::CONTENT_TYPE, RON_CONTENT_TYPE)],
ron_as_str,
)
.into_response()
}
}
impl From<RonError> for Response {
fn from(value: RonError) -> Self {
value.into_response()
}
}
pub fn ron_error(status: StatusCode, message: &str) -> impl IntoResponse {
(
status,
[(header::CONTENT_TYPE, RON_CONTENT_TYPE)],
RonError {
error: message.to_string(),
},
)
}
pub fn ron_response<T>(status: StatusCode, ron: T) -> impl IntoResponse
where
T: Serialize,
{
let ron_as_str = ron_api::to_string(&ron);
(
status,
[(header::CONTENT_TYPE, RON_CONTENT_TYPE)],
ron_as_str,
)
}
pub fn parse_body<T>(body: Bytes) -> Result<T, RonError>
where
T: DeserializeOwned,
{
match from_bytes::<T>(&body) {
Ok(ron) => Ok(ron),
Err(error) => {
return Err(RonError {
error: format!("Ron parsing error: {}", error),
});
}
}
}

View file

@ -1,11 +1,11 @@
use std::{collections::HashMap, net::SocketAddr}; use std::{collections::HashMap, net::SocketAddr};
use askama::Template;
use axum::{ use axum::{
body::Body, body::{self, Body},
debug_handler, debug_handler,
extract::{ConnectInfo, Extension, Host, Path, Query, Request, State}, extract::{ConnectInfo, Extension, Host, Path, Query, Request, State},
http::{HeaderMap, StatusCode}, http::{header, HeaderMap},
middleware::Next,
response::{IntoResponse, Redirect, Response, Result}, response::{IntoResponse, Redirect, Response, Result},
Form, Form,
}; };
@ -14,30 +14,36 @@ use chrono::Duration;
use serde::Deserialize; use serde::Deserialize;
use tracing::{event, Level}; use tracing::{event, Level};
use crate::{config::Config, consts, data::db, email, model, utils, AppState}; use crate::{
config::Config, consts, data::db, email, html_templates::*, model, ron_utils, utils, AppState,
};
pub mod ron; pub mod ron;
impl axum::response::IntoResponse for db::DBError { // Will embed RON error in HTML page.
fn into_response(self) -> Response { pub async fn ron_error_to_html(req: Request, next: Next) -> Result<Response> {
let body = MessageTemplate { let response = next.run(req).await;
user: None,
message: &self.to_string(), if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) {
}; if content_type == ron_utils::RON_CONTENT_TYPE {
(StatusCode::INTERNAL_SERVER_ERROR, body).into_response() let message = match body::to_bytes(response.into_body(), usize::MAX).await {
Ok(bytes) => String::from_utf8(bytes.to_vec()).unwrap_or_default(),
Err(error) => error.to_string(),
};
return Ok(MessageTemplate {
user: None,
message: &message,
as_code: true,
}
.into_response());
}
} }
Ok(response)
} }
///// HOME ///// ///// HOME /////
#[derive(Template)]
#[template(path = "home.html")]
struct HomeTemplate {
user: Option<model::User>,
recipes: Vec<(i64, String)>,
current_recipe_id: Option<i64>,
}
#[debug_handler] #[debug_handler]
pub async fn home_page( pub async fn home_page(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
@ -54,15 +60,6 @@ pub async fn home_page(
///// VIEW RECIPE ///// ///// VIEW RECIPE /////
#[derive(Template)]
#[template(path = "view_recipe.html")]
struct ViewRecipeTemplate {
user: Option<model::User>,
recipes: Vec<(i64, String)>,
current_recipe_id: Option<i64>,
current_recipe: model::Recipe,
}
#[debug_handler] #[debug_handler]
pub async fn view_recipe( pub async fn view_recipe(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
@ -78,121 +75,36 @@ pub async fn view_recipe(
current_recipe: recipe, current_recipe: recipe,
} }
.into_response()), .into_response()),
None => Ok(MessageTemplate { None => Ok(MessageTemplate::new_with_user(
&format!("Cannot find the recipe {}", recipe_id),
user, user,
message: &format!("Cannot find the recipe {}", recipe_id), )
}
.into_response()), .into_response()),
} }
} }
///// EDIT/NEW RECIPE /////
// #[derive(Template)]
// #[template(path = "edit_recipe.html")]
// struct EditRecipeTemplate {
// user: Option<model::User>,
// recipes: Vec<(i64, String)>,
// current_recipe_id: Option<i64>,
// current_recipe: model::Recipe,
// }
// #[get("/recipe/edit/{id}")]
// pub async fn edit_recipe(
// req: HttpRequest,
// path: web::Path<(i64,)>,
// connection: web::Data<db::Connection>,
// ) -> Result<HttpResponse> {
// let (id,) = path.into_inner();
// let user = match get_current_user(&req, connection.clone()).await {
// Some(u) => u,
// None => {
// return Ok(MessageTemplate {
// user: None,
// message: "Cannot edit a recipe without being logged in",
// }
// .to_response())
// }
// };
// let recipe = connection.get_recipe_async(id).await?;
// if recipe.user_id != user.id {
// return Ok(MessageTemplate {
// message: "Cannot edit a recipe you don't own",
// user: Some(user),
// }
// .to_response());
// }
// let recipes = connection.get_all_recipe_titles_async().await?;
// Ok(EditRecipeTemplate {
// user: Some(user),
// current_recipe_id: Some(recipe.id),
// recipes,
// current_recipe: recipe,
// }
// .to_response())
// }
// #[get("/recipe/new")]
// pub async fn new_recipe(
// req: HttpRequest,
// connection: web::Data<db::Connection>,
// ) -> Result<HttpResponse> {
// let user = match get_current_user(&req, connection.clone()).await {
// Some(u) => u,
// None => {
// return Ok(MessageTemplate {
// message: "Cannot create a recipe without being logged in",
// user: None,
// }
// .to_response())
// }
// };
// let recipe_id = connection.create_recipe_async(user.id).await?;
// let recipes = connection.get_all_recipe_titles_async().await?;
// let user_id = user.id;
// Ok(EditRecipeTemplate {
// user: Some(user),
// current_recipe_id: Some(recipe_id),
// recipes,
// current_recipe: model::Recipe::empty(recipe_id, user_id),
// }
// .to_response())
// }
///// MESSAGE ///// ///// MESSAGE /////
#[derive(Template)] impl<'a> MessageTemplate<'a> {
#[template(path = "message_without_user.html")] pub fn new(message: &'a str) -> MessageTemplate<'a> {
struct MessageWithoutUser<'a> { MessageTemplate {
message: &'a str, user: None,
} message,
as_code: false,
}
}
#[derive(Template)] pub fn new_with_user(message: &'a str, user: Option<model::User>) -> MessageTemplate<'a> {
#[template(path = "message.html")] MessageTemplate {
struct MessageTemplate<'a> { user,
user: Option<model::User>, message,
message: &'a str, as_code: false,
}
}
} }
//// SIGN UP ///// //// SIGN UP /////
#[derive(Template)]
#[template(path = "sign_up_form.html")]
struct SignUpFormTemplate {
user: Option<model::User>,
email: String,
message: String,
message_email: String,
message_password: String,
}
#[debug_handler] #[debug_handler]
pub async fn sign_up_get( pub async fn sign_up_get(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
@ -300,11 +212,10 @@ pub async fn sign_up_post(
) )
.await .await
{ {
Ok(()) => Ok(MessageTemplate { Ok(()) => Ok(
user, MessageTemplate::new_with_user(
message: "An email has been sent, follow the link to validate your account.", "An email has been sent, follow the link to validate your account.",
} user).into_response()),
.into_response()),
Err(_) => { Err(_) => {
// error!("Email validation error: {}", error); // TODO: log // error!("Email validation error: {}", error); // TODO: log
error_response(SignUpError::UnableSendEmail, &form_data, user) error_response(SignUpError::UnableSendEmail, &form_data, user)
@ -330,10 +241,7 @@ pub async fn sign_up_validation(
if user.is_some() { if user.is_some() {
return Ok(( return Ok((
jar, jar,
MessageTemplate { MessageTemplate::new_with_user("User already exists", user),
user,
message: "User already exists",
},
)); ));
} }
let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr); let (client_ip, client_user_agent) = utils::get_ip_and_user_agent(&headers, addr);
@ -355,48 +263,34 @@ pub async fn sign_up_validation(
let user = connection.load_user(user_id).await?; let user = connection.load_user(user_id).await?;
Ok(( Ok((
jar, jar,
MessageTemplate { MessageTemplate::new_with_user(
"Email validation successful, your account has been created",
user, user,
message: "Email validation successful, your account has been created", ),
},
)) ))
} }
db::ValidationResult::ValidationExpired => Ok(( db::ValidationResult::ValidationExpired => Ok((
jar, jar,
MessageTemplate { MessageTemplate::new_with_user(
"The validation has expired. Try to sign up again",
user, user,
message: "The validation has expired. Try to sign up again", ),
},
)), )),
db::ValidationResult::UnknownUser => Ok(( db::ValidationResult::UnknownUser => Ok((
jar, jar,
MessageTemplate { MessageTemplate::new_with_user("Validation error. Try to sign up again", user),
user,
message: "Validation error. Try to sign up again",
},
)), )),
} }
} }
None => Ok(( None => Ok((
jar, jar,
MessageTemplate { MessageTemplate::new_with_user("Validation error", user),
user,
message: "Validation error",
},
)), )),
} }
} }
///// SIGN IN ///// ///// SIGN IN /////
#[derive(Template)]
#[template(path = "sign_in_form.html")]
struct SignInFormTemplate {
user: Option<model::User>,
email: String,
message: String,
}
#[debug_handler] #[debug_handler]
pub async fn sign_in_get( pub async fn sign_in_get(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
@ -477,24 +371,15 @@ pub async fn sign_out(
///// RESET PASSWORD ///// ///// RESET PASSWORD /////
#[derive(Template)]
#[template(path = "ask_reset_password.html")]
struct AskResetPasswordTemplate {
user: Option<model::User>,
email: String,
message: String,
message_email: String,
}
#[debug_handler] #[debug_handler]
pub async fn ask_reset_password_get( pub async fn ask_reset_password_get(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
) -> Result<Response> { ) -> Result<Response> {
if user.is_some() { if user.is_some() {
Ok(MessageTemplate { Ok(MessageTemplate::new_with_user(
"Can't ask to reset password when already logged in",
user, user,
message: "Can't ask to reset password when already logged in", )
}
.into_response()) .into_response())
} else { } else {
Ok(AskResetPasswordTemplate { Ok(AskResetPasswordTemplate {
@ -591,10 +476,10 @@ pub async fn ask_reset_password_post(
) )
.await .await
{ {
Ok(()) => Ok(MessageTemplate { Ok(()) => Ok(MessageTemplate::new_with_user(
"An email has been sent, follow the link to reset your password.",
user, user,
message: "An email has been sent, follow the link to reset your password.", )
}
.into_response()), .into_response()),
Err(_) => { Err(_) => {
// error!("Email validation error: {}", error); // TODO: log // error!("Email validation error: {}", error); // TODO: log
@ -613,15 +498,6 @@ pub async fn ask_reset_password_post(
} }
} }
#[derive(Template)]
#[template(path = "reset_password.html")]
struct ResetPasswordTemplate {
user: Option<model::User>,
reset_token: String,
message: String,
message_password: String,
}
#[debug_handler] #[debug_handler]
pub async fn reset_password_get( pub async fn reset_password_get(
Extension(user): Extension<Option<model::User>>, Extension(user): Extension<Option<model::User>>,
@ -636,11 +512,7 @@ pub async fn reset_password_get(
} }
.into_response()) .into_response())
} else { } else {
Ok(MessageTemplate { Ok(MessageTemplate::new_with_user("Reset token missing", user).into_response())
user,
message: "Reset token missing",
}
.into_response())
} }
} }
@ -708,10 +580,10 @@ pub async fn reset_password_post(
) )
.await .await
{ {
Ok(db::ResetPasswordResult::Ok) => Ok(MessageTemplate { Ok(db::ResetPasswordResult::Ok) => Ok(MessageTemplate::new_with_user(
"Your password has been reset",
user, user,
message: "Your password has been reset", )
}
.into_response()), .into_response()),
Ok(db::ResetPasswordResult::ResetTokenExpired) => { Ok(db::ResetPasswordResult::ResetTokenExpired) => {
error_response(ResetPasswordError::TokenExpired, &form_data, user) error_response(ResetPasswordError::TokenExpired, &form_data, user)
@ -722,12 +594,6 @@ pub async fn reset_password_post(
///// EDIT PROFILE ///// ///// EDIT PROFILE /////
#[derive(Template)]
#[template(path = "profile.html")]
struct ProfileTemplate {
user: Option<model::User>,
}
#[debug_handler] #[debug_handler]
pub async fn edit_user( pub async fn edit_user(
State(connection): State<db::Connection>, State(connection): State<db::Connection>,
@ -736,18 +602,12 @@ pub async fn edit_user(
if user.is_some() { if user.is_some() {
ProfileTemplate { user }.into_response() ProfileTemplate { user }.into_response()
} else { } else {
MessageTemplate { MessageTemplate::new("Not logged in").into_response()
user: None,
message: "Not logged in",
}
.into_response()
} }
} }
///// 404 ///// ///// 404 /////
#[debug_handler] #[debug_handler]
pub async fn not_found() -> Result<impl IntoResponse> { pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
Ok(MessageWithoutUser { MessageTemplate::new_with_user("404: Not found", user)
message: "404: Not found",
})
} }

View file

@ -46,21 +46,15 @@
// #[put("/ron-api/recipe/rm-step")] // #[put("/ron-api/recipe/rm-step")]
// #[put("/ron-api/recipe/set-steps-order")] // #[put("/ron-api/recipe/set-steps-order")]
use askama_axum::IntoResponse;
use axum::{ use axum::{
debug_handler, debug_handler,
extract::{Extension, State}, extract::{Extension, State},
http::StatusCode, http::StatusCode,
response::ErrorResponse, response::{ErrorResponse, IntoResponse, Result},
response::Result,
}; };
use tracing::{event, Level}; use tracing::{event, Level};
use crate::{ use crate::{data::db, model, ron_extractor::ExtractRon, ron_utils::ron_error};
data::db,
model,
ron_extractor::{ron_error, ron_response, ExtractRon},
};
#[debug_handler] #[debug_handler]
pub async fn update_user( pub async fn update_user(
@ -69,7 +63,14 @@ pub async fn update_user(
ExtractRon(ron): ExtractRon<common::ron_api::UpdateProfile>, ExtractRon(ron): ExtractRon<common::ron_api::UpdateProfile>,
) -> Result<StatusCode> { ) -> Result<StatusCode> {
if let Some(user) = user { if let Some(user) = user {
// connection.set_user_name(user.id, &ron.name).await?; connection
.update_user(
user.id,
ron.email.as_deref(),
ron.name.as_deref(),
ron.password.as_deref(),
)
.await?;
} else { } else {
return Err(ErrorResponse::from(ron_error( return Err(ErrorResponse::from(ron_error(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
@ -79,17 +80,8 @@ pub async fn update_user(
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
/* Example with RON return value. ///// 404 /////
#[debug_handler] #[debug_handler]
pub async fn set_user_name( pub async fn not_found(Extension(user): Extension<Option<model::User>>) -> impl IntoResponse {
State(connection): State<db::Connection>, ron_error(StatusCode::NOT_FOUND, "Not found")
Extension(user): Extension<Option<model::User>>,
ExtractRon(ron): ExtractRon<common::ron_api::SetProfileName>,
) -> Result<impl IntoResponse> {
Ok(ron_response(common::ron_api::SetProfileName {
name: "abc".to_string(),
}))
} }
*/

View file

@ -1,71 +0,0 @@
@font-face{font-family: Fira Code; font-weight:200; src:url(FiraCode-Light.woff2) format("woff2"); }
@font-face{font-family: Fira Code; font-weight:400; src:url(FiraCode-Regular.woff2) format("woff2"); }
@font-face{font-family: Fira Code; font-weight:600; src:url(FiraCode-SemiBold.woff2) format("woff2"); }
@font-face{font-family: Fira Code; font-weight:700; src:url(FiraCode-Bold.woff2) format("woff2"); }
$primary: #182430;
$background: darken($primary, 5%);
$background-container: lighten($primary, 10%);
* {
margin: 10px;
padding: 0px;
}
html {
font-size: 80%
}
a {
color: lighten($primary, 40%);
text-decoration: none;
&:hover { color: lighten($primary, 60%); }
}
body {
font-family: Fira Code, Helvetica Neue, Helvetica, Arial, sans-serif;
text-shadow: 2px 2px 2px rgb(0, 0, 0);
text-align: center;
// line-height: 18px;
color: rgb(255, 255, 255);
background-color: $background;
margin: 0px;
.recipe-item {
padding: 4px;
}
.recipe-item-current {
padding: 3px;
border: 1px solid white;
}
/*
.header-container {
}
*/
.main-container {
display: flex;
.list {
text-align: left;
}
.content {
background-color: $background-container;
border: 0.1em solid white;
padding: 0.5em;
}
}
.footer-container {
font-size: 0.5em;
}
}
img {
border: 0px;
}

View file

@ -16,7 +16,10 @@
run(); run();
</script> </script>
<div id="toast"></div>
{% block body_container %}{% endblock %} {% block body_container %}{% endblock %}
<footer class="footer-container">gburri - 2022</footer> <footer class="footer-container">gburri - 2022</footer>
</body> </body>
</html> </html>

View file

@ -5,7 +5,7 @@
{% endmacro %} {% endmacro %}
{% block main_container %} {% block main_container %}
<nav class="list"> <nav class="recipes-list">
<ul> <ul>
{% for (id, title) in recipes %} {% for (id, title) in recipes %}
<li> <li>

View file

@ -1,6 +1,18 @@
{% extends "base_with_header.html" %} {% extends "base_with_header.html" %}
{% block main_container %} {% block main_container %}
{{ message|markdown }}
<div class="message">
{% if as_code %}
<pre><code>
{% endif %}
{{ message|markdown }}
{% if as_code %}
</code></pre>
{% endif %}
</div>
<a href="/">Go to home</a> <a href="/">Go to home</a>
{% endblock %} {% endblock %}

View file

@ -1,6 +0,0 @@
{% extends "base.html" %}
{% block body_container %}
{% include "title.html" %}
{{ message|markdown }}
{% endblock %}

View file

@ -2,31 +2,35 @@
{% block main_container %} {% block main_container %}
<h2>Profile</h2>
{% match user %} {% match user %}
{% when Some with (user) %} {% when Some with (user) %}
<div id="user-edit"> <div class="content">
<label for="title_field">Name</label> <h1>Profile</h1>
<input
id="name_field" <form id="user-edit">
type="text"
name="name" <label for="input-name">Name</label>
value="{{ user.name }}" <input
autocapitalize="none" id="input-name"
autocomplete="title" type="text"
autofocus="autofocus" /> name="name"
value="{{ user.name }}"
autocapitalize="none"
autocomplete="title"
autofocus="autofocus" />
<label for="password_field_1">New password (minimum 8 characters)</label> <label for="input-password-1">New password (minimum 8 characters)</label>
<input id="password_field_1" type="password" name="password_1" /> <input id="input-password-1" type="password" name="password_1" />
<label for="password_field_1">Re-enter password</label> <label for="input-password-2">Re-enter password</label>
<input id="password_field_2" type="password" name="password_2" /> <input id="input-password-2" type="password" name="password_2" />
<input type="button" value="Save" />
</form>
<button class="button" typed="button">Save</button>
</div> </div>
{% when None %} {% when None %}

View file

@ -1,16 +1,21 @@
{% extends "base_with_header.html" %} {% extends "base_with_header.html" %}
{% block main_container %} {% block main_container %}
<div class="content">
<form action="/signin" method="post">
<label for="email_field">Email address</label>
<input id="email_field" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
<label for="password_field">Password</label> <div id="sign-in" class="content">
<input id="password_field" type="password" name="password" autocomplete="current-password" />
<h1>Sign in</h1>
<form action="/signin" method="post">
<label for="input-email">Email address</label>
<input id="input-email" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
<label for="input-password">Password</label>
<input id="input-password" type="password" name="password" autocomplete="current-password" />
<input type="submit" value="Sign in" />
</form>
{{ message }}
</div>
<input type="submit" name="commit" value="Sign in" />
</form>
{{ message }}
</div>
{% endblock %} {% endblock %}

View file

@ -1,22 +1,25 @@
{% extends "base_with_header.html" %} {% extends "base_with_header.html" %}
{% block main_container %} {% block main_container %}
<div class="content"> <div class="content">
<form action="/signup" method="post">
<label for="email_field">Your email address</label>
<input id="email_field" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
{{ message_email }}
<label for="password_field_1">Choose a password (minimum 8 characters)</label> <h1>Sign up</h1>
<input id="password_field_1" type="password" name="password_1" />
<label for="password_field_1">Re-enter password</label> <form action="/signup" method="post">
<input id="password_field_2" type="password" name="password_2" /> <label for="input-email">Your email address</label>
<input id="input-email" type="email" name="email" value="{{ email }}" autocapitalize="none" autocomplete="email" autofocus="autofocus" />
{{ message_email }}
{{ message_password }} <label for="input-password-1">Choose a password (minimum 8 characters)</label>
<input id="input-password-1" type="password" name="password_1" />
<input type="submit" name="commit" value="Sign up" /> <label for="input-password-2">Re-enter password</label>
</form> <input id="input-password-2" type="password" name="password_2" />
{{ message }}
</div> {{ message_password }}
<input type="submit" name="commit" value="Sign up" />
</form>
{{ message }}
</div>
{% endblock %} {% endblock %}

View file

@ -9,4 +9,4 @@ regex = "1"
lazy_static = "1" lazy_static = "1"
ron = "0.8" ron = "0.8"
serde = {version = "1.0", features = ["derive"]} serde = { version = "1.0", features = ["derive"] }

View file

@ -1,4 +1,8 @@
use serde::{Deserialize, Serialize}; use ron::{
de::from_bytes,
ser::{to_string_pretty, PrettyConfig},
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
///// RECIPE ///// ///// RECIPE /////
@ -102,3 +106,11 @@ pub struct UpdateProfile {
pub email: Option<String>, pub email: Option<String>,
pub password: Option<String>, pub password: Option<String>,
} }
pub fn to_string<T>(ron: T) -> String
where
T: Serialize,
{
// TODO: handle'unwrap'.
to_string_pretty(&ron, PrettyConfig::new()).unwrap()
}

View file

@ -14,6 +14,7 @@ default = ["console_error_panic_hook"]
common = { path = "../common" } common = { path = "../common" }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [ web-sys = { version = "0.3", features = [
"console", "console",
"Document", "Document",
@ -24,8 +25,11 @@ web-sys = { version = "0.3", features = [
"Location", "Location",
"EventTarget", "EventTarget",
"HtmlLabelElement", "HtmlLabelElement",
"HtmlInputElement",
] } ] }
gloo = "0.11"
# The `console_error_panic_hook` crate provides better debugging of panics by # The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires # logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for

View file

@ -1,10 +1,73 @@
use gloo::{console::log, events::EventListener, net::http::Request};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::{Document, HtmlLabelElement}; use wasm_bindgen_futures::spawn_local;
use web_sys::{Document, HtmlInputElement};
pub fn recipe_edit(doc: &Document) { use crate::toast::{self, Level};
pub fn recipe_edit(doc: Document) -> Result<(), JsValue> {
let title_input = doc.get_element_by_id("title_field").unwrap(); let title_input = doc.get_element_by_id("title_field").unwrap();
Ok(())
} }
pub fn user_edit(doc: &Document) { pub fn user_edit(doc: Document) -> Result<(), JsValue> {
// let name_input = doc.get_element_by_id("name_field").unwrap().dyn_ref::<>() log!("user_edit");
let button = doc
.query_selector("#user-edit input[type='button']")?
.unwrap();
let on_click_submit = EventListener::new(&button, "click", move |_event| {
log!("Click!");
let input_name = doc.get_element_by_id("input-name").unwrap();
let name = input_name.dyn_ref::<HtmlInputElement>().unwrap().value();
let update_data = common::ron_api::UpdateProfile {
name: Some(name),
email: None,
password: None,
};
let body = common::ron_api::to_string(update_data);
let doc = doc.clone();
spawn_local(async move {
match Request::put("/ron-api/user/update")
.header("Content-Type", "application/ron")
.body(body)
.unwrap()
.send()
.await
{
Ok(resp) => {
log!("Status code: {}", resp.status());
if resp.status() == 200 {
toast::show(Level::Info, "Profile saved", doc);
} else {
toast::show(
Level::Error,
&format!(
"Status code: {} {}",
resp.status(),
resp.text().await.unwrap()
),
doc,
);
}
}
Err(error) => {
toast::show(
Level::Info,
&format!("Internal server error: {}", error),
doc,
);
}
}
});
});
on_click_submit.forget();
Ok(())
} }

View file

@ -1,19 +1,21 @@
mod handles; mod handles;
mod toast;
mod utils; mod utils;
use gloo::{console::log, events::EventListener};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use web_sys::console; use web_sys::console;
#[wasm_bindgen] // #[wasm_bindgen]
extern "C" { // extern "C" {
fn alert(s: &str); // fn alert(s: &str);
} // }
#[wasm_bindgen] // #[wasm_bindgen]
pub fn greet(name: &str) { // pub fn greet(name: &str) {
alert(&format!("Hello, {}!", name)); // alert(&format!("Hello, {}!", name));
console::log_1(&"Hello bg".into()); // console::log_1(&"Hello bg".into());
} // }
#[wasm_bindgen(start)] #[wasm_bindgen(start)]
pub fn main() -> Result<(), JsValue> { pub fn main() -> Result<(), JsValue> {
@ -33,17 +35,16 @@ pub fn main() -> Result<(), JsValue> {
* - Call (as AJAR) /ron-api/set-title and set the body to a serialized RON of the type common::ron_api::SetTitle * - Call (as AJAR) /ron-api/set-title and set the body to a serialized RON of the type common::ron_api::SetTitle
* - Display error message if needed * - Display error message if needed
*/ */
match path[..] { match path[..] {
["recipe", "edit", id] => { ["recipe", "edit", id] => {
let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap. let id = id.parse::<i64>().unwrap(); // TODO: remove unwrap.
console_log!("recipe edit ID: {}", id); log!("recipe edit ID: {}", id);
handles::recipe_edit(&document); handles::recipe_edit(document)?;
} }
["user", "edit"] => { ["user", "edit"] => {
handles::user_edit(&document); handles::user_edit(document)?;
} }
_ => (), _ => (),
} }

20
frontend/src/toast.rs Normal file
View file

@ -0,0 +1,20 @@
use gloo::{console::log, timers::callback::Timeout};
use web_sys::{console, Document, HtmlInputElement};
pub enum Level {
Success,
Error,
Info,
Warning,
}
pub fn show(level: Level, message: &str, doc: Document) {
let toast_element = doc.get_element_by_id("toast").unwrap();
toast_element.set_inner_html(message);
toast_element.set_class_name("show");
Timeout::new(4_000, move || {
toast_element.set_class_name("");
})
.forget();
}

View file

@ -1,4 +1,4 @@
use web_sys::console; // use web_sys::console;
pub fn set_panic_hook() { pub fn set_panic_hook() {
// When the `console_error_panic_hook` feature is enabled, we can call the // When the `console_error_panic_hook` feature is enabled, we can call the
@ -11,9 +11,9 @@ pub fn set_panic_hook() {
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
} }
#[macro_export] // #[macro_export]
macro_rules! console_log { // macro_rules! console_log {
// Note that this is using the `log` function imported above during // // Note that this is using the `log` function imported above during
// `bare_bones` // // `bare_bones`
($($t:tt)*) => (console::log_1(&format_args!($($t)*).to_string().into())) // ($($t:tt)*) => (console::log_1(&format_args!($($t)*).to_string().into()))
} // }