diff --git a/migrations/20250210231615_add_pinned.sql b/migrations/20250210231615_add_pinned.sql new file mode 100644 index 0000000..493ab49 --- /dev/null +++ b/migrations/20250210231615_add_pinned.sql @@ -0,0 +1,9 @@ +-- Add migration script here +CREATE TABLE pinned_fish ( + id SERIAL PRIMARY KEY, + account_id VARCHAR(10) NOT NULL, + fish_id INTEGER NOT NULL, + FOREIGN KEY (account_id) REFERENCES accounts (account_id) ON DELETE CASCADE ON UPDATE NO ACTION +); + +CREATE INDEX pinned_fish_accounts ON pinned_fish (account_id); diff --git a/src/db.rs b/src/db.rs index 51bbe8b..95f762a 100644 --- a/src/db.rs +++ b/src/db.rs @@ -14,6 +14,13 @@ pub struct CaughtFish { pub fish_id: i32, } +#[derive(FromRow)] +pub struct PinnedFish { + pub id: Option, + pub account_id: String, + pub fish_id: i32, +} + pub async fn insert_account(id: &str, pool: &Pool) -> Result<(), AppError> { sqlx::query("INSERT INTO accounts (account_id) VALUES ($1)") .bind(id) @@ -62,3 +69,45 @@ pub async fn get_caught_fish(acc_id: &str, pool: &Pool) -> Result, +) -> Result<(), AppError> { + sqlx::query( + " + INSERT INTO pinned_fish (account_id, fish_id) VALUES ($1, $2) + ", + ) + .bind(acc_id) + .bind(fish_id) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_pinned_fish(acc_id: &str, pool: &Pool) -> Result, AppError> { + let results: Vec = + sqlx::query_as("SELECT * FROM pinned_fish WHERE account_id = $1") + .bind(acc_id) + .fetch_all(pool) + .await?; + + Ok(results.iter().map(|cf| cf.fish_id).collect()) +} + +pub async fn delete_pinned_fish( + acc_id: &str, + fish_id: &i32, + pool: &Pool, +) -> Result<(), AppError> { + sqlx::query("DELETE FROM pinned_fish WHERE account_id = $1 AND fish_id = $2") + .bind(acc_id) + .bind(fish_id) + .fetch_all(pool) + .await?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index dad21b4..2fedf87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use axum::{ Router, }; use data::{CombinedFish, Data, Filters}; +use db::delete_pinned_fish; use maud::{html, Markup}; use nanoid::nanoid; use serde::Deserialize; @@ -77,6 +78,7 @@ async fn main_handler( return Ok(html! { "The provided account ID doesn't eixst." }.into_response()); } let caught_fish = db::get_caught_fish(&acc_id, &state.pool).await?; + let pinned_fish = db::get_pinned_fish(&acc_id, &state.pool).await?; let filters = Filters::from_query(query); // If this is a HTMX-sent request, don't resend the entire page, just the list. @@ -86,9 +88,10 @@ async fn main_handler( let meta = state.data.fish_with_meta(); let mut values: Vec<&CombinedFish> = filters.filter(meta.values().collect(), &caught_fish); values.sort_by(|afish, bfish| { - bfish - .is_up - .cmp(&afish.is_up) + pinned_fish + .contains(&(bfish.entry.id as i32)) + .cmp(&pinned_fish.contains(&(afish.entry.id as i32))) + .then(bfish.is_up.cmp(&afish.is_up)) .then(bfish.is_always_up.cmp(&afish.is_always_up)) .then(bfish.rarity.total_cmp(&afish.rarity).reverse()) .then(bfish.meta.name_en.cmp(&afish.meta.name_en)) @@ -100,6 +103,7 @@ async fn main_handler( state, fish: values, caught_fish, + pinned_fish, acc_id, filters, only_list: is_htmx, @@ -121,6 +125,34 @@ async fn insert_cf_handler( Ok(Redirect::to(&format!("/{}", acc_id)).into_response()) } +async fn insert_pin_handler( + state: State>, + Path((acc_id, fish_id)): Path<(String, String)>, +) -> Result { + let exists = db::get_account(&acc_id, &state.pool).await?; + if !exists { + return Ok(html! { "The provided account ID doesn't exist." }.into_response()); + } + + db::insert_pinned_fish(&acc_id, &fish_id.parse::()?, &state.pool).await?; + + Ok(Redirect::to(&format!("/{}", acc_id)).into_response()) +} + +async fn delete_pin_handler( + state: State>, + Path((acc_id, fish_id)): Path<(String, String)>, +) -> Result { + let exists = db::get_account(&acc_id, &state.pool).await?; + if !exists { + return Ok(html! { "The provided account ID doesn't exist." }.into_response()); + } + + db::delete_pinned_fish(&acc_id, &fish_id.parse::()?, &state.pool).await?; + + Ok(Redirect::to(&format!("/{}", acc_id)).into_response()) +} + async fn to_handler( state: State>, query: Query>, @@ -171,6 +203,8 @@ async fn main() { .route("/to", get(to_handler)) .route("/{id}", get(main_handler)) .route("/{acc_id}/catch/{fish_id}", post(insert_cf_handler)) + .route("/{acc_id}/pin/{fish_id}", post(insert_pin_handler)) + .route("/{acc_id}/pin/{fish_id}/delete", post(delete_pin_handler)) .route("/changelog", get(|| async { changelog::changelog_page() })) .route( "/logout", diff --git a/src/templates.rs b/src/templates.rs index 3fff20b..1847c0a 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -13,6 +13,7 @@ pub struct ViewData<'a> { pub state: State>, pub fish: Vec<&'a CombinedFish<'a>>, pub caught_fish: Vec, + pub pinned_fish: Vec, pub acc_id: String, pub filters: Filters, pub only_list: bool, @@ -74,7 +75,7 @@ pub fn main_page(data: ViewData) -> Markup { pub fn fish_list(data: &ViewData) -> Markup { html! { @for fish in data.fish.clone() { - section.fish.up[fish.is_up].alwaysup[fish.is_always_up] { + section.fish.up[fish.is_up].alwaysup[fish.is_always_up].pinned[data.pinned_fish.contains(&(fish.entry.id as i32))] { .title { div { @if !data.caught_fish.contains(&(fish.entry.id as i32)) { @@ -82,6 +83,16 @@ pub fn fish_list(data: &ViewData) -> Markup { button.catch-button type="submit" { (PreEscaped("✓")) } } } + + @if !data.pinned_fish.contains(&(fish.entry.id as i32)) { + form action=(format!("/{}/pin/{}", data.acc_id, fish.entry.id)) method="post" { + button.pin-button type="submit" { (PreEscaped("☆"))} + } + } @else { + form action=(format!("/{}/pin/{}/delete", data.acc_id, fish.entry.id)) method="post" { + button.pin-button type="submit" { (PreEscaped("★"))} + } + } } div { h3 { @@ -165,8 +176,8 @@ pub fn fish_list(data: &ViewData) -> Markup { @if fish.entry.start_hour.is_some() && fish.entry.end_hour.is_some() { div { - "ET " @if !fish.is_always_up { + "ET " (clock::display_eorzea_time(&clock::set_hm_from_float(&clock::get_current_eorzea_date(), fish.entry.start_hour.unwrap()))) "-" (clock::display_eorzea_time(&clock::set_hm_from_float(&clock::get_current_eorzea_date(), fish.entry.end_hour.unwrap()))) diff --git a/static/style.css b/static/style.css index 78fd7c5..918d02f 100644 --- a/static/style.css +++ b/static/style.css @@ -210,3 +210,12 @@ h2 small { color: gray; margin-right: 4px; } + +section.pinned { + background-color: tomato !important; + background-image: none; +} + +.pinned *:not(button) { + color: white; +}