diff --git a/.gitignore b/.gitignore index fedaa2b..c646814 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target .env + +announcement.db diff --git a/announcements.db b/announcements.db deleted file mode 100644 index 0be4bd8..0000000 Binary files a/announcements.db and /dev/null differ diff --git a/src/db.rs b/src/db.rs index e4121b6..ea1e19f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,5 +1,41 @@ use rusqlite::{params, Connection, Result}; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum AnnouncementFlag { + ShowAllEntries, +} + +impl AnnouncementFlag { + pub fn display_name(&self) -> &'static str { + match self { + AnnouncementFlag::ShowAllEntries => "Show all entries", + } + } + + pub fn all() -> &'static [AnnouncementFlag] { + &[AnnouncementFlag::ShowAllEntries] + } +} + +impl std::str::FromStr for AnnouncementFlag { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "show_all_entries" => Ok(AnnouncementFlag::ShowAllEntries), + _ => Err(format!("unknown flag: {s}")), + } + } +} + +impl std::fmt::Display for AnnouncementFlag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let json = serde_json::to_value(self).map_err(|_| std::fmt::Error)?; + let s = json.as_str().unwrap_or("?"); + write!(f, "{s}") + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct Announcement { pub id: i64, @@ -8,6 +44,7 @@ pub struct Announcement { pub start_date: String, pub end_date: String, pub created_at: String, + pub flags: Vec, } pub fn open(db_path: &str) -> Result { @@ -19,15 +56,20 @@ pub fn open(db_path: &str) -> Result { text_content TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL, + flags TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now')) );", )?; + let _ = conn.execute( + "ALTER TABLE announcements ADD COLUMN flags TEXT NOT NULL DEFAULT '[]'", + [], + ); Ok(conn) } pub fn get_for_date(conn: &Connection, date: &str) -> Result> { let mut stmt = conn.prepare( - "SELECT id, author, text_content, start_date, end_date, created_at + "SELECT id, author, text_content, start_date, end_date, flags, created_at FROM announcements WHERE date(?) BETWEEN date(start_date) AND date(end_date) ORDER BY created_at DESC", @@ -36,27 +78,76 @@ pub fn get_for_date(conn: &Connection, date: &str) -> Result> rows.collect() } -pub fn list_all(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, author, text_content, start_date, end_date, created_at - FROM announcements - ORDER BY start_date DESC, created_at DESC", - )?; - let rows = stmt.query_map([], map_row)?; +pub fn list_all( + conn: &Connection, + page: u32, + page_size: u32, + filter_date: Option<&str>, +) -> Result> { + let offset = ((page.max(1) - 1) * page_size) as i64; + let limit = page_size as i64; + + let (sql, param_values): (&str, Vec>) = + if let Some(date) = filter_date { + ( + "SELECT id, author, text_content, start_date, end_date, flags, created_at + FROM announcements + WHERE date(?1) BETWEEN date(start_date) AND date(end_date) + ORDER BY start_date DESC, created_at DESC + LIMIT ?2 OFFSET ?3", + vec![ + Box::new(date.to_string()), + Box::new(limit), + Box::new(offset), + ], + ) + } else { + ( + "SELECT id, author, text_content, start_date, end_date, flags, created_at + FROM announcements + ORDER BY start_date DESC, created_at DESC + LIMIT ?1 OFFSET ?2", + vec![Box::new(limit), Box::new(offset)], + ) + }; + + let mut stmt = conn.prepare(sql)?; + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|b| b.as_ref()).collect(); + let rows = stmt.query_map(params_ref.as_slice(), map_row)?; rows.collect() } +pub fn count_all(conn: &Connection, filter_date: Option<&str>) -> Result { + let (sql, param_values): (&str, Vec>) = if let Some(date) = + filter_date + { + ( + "SELECT COUNT(*) FROM announcements WHERE date(?1) BETWEEN date(start_date) AND date(end_date)", + vec![Box::new(date.to_string())], + ) + } else { + ("SELECT COUNT(*) FROM announcements", vec![]) + }; + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + param_values.iter().map(|b| b.as_ref()).collect(); + conn.query_row(sql, params_ref.as_slice(), |row| row.get(0)) +} + pub fn create( conn: &Connection, author: &str, text_content: &str, start_date: &str, end_date: &str, + flags: &[AnnouncementFlag], ) -> Result { + let flags_json = serde_json::to_string(flags).unwrap_or_else(|_| "[]".to_string()); conn.execute( - "INSERT INTO announcements (author, text_content, start_date, end_date) - VALUES (?1, ?2, ?3, ?4)", - params![author, text_content, start_date, end_date], + "INSERT INTO announcements (author, text_content, start_date, end_date, flags) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![author, text_content, start_date, end_date, flags_json], )?; let id = conn.last_insert_rowid(); get_by_id(conn, id)?.ok_or_else(|| { @@ -70,19 +161,22 @@ pub fn delete(conn: &Connection, id: i64) -> Result { } fn map_row(row: &rusqlite::Row) -> rusqlite::Result { + let flags_json: String = row.get(5)?; + let flags: Vec = serde_json::from_str(&flags_json).unwrap_or_default(); Ok(Announcement { id: row.get(0)?, author: row.get(1)?, text_content: row.get(2)?, start_date: row.get(3)?, end_date: row.get(4)?, - created_at: row.get(5)?, + flags, + created_at: row.get(6)?, }) } fn get_by_id(conn: &Connection, id: i64) -> Result> { let mut stmt = conn.prepare( - "SELECT id, author, text_content, start_date, end_date, created_at + "SELECT id, author, text_content, start_date, end_date, flags, created_at FROM announcements WHERE id = ?1", )?; let mut rows = stmt.query_map(params![id], map_row)?; diff --git a/src/management/mod.rs b/src/management/mod.rs index 5accff2..b0f3412 100644 --- a/src/management/mod.rs +++ b/src/management/mod.rs @@ -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>; @@ -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, +} + +const fn default_page_size() -> u32 { + 20 +} + // ── Handlers ────────────────────────────────────────────────────────────────── -async fn management_page(headers: HeaderMap, State(db): State) -> impl IntoResponse { +async fn management_page( + headers: HeaderMap, + State(db): State, + Query(params): Query, +) -> 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#"No announcements yet."#, + colspan + ) + } else { + announcements + .iter() + .map(|a| { + let flags_display: String = a + .flags + .iter() + .map(|f| { + format!(r#"{}"#, escape_html(f.display_name())) + }) + .collect::>() + .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#""#, + escape_html(&value), + escape_html(f.display_name()), ) }) - .collect(); + .collect::>() + .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#"Page "#, + ); + html.push_str(¤t.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)
"#); + + // prev + if current > 1 { + html.push_str(&format!( + r#"« Prev"#, + current - 1, + filter_param + )); + } else { + html.push_str(r#"« Prev"#); + } + + // 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#"1"#, + filter_param + )); + if start_page > 2 { + html.push_str(r#"..."#); + } + } + + for p in start_page..=end_page { + if p == current { + html.push_str(&format!(r#"{p}"#)); + } else { + html.push_str(&format!( + r#"{p}"#, + filter_param + )); + } + } + + if end_page < total { + if end_page < total - 1 { + html.push_str(r#"..."#); + } + html.push_str(&format!( + r#"{total}"#, + filter_param + )); + } + + // next + if current < total { + html.push_str(&format!( + r#"Next »"#, + current + 1, + filter_param + )); + } else { + html.push_str(r#"Next »"#); + } + + html.push_str("
"); + html +} + #[derive(Deserialize)] struct CreateInput { author: String, text_content: String, start_date: String, end_date: String, + #[serde(default)] + flags: Vec, } async fn create_announcement( @@ -110,8 +252,14 @@ async fn create_announcement( return unauthorized(); } + let flags: Vec = input + .flags + .iter() + .filter_map(|s| s.parse::().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, + Query(params): Query, ) -> 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('"', """) } +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(); diff --git a/templates/announcement_row.html b/templates/announcement_row.html index 8a0cd09..92529c1 100644 --- a/templates/announcement_row.html +++ b/templates/announcement_row.html @@ -1,12 +1,13 @@ - {id} + {id} {author} - {text} - {start} - {end} - -
- + {text} + {start} + {end} + {flags_display} + + +
- \ No newline at end of file + diff --git a/templates/empty_table.html b/templates/empty_table.html index 3994c70..3c9c987 100644 --- a/templates/empty_table.html +++ b/templates/empty_table.html @@ -1 +1 @@ -
No announcements yet.
\ No newline at end of file +
No announcements yet.
\ No newline at end of file diff --git a/templates/management_page.html b/templates/management_page.html index c826823..15c02f3 100644 --- a/templates/management_page.html +++ b/templates/management_page.html @@ -6,47 +6,137 @@ Announcement Manager -

Announcement Manager

- -

Create Announcement

-
-
- - - - - +
+

Announcement Manager

+
+ {total_count} total +
- +
-

Existing Announcements

- {rows} +
+
+
+ + +
+ Clear +
+ +
+ + + + + + + + + + + + + + {rows} + +
IDAuthorTextStartEndFlagsActions
+ {pagination} +
+
+ + +
+
New Announcement
+
+ + + + + +
+ +
+
diff --git a/templates/table_wrapper.html b/templates/table_wrapper.html index d105ac8..a1a6eb0 100644 --- a/templates/table_wrapper.html +++ b/templates/table_wrapper.html @@ -1,4 +1,4 @@ - + {rows}
IDAuthorTextStartEndActions
IDAuthorTextStartEndFlagsActions
\ No newline at end of file