This commit is contained in:
Peter Li 2026-02-08 00:11:49 -08:00
parent b6ae017457
commit 97412a8589
8 changed files with 676 additions and 4 deletions

161
src/db.rs
View File

@ -42,6 +42,23 @@ pub struct PlanningConfig {
pub public_planning: bool, pub public_planning: bool,
} }
#[derive(Clone)]
pub struct SubscriptionTarget {
pub user_id: i64,
pub username: String,
}
#[derive(Clone)]
pub struct NotificationRecord {
pub id: i64,
pub target_username: String,
pub previous_day: String,
pub message: String,
pub is_loud: bool,
pub is_viewed: bool,
pub created_at: String,
}
pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> { pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS users ( "CREATE TABLE IF NOT EXISTS users (
@ -89,6 +106,28 @@ pub fn init_db(conn: &Connection) -> Result<(), rusqlite::Error> {
public_weights INTEGER NOT NULL DEFAULT 0, public_weights INTEGER NOT NULL DEFAULT 0,
public_reports INTEGER NOT NULL DEFAULT 0, public_reports INTEGER NOT NULL DEFAULT 0,
public_planning INTEGER NOT NULL DEFAULT 0 public_planning INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS subscriptions (
subscriber_user_id INTEGER NOT NULL,
target_user_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (subscriber_user_id, target_user_id)
);
CREATE INDEX IF NOT EXISTS idx_subscriptions_subscriber
ON subscriptions(subscriber_user_id);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
target_user_id INTEGER NOT NULL,
notification_date TEXT NOT NULL,
previous_day TEXT NOT NULL,
message TEXT NOT NULL,
is_loud INTEGER NOT NULL DEFAULT 0,
is_viewed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, target_user_id, notification_date)
);", );",
)?; )?;
@ -192,6 +231,128 @@ pub fn update_user_timezone(
Ok(()) Ok(())
} }
pub fn create_subscription(
conn: &Connection,
subscriber_user_id: i64,
target_user_id: i64,
) -> Result<bool, rusqlite::Error> {
let changed = conn.execute(
"INSERT INTO subscriptions (subscriber_user_id, target_user_id)
VALUES (?1, ?2)
ON CONFLICT(subscriber_user_id, target_user_id) DO NOTHING",
params![subscriber_user_id, target_user_id],
)?;
Ok(changed > 0)
}
pub fn fetch_subscription_targets(
conn: &Connection,
subscriber_user_id: i64,
) -> Result<Vec<SubscriptionTarget>, rusqlite::Error> {
let mut statement = conn.prepare(
"SELECT u.id, u.username
FROM subscriptions s
JOIN users u ON u.id = s.target_user_id
WHERE s.subscriber_user_id = ?1
ORDER BY LOWER(u.username)",
)?;
let iter = statement.query_map(params![subscriber_user_id], |row| {
Ok(SubscriptionTarget {
user_id: row.get(0)?,
username: row.get(1)?,
})
})?;
let mut out = Vec::new();
for row in iter {
out.push(row?);
}
Ok(out)
}
pub fn count_activity_for_day(
conn: &Connection,
user_id: i64,
day: &str,
) -> Result<i64, rusqlite::Error> {
conn.query_row(
"SELECT
(SELECT COUNT(*) FROM user_food_entries WHERE user_id = ?1 AND entry_date = ?2) +
(SELECT COUNT(*) FROM user_daily_weights WHERE user_id = ?1 AND entry_date = ?2)",
params![user_id, day],
|row| row.get(0),
)
}
pub fn create_daily_notification(
conn: &Connection,
user_id: i64,
target_user_id: i64,
notification_date: &str,
previous_day: &str,
message: &str,
is_loud: bool,
) -> Result<bool, rusqlite::Error> {
let changed = conn.execute(
"INSERT INTO notifications (
user_id, target_user_id, notification_date, previous_day, message, is_loud
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(user_id, target_user_id, notification_date) DO NOTHING",
params![
user_id,
target_user_id,
notification_date,
previous_day,
message,
if is_loud { 1 } else { 0 }
],
)?;
Ok(changed > 0)
}
pub fn fetch_notifications_for_user(
conn: &Connection,
user_id: i64,
) -> Result<Vec<NotificationRecord>, rusqlite::Error> {
let mut statement = conn.prepare(
"SELECT n.id, u.username, n.previous_day, n.message, n.is_loud, n.is_viewed, n.created_at
FROM notifications n
JOIN users u ON u.id = n.target_user_id
WHERE n.user_id = ?1
ORDER BY n.id DESC",
)?;
let iter = statement.query_map(params![user_id], |row| {
Ok(NotificationRecord {
id: row.get(0)?,
target_username: row.get(1)?,
previous_day: row.get(2)?,
message: row.get(3)?,
is_loud: row.get::<_, i64>(4)? == 1,
is_viewed: row.get::<_, i64>(5)? == 1,
created_at: row.get(6)?,
})
})?;
let mut out = Vec::new();
for row in iter {
out.push(row?);
}
Ok(out)
}
pub fn mark_notification_viewed(
conn: &Connection,
user_id: i64,
notification_id: i64,
) -> Result<(), rusqlite::Error> {
conn.execute(
"UPDATE notifications
SET is_viewed = 1
WHERE id = ?1 AND user_id = ?2",
params![notification_id, user_id],
)?;
Ok(())
}
pub fn create_or_replace_session( pub fn create_or_replace_session(
conn: &Connection, conn: &Connection,
token: &str, token: &str,

View File

@ -15,8 +15,9 @@ use crate::config::AppConfig;
use crate::db; use crate::db;
use crate::views::{ use crate::views::{
self, CalendarCellView, CalendarNav, CalendarPageView, ChangePasswordPageView, DayPageView, self, CalendarCellView, CalendarNav, CalendarPageView, ChangePasswordPageView, DayPageView,
LoginPageView, PlanningPageView, PublicDayPageView, PublicDaySummaryView, PublicProfilePageView, InboxNotificationView, InboxPageView, LoginPageView, PlanningPageView, PublicDayPageView,
ReportCardView, ReportsPageView, SignupPageView, PublicDaySummaryView, PublicProfilePageView, ReportCardView, ReportsPageView, SignupPageView,
SubscribePageView,
}; };
type AppError = (StatusCode, String); type AppError = (StatusCode, String);
@ -200,6 +201,147 @@ pub async fn logout(
Ok(clear_session_cookie(Redirect::to("/login").into_response())) Ok(clear_session_cookie(Redirect::to("/login").into_response()))
} }
pub async fn show_profile(
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());
};
Ok(with_session_cookie(
Redirect::to(&format!("/u/{}", auth.user.username)).into_response(),
&auth.token,
))
}
pub async fn show_subscribe(
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());
};
ensure_daily_notifications_for_user(&state, &auth.user).await?;
render_subscribe_page_with_messages(&state, &auth, "", "").await
}
pub async fn subscribe_to_user(
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 username = form.get("username").map(|v| v.trim()).unwrap_or("");
if username.is_empty() {
return render_subscribe_page_with_messages(&state, &auth, "", "Username is required").await;
}
if username.eq_ignore_ascii_case(&auth.user.username) {
return render_subscribe_page_with_messages(
&state,
&auth,
"",
"You cannot subscribe to yourself",
)
.await;
}
let target = {
let db_conn = state.db.lock().await;
db::fetch_user_by_username(&db_conn, username).map_err(internal_db_error)?
};
let Some(target) = target else {
return render_subscribe_page_with_messages(
&state,
&auth,
"",
"User not found",
)
.await;
};
let created = {
let db_conn = state.db.lock().await;
db::create_subscription(&db_conn, auth.user.id, target.id).map_err(internal_db_error)?
};
if created {
render_subscribe_page_with_messages(
&state,
&auth,
&format!("Subscribed to @{}", target.username),
"",
)
.await
} else {
render_subscribe_page_with_messages(
&state,
&auth,
"",
&format!("You are already subscribed to @{}", target.username),
)
.await
}
}
pub async fn show_inbox(
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());
};
ensure_daily_notifications_for_user(&state, &auth.user).await?;
let notifications = {
let db_conn = state.db.lock().await;
db::fetch_notifications_for_user(&db_conn, auth.user.id).map_err(internal_db_error)?
};
let unread_count = notifications.iter().filter(|item| !item.is_viewed).count();
let items = notifications
.into_iter()
.map(|item| InboxNotificationView {
id: item.id,
target_username: item.target_username,
previous_day: item.previous_day,
message: item.message,
is_loud: item.is_loud,
is_viewed: item.is_viewed,
created_at: item.created_at,
})
.collect::<Vec<_>>();
let page = InboxPageView { unread_count, items };
let resp = Html(views::render_inbox_page(&page).map_err(internal_template_error)?)
.into_response();
Ok(with_session_cookie(resp, &auth.token))
}
pub async fn mark_inbox_notification_viewed(
State(state): State<AppState>,
headers: HeaderMap,
Path(notification_id): Path<i64>,
) -> Result<Response, AppError> {
let auth = get_auth_user(&state, &headers).await?;
let Some(auth) = auth else {
return Ok(Redirect::to("/login").into_response());
};
{
let db_conn = state.db.lock().await;
db::mark_notification_viewed(&db_conn, auth.user.id, notification_id)
.map_err(internal_db_error)?;
}
Ok(with_session_cookie(
Redirect::to("/inbox").into_response(),
&auth.token,
))
}
pub async fn show_public_profile( pub async fn show_public_profile(
State(state): State<AppState>, State(state): State<AppState>,
Path(username): Path<String>, Path(username): Path<String>,
@ -218,6 +360,10 @@ pub async fn show_public_profile(
}; };
let today = user_local_today(&user.timezone); let today = user_local_today(&user.timezone);
let focus_date = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"Invalid date".to_string(),
))?;
let start = today - Days::new(13); let start = today - Days::new(13);
let (calorie_map, weight_map) = { let (calorie_map, weight_map) = {
let db_conn = state.db.lock().await; let db_conn = state.db.lock().await;
@ -237,6 +383,64 @@ pub async fn show_public_profile(
.map_err(internal_db_error)?; .map_err(internal_db_error)?;
(c, w) (c, w)
}; };
let (first_day, first_of_next_month, days_in_month) = month_bounds(focus_date)?;
let (month_totals, month_weights) = {
let db_conn = state.db.lock().await;
let totals = db::fetch_month_summaries(
&db_conn,
user.id,
&first_day.format("%Y-%m-%d").to_string(),
&first_of_next_month.format("%Y-%m-%d").to_string(),
)
.map_err(internal_db_error)?;
let weights = db::fetch_daily_weights_for_range(
&db_conn,
user.id,
&first_day.format("%Y-%m-%d").to_string(),
&first_of_next_month.format("%Y-%m-%d").to_string(),
)
.map_err(internal_db_error)?;
(totals, weights)
};
let mut calendar_cells = Vec::with_capacity(42);
let first_weekday = first_day.weekday().num_days_from_sunday() as usize;
for _ in 0..first_weekday {
calendar_cells.push(CalendarCellView::padding());
}
for day_num in 1..=days_in_month {
if let Some(date) =
NaiveDate::from_ymd_opt(focus_date.year(), focus_date.month(), day_num as u32)
{
let date_text = date.format("%Y-%m-%d").to_string();
let summary = month_totals.get(&date_text);
let entry_count = summary.map(|s| s.entry_count).unwrap_or(0);
let weight_label = month_weights
.get(&date_text)
.map(|w| format!("{w:.1} lbs"))
.unwrap_or_else(|| "No weight (lbs)".to_string());
let href = if planning.public_entries && date <= today {
format!("/u/{}/day/{}", user.username, date_text)
} else {
"#".to_string()
};
calendar_cells.push(CalendarCellView::day(
href,
date_text,
day_num as u32,
summary.map(|s| s.total_calories).unwrap_or(0),
entry_count_label(entry_count),
weight_label,
date == today,
date > today,
));
}
}
let total_cells = first_weekday + days_in_month as usize;
let tail_padding = (7 - (total_cells % 7)) % 7;
for _ in 0..tail_padding {
calendar_cells.push(CalendarCellView::padding());
}
let mut recent_days = Vec::new(); let mut recent_days = Vec::new();
let mut day = start; let mut day = start;
@ -302,6 +506,8 @@ pub async fn show_public_profile(
show_weights: planning.public_weights, show_weights: planning.public_weights,
show_reports: planning.public_reports, show_reports: planning.public_reports,
show_planning: planning.public_planning, show_planning: planning.public_planning,
month_label: focus_date.format("%B %Y").to_string(),
calendar_cells,
recent_days, recent_days,
report_cards, report_cards,
chart_labels_js: js_string_array(&chart_labels), chart_labels_js: js_string_array(&chart_labels),
@ -986,6 +1192,72 @@ async fn render_signup_with_error(
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())
} }
async fn render_subscribe_page_with_messages(
state: &AppState,
auth: &AuthUser,
message: &str,
error: &str,
) -> Result<Response, AppError> {
let subscriptions = {
let db_conn = state.db.lock().await;
db::fetch_subscription_targets(&db_conn, auth.user.id).map_err(internal_db_error)?
};
let page = SubscribePageView {
message: message.to_string(),
error: error.to_string(),
subscriptions: subscriptions
.into_iter()
.map(|item| item.username)
.collect::<Vec<_>>(),
};
let resp = Html(views::render_subscribe_page(&page).map_err(internal_template_error)?)
.into_response();
Ok(with_session_cookie(resp, &auth.token))
}
async fn ensure_daily_notifications_for_user(
state: &AppState,
user: &db::UserRecord,
) -> Result<(), AppError> {
let today = user_local_today(&user.timezone);
let previous_day = today - Days::new(1);
let notification_date = today.format("%Y-%m-%d").to_string();
let previous_day_text = previous_day.format("%Y-%m-%d").to_string();
let targets = {
let db_conn = state.db.lock().await;
db::fetch_subscription_targets(&db_conn, user.id).map_err(internal_db_error)?
};
let db_conn = state.db.lock().await;
for target in targets {
let activity_count = db::count_activity_for_day(&db_conn, target.user_id, &previous_day_text)
.map_err(internal_db_error)?;
let is_loud = activity_count == 0;
let message = if is_loud {
format!(
"@{} had no entries yesterday ({})",
target.username, previous_day_text
)
} else {
format!(
"@{} logged {} update(s) yesterday ({})",
target.username, activity_count, previous_day_text
)
};
let _ = db::create_daily_notification(
&db_conn,
user.id,
target.user_id,
&notification_date,
&previous_day_text,
&message,
is_loud,
)
.map_err(internal_db_error)?;
}
Ok(())
}
fn render_change_password_page( fn render_change_password_page(
token: &str, token: &str,
error: &str, error: &str,

View File

@ -40,6 +40,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/signup", get(handlers::show_signup)) .route("/signup", get(handlers::show_signup))
.route("/signup", post(handlers::signup)) .route("/signup", post(handlers::signup))
.route("/logout", post(handlers::logout)) .route("/logout", post(handlers::logout))
.route("/profile", get(handlers::show_profile))
.route("/inbox", get(handlers::show_inbox))
.route(
"/inbox/{id}/view",
post(handlers::mark_inbox_notification_viewed),
)
.route("/subscribe", get(handlers::show_subscribe))
.route("/subscribe", post(handlers::subscribe_to_user))
.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("/u/{username}/day/{date}", get(handlers::show_public_day))
.route("/reports", get(handlers::show_reports)) .route("/reports", get(handlers::show_reports))

View File

@ -159,6 +159,27 @@ pub struct ChangePasswordPageView {
pub success: String, pub success: String,
} }
pub struct SubscribePageView {
pub message: String,
pub error: String,
pub subscriptions: Vec<String>,
}
pub struct InboxNotificationView {
pub id: i64,
pub target_username: String,
pub previous_day: String,
pub message: String,
pub is_loud: bool,
pub is_viewed: bool,
pub created_at: String,
}
pub struct InboxPageView {
pub unread_count: usize,
pub items: Vec<InboxNotificationView>,
}
pub struct LoginPageView { pub struct LoginPageView {
pub allow_signup: bool, pub allow_signup: bool,
pub error: String, pub error: String,
@ -184,6 +205,8 @@ pub struct PublicProfilePageView {
pub show_weights: bool, pub show_weights: bool,
pub show_reports: bool, pub show_reports: bool,
pub show_planning: bool, pub show_planning: bool,
pub month_label: String,
pub calendar_cells: Vec<CalendarCellView>,
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 chart_labels_js: String,
@ -235,6 +258,18 @@ struct ChangePasswordTemplate<'a> {
page: &'a ChangePasswordPageView, page: &'a ChangePasswordPageView,
} }
#[derive(Template)]
#[template(path = "subscribe.html")]
struct SubscribeTemplate<'a> {
page: &'a SubscribePageView,
}
#[derive(Template)]
#[template(path = "inbox.html")]
struct InboxTemplate<'a> {
page: &'a InboxPageView,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "login.html")] #[template(path = "login.html")]
struct LoginTemplate<'a> { struct LoginTemplate<'a> {
@ -289,6 +324,18 @@ impl ChangePasswordTemplate<'_> {
} }
} }
impl SubscribeTemplate<'_> {
fn active_tab(&self) -> &str {
"subscribe"
}
}
impl InboxTemplate<'_> {
fn active_tab(&self) -> &str {
"inbox"
}
}
impl LoginTemplate<'_> { impl LoginTemplate<'_> {
fn active_tab(&self) -> &str { fn active_tab(&self) -> &str {
"" ""
@ -303,13 +350,13 @@ impl SignupTemplate<'_> {
impl PublicProfileTemplate<'_> { impl PublicProfileTemplate<'_> {
fn active_tab(&self) -> &str { fn active_tab(&self) -> &str {
"" "profile"
} }
} }
impl PublicDayTemplate<'_> { impl PublicDayTemplate<'_> {
fn active_tab(&self) -> &str { fn active_tab(&self) -> &str {
"" "profile"
} }
} }
@ -333,6 +380,14 @@ pub fn render_change_password_page(page: &ChangePasswordPageView) -> Result<Stri
ChangePasswordTemplate { page }.render() ChangePasswordTemplate { page }.render()
} }
pub fn render_subscribe_page(page: &SubscribePageView) -> Result<String, askama::Error> {
SubscribeTemplate { page }.render()
}
pub fn render_inbox_page(page: &InboxPageView) -> Result<String, askama::Error> {
InboxTemplate { 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()
} }

