feat: Class-specific announcements
This commit is contained in:
@@ -45,6 +45,8 @@ pub struct Announcement {
|
|||||||
pub end_date: String,
|
pub end_date: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub flags: Vec<AnnouncementFlag>,
|
pub flags: Vec<AnnouncementFlag>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub classes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open(db_path: &str) -> Result<Connection> {
|
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 '[]'",
|
"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)
|
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, flags, created_at
|
"SELECT id, author, text_content, start_date, end_date, flags, classes, 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",
|
||||||
@@ -90,7 +96,7 @@ pub fn list_all(
|
|||||||
let (sql, param_values): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) =
|
let (sql, param_values): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) =
|
||||||
if let Some(date) = filter_date {
|
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
|
FROM announcements
|
||||||
WHERE date(?1) BETWEEN date(start_date) AND date(end_date)
|
WHERE date(?1) BETWEEN date(start_date) AND date(end_date)
|
||||||
ORDER BY start_date DESC, created_at DESC
|
ORDER BY start_date DESC, created_at DESC
|
||||||
@@ -103,7 +109,7 @@ pub fn list_all(
|
|||||||
)
|
)
|
||||||
} else {
|
} 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
|
FROM announcements
|
||||||
ORDER BY start_date DESC, created_at DESC
|
ORDER BY start_date DESC, created_at DESC
|
||||||
LIMIT ?1 OFFSET ?2",
|
LIMIT ?1 OFFSET ?2",
|
||||||
@@ -141,13 +147,22 @@ pub fn create(
|
|||||||
text_content: Option<&str>,
|
text_content: Option<&str>,
|
||||||
start_date: &str,
|
start_date: &str,
|
||||||
end_date: &str,
|
end_date: &str,
|
||||||
|
classes: &[String],
|
||||||
flags: &[AnnouncementFlag],
|
flags: &[AnnouncementFlag],
|
||||||
) -> Result<Announcement> {
|
) -> 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());
|
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, flags)
|
"INSERT INTO announcements (author, text_content, start_date, end_date, classes, flags)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
params![author, text_content, start_date, end_date, flags_json],
|
params![
|
||||||
|
author,
|
||||||
|
text_content,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
classes_json,
|
||||||
|
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(|| {
|
||||||
@@ -162,7 +177,9 @@ 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_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 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 {
|
Ok(Announcement {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
author: row.get(1)?,
|
author: row.get(1)?,
|
||||||
@@ -170,13 +187,14 @@ fn map_row(row: &rusqlite::Row) -> rusqlite::Result<Announcement> {
|
|||||||
start_date: row.get(3)?,
|
start_date: row.get(3)?,
|
||||||
end_date: row.get(4)?,
|
end_date: row.get(4)?,
|
||||||
flags,
|
flags,
|
||||||
created_at: row.get(6)?,
|
classes,
|
||||||
|
created_at: row.get(7)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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, flags, created_at
|
"SELECT id, author, text_content, start_date, end_date, flags, classes, 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)?;
|
||||||
|
|||||||
+21
-2
@@ -92,7 +92,7 @@ async fn management_page(
|
|||||||
let total_pages = (total as u32).div_ceil(page_size).max(1);
|
let total_pages = (total as u32).div_ceil(page_size).max(1);
|
||||||
|
|
||||||
let rows: String = if announcements.is_empty() {
|
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!(
|
format!(
|
||||||
r#"<tr><td class="empty" colspan="{}">No announcements yet.</td></tr>"#,
|
r#"<tr><td class="empty" colspan="{}">No announcements yet.</td></tr>"#,
|
||||||
colspan
|
colspan
|
||||||
@@ -109,6 +109,15 @@ async fn management_page(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.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
|
let text_display = a
|
||||||
.text_content
|
.text_content
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -121,6 +130,7 @@ async fn management_page(
|
|||||||
text = text_display,
|
text = text_display,
|
||||||
start = a.start_date,
|
start = a.start_date,
|
||||||
end = a.end_date,
|
end = a.end_date,
|
||||||
|
classes_display = classes_display,
|
||||||
flags_display = flags_display,
|
flags_display = flags_display,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -244,6 +254,8 @@ struct CreateInput {
|
|||||||
text_content: Option<String>,
|
text_content: Option<String>,
|
||||||
start_date: String,
|
start_date: String,
|
||||||
end_date: String,
|
end_date: String,
|
||||||
|
#[serde(default)]
|
||||||
|
classes: String,
|
||||||
#[serde(default, deserialize_with = "one_or_many")]
|
#[serde(default, deserialize_with = "one_or_many")]
|
||||||
flags: Vec<String>,
|
flags: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -263,10 +275,17 @@ async fn create_announcement(
|
|||||||
.filter_map(|s| s.parse::<AnnouncementFlag>().ok())
|
.filter_map(|s| s.parse::<AnnouncementFlag>().ok())
|
||||||
.collect();
|
.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 text = input.text_content.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
let conn = db.lock().await;
|
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 (
|
return (
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
[("location", "/")],
|
[("location", "/")],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<td class="cell-text" title="{text}">{text}</td>
|
<td class="cell-text" title="{text}">{text}</td>
|
||||||
<td class="cell-date">{start}</td>
|
<td class="cell-date">{start}</td>
|
||||||
<td class="cell-date">{end}</td>
|
<td class="cell-date">{end}</td>
|
||||||
|
<td>{classes_display}</td>
|
||||||
<td>{flags_display}</td>
|
<td>{flags_display}</td>
|
||||||
<td class="cell-actions">
|
<td class="cell-actions">
|
||||||
<form action="/api/announcements/{id}/delete" method="POST" style="display:inline" onsubmit="return confirm('Delete announcement #{id}?')">
|
<form action="/api/announcements/{id}/delete" method="POST" style="display:inline" onsubmit="return confirm('Delete announcement #{id}?')">
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
.cell-actions {{ white-space: nowrap; }}
|
.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; }}
|
.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; }}
|
.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 {{ 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>Text</th>
|
||||||
<th>Start</th>
|
<th>Start</th>
|
||||||
<th>End</th>
|
<th>End</th>
|
||||||
|
<th>For</th>
|
||||||
<th>Flags</th>
|
<th>Flags</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -125,7 +129,12 @@
|
|||||||
End date
|
End date
|
||||||
<input type="date" name="end_date" required>
|
<input type="date" name="end_date" required>
|
||||||
</label>
|
</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
|
Flags
|
||||||
<div class="flag-checkboxes">
|
<div class="flag-checkboxes">
|
||||||
{flags_checkboxes}
|
{flags_checkboxes}
|
||||||
@@ -138,5 +147,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user