feat: A lot of changes

This commit is contained in:
2026-06-02 10:06:18 +02:00
parent 0e0c0ee897
commit 43bd110268
8 changed files with 452 additions and 85 deletions
+205 -25
View File
@@ -2,7 +2,7 @@ use std::sync::Arc;
use tokio::sync::Mutex;
use axum::{
extract::{Form, Path, State},
extract::{Form, Path, Query, State},
http::{header, HeaderMap, StatusCode},
response::IntoResponse,
routing::{get, post},
@@ -11,7 +11,7 @@ use axum::{
use rusqlite::Connection;
use serde::Deserialize;
use crate::db;
use crate::db::{self, AnnouncementFlag};
type Db = Arc<Mutex<Connection>>;
@@ -53,52 +53,194 @@ fn unauthorized() -> Response {
use axum::response::Response;
// ── Query params ──────────────────────────────────────────────────────────────
#[derive(Deserialize, Default)]
struct ListParams {
#[serde(default)]
page: u32,
#[serde(default = "default_page_size")]
page_size: u32,
#[serde(default)]
filter_date: Option<String>,
}
const fn default_page_size() -> u32 {
20
}
// ── Handlers ──────────────────────────────────────────────────────────────────
async fn management_page(headers: HeaderMap, State(db): State<Db>) -> impl IntoResponse {
async fn management_page(
headers: HeaderMap,
State(db): State<Db>,
Query(params): Query<ListParams>,
) -> impl IntoResponse {
if !check_auth(&headers) {
return unauthorized();
}
let page = params.page.max(1);
let page_size = params.page_size.clamp(1, 100);
let filter_date = params.filter_date.filter(|s| !s.is_empty());
let conn = db.lock().await;
let announcements = db::list_all(&conn).unwrap_or_default();
let announcements = db::list_all(&conn, page, page_size, filter_date.as_deref()).unwrap_or_default();
let total = db::count_all(&conn, filter_date.as_deref()).unwrap_or(0);
drop(conn);
let rows: String = announcements
let total_pages = (total as u32).div_ceil(page_size).max(1);
let rows: String = if announcements.is_empty() {
let colspan = if filter_date.is_some() { "8" } else { "7" };
format!(
r#"<tr><td class="empty" colspan="{}">No announcements yet.</td></tr>"#,
colspan
)
} else {
announcements
.iter()
.map(|a| {
let flags_display: String = a
.flags
.iter()
.map(|f| {
format!(r#"<span class="flag">{}</span>"#, escape_html(f.display_name()))
})
.collect::<Vec<_>>()
.join(" ");
format!(
include_str!("../../templates/announcement_row.html"),
id = a.id,
author = escape_html(&a.author),
text = escape_html(&a.text_content),
start = a.start_date,
end = a.end_date,
flags_display = flags_display,
)
})
.collect()
};
let flags_checkboxes: String = AnnouncementFlag::all()
.iter()
.map(|a| {
.map(|f| {
let value = f.to_string().to_lowercase();
format!(
include_str!("../../templates/announcement_row.html"),
id = a.id,
author = escape_html(&a.author),
text = escape_html(&a.text_content),
start = a.start_date,
end = a.end_date,
r#"<label><input type="checkbox" name="flags" value="{}"> {}</label>"#,
escape_html(&value),
escape_html(f.display_name()),
)
})
.collect();
.collect::<Vec<_>>()
.join("");
let filter_date_value = filter_date.unwrap_or_default();
let filter_param = if filter_date_value.is_empty() {
String::new()
} else {
format!("&filter_date={}", urlencode(&filter_date_value))
};
let pagination = render_pagination(page, total_pages, total as u64, &filter_param);
let html = format!(
include_str!("../../templates/management_page.html"),
rows = if announcements.is_empty() {
include_str!("../../templates/empty_table.html").to_string()
} else {
format!(
include_str!("../../templates/table_wrapper.html"),
rows = rows
)
}
total_count = total,
filter_date = escape_html(&filter_date_value),
flags_checkboxes = flags_checkboxes,
rows = rows,
pagination = pagination,
);
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
}
fn render_pagination(current: u32, total: u32, count: u64, filter_param: &str) -> String {
if total <= 1 {
return String::new();
}
let mut html = String::from(
r#"<span class="pagination-info">Page "#,
);
html.push_str(&current.to_string());
html.push_str(r#" of "#);
html.push_str(&total.to_string());
html.push_str(r#" ("#);
html.push_str(&count.to_string());
html.push_str(r#" total)</span><div class="pagination-pages">"#);
// prev
if current > 1 {
html.push_str(&format!(
r#"<a href="/?page={}{}" class="page-btn">&laquo; Prev</a>"#,
current - 1,
filter_param
));
} else {
html.push_str(r#"<span class="page-btn disabled">&laquo; Prev</span>"#);
}
// page numbers - show a window around current page
let window: u32 = 2;
let start_page = if current > window + 1 { current - window } else { 1 };
let end_page = if current + window < total { current + window } else { total };
if start_page > 1 {
html.push_str(&format!(
r#"<a href="/?page=1{}" class="page-btn">1</a>"#,
filter_param
));
if start_page > 2 {
html.push_str(r#"<span class="page-btn dots">...</span>"#);
}
}
for p in start_page..=end_page {
if p == current {
html.push_str(&format!(r#"<span class="page-btn active">{p}</span>"#));
} else {
html.push_str(&format!(
r#"<a href="/?page={p}{}" class="page-btn">{p}</a>"#,
filter_param
));
}
}
if end_page < total {
if end_page < total - 1 {
html.push_str(r#"<span class="page-btn dots">...</span>"#);
}
html.push_str(&format!(
r#"<a href="/?page={total}{}" class="page-btn">{total}</a>"#,
filter_param
));
}
// next
if current < total {
html.push_str(&format!(
r#"<a href="/?page={}{}" class="page-btn">Next &raquo;</a>"#,
current + 1,
filter_param
));
} else {
html.push_str(r#"<span class="page-btn disabled">Next &raquo;</span>"#);
}
html.push_str("</div></div>");
html
}
#[derive(Deserialize)]
struct CreateInput {
author: String,
text_content: String,
start_date: String,
end_date: String,
#[serde(default)]
flags: Vec<String>,
}
async fn create_announcement(
@@ -110,8 +252,14 @@ async fn create_announcement(
return unauthorized();
}
let flags: Vec<AnnouncementFlag> = input
.flags
.iter()
.filter_map(|s| s.parse::<AnnouncementFlag>().ok())
.collect();
let conn = db.lock().await;
if let Err(e) = db::create(&conn, &input.author, &input.text_content, &input.start_date, &input.end_date) {
if let Err(e) = db::create(&conn, &input.author, &input.text_content, &input.start_date, &input.end_date, &flags) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[("location", "/")],
@@ -126,14 +274,32 @@ async fn create_announcement(
async fn list_announcements(
headers: HeaderMap,
State(db): State<Db>,
Query(params): Query<ListParams>,
) -> impl IntoResponse {
if !check_auth(&headers) {
return unauthorized();
}
let page = params.page.max(1);
let page_size = params.page_size.clamp(1, 100);
let filter_date = params.filter_date.filter(|s| !s.is_empty());
let conn = db.lock().await;
match db::list_all(&conn) {
Ok(list) => (StatusCode::OK, Json(serde_json::json!(list))).into_response(),
let list = db::list_all(&conn, page, page_size, filter_date.as_deref());
let total = db::count_all(&conn, filter_date.as_deref()).unwrap_or(0);
drop(conn);
match list {
Ok(items) => (
StatusCode::OK,
Json(serde_json::json!({
"items": items,
"page": page,
"page_size": page_size,
"total": total,
})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
@@ -166,8 +332,22 @@ fn escape_html(s: &str) -> String {
.replace('"', "&quot;")
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => {
out.push_str(&format!("%{:02X}", byte));
}
}
}
out
}
fn base64_encode(input: &str) -> String {
// minimal Base64 implementation to avoid extra dependencies
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let bytes = input.as_bytes();
let mut out = String::new();