View File

@ -29,6 +29,24 @@
> >
Planning Planning
</a> </a>
<a
href="/profile"
class="rounded-lg px-4 py-2 text-sm font-semibold {% if self.active_tab() == "profile" %}bg-slate-900 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}"
>
Profile
</a>
<a
href="/subscribe"
class="rounded-lg px-4 py-2 text-sm font-semibold {% if self.active_tab() == "subscribe" %}bg-slate-900 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}"
>
Subscribe
</a>
<a
href="/inbox"
class="rounded-lg px-4 py-2 text-sm font-semibold {% if self.active_tab() == "inbox" %}bg-slate-900 text-white{% else %}text-slate-700 hover:bg-slate-100{% endif %}"
>
Inbox
</a>
</div> </div>
<form method="post" action="/logout"> <form method="post" action="/logout">
<button <button

44
templates/inbox.html Normal file
View File

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Inbox{% 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">Inbox</h1>
<p class="mt-3 text-sm text-slate-600">Daily subscription notifications.</p>
<p class="mt-1 text-xs text-slate-500">Unread: {{ page.unread_count }}</p>
</section>
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm">
{% if page.items.is_empty() %}
<p class="text-sm text-slate-600">No notifications yet. Subscribe to someone first.</p>
{% else %}
<div class="grid gap-3">
{% for item in page.items %}
<article class="rounded-2xl border p-4 {% if item.is_viewed %}border-slate-200 bg-slate-50{% else %}border-teal-200 bg-teal-50{% endif %}">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-semibold {% if item.is_viewed %}text-slate-700{% else %}text-slate-900{% endif %}">
{% if item.is_loud %}Loud alert:{% else %}Update:{% endif %} @{{ item.target_username }}
</p>
<p class="text-xs text-slate-500">{{ item.created_at }}</p>
</div>
<p class="mt-2 text-sm {% if item.is_loud %}font-semibold text-rose-700{% else %}text-slate-700{% endif %}">
{{ item.message }}
</p>
<p class="mt-1 text-xs text-slate-500">For day: {{ item.previous_day }}</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<a href="/u/{{ item.target_username }}" class="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-800 hover:bg-slate-100">Open profile</a>
{% if !item.is_viewed %}
<form method="post" action="/inbox/{{ item.id }}/view">
<button type="submit" class="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white hover:bg-slate-700">Mark viewed</button>
</form>
{% else %}
<span class="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-500">Viewed</span>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% endif %}
</section>
{% endblock %}

