adding chang epassword and public
This commit is contained in:
parent
c2fec04090
commit
bc4cf24f23
|
|
@ -1,4 +1,5 @@
|
|||
/target
|
||||
.DS_Store
|
||||
config/
|
||||
tmp/
|
||||
data/
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ Open: http://127.0.0.1:3000
|
|||
|
||||
```conf
|
||||
allow_signup = true
|
||||
bind_port = 3000
|
||||
```
|
||||
|
||||
If the file is missing, the app creates it with defaults on startup.
|
||||
|
|
|
|||
|
|
@ -4,11 +4,15 @@ use std::path::Path;
|
|||
#[derive(Clone, Copy)]
|
||||
pub struct AppConfig {
|
||||
pub allow_signup: bool,
|
||||
pub bind_port: u16,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self { allow_signup: true }
|
||||
Self {
|
||||
allow_signup: true,
|
||||
bind_port: 3000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -27,9 +31,17 @@ pub fn load_config() -> AppConfig {
|
|||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
if key.trim() == "allow_signup" {
|
||||
let v = value.trim();
|
||||
cfg.allow_signup = v.eq_ignore_ascii_case("true") || v == "1";
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
|
|
|||
12
src/db.rs
12
src/db.rs
|
|
@ -156,6 +156,18 @@ pub fn fetch_user_by_id(
|
|||
.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(
|
||||
conn: &Connection,
|
||||
token: &str,
|
||||
|
|
|
|||
150
src/handlers.rs
150
src/handlers.rs
|
|
@ -14,9 +14,9 @@ use tokio::sync::Mutex;
|
|||
use crate::config::AppConfig;
|
||||
use crate::db;
|
||||
use crate::views::{
|
||||
self, CalendarCellView, CalendarNav, CalendarPageView, DayPageView, LoginPageView,
|
||||
PlanningPageView, PublicDaySummaryView, PublicProfilePageView, ReportCardView, ReportsPageView,
|
||||
SignupPageView,
|
||||
self, CalendarCellView, CalendarNav, CalendarPageView, ChangePasswordPageView, DayPageView,
|
||||
LoginPageView, PlanningPageView, PublicDayPageView, PublicDaySummaryView, PublicProfilePageView,
|
||||
ReportCardView, ReportsPageView, SignupPageView,
|
||||
};
|
||||
|
||||
type AppError = (StatusCode, String);
|
||||
|
|
@ -228,6 +228,7 @@ pub async fn show_public_profile(
|
|||
let key = day.format("%Y-%m-%d").to_string();
|
||||
recent_days.push(PublicDaySummaryView {
|
||||
date_text: key.clone(),
|
||||
day_href: format!("/u/{}/day/{}", user.username, key),
|
||||
calories: *calorie_map.get(&key).unwrap_or(&0),
|
||||
weight_label: weight_map
|
||||
.get(&key)
|
||||
|
|
@ -269,6 +270,16 @@ pub async fn show_public_profile(
|
|||
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 {
|
||||
username: user.username,
|
||||
show_entries: planning.public_entries,
|
||||
|
|
@ -277,6 +288,9 @@ pub async fn show_public_profile(
|
|||
show_planning: planning.public_planning,
|
||||
recent_days,
|
||||
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
|
||||
.map(|v| format!("{v:.1} lbs"))
|
||||
|
|
@ -410,6 +424,67 @@ pub async fn show_planning(
|
|||
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(
|
||||
State(state): State<AppState>,
|
||||
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(
|
||||
state: &AppState,
|
||||
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())
|
||||
}
|
||||
|
||||
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> {
|
||||
let salt = SaltString::generate(&mut ArgonOsRng);
|
||||
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_PATH: &str = "data/app.db";
|
||||
const LISTEN_ADDR: &str = "127.0.0.1:3000";
|
||||
const LISTEN_HOST: &str = "127.0.0.1";
|
||||
|
||||
#[tokio::main]
|
||||
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("/logout", post(handlers::logout))
|
||||
.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("/planning", get(handlers::show_planning))
|
||||
.route("/planning", post(handlers::update_planning))
|
||||
.route("/planning/password", get(handlers::show_change_password))
|
||||
.route("/planning/password", post(handlers::change_password))
|
||||
.route(
|
||||
"/calendar/{year}/{month}",
|
||||
get(handlers::show_calendar_for_month),
|
||||
|
|
@ -57,8 +60,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
)
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(LISTEN_ADDR).await?;
|
||||
println!("Listening on http://{}", LISTEN_ADDR);
|
||||
let listen_addr = format!("{}:{}", LISTEN_HOST, app_config.bind_port);
|
||||
let listener = tokio::net::TcpListener::bind(&listen_addr).await?;
|
||||
println!("Listening on http://{}", listen_addr);
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
52
src/views.rs
52
src/views.rs
|
|
@ -153,6 +153,11 @@ pub struct PlanningPageView {
|
|||
pub public_planning: bool,
|
||||
}
|
||||
|
||||
pub struct ChangePasswordPageView {
|
||||
pub error: String,
|
||||
pub success: String,
|
||||
}
|
||||
|
||||
pub struct LoginPageView {
|
||||
pub allow_signup: bool,
|
||||
pub error: String,
|
||||
|
|
@ -166,6 +171,7 @@ pub struct SignupPageView {
|
|||
#[derive(Clone)]
|
||||
pub struct PublicDaySummaryView {
|
||||
pub date_text: String,
|
||||
pub day_href: String,
|
||||
pub calories: i64,
|
||||
pub weight_label: String,
|
||||
}
|
||||
|
|
@ -178,11 +184,25 @@ pub struct PublicProfilePageView {
|
|||
pub show_planning: bool,
|
||||
pub recent_days: Vec<PublicDaySummaryView>,
|
||||
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_calories_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)]
|
||||
#[template(path = "calendar.html")]
|
||||
struct CalendarTemplate<'a> {
|
||||
|
|
@ -207,6 +227,12 @@ struct PlanningTemplate<'a> {
|
|||
page: &'a PlanningPageView,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "change_password.html")]
|
||||
struct ChangePasswordTemplate<'a> {
|
||||
page: &'a ChangePasswordPageView,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
struct LoginTemplate<'a> {
|
||||
|
|
@ -225,6 +251,12 @@ struct PublicProfileTemplate<'a> {
|
|||
page: &'a PublicProfilePageView,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "public_day.html")]
|
||||
struct PublicDayTemplate<'a> {
|
||||
page: &'a PublicDayPageView,
|
||||
}
|
||||
|
||||
impl CalendarTemplate<'_> {
|
||||
fn active_tab(&self) -> &str {
|
||||
"calendar"
|
||||
|
|
@ -249,6 +281,12 @@ impl PlanningTemplate<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
impl ChangePasswordTemplate<'_> {
|
||||
fn active_tab(&self) -> &str {
|
||||
"planning"
|
||||
}
|
||||
}
|
||||
|
||||
impl LoginTemplate<'_> {
|
||||
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> {
|
||||
CalendarTemplate { page }.render()
|
||||
}
|
||||
|
|
@ -283,6 +327,10 @@ pub fn render_planning_page(page: &PlanningPageView) -> Result<String, askama::E
|
|||
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> {
|
||||
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> {
|
||||
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>
|
||||
</form>
|
||||
</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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{% for row in page.recent_days %}
|
||||
<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_weights %}{{ row.weight_label }}{% else %}Hidden{% endif %}</td>
|
||||
</tr>
|
||||
|
|
@ -30,6 +36,31 @@
|
|||
</section>
|
||||
{% 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 %}
|
||||
<section class="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{% for card in page.report_cards %}
|
||||
|
|
@ -60,3 +91,68 @@
|
|||
</section>
|
||||
{% endif %}
|
||||
{% 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