feat: A lot of changes
This commit is contained in:
@@ -1,2 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
announcement.db
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,41 @@
|
|||||||
use rusqlite::{params, Connection, Result};
|
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<Self, Self::Err> {
|
||||||
|
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)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct Announcement {
|
pub struct Announcement {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -8,6 +44,7 @@ pub struct Announcement {
|
|||||||
pub start_date: String,
|
pub start_date: String,
|
||||||
pub end_date: String,
|
pub end_date: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub flags: Vec<AnnouncementFlag>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open(db_path: &str) -> Result<Connection> {
|
pub fn open(db_path: &str) -> Result<Connection> {
|
||||||
@@ -19,15 +56,20 @@ pub fn open(db_path: &str) -> Result<Connection> {
|
|||||||
text_content TEXT NOT NULL,
|
text_content TEXT NOT NULL,
|
||||||
start_date TEXT NOT NULL,
|
start_date TEXT NOT NULL,
|
||||||
end_date TEXT NOT NULL,
|
end_date TEXT NOT NULL,
|
||||||
|
flags TEXT NOT NULL DEFAULT '[]',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);",
|
);",
|
||||||
)?;
|
)?;
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE announcements ADD COLUMN flags TEXT NOT NULL DEFAULT '[]'",
|
||||||
|
[],
|
||||||
|
);
|
||||||
Ok(conn)
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_for_date(conn: &Connection, date: &str) -> Result<Vec<Announcement>> {
|
pub fn get_for_date(conn: &Connection, date: &str) -> Result<Vec<Announcement>> {
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM announcements
|
||||||
WHERE date(?) BETWEEN date(start_date) AND date(end_date)
|
WHERE date(?) BETWEEN date(start_date) AND date(end_date)
|
||||||
ORDER BY created_at DESC",
|
ORDER BY created_at DESC",
|
||||||
@@ -36,27 +78,76 @@ pub fn get_for_date(conn: &Connection, date: &str) -> Result<Vec<Announcement>>
|
|||||||
rows.collect()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_all(conn: &Connection) -> Result<Vec<Announcement>> {
|
pub fn list_all(
|
||||||
let mut stmt = conn.prepare(
|
conn: &Connection,
|
||||||
"SELECT id, author, text_content, start_date, end_date, created_at
|
page: u32,
|
||||||
FROM announcements
|
page_size: u32,
|
||||||
ORDER BY start_date DESC, created_at DESC",
|
filter_date: Option<&str>,
|
||||||
)?;
|
) -> Result<Vec<Announcement>> {
|
||||||
let rows = stmt.query_map([], map_row)?;
|
let offset = ((page.max(1) - 1) * page_size) as i64;
|
||||||
|
let limit = page_size as i64;
|
||||||
|
|
||||||
|
let (sql, param_values): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) =
|
||||||
|
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()
|
rows.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn count_all(conn: &Connection, filter_date: Option<&str>) -> Result<u64> {
|
||||||
|
let (sql, param_values): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = 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(
|
pub fn create(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
author: &str,
|
author: &str,
|
||||||
text_content: &str,
|
text_content: &str,
|
||||||
start_date: &str,
|
start_date: &str,
|
||||||
end_date: &str,
|
end_date: &str,
|
||||||
|
flags: &[AnnouncementFlag],
|
||||||
) -> Result<Announcement> {
|
) -> Result<Announcement> {
|
||||||
|
let flags_json = serde_json::to_string(flags).unwrap_or_else(|_| "[]".to_string());
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO announcements (author, text_content, start_date, end_date)
|
"INSERT INTO announcements (author, text_content, start_date, end_date, flags)
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
params![author, text_content, start_date, end_date],
|
params![author, text_content, start_date, end_date, flags_json],
|
||||||
)?;
|
)?;
|
||||||
let id = conn.last_insert_rowid();
|
let id = conn.last_insert_rowid();
|
||||||
get_by_id(conn, id)?.ok_or_else(|| {
|
get_by_id(conn, id)?.ok_or_else(|| {
|
||||||
@@ -70,19 +161,22 @@ pub fn delete(conn: &Connection, id: i64) -> Result<bool> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn map_row(row: &rusqlite::Row) -> rusqlite::Result<Announcement> {
|
fn map_row(row: &rusqlite::Row) -> rusqlite::Result<Announcement> {
|
||||||
|
let flags_json: String = row.get(5)?;
|
||||||
|
let flags: Vec<AnnouncementFlag> = serde_json::from_str(&flags_json).unwrap_or_default();
|
||||||
Ok(Announcement {
|
Ok(Announcement {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
author: row.get(1)?,
|
author: row.get(1)?,
|
||||||
text_content: row.get(2)?,
|
text_content: row.get(2)?,
|
||||||
start_date: row.get(3)?,
|
start_date: row.get(3)?,
|
||||||
end_date: row.get(4)?,
|
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<Option<Announcement>> {
|
fn get_by_id(conn: &Connection, id: i64) -> Result<Option<Announcement>> {
|
||||||
let mut stmt = conn.prepare(
|
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",
|
FROM announcements WHERE id = ?1",
|
||||||
)?;
|
)?;
|
||||||
let mut rows = stmt.query_map(params![id], map_row)?;
|
let mut rows = stmt.query_map(params![id], map_row)?;
|
||||||
|
|||||||
+205
-25
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Form, Path, State},
|
extract::{Form, Path, Query, State},
|
||||||
http::{header, HeaderMap, StatusCode},
|
http::{header, HeaderMap, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
@@ -11,7 +11,7 @@ use axum::{
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::db;
|
use crate::db::{self, AnnouncementFlag};
|
||||||
|
|
||||||
type Db = Arc<Mutex<Connection>>;
|
type Db = Arc<Mutex<Connection>>;
|
||||||
|
|
||||||
@@ -53,52 +53,194 @@ fn unauthorized() -> Response {
|
|||||||
|
|
||||||
use axum::response::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 ──────────────────────────────────────────────────────────────────
|
// ── 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) {
|
if !check_auth(&headers) {
|
||||||
return unauthorized();
|
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 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);
|
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()
|
.iter()
|
||||||
.map(|a| {
|
.map(|f| {
|
||||||
|
let value = f.to_string().to_lowercase();
|
||||||
format!(
|
format!(
|
||||||
include_str!("../../templates/announcement_row.html"),
|
r#"<label><input type="checkbox" name="flags" value="{}"> {}</label>"#,
|
||||||
id = a.id,
|
escape_html(&value),
|
||||||
author = escape_html(&a.author),
|
escape_html(f.display_name()),
|
||||||
text = escape_html(&a.text_content),
|
|
||||||
start = a.start_date,
|
|
||||||
end = a.end_date,
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.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!(
|
let html = format!(
|
||||||
include_str!("../../templates/management_page.html"),
|
include_str!("../../templates/management_page.html"),
|
||||||
rows = if announcements.is_empty() {
|
total_count = total,
|
||||||
include_str!("../../templates/empty_table.html").to_string()
|
filter_date = escape_html(&filter_date_value),
|
||||||
} else {
|
flags_checkboxes = flags_checkboxes,
|
||||||
format!(
|
rows = rows,
|
||||||
include_str!("../../templates/table_wrapper.html"),
|
pagination = pagination,
|
||||||
rows = rows
|
|
||||||
)
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
|
([(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(¤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)</span><div class="pagination-pages">"#);
|
||||||
|
|
||||||
|
// prev
|
||||||
|
if current > 1 {
|
||||||
|
html.push_str(&format!(
|
||||||
|
r#"<a href="/?page={}{}" class="page-btn">« Prev</a>"#,
|
||||||
|
current - 1,
|
||||||
|
filter_param
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
html.push_str(r#"<span class="page-btn disabled">« 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 »</a>"#,
|
||||||
|
current + 1,
|
||||||
|
filter_param
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
html.push_str(r#"<span class="page-btn disabled">Next »</span>"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.push_str("</div></div>");
|
||||||
|
html
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct CreateInput {
|
struct CreateInput {
|
||||||
author: String,
|
author: String,
|
||||||
text_content: String,
|
text_content: String,
|
||||||
start_date: String,
|
start_date: String,
|
||||||
end_date: String,
|
end_date: String,
|
||||||
|
#[serde(default)]
|
||||||
|
flags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_announcement(
|
async fn create_announcement(
|
||||||
@@ -110,8 +252,14 @@ async fn create_announcement(
|
|||||||
return unauthorized();
|
return unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let flags: Vec<AnnouncementFlag> = input
|
||||||
|
.flags
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| s.parse::<AnnouncementFlag>().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
let conn = db.lock().await;
|
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 (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
[("location", "/")],
|
[("location", "/")],
|
||||||
@@ -126,14 +274,32 @@ async fn create_announcement(
|
|||||||
async fn list_announcements(
|
async fn list_announcements(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
State(db): State<Db>,
|
State(db): State<Db>,
|
||||||
|
Query(params): Query<ListParams>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
if !check_auth(&headers) {
|
if !check_auth(&headers) {
|
||||||
return unauthorized();
|
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 conn = db.lock().await;
|
||||||
match db::list_all(&conn) {
|
let list = db::list_all(&conn, page, page_size, filter_date.as_deref());
|
||||||
Ok(list) => (StatusCode::OK, Json(serde_json::json!(list))).into_response(),
|
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) => (
|
Err(e) => (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(serde_json::json!({ "error": e.to_string() })),
|
Json(serde_json::json!({ "error": e.to_string() })),
|
||||||
@@ -166,8 +332,22 @@ fn escape_html(s: &str) -> String {
|
|||||||
.replace('"', """)
|
.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 {
|
fn base64_encode(input: &str) -> String {
|
||||||
// minimal Base64 implementation to avoid extra dependencies
|
|
||||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
let bytes = input.as_bytes();
|
let bytes = input.as_bytes();
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{id}</td>
|
<td class="cell-id">{id}</td>
|
||||||
<td>{author}</td>
|
<td>{author}</td>
|
||||||
<td>{text}</td>
|
<td class="cell-text" title="{text}">{text}</td>
|
||||||
<td>{start}</td>
|
<td class="cell-date">{start}</td>
|
||||||
<td>{end}</td>
|
<td class="cell-date">{end}</td>
|
||||||
<td>
|
<td>{flags_display}</td>
|
||||||
<form action="/api/announcements/{id}/delete" method="POST" style="display:inline">
|
<td class="cell-actions">
|
||||||
<button type="submit">Delete</button>
|
<form action="/api/announcements/{id}/delete" method="POST" style="display:inline" onsubmit="return confirm('Delete announcement #{id}?')">
|
||||||
|
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<table><tr><td class="empty" colspan="6">No announcements yet.</td></tr></table>
|
<table><tr><td class="empty" colspan="7">No announcements yet.</td></tr></table>
|
||||||
+127
-37
@@ -6,47 +6,137 @@
|
|||||||
<title>Announcement Manager</title>
|
<title>Announcement Manager</title>
|
||||||
<style>
|
<style>
|
||||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||||
body {{ font-family: system-ui, sans-serif; background: #f5f5f5; padding: 2rem; color: #333; }}
|
body {{ font-family: system-ui, -apple-system, sans-serif; background: #f1f5f9; color: #1e293b; min-height: 100vh; }}
|
||||||
h1 {{ margin-bottom: 1.5rem; }}
|
|
||||||
h2 {{ margin: 1.5rem 0 0.75rem; }}
|
.header {{ background: #fff; border-bottom: 1px solid #e2e8f0; padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 10; }}
|
||||||
form {{ background: #fff; padding: 1rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }}
|
.header h1 {{ font-size: 1.25rem; font-weight: 600; }}
|
||||||
.form-row {{ display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: end; }}
|
.header-actions {{ display: flex; gap: 0.75rem; align-items: center; }}
|
||||||
label {{ display: flex; flex-direction: column; font-size: 0.85rem; gap: 0.25rem; }}
|
.header-actions .count {{ font-size: 0.85rem; color: #64748b; }}
|
||||||
input, textarea {{ padding: 0.4rem 0.6rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.95rem; }}
|
|
||||||
textarea {{ min-width: 220px; resize: vertical; }}
|
.main {{ padding: 2rem; max-width: 1400px; margin: 0 auto; }}
|
||||||
button {{ padding: 0.4rem 1rem; background: #2563eb; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 0.95rem; }}
|
|
||||||
button:hover {{ background: #1d4ed8; }}
|
.toolbar {{ display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; margin-bottom: 1.25rem; padding: 1rem 1.25rem; background: #fff; border-radius: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.06); border: 1px solid #e2e8f0; }}
|
||||||
button.danger {{ background: #dc2626; }}
|
.toolbar form {{ display: flex; gap: 0.5rem; align-items: center; }}
|
||||||
button.danger:hover {{ background: #b91c1c; }}
|
.toolbar input[type=date] {{ padding: 0.45rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; }}
|
||||||
table {{ width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1); }}
|
.toolbar .clear {{ font-size: 0.85rem; color: #64748b; text-decoration: none; }}
|
||||||
th, td {{ padding: 0.6rem 0.75rem; text-align: left; border-bottom: 1px solid #eee; }}
|
.toolbar .clear:hover {{ color: #1e293b; text-decoration: underline; }}
|
||||||
th {{ background: #f9fafb; font-weight: 600; }}
|
|
||||||
.empty {{ padding: 2rem; text-align: center; color: #888; }}
|
.btn {{ padding: 0.45rem 1rem; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9rem; font-weight: 500; display: inline-flex; align-items: center; gap: 0.35rem; text-decoration: none; }}
|
||||||
|
.btn-primary {{ background: #2563eb; color: #fff; }}
|
||||||
|
.btn-primary:hover {{ background: #1d4ed8; }}
|
||||||
|
.btn-ghost {{ background: transparent; color: #64748b; border: 1px solid #e2e8f0; }}
|
||||||
|
.btn-ghost:hover {{ background: #f8fafc; color: #1e293b; }}
|
||||||
|
.btn-danger {{ background: #ef4444; color: #fff; }}
|
||||||
|
.btn-danger:hover {{ background: #dc2626; }}
|
||||||
|
.btn-sm {{ padding: 0.3rem 0.65rem; font-size: 0.8rem; }}
|
||||||
|
|
||||||
|
.table-wrap {{ background: #fff; border-radius: 10px; border: 1px solid #e2e8f0; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.06); }}
|
||||||
|
table {{ width: 100%; border-collapse: collapse; }}
|
||||||
|
th {{ padding: 0.85rem 1rem; text-align: left; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #64748b; background: #f8fafc; border-bottom: 1px solid #e2e8f0; }}
|
||||||
|
td {{ padding: 0.85rem 1rem; border-bottom: 1px solid #f1f5f9; font-size: 0.9rem; vertical-align: middle; }}
|
||||||
|
tr:last-child td {{ border-bottom: none; }}
|
||||||
|
tr:hover td {{ background: #f8fafc; }}
|
||||||
|
.cell-id {{ color: #94a3b8; font-size: 0.8rem; font-family: monospace; vertical-align: top; }}
|
||||||
|
.cell-text {{ max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
|
||||||
|
.cell-date {{ font-size: 0.85rem; color: #64748b; white-space: nowrap; font-variant-numeric: tabular-nums; }}
|
||||||
|
.cell-actions {{ white-space: nowrap; }}
|
||||||
|
|
||||||
|
.flag {{ display: inline-block; padding: 0.2rem 0.55rem; border-radius: 999px; background: #dbeafe; color: #1e40af; font-size: 0.75rem; font-weight: 500; white-space: nowrap; }}
|
||||||
|
.empty {{ padding: 3rem 1rem; text-align: center; color: #94a3b8; font-size: 0.95rem; }}
|
||||||
|
|
||||||
|
.pagination {{ display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem; padding: 1rem 1.25rem; background: #fff; border-top: 1px solid #e2e8f0; }}
|
||||||
|
.pagination-info {{ font-size: 0.9rem; color: #64748b; padding: 0.25rem 0; }}
|
||||||
|
.pagination-pages {{ display: flex; gap: 0.4rem; align-items: center; }}
|
||||||
|
.page-btn {{ display: inline-flex; align-items: center; justify-content: center; min-width: 2.5rem; height: 2.5rem; padding: 0 0.75rem; border-radius: 8px; font-size: 0.9rem; font-weight: 500; text-decoration: none; color: #475569; background: #f8fafc; border: 1px solid #e2e8f0; }}
|
||||||
|
.page-btn:hover {{ background: #f1f5f9; border-color: #cbd5e1; }}
|
||||||
|
.page-btn.active {{ background: #2563eb; color: #fff; font-weight: 600; border-color: #2563eb; }}
|
||||||
|
.page-btn.disabled {{ color: #cbd5e1; cursor: default; background: #f8fafc; border-color: #f1f5f9; }}
|
||||||
|
.page-btn.dots {{ color: #94a3b8; border: none; min-width: auto; padding: 0 0.3rem; background: transparent; }}
|
||||||
|
|
||||||
|
dialog {{ margin: auto; border: none; border-radius: 12px; padding: 0; box-shadow: 0 20px 60px rgba(0,0,0,.2); max-width: 520px; width: 90vw; }}
|
||||||
|
dialog::backdrop {{ background: rgba(0,0,0,.4); }}
|
||||||
|
dialog form {{ display: flex; flex-direction: column; }}
|
||||||
|
.dialog-header {{ padding: 1.25rem 1.5rem; border-bottom: 1px solid #e2e8f0; font-size: 1.1rem; font-weight: 600; }}
|
||||||
|
.dialog-body {{ padding: 1.25rem 1.5rem; display: flex; flex-direction: column; gap: 1rem; }}
|
||||||
|
.dialog-body label {{ display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; font-weight: 500; }}
|
||||||
|
.dialog-body input, .dialog-body textarea {{ padding: 0.5rem 0.65rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; font-family: inherit; }}
|
||||||
|
.dialog-body textarea {{ min-height: 80px; resize: vertical; }}
|
||||||
|
.dialog-footer {{ padding: 1rem 1.5rem; border-top: 1px solid #e2e8f0; display: flex; justify-content: flex-end; gap: 0.5rem; }}
|
||||||
|
.flag-checkboxes {{ display: flex; gap: 0.75rem; flex-wrap: wrap; }}
|
||||||
|
.flag-checkboxes label {{ display: flex; flex-direction: row; align-items: center; gap: 0.3rem; font-size: 0.85rem; font-weight: 400; cursor: pointer; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Announcement Manager</h1>
|
<header class="header">
|
||||||
|
<h1>Announcement Manager</h1>
|
||||||
<h2>Create Announcement</h2>
|
<div class="header-actions">
|
||||||
<form action="/api/announcements" method="POST">
|
<span class="count">{total_count} total</span>
|
||||||
<div class="form-row">
|
<button class="btn btn-primary" onclick="document.getElementById('create-dialog').showModal()">+ New</button>
|
||||||
<label>Author
|
|
||||||
<input name="author" placeholder="e.g. School office" required>
|
|
||||||
</label>
|
|
||||||
<label>Text
|
|
||||||
<textarea name="text_content" placeholder="Announcement text…" required></textarea>
|
|
||||||
</label>
|
|
||||||
<label>Start date
|
|
||||||
<input type="date" name="start_date" required>
|
|
||||||
</label>
|
|
||||||
<label>End date
|
|
||||||
<input type="date" name="end_date" required>
|
|
||||||
</label>
|
|
||||||
<button type="submit">Create</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</header>
|
||||||
|
|
||||||
<h2>Existing Announcements</h2>
|
<div class="main">
|
||||||
{rows}
|
<div class="toolbar">
|
||||||
|
<form action="/" method="GET">
|
||||||
|
<input type="date" name="filter_date" value="{filter_date}">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
|
<a class="clear" href="/">Clear</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Text</th>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>End</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{pagination}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog id="create-dialog">
|
||||||
|
<form action="/api/announcements" method="POST">
|
||||||
|
<div class="dialog-header">New Announcement</div>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<label>
|
||||||
|
Author
|
||||||
|
<input name="author" placeholder="e.g. School office" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Text
|
||||||
|
<textarea name="text_content" placeholder="Announcement text…" required></textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Start date
|
||||||
|
<input type="date" name="start_date" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
End date
|
||||||
|
<input type="date" name="end_date" required>
|
||||||
|
</label>
|
||||||
|
<label style="gap:0.5rem">
|
||||||
|
Flags
|
||||||
|
<div class="flag-checkboxes">
|
||||||
|
{flags_checkboxes}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<button class="btn btn-ghost" type="button" onclick="this.closest('dialog').close()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" type="submit">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead><tr><th>ID</th><th>Author</th><th>Text</th><th>Start</th><th>End</th><th>Actions</th></tr></thead>
|
<thead><tr><th>ID</th><th>Author</th><th>Text</th><th>Start</th><th>End</th><th>Flags</th><th>Actions</th></tr></thead>
|
||||||
<tbody>{rows}</tbody>
|
<tbody>{rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
Reference in New Issue
Block a user