View File

@ -9,6 +9,74 @@
</section> </section>
{% if page.show_entries || page.show_weights %} {% 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">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold tracking-tight">{{ page.month_label }}</h2>
<p class="text-xs text-slate-500">Calendar view</p>
</div>
<div class="mt-4 overflow-x-auto">
<div class="min-w-[720px]">
<div class="mb-4 grid grid-cols-7 gap-2">
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Sun</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Mon</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Tue</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Wed</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Thu</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Fri</div>
<div class="text-center text-xs font-semibold uppercase tracking-wide text-slate-500">Sat</div>
</div>
<div class="grid grid-cols-7 gap-2">
{% for cell in page.calendar_cells %}
{% if cell.is_padding %}
<div class="h-32 rounded-2xl border border-dashed border-slate-300/80 bg-white/40" aria-hidden="true"></div>
{% else %}
{% if cell.is_future %}
<div class="h-32 rounded-2xl border border-slate-200 bg-slate-100/80 p-3 opacity-65">
<div class="flex items-start justify-between">
<span class="text-sm font-semibold text-slate-500">{{ cell.date_text }}</span>
<span class="rounded-full bg-slate-400 px-2 py-0.5 text-xs font-semibold text-white">{{ cell.day_num }}</span>
</div>
<div class="mt-6">
<p class="text-xl font-bold text-slate-500">{% if page.show_entries %}{{ cell.total }} cal{% else %}Hidden{% endif %}</p>
<p class="text-xs text-slate-500">{% if page.show_entries %}{{ cell.entry_label }}{% else %}Entries hidden{% endif %}</p>
<p class="text-xs text-slate-500">{% if page.show_weights %}{{ cell.weight_label }}{% else %}Weight hidden{% endif %}</p>
<p class="mt-1 text-[11px] uppercase tracking-wide text-slate-400">Future</p>
</div>
</div>
{% else %}
{% if cell.href != "#" %}
<a class="{{ cell.card_class }}" href="{{ cell.href }}">
<div class="flex items-start justify-between">
<span class="text-sm font-semibold text-slate-500">{{ cell.date_text }}</span>
<span class="{{ cell.badge_class }}">{{ cell.day_num }}</span>
</div>
<div class="mt-6">
<p class="{{ cell.total_class }}">{% if page.show_entries %}{{ cell.total }} cal{% else %}Hidden{% endif %}</p>
<p class="{{ cell.entry_class }}">{% if page.show_entries %}{{ cell.entry_label }}{% else %}Entries hidden{% endif %}</p>
<p class="{{ cell.entry_class }}">{% if page.show_weights %}{{ cell.weight_label }}{% else %}Weight hidden{% endif %}</p>
</div>
</a>
{% else %}
<div class="h-32 rounded-2xl border border-slate-200 bg-white p-3">
<div class="flex items-start justify-between">
<span class="text-sm font-semibold text-slate-500">{{ cell.date_text }}</span>
<span class="rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold text-white">{{ cell.day_num }}</span>
</div>
<div class="mt-6">
<p class="text-xl font-bold text-slate-900">{% if page.show_entries %}{{ cell.total }} cal{% else %}Hidden{% endif %}</p>
<p class="text-xs text-slate-600">{% if page.show_entries %}{{ cell.entry_label }}{% else %}Entries hidden{% endif %}</p>
<p class="text-xs text-slate-600">{% if page.show_weights %}{{ cell.weight_label }}{% else %}Weight hidden{% endif %}</p>
</div>
</div>
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
</div>
</div>
</div>
</section>
<section class="mt-4 rounded-3xl border border-slate-200/80 bg-white/90 p-6 shadow-sm"> <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">Recent Days</h2> <h2 class="text-xl font-bold tracking-tight">Recent Days</h2>
<div class="mt-3 overflow-x-auto"> <div class="mt-3 overflow-x-auto">

46
templates/subscribe.html Normal file
View File

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Subscribe{% 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">Subscribe</h1>
<p class="mt-3 text-sm text-slate-600">Enter a friend's username to subscribe. No user search is provided.</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.message.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.message }}</p>
{% endif %}
<form method="post" action="/subscribe" class="mt-5 flex max-w-lg flex-wrap items-center gap-2">
<input
type="text"
name="username"
placeholder="Friend username"
required
class="flex-1 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-teal-500 focus:outline-none"
/>
<button type="submit" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700">Subscribe</button>
</form>
<div class="mt-6">
<h2 class="text-lg font-bold tracking-tight text-slate-900">Subscribed Users</h2>
{% if page.subscriptions.is_empty() %}
<p class="mt-2 text-sm text-slate-600">No subscriptions yet.</p>
{% else %}
<div class="mt-3 flex flex-wrap gap-2">
{% for username in page.subscriptions %}
<a
href="/u/{{ username }}"
class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-100"
>
@{{ username }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endblock %}