adding chang epassword and public
This commit is contained in:
parent
c2fec04090
commit
bc4cf24f23
|
|
@ -1,4 +1,5 @@
|
||||||
/target
|
/target
|
||||||
|
.DS_Store
|
||||||
config/
|
config/
|
||||||
tmp/
|
tmp/
|
||||||
data/
|
data/
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ Open: http://127.0.0.1:3000
|
||||||
|
|
||||||
```conf
|
```conf
|
||||||
allow_signup = true
|
allow_signup = true
|
||||||
|
bind_port = 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
If the file is missing, the app creates it with defaults on startup.
|
If the file is missing, the app creates it with defaults on startup.
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,15 @@ use std::path::Path;
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub allow_signup: bool,
|
pub allow_signup: bool,
|
||||||
|
pub bind_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { allow_signup: true }
|
Self {
|
||||||
|
allow_signup: true,
|
||||||
|
bind_port: 3000,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,9 +31,17 @@ pub fn load_config() -> AppConfig {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some((key, value)) = line.split_once('=') {
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
if key.trim() == "allow_signup" {
|
let key = key.trim();
|
||||||
let v = value.trim();
|
let value = value.trim();
|
||||||
cfg.allow_signup = v.eq_ignore_ascii_case("true") || v == "1";
|
|
||||||
|
if key == "allow_signup" {
|
||||||
|
cfg.allow_signup = value.eq_ignore_ascii_case("true") || value == "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
if key == "bind_port" {
|
||||||
|
if let Ok(port) = value.parse::<u16>() {
|
||||||
|
cfg.bind_port = port;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +57,6 @@ fn ensure_default_config_file(path: &str) {
|
||||||
let _ = fs::create_dir_all(parent);
|
let _ = fs::create_dir_all(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
let default = "# Set to false to disable open sign-up after bootstrap.\nallow_signup = true\n";
|
let default = "# Set to false to disable open sign-up after bootstrap.\nallow_signup = true\n\n# TCP port the web server binds to.\nbind_port = 3000\n";
|
||||||
let _ = fs::write(path, default);
|
let _ = fs::write(path, default);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
src/db.rs
12
src/db.rs
|
|
@ -156,6 +156,18 @@ pub fn fetch_user_by_id(
|
||||||
.optional()
|
.optional()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_user_password(
|
||||||
|
conn: &Connection,
|
||||||
|
user_id: i64,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<(), rusqlite::Error> {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET password_hash = ?1 WHERE id = ?2",
|
||||||
|
params![password_hash, user_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_or_replace_session(
|
pub fn create_or_replace_session(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
token: &str,
|
token: &str,
|
||||||
|
|
|
||||||
150
src/handlers.rs
150
src/handlers.rs
|
|
@ -14,9 +14,9 @@ use tokio::sync::Mutex;
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::views::{
|
use crate::views::{
|
||||||
self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, LoginPageView,
|
self, CalendarCellView, CalendarNav, CalendarPageView, ChangePasswordPageView, DayPageView,
|
||||||
PlanningPageView, PublicDaySummaryView, PublicProfilePageView, ReportCardView, ReportsPageView,
|
LoginPageView, PlanningPageView, PublicDayPageView, PublicDaySummaryView, PublicProfilePageView,
|
||||||
SignupPageView,
|
ReportCardView, ReportsPageView, SignupPageView,
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppError = (StatusCode, String);
|
type AppError = (StatusCode, String);
|
||||||
|
|
@ -228,6 +228,7 @@ pub async fn show_public_profile(
|
||||||
let key = day.format("%Y-%m-%d").to_string();
|
let key = day.format("%Y-%m-%d").to_string();
|
||||||
recent_days.push(PublicDaySummaryView {
|
recent_days.push(PublicDaySummaryView {
|
||||||
date_text: key.clone(),
|
date_text: key.clone(),
|
||||||
|
day_href: format!("/u/{}/day/{}", user.username, key),
|
||||||
calories: *calorie_map.get(&key).unwrap_or(&0),
|
calories: *calorie_map.get(&key).unwrap_or(&0),
|
||||||
weight_label: weight_map
|
weight_label: weight_map
|
||||||
.get(&key)
|
.get(&key)
|
||||||
|
|
@ -269,6 +270,16 @@ pub async fn show_public_profile(
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut chart_labels = Vec::new();
|
||||||
|
let mut calories_series = Vec::new();
|
||||||
|
let mut weights_series = Vec::new();
|
||||||
|
for row in &recent_days {
|
||||||
|
chart_labels.push(row.date_text.clone());
|
||||||
|
calories_series.push(row.calories as f64);
|
||||||
|
let weight = weight_map.get(&row.date_text).copied();
|
||||||
|
weights_series.push(weight);
|
||||||
|
}
|
||||||
|
|
||||||
let page = PublicProfilePageView {
|
let page = PublicProfilePageView {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
show_entries: planning.public_entries,
|
show_entries: planning.public_entries,
|
||||||
|
|
@ -277,6 +288,9 @@ pub async fn show_public_profile(
|
||||||
show_planning: planning.public_planning,
|
show_planning: planning.public_planning,
|
||||||
recent_days,
|
recent_days,
|
||||||
report_cards,
|
report_cards,
|
||||||
|
chart_labels_js: js_string_array(&chart_labels),
|
||||||
|
calories_js: js_f64_array(&calories_series),
|
||||||
|
weights_js: js_optional_f64_array(&weights_series),
|
||||||
target_weight_label: planning
|
target_weight_label: planning
|
||||||
.target_weight
|
.target_weight
|
||||||
.map(|v| format!("{v:.1} lbs"))
|
.map(|v| format!("{v:.1} lbs"))
|
||||||
|
|
@ -410,6 +424,67 @@ pub async fn show_planning(
|
||||||
Ok(with_session_cookie(resp, &auth.token))
|
Ok(with_session_cookie(resp, &auth.token))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn show_change_password(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let auth = get_auth_user(&state, &headers).await?;
|
||||||
|
let Some(auth) = auth else {
|
||||||
|
return Ok(Redirect::to("/login").into_response());
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = ChangePasswordPageView {
|
||||||
|
error: String::new(),
|
||||||
|
success: String::new(),
|
||||||
|
};
|
||||||
|
let resp = Html(views::render_change_password_page(&page).map_err(internal_template_error)?)
|
||||||
|
.into_response();
|
||||||
|
Ok(with_session_cookie(resp, &auth.token))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn change_password(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let auth = get_auth_user(&state, &headers).await?;
|
||||||
|
let Some(auth) = auth else {
|
||||||
|
return Ok(Redirect::to("/login").into_response());
|
||||||
|
};
|
||||||
|
|
||||||
|
let current = form.get("current_password").map(|v| v.as_str()).unwrap_or("");
|
||||||
|
let new_password = form.get("new_password").map(|v| v.as_str()).unwrap_or("");
|
||||||
|
let confirm = form.get("confirm_password").map(|v| v.as_str()).unwrap_or("");
|
||||||
|
if current.is_empty() || new_password.is_empty() || confirm.is_empty() {
|
||||||
|
return render_change_password_page(
|
||||||
|
&auth.token,
|
||||||
|
"All password fields are required",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !verify_password(&auth.user.password_hash, current) {
|
||||||
|
return render_change_password_page(&auth.token, "Current password is incorrect", "");
|
||||||
|
}
|
||||||
|
if new_password.len() < 4 {
|
||||||
|
return render_change_password_page(
|
||||||
|
&auth.token,
|
||||||
|
"New password must be at least 4 chars",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if new_password != confirm {
|
||||||
|
return render_change_password_page(&auth.token, "New passwords do not match", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_hash = hash_password(new_password)?;
|
||||||
|
{
|
||||||
|
let db_conn = state.db.lock().await;
|
||||||
|
db::update_user_password(&db_conn, auth.user.id, &new_hash).map_err(internal_db_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_change_password_page(&auth.token, "", "Password updated")
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update_planning(
|
pub async fn update_planning(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
|
@ -578,6 +653,61 @@ pub async fn remove_entry(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn show_public_day(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((username, date_text)): Path<(String, String)>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
validate_date(&date_text)?;
|
||||||
|
|
||||||
|
let user = {
|
||||||
|
let db_conn = state.db.lock().await;
|
||||||
|
db::fetch_user_by_username(&db_conn, &username).map_err(internal_db_error)?
|
||||||
|
};
|
||||||
|
let Some(user) = user else {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "User not found".to_string()));
|
||||||
|
};
|
||||||
|
|
||||||
|
let planning = {
|
||||||
|
let db_conn = state.db.lock().await;
|
||||||
|
db::fetch_planning(&db_conn, user.id).map_err(internal_db_error)?
|
||||||
|
};
|
||||||
|
if !planning.public_entries {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Page not found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (entries, weight) = {
|
||||||
|
let db_conn = state.db.lock().await;
|
||||||
|
let entries =
|
||||||
|
db::fetch_day_entries(&db_conn, user.id, &date_text).map_err(internal_db_error)?;
|
||||||
|
let weight = db::fetch_weight_for_day(&db_conn, user.id, &date_text)
|
||||||
|
.map_err(internal_db_error)?;
|
||||||
|
(entries, weight)
|
||||||
|
};
|
||||||
|
let daily_total = entries.iter().map(|entry| entry.calories).sum();
|
||||||
|
let chart_labels = entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.name.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let chart_values = entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.calories as f64)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let page = PublicDayPageView {
|
||||||
|
username: user.username,
|
||||||
|
date_text,
|
||||||
|
daily_total,
|
||||||
|
entries,
|
||||||
|
show_weight: planning.public_weights,
|
||||||
|
weight_label: weight
|
||||||
|
.map(|w| format!("{w:.1} lbs"))
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
chart_labels_js: js_string_array(&chart_labels),
|
||||||
|
chart_values_js: js_f64_array(&chart_values),
|
||||||
|
};
|
||||||
|
Ok(Html(views::render_public_day_page(&page).map_err(internal_template_error)?).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
async fn render_calendar_for_month(
|
async fn render_calendar_for_month(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
auth: &AuthUser,
|
auth: &AuthUser,
|
||||||
|
|
@ -832,6 +962,20 @@ async fn render_signup_with_error(state: &AppState, error: &str) -> Result<Respo
|
||||||
Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response())
|
Ok(Html(views::render_signup_page(&page).map_err(internal_template_error)?).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_change_password_page(
|
||||||
|
token: &str,
|
||||||
|
error: &str,
|
||||||
|
success: &str,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let page = ChangePasswordPageView {
|
||||||
|
error: error.to_string(),
|
||||||
|
success: success.to_string(),
|
||||||
|
};
|
||||||
|
let resp = Html(views::render_change_password_page(&page).map_err(internal_template_error)?)
|
||||||
|
.into_response();
|
||||||
|
Ok(with_session_cookie(resp, token))
|
||||||
|
}
|
||||||
|
|
||||||
fn hash_password(password: &str) -> Result<String, AppError> {
|
fn hash_password(password: &str) -> Result<String, AppError> {
|
||||||
let salt = SaltString::generate(&mut ArgonOsRng);
|
let salt = SaltString::generate(&mut ArgonOsRng);
|
||||||
Argon2::default()
|
Argon2::default()
|
||||||
|
|
|
||||||
10
src/main.rs
10
src/main.rs
|
|
@ -15,7 +15,7 @@ use crate::handlers::AppState;
|
||||||
|
|
||||||
const DB_DIR: &str = "data";
|
const DB_DIR: &str = "data";
|
||||||
const DB_PATH: &str = "data/app.db";
|
const DB_PATH: &str = "data/app.db";
|
||||||
const LISTEN_ADDR: &str = "127.0.0.1:3000";
|
const LISTEN_HOST: &str = "127.0.0.1";
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
@ -40,9 +40,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
.route("/signup", post(handlers::signup))
|
.route("/signup", post(handlers::signup))
|
||||||
.route("/logout", post(handlers::logout))
|
.route("/logout", post(handlers::logout))
|
||||||
.route("/u/{username}", get(handlers::show_public_profile))
|
.route("/u/{username}", get(handlers::show_public_profile))
|
||||||
|
.route("/u/{username}/day/{date}", get(handlers::show_public_day))
|
||||||
.route("/reports", get(handlers::show_reports))
|
.route("/reports", get(handlers::show_reports))
|
||||||
.route("/planning", get(handlers::show_planning))
|
.route("/planning", get(handlers::show_planning))
|
||||||
.route("/planning", post(handlers::update_planning))
|
.route("/planning", post(handlers::update_planning))
|
||||||
|
.route("/planning/password", get(handlers::show_change_password))
|
||||||
|
.route("/planning/password", post(handlers::change_password))
|
||||||
.route(
|
.route(
|
||||||
"/calendar/{year}/{month}",
|
"/calendar/{year}/{month}",
|
||||||
get(handlers::show_calendar_for_month),
|
get(handlers::show_calendar_for_month),
|
||||||
|
|
@ -57,8 +60,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
)
|
)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(LISTEN_ADDR).await?;
|
let listen_addr = format!("{}:{}", LISTEN_HOST, app_config.bind_port);
|
||||||
println!("Listening on http://{}", LISTEN_ADDR);
|
let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
|
||||||
|
println!("Listening on http://{}", listen_addr);
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
52
src/views.rs
52
src/views.rs
|
|
@ -153,6 +153,11 @@ pub struct PlanningPageView {
|
||||||
pub public_planning: bool,
|
pub public_planning: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct ChangePasswordPageView {
|
||||||
|
pub error: String,
|
||||||
|
pub success: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct LoginPageView {
|
pub struct LoginPageView {
|
||||||
pub allow_signup: bool,
|
pub allow_signup: bool,
|
||||||
pub error: String,
|
pub error: String,
|
||||||
|
|
@ -166,6 +171,7 @@ pub struct SignupPageView {
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PublicDaySummaryView {
|
pub struct PublicDaySummaryView {
|
||||||
pub date_text: String,
|
pub date_text: String,
|
||||||
|
pub day_href: String,
|
||||||
pub calories: i64,
|
pub calories: i64,
|
||||||
pub weight_label: String,
|
pub weight_label: String,
|
||||||
}
|
}
|
||||||
|
|
@ -178,11 +184,25 @@ pub struct PublicProfilePageView {
|
||||||
pub show_planning: bool,
|
pub show_planning: bool,
|
||||||
pub recent_days: Vec<PublicDaySummaryView>,
|
pub recent_days: Vec<PublicDaySummaryView>,
|
||||||
pub report_cards: Vec<ReportCardView>,
|
pub report_cards: Vec<ReportCardView>,
|
||||||
|
pub chart_labels_js: String,
|
||||||
|
pub calories_js: String,
|
||||||
|
pub weights_js: String,
|
||||||
pub target_weight_label: String,
|
pub target_weight_label: String,
|
||||||
pub target_calories_label: String,
|
pub target_calories_label: String,
|
||||||
pub bmr_label: String,
|
pub bmr_label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PublicDayPageView {
|
||||||
|
pub username: String,
|
||||||
|
pub date_text: String,
|
||||||
|
pub daily_total: i64,
|
||||||
|
pub entries: Vec<FoodEntry>,
|
||||||
|
pub show_weight: bool,
|
||||||
|
pub weight_label: String,
|
||||||
|
pub chart_labels_js: String,
|
||||||
|
pub chart_values_js: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "calendar.html")]
|
#[template(path = "calendar.html")]
|
||||||
struct CalendarTemplate<'a> {
|
struct CalendarTemplate<'a> {
|
||||||
|
|
@ -207,6 +227,12 @@ struct PlanningTemplate<'a> {
|
||||||
page: &'a PlanningPageView,
|
page: &'a PlanningPageView,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "change_password.html")]
|
||||||
|
struct ChangePasswordTemplate<'a> {
|
||||||
|
page: &'a ChangePasswordPageView,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "login.html")]
|
#[template(path = "login.html")]
|
||||||
struct LoginTemplate<'a> {
|
struct LoginTemplate<'a> {
|
||||||
|
|
@ -225,6 +251,12 @@ struct PublicProfileTemplate<'a> {
|
||||||
page: &'a PublicProfilePageView,
|
page: &'a PublicProfilePageView,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "public_day.html")]
|
||||||
|
struct PublicDayTemplate<'a> {
|
||||||
|
page: &'a PublicDayPageView,
|
||||||
|
}
|
||||||
|
|
||||||
impl CalendarTemplate<'_> {
|
impl CalendarTemplate<'_> {
|
||||||
fn active_tab(&self) -> &str {
|
fn active_tab(&self) -> &str {
|
||||||
"calendar"
|
"calendar"
|
||||||
|
|
@ -249,6 +281,12 @@ impl PlanningTemplate<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ChangePasswordTemplate<'_> {
|
||||||
|
fn active_tab(&self) -> &str {
|
||||||
|
"planning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl LoginTemplate<'_> {
|
impl LoginTemplate<'_> {
|
||||||
fn active_tab(&self) -> &str {
|
fn active_tab(&self) -> &str {
|
||||||
""
|
""
|
||||||
|
|
@ -267,6 +305,12 @@ impl PublicProfileTemplate<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PublicDayTemplate<'_> {
|
||||||
|
fn active_tab(&self) -> &str {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_calendar_page(page: &CalendarPageView) -> Result<String, askama::Error> {
|
pub fn render_calendar_page(page: &CalendarPageView) -> Result<String, askama::Error> {
|
||||||
CalendarTemplate { page }.render()
|
CalendarTemplate { page }.render()
|
||||||
}
|
}
|
||||||
|
|
@ -283,6 +327,10 @@ pub fn render_planning_page(page: &PlanningPageView) -> Result<String, askama::E
|
||||||
PlanningTemplate { page }.render()
|
PlanningTemplate { page }.render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_change_password_page(page: &ChangePasswordPageView) -> Result<String, askama::Error> {
|
||||||
|
ChangePasswordTemplate { page }.render()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_login_page(page: &LoginPageView) -> Result<String, askama::Error> {
|
pub fn render_login_page(page: &LoginPageView) -> Result<String, askama::Error> {
|
||||||
LoginTemplate { page }.render()
|
LoginTemplate { page }.render()
|
||||||
}
|
}
|
||||||
|
|
@ -294,3 +342,7 @@ pub fn render_signup_page(page: &SignupPageView) -> Result<String, askama::Error
|
||||||
pub fn render_public_profile_page(page: &PublicProfilePageView) -> Result<String, askama::Error> {
|
pub fn render_public_profile_page(page: &PublicProfilePageView) -> Result<String, askama::Error> {
|
||||||
PublicProfileTemplate { page }.render()
|
PublicProfileTemplate { page }.render()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_public_day_page(page: &PublicDayPageView) -> Result<String, askama::Error> {
|
||||||
|
PublicDayTemplate { page }.render()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Change Password{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-sm backdrop-blur">
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">Change Password</h1>
|
||||||
|
<p class="mt-3 text-sm text-slate-600">Use your current password to set a new one.</p>
|
||||||
|
|
||||||
|
{% if !page.error.is_empty() %}
|
||||||
|
<p class="mt-4 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{{ page.error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if !page.success.is_empty() %}
|
||||||
|
<p class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{{ page.success }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/planning/password" class="mt-6 grid max-w-xl gap-4">
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-sm font-semibold text-slate-700">Current Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="current_password"
|
||||||
|
required
|
||||||
|
class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-teal-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-sm font-semibold text-slate-700">New Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="new_password"
|
||||||
|
required
|
||||||
|
minlength="4"
|
||||||
|
class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-teal-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-2">
|
||||||
|
<span class="text-sm font-semibold text-slate-700">Confirm New Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="confirm_password"
|
||||||
|
required
|
||||||
|
minlength="4"
|
||||||
|
class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-teal-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button type="submit" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700">Update Password</button>
|
||||||
|
<a href="/planning" class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-100">Back to Planning</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -71,4 +71,12 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-sm backdrop-blur">
|
||||||
|
<h2 class="text-xl font-bold tracking-tight">Security</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">Update your account password.</p>
|
||||||
|
<a href="/planning/password" class="mt-4 inline-flex rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-100">
|
||||||
|
Change Password
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Shared Day {{ page.date_text }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<a href="/u/{{ page.username }}" class="inline-flex items-center gap-2 text-sm font-medium text-slate-600 transition hover:text-slate-900">
|
||||||
|
<span>←</span>
|
||||||
|
<span>Back to @{{ page.username }}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">@{{ page.username }} - {{ page.date_text }}</h1>
|
||||||
|
<p class="mt-3 inline-flex items-center rounded-full bg-emerald-100 px-3 py-1 text-sm font-semibold text-emerald-900">
|
||||||
|
Daily total: {{ page.daily_total }} cal
|
||||||
|
</p>
|
||||||
|
{% if page.show_weight %}
|
||||||
|
<p class="mt-2 text-sm text-slate-700">Weight: {{ page.weight_label }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
||||||
|
<h2 class="text-xl font-bold tracking-tight">Food Entries</h2>
|
||||||
|
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200">
|
||||||
|
<table class="w-full border-collapse">
|
||||||
|
<thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||||
|
<tr><th class="px-5 py-3">Food</th><th class="px-5 py-3">Calories</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white">
|
||||||
|
{% if page.entries.is_empty() %}
|
||||||
|
<tr><td colspan="2" class="px-5 py-8 text-center text-slate-500">No entries shared for this day.</td></tr>
|
||||||
|
{% else %}
|
||||||
|
{% for entry in page.entries %}
|
||||||
|
<tr class="border-t border-slate-200">
|
||||||
|
<td class="px-5 py-3 text-sm text-slate-800">{{ entry.name }}</td>
|
||||||
|
<td class="px-5 py-3 text-sm font-semibold text-slate-900">{{ entry.calories }} cal</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if !page.entries.is_empty() %}
|
||||||
|
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
||||||
|
<h2 class="text-xl font-bold tracking-tight text-slate-900">Calories by Entry</h2>
|
||||||
|
<div class="mt-4 h-80">
|
||||||
|
<canvas id="public-day-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if !page.entries.is_empty() %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
const labels = {{ page.chart_labels_js|safe }};
|
||||||
|
const values = {{ page.chart_values_js|safe }};
|
||||||
|
new Chart(
|
||||||
|
document.getElementById("public-day-chart"),
|
||||||
|
{
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Calories",
|
||||||
|
data: values,
|
||||||
|
backgroundColor: "rgba(15, 118, 110, 0.25)",
|
||||||
|
borderColor: "#0f766e",
|
||||||
|
borderWidth: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: { y: { beginAtZero: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -19,7 +19,13 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in page.recent_days %}
|
{% for row in page.recent_days %}
|
||||||
<tr class="border-t border-slate-200">
|
<tr class="border-t border-slate-200">
|
||||||
<td class="px-3 py-2 text-sm">{{ row.date_text }}</td>
|
<td class="px-3 py-2 text-sm">
|
||||||
|
{% if page.show_entries %}
|
||||||
|
<a href="{{ row.day_href }}" class="font-semibold text-teal-700 hover:text-teal-900 hover:underline">{{ row.date_text }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ row.date_text }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td class="px-3 py-2 text-sm">{% if page.show_entries %}{{ row.calories }}{% else %}Hidden{% endif %}</td>
|
<td class="px-3 py-2 text-sm">{% if page.show_entries %}{{ row.calories }}{% else %}Hidden{% endif %}</td>
|
||||||
<td class="px-3 py-2 text-sm">{% if page.show_weights %}{{ row.weight_label }}{% else %}Hidden{% endif %}</td>
|
<td class="px-3 py-2 text-sm">{% if page.show_weights %}{{ row.weight_label }}{% else %}Hidden{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -30,6 +36,31 @@
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if page.show_entries || page.show_weights %}
|
||||||
|
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
|
||||||
|
<h2 class="text-xl font-bold tracking-tight text-slate-900">Recent Trends</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">Past 14 days</p>
|
||||||
|
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||||
|
{% if page.show_entries %}
|
||||||
|
<article class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-600">Daily Calories</h3>
|
||||||
|
<div class="mt-3 h-72">
|
||||||
|
<canvas id="public-calorie-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
{% if page.show_weights %}
|
||||||
|
<article class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-600">Daily Weight (lbs)</h3>
|
||||||
|
<div class="mt-3 h-72">
|
||||||
|
<canvas id="public-weight-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if page.show_reports %}
|
{% if page.show_reports %}
|
||||||
<section class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<section class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
{% for card in page.report_cards %}
|
{% for card in page.report_cards %}
|
||||||
|
|
@ -60,3 +91,68 @@
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if page.show_entries || page.show_weights %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
const labels = {{ page.chart_labels_js|safe }};
|
||||||
|
|
||||||
|
{% if page.show_entries %}
|
||||||
|
const calories = {{ page.calories_js|safe }};
|
||||||
|
new Chart(
|
||||||
|
document.getElementById("public-calorie-chart"),
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Daily calories",
|
||||||
|
data: calories,
|
||||||
|
borderColor: "#0f766e",
|
||||||
|
backgroundColor: "rgba(15, 118, 110, 0.15)",
|
||||||
|
fill: true,
|
||||||
|
tension: 0.25,
|
||||||
|
pointRadius: 1.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: { y: { beginAtZero: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if page.show_weights %}
|
||||||
|
const weights = {{ page.weights_js|safe }};
|
||||||
|
new Chart(
|
||||||
|
document.getElementById("public-weight-chart"),
|
||||||
|
{
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Daily weight (lbs)",
|
||||||
|
data: weights,
|
||||||
|
borderColor: "#2563eb",
|
||||||
|
backgroundColor: "rgba(37, 99, 235, 0.15)",
|
||||||
|
fill: true,
|
||||||
|
tension: 0.25,
|
||||||
|
pointRadius: 1.5,
|
||||||
|
spanGaps: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
maintainAspectRatio: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue