feat: Class-specific announcements

This commit is contained in:
2026-06-02 17:12:30 +02:00
parent fdcacc738d
commit 20b221acc8
4 changed files with 76 additions and 11 deletions
+26 -8
View File
@@ -45,6 +45,8 @@ pub struct Announcement {
pub end_date: String,
pub created_at: String,
pub flags: Vec<AnnouncementFlag>,
#[serde(default)]
pub classes: Vec<String>,
}
pub fn open(db_path: &str) -> Result<Connection> {
@@ -64,12 +66,16 @@ pub fn open(db_path: &str) -> Result<Connection> {
"ALTER TABLE announcements ADD COLUMN flags TEXT NOT NULL DEFAULT '[]'",
[],
);
let _ = conn.execute(
"ALTER TABLE announcements ADD COLUMN classes 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, flags, created_at
"SELECT id, author, text_content, start_date, end_date, flags, classes, created_at
FROM announcements
WHERE date(?) BETWEEN date(start_date) AND date(end_date)
ORDER BY created_at DESC",
@@ -90,7 +96,7 @@ pub fn list_all(
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
"SELECT id, author, text_content, start_date, end_date, flags, classes, created_at
FROM announcements
WHERE date(?1) BETWEEN date(start_date) AND date(end_date)
ORDER BY start_date DESC, created_at DESC
@@ -103,7 +109,7 @@ pub fn list_all(
)
} else {
(
"SELECT id, author, text_content, start_date, end_date, flags, created_at
"SELECT id, author, text_content, start_date, end_date, flags, classes, created_at
FROM announcements
ORDER BY start_date DESC, created_at DESC
LIMIT ?1 OFFSET ?2",
@@ -141,13 +147,22 @@ pub fn create(
text_content: Option<&str>,
start_date: &str,
end_date: &str,
classes: &[String],
flags: &[AnnouncementFlag],
) -> Result<Announcement> {
let classes_json = serde_json::to_string(classes).unwrap_or_else(|_| "[]".to_string());
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, flags)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![author, text_content, start_date, end_date, flags_json],
"INSERT INTO announcements (author, text_content, start_date, end_date, classes, flags)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
author,
text_content,
start_date,
end_date,
classes_json,
flags_json
],
)?;
let id = conn.last_insert_rowid();
get_by_id(conn, id)?.ok_or_else(|| {
@@ -162,7 +177,9 @@ 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 classes_json: String = row.get(6)?;
let flags: Vec<AnnouncementFlag> = serde_json::from_str(&flags_json).unwrap_or_default();
let classes: Vec<String> = serde_json::from_str(&classes_json).unwrap_or_default();
Ok(Announcement {
id: row.get(0)?,
author: row.get(1)?,
@@ -170,13 +187,14 @@ fn map_row(row: &rusqlite::Row) -> rusqlite::Result<Announcement> {
start_date: row.get(3)?,
end_date: row.get(4)?,
flags,
created_at: row.get(6)?,
classes,
created_at: row.get(7)?,
})
}
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, flags, created_at
"SELECT id, author, text_content, start_date, end_date, flags, classes, created_at
FROM announcements WHERE id = ?1",
)?;
let mut rows = stmt.query_map(params![id], map_row)?;
+21 -2
View File
@@ -92,7 +92,7 @@ async fn management_page(
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" };
let colspan = if filter_date.is_some() { "9" } else { "8" };
format!(
r#"<tr><td class="empty" colspan="{}">No announcements yet.</td></tr>"#,
colspan
@@ -109,6 +109,15 @@ async fn management_page(
})
.collect::<Vec<_>>()
.join(" ");
let classes_display: String = if a.classes.is_empty() {
r#"<span class="class-pill all">All</span>"#.to_string()
} else {
a.classes
.iter()
.map(|c| format!(r#"<span class="class-pill">{}</span>"#, escape_html(c)))
.collect::<Vec<_>>()
.join(" ")
};
let text_display = a
.text_content
.as_ref()
@@ -121,6 +130,7 @@ async fn management_page(
text = text_display,
start = a.start_date,
end = a.end_date,
classes_display = classes_display,
flags_display = flags_display,
)
})
@@ -244,6 +254,8 @@ struct CreateInput {
text_content: Option<String>,
start_date: String,
end_date: String,
#[serde(default)]
classes: String,
#[serde(default, deserialize_with = "one_or_many")]
flags: Vec<String>,
}
@@ -263,10 +275,17 @@ async fn create_announcement(
.filter_map(|s| s.parse::<AnnouncementFlag>().ok())
.collect();
let classes: Vec<String> = input
.classes
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let text = input.text_content.filter(|s| !s.is_empty());
let conn = db.lock().await;
if let Err(e) = db::create(&conn, &input.author, text.as_deref(), &input.start_date, &input.end_date, &flags) {
if let Err(e) = db::create(&conn, &input.author, text.as_deref(), &input.start_date, &input.end_date, &classes, &flags) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[("location", "/")],
+1
View File
@@ -4,6 +4,7 @@
<td class="cell-text" title="{text}">{text}</td>
<td class="cell-date">{start}</td>
<td class="cell-date">{end}</td>
<td>{classes_display}</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}?')">
+28 -1
View File
@@ -42,6 +42,9 @@
.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; }}
.class-pill {{ display: inline-block; padding: 0.2rem 0.55rem; border-radius: 999px; background: #f0fdf4; color: #166534; font-size: 0.75rem; font-weight: 500; white-space: nowrap; }}
.class-pill.all {{ background: #f1f5f9; color: #64748b; font-style: italic; }}
.field-hint {{ font-size: 0.8rem; color: #94a3b8; font-weight: 400; }}
.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; }}
@@ -93,6 +96,7 @@
<th>Text</th>
<th>Start</th>
<th>End</th>
<th>For</th>
<th>Flags</th>
<th>Actions</th>
</tr>
@@ -125,7 +129,12 @@
End date
<input type="date" name="end_date" required>
</label>
<label style="gap:0.5rem">
<label>
Classes
<input id="classes-input" name="classes" placeholder="e.g. C2c, A1" autocomplete="off">
<span class="field-hint">Leave empty for all classes. Separate multiple with commas.</span>
</label>
<label id="flags-section" style="gap:0.5rem">
Flags
<div class="flag-checkboxes">
{flags_checkboxes}
@@ -138,5 +147,23 @@
</div>
</form>
</dialog>
<script>
document.addEventListener('DOMContentLoaded', function() {{
var classesInput = document.getElementById('classes-input');
var flagsSection = document.getElementById('flags-section');
function toggleFlags() {{
if (classesInput.value.trim() !== '') {{
flagsSection.style.display = 'none';
var cbs = flagsSection.querySelectorAll('input[type="checkbox"]');
for (var i = 0; i < cbs.length; i++) {{ cbs[i].checked = false; }}
}} else {{
flagsSection.style.display = '';
}}
}}
classesInput.addEventListener('input', toggleFlags);
toggleFlags();
}});
</script>
</body>
</html>