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
+2
View File
@@ -1,2 +1,4 @@
/target
.env
announcement.db
BIN
View File
Binary file not shown.
+107 -13
View File
@@ -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<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)]
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<AnnouncementFlag>,
}
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,
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<Vec<Announcement>> {
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<Vec<Announcement>>
rows.collect()
}
pub fn list_all(conn: &Connection) -> Result<Vec<Announcement>> {
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<Vec<Announcement>> {
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()
}
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(
conn: &Connection,
author: &str,
text_content: &str,
start_date: &str,
end_date: &str,
flags: &[AnnouncementFlag],
) -> Result<Announcement> {
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<bool> {
}
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 {
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<Option<Announcement>> {
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)?;
+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();
+8 -7
View File
@@ -1,12 +1,13 @@
<tr>
<td>{id}</td>
<td class="cell-id">{id}</td>
<td>{author}</td>
<td>{text}</td>
<td>{start}</td>
<td>{end}</td>
<td>
<form action="/api/announcements/{id}/delete" method="POST" style="display:inline">
<button type="submit">Delete</button>
<td class="cell-text" title="{text}">{text}</td>
<td class="cell-date">{start}</td>
<td class="cell-date">{end}</td>
<td>{flags_display}</td>
<td class="cell-actions">
<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>
</td>
</tr>
+1 -1
View File
@@ -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
View File
@@ -6,47 +6,137 @@
<title>Announcement Manager</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: system-ui, sans-serif; background: #f5f5f5; padding: 2rem; color: #333; }}
h1 {{ margin-bottom: 1.5rem; }}
h2 {{ margin: 1.5rem 0 0.75rem; }}
form {{ background: #fff; padding: 1rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }}
.form-row {{ display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: end; }}
label {{ display: flex; flex-direction: column; font-size: 0.85rem; gap: 0.25rem; }}
input, textarea {{ padding: 0.4rem 0.6rem; border: 1px solid #ccc; border-radius: 4px; font-size: 0.95rem; }}
textarea {{ min-width: 220px; resize: vertical; }}
button {{ padding: 0.4rem 1rem; background: #2563eb; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 0.95rem; }}
button:hover {{ background: #1d4ed8; }}
button.danger {{ background: #dc2626; }}
button.danger:hover {{ background: #b91c1c; }}
table {{ width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1); }}
th, td {{ padding: 0.6rem 0.75rem; text-align: left; border-bottom: 1px solid #eee; }}
th {{ background: #f9fafb; font-weight: 600; }}
.empty {{ padding: 2rem; text-align: center; color: #888; }}
body {{ font-family: system-ui, -apple-system, sans-serif; background: #f1f5f9; color: #1e293b; min-height: 100vh; }}
.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; }}
.header h1 {{ font-size: 1.25rem; font-weight: 600; }}
.header-actions {{ display: flex; gap: 0.75rem; align-items: center; }}
.header-actions .count {{ font-size: 0.85rem; color: #64748b; }}
.main {{ padding: 2rem; max-width: 1400px; margin: 0 auto; }}
.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; }}
.toolbar form {{ display: flex; gap: 0.5rem; align-items: center; }}
.toolbar input[type=date] {{ padding: 0.45rem 0.7rem; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.9rem; }}
.toolbar .clear {{ font-size: 0.85rem; color: #64748b; text-decoration: none; }}
.toolbar .clear:hover {{ color: #1e293b; text-decoration: underline; }}
.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>
</head>
<body>
<h1>Announcement Manager</h1>
<h2>Create Announcement</h2>
<form action="/api/announcements" method="POST">
<div class="form-row">
<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>
<header class="header">
<h1>Announcement Manager</h1>
<div class="header-actions">
<span class="count">{total_count} total</span>
<button class="btn btn-primary" onclick="document.getElementById('create-dialog').showModal()">+ New</button>
</div>
</form>
</header>
<h2>Existing Announcements</h2>
{rows}
<div class="main">
<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&hellip;" 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>
</html>
+1 -1
View File
@@ -1,4 +1,4 @@
<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>
</table>