diff --git a/endsong_web/Cargo.lock b/endsong_web/Cargo.lock index 6e8a664..ac05765 100644 --- a/endsong_web/Cargo.lock +++ b/endsong_web/Cargo.lock @@ -524,9 +524,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "js-sys" -version = "0.3.71" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cb94a0ffd3f3ee755c20f7d8752f45cac88605a4dcf808abcff72873296ec7b" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" dependencies = [ "wasm-bindgen", ] @@ -741,6 +741,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666f0f59e259aea2d72e6012290c09877a780935cc3c18b1ceded41f3890d59c" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "quote" version = "1.0.37" @@ -839,6 +850,7 @@ dependencies = [ "mime_guess", "once_map", "proc-macro2", + "pulldown-cmark", "quote", "rinja_parser", "rustc-hash", @@ -1232,9 +1244,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.94" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef073ced962d62984fb38a36e5fdc1a2b23c9e0e1fa0689bb97afa4202ef6887" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" dependencies = [ "cfg-if", "once_cell", @@ -1243,9 +1255,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.94" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4bfab14ef75323f4eb75fa52ee0a3fb59611977fd3240da19b2cf36ff85030e" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" dependencies = [ "bumpalo", "log", @@ -1258,9 +1270,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.94" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7bec9830f60924d9ceb3ef99d55c155be8afa76954edffbb5936ff4509474e7" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1268,9 +1280,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.94" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c74f6e152a76a2ad448e223b0fc0b6b5747649c3d769cc6bf45737bf97d0ed6" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", @@ -1281,9 +1293,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.94" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a42f6c679374623f295a8623adfe63d9284091245c3504bde47c17a3ce2777d9" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" [[package]] name = "winapi" diff --git a/endsong_web/Cargo.toml b/endsong_web/Cargo.toml index ad9f905..44e8749 100644 --- a/endsong_web/Cargo.toml +++ b/endsong_web/Cargo.toml @@ -8,7 +8,7 @@ axum = { version = "0.7", features = ["form"] } tower-http = { version = "0.6", features = ["compression-br"] } endsong = { path = ".." } itertools = "0.13" -rinja = { version = "0.3", features = ["with-axum"] } +rinja = { version = "0.3", features = ["with-axum", "code-in-doc"] } rinja_axum = "0.3" tokio = { version = "1.40", features = ["full"] } tracing = "0.1" diff --git a/endsong_web/src/artist.rs b/endsong_web/src/artist.rs new file mode 100644 index 0000000..2490cc1 --- /dev/null +++ b/endsong_web/src/artist.rs @@ -0,0 +1,84 @@ +//! Contains templates for `/artist` route + +use crate::*; + +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, State}, + response::{IntoResponse, Response}, +}; +use endsong::prelude::*; +use rinja_axum::Template; +use serde::Deserialize; +use tracing::debug; + +/// To choose an artist if there are multiple with same capitalization +/// (in my dataset tia) +#[derive(Deserialize)] +pub struct ArtistQuery { + id: usize, +} +/// [`Template`] for if there are multiple artist with different +/// capitalization in [`artist`] +#[derive(Template)] +#[template(path = "artist_selection.html", print = "none")] +struct ArtistSelectionTemplate { + artists: Vec, +} +/// [`Template`] for [`artist`] +#[derive(Template)] +#[template(path = "artist.html", print = "none")] +struct ArtistTemplate<'a> { + artist: &'a Artist, + plays: usize, + time_played: TimeDelta, +} +/// GET `/artist/:artist_name(?id=usize)` +/// +/// Artist page +/// +/// Returns an [`ArtistTemplate`] with a valid `artist_name`, +/// an [`ArtistSelectionTemplate`] if there are +/// multiple artists with this name +/// but different capitalization, +/// and [`not_found`] if it's not in the dataset +pub async fn base( + State(state): State>, + Path(artist_name): Path, + options: Option>, +) -> Response { + debug!( + artist_name = artist_name, + query = options.is_some(), + "GET /artist/:artist_name(?query)" + ); + + let entries = state.entries.read().await; + + let Some(artists) = entries.find().artist(&artist_name) else { + return not_found().await.into_response(); + }; + + let artist = if artists.len() == 1 { + artists.first() + } else if let Some(Query(options)) = options { + artists.get(options.id) + } else { + None + }; + + let artist = if let Some(artist) = artist { + artist + } else { + // query if multiple artists with different capitalization + return ArtistSelectionTemplate { artists }.into_response(); + }; + + ArtistTemplate { + plays: gather::plays(&entries, artist), + time_played: gather::listening_time(&entries, artist), + artist, + } + .into_response() +} diff --git a/endsong_web/src/artists.rs b/endsong_web/src/artists.rs new file mode 100644 index 0000000..79f2a2d --- /dev/null +++ b/endsong_web/src/artists.rs @@ -0,0 +1,74 @@ +//! Contains templates for `/artists`` routes + +use crate::AppState; + +use std::sync::Arc; + +use axum::{ + extract::{Form, State}, + response::IntoResponse, +}; +use rinja::Template; +use serde::Deserialize; +use tracing::debug; + +/// [`Template`] for [`base`] +#[derive(Template)] +#[template(path = "artists.html", print = "none")] +struct BaseTemplate {} +/// GET `/artists` +/// +/// List of artists (HTML Template will call [`elements`] on-load) +pub async fn base() -> impl IntoResponse { + debug!("GET /artists"); + + BaseTemplate {} +} + +#[derive(Deserialize)] +pub struct ArtistListForm { + search: String, +} +/// [`Template`] for [`elements`] +/// +/// Template: +/// ```rinja +/// {% for artist in artist_names %} +///
  • {{ artist }}
  • +/// {% endfor %} +/// ``` +#[derive(Template)] +#[template(in_doc = true, ext = "html", print = "none")] +struct ElementsTemplate { + artist_names: Vec>, +} +/// POST `/artists` +/// +/// List of artists +pub async fn elements( + State(state): State>, + Form(form): Form, +) -> impl IntoResponse { + debug!(search = form.search, "POST /artists"); + + let artists = state.artists.read().await; + + let lowercase_search = form.search.to_lowercase(); + + let artist_names = artists + .iter() + .filter(|artist| artist.to_lowercase().contains(&lowercase_search)) + .cloned() + .collect(); + + ElementsTemplate { artist_names } +} + +mod filters { + use urlencoding::encode; + + pub fn encodeurl(name: &str) -> rinja::Result { + // bc of artists like AC/DC + Ok(encode(name).to_string()) + } +} diff --git a/endsong_web/src/lib.rs b/endsong_web/src/lib.rs new file mode 100644 index 0000000..df8b747 --- /dev/null +++ b/endsong_web/src/lib.rs @@ -0,0 +1,58 @@ +pub mod artist; +pub mod artists; +pub mod r#static; + +use std::sync::Arc; + +use axum::{extract::State, http::StatusCode, response::IntoResponse}; +use endsong::prelude::*; +use rinja::Template; +use tokio::sync::RwLock; +use tracing::debug; + +/// State shared across all handlers +#[derive(Clone)] +pub struct AppState { + /// Reference to the [`SongEntries`] instance used + pub entries: Arc>, + /// Sorted list of all artist names in the dataset + pub artists: Arc>>>, +} +impl AppState { + pub fn new(entries: SongEntries) -> Arc { + Arc::new(Self { + artists: Arc::new(RwLock::new(entries.artists())), + entries: Arc::new(RwLock::new(entries)), + }) + } +} + +/// [`Template`] for [`not_found`] +#[derive(Template)] +#[template(path = "404.html", print = "none")] +struct NotFoundTemplate; +/// 404 +pub async fn not_found() -> impl IntoResponse { + debug!("404"); + + (StatusCode::NOT_FOUND, NotFoundTemplate {}) +} + +/// [`Template`] for [`index`] +#[derive(Template)] +#[template(path = "index.html", print = "none")] +struct IndexTemplate { + total_listened: TimeDelta, + playcount: usize, +} +/// GET `/` +pub async fn index(State(state): State>) -> impl IntoResponse { + debug!("GET /"); + + let entries = state.entries.read().await; + + IndexTemplate { + total_listened: gather::total_listening_time(&entries), + playcount: gather::all_plays(&entries), + } +} diff --git a/endsong_web/src/main.rs b/endsong_web/src/main.rs index d2d3b94..5963c3c 100644 --- a/endsong_web/src/main.rs +++ b/endsong_web/src/main.rs @@ -1,31 +1,11 @@ -use std::sync::Arc; +use endsong_web::*; -use axum::{ - extract::{Path, Query, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::get, - routing::post, - Form, Router, -}; +use axum::{routing::get, routing::post, Router}; use endsong::prelude::*; -use rinja_axum::Template; -use tokio::sync::RwLock; use tower_http::compression::CompressionLayer; use tracing::debug; use tracing_subscriber::filter::{EnvFilter, LevelFilter}; -/// Tailwind-generated CSS used on this web page -const STYLING: &str = include_str!("../static/tailwind_style.css"); - -/// HTMX code () -const HTMX: &str = include_str!("../static/htmx.min.2.0.3.js"); - -#[derive(Clone)] -struct AppState { - entries: Arc>, -} - #[tokio::main] async fn main() { let env = EnvFilter::builder() @@ -49,19 +29,17 @@ async fn main() { .sum_different_capitalization() .filter(30, TimeDelta::try_seconds(10).unwrap()); - let state = Arc::new(AppState { - entries: Arc::new(RwLock::new(entries)), - }); + let state = AppState::new(entries); let compression = CompressionLayer::new().br(true); let app = Router::new() .route("/", get(index)) - .route("/styles.css", get(styles)) - .route("/htmx.js", get(htmx)) - .route("/artists", get(artists)) - .route("/artists", post(artists_search)) - .route("/artist/:artist_name", get(artist)) + .route("/styles.css", get(r#static::styles)) + .route("/htmx.js", get(r#static::htmx)) + .route("/artists", get(artists::base)) + .route("/artists", post(artists::elements)) + .route("/artist/:artist_name", get(artist::base)) .with_state(state) .fallback(not_found) .layer(compression); @@ -74,176 +52,3 @@ async fn main() { axum::serve(listener, app).await.unwrap(); } - -/// [`Template`] for [`not_found`] -#[derive(Template)] -#[template(path = "404.html", print = "none")] -struct NotFound; -/// 404 -async fn not_found() -> impl IntoResponse { - debug!("404"); - - (StatusCode::NOT_FOUND, NotFound {}) -} - -/// GET `/styles` - CSS -/// -/// Idk yet how, but should be cached somehow for the future so that -/// it isn't requested on each load in full? idk -async fn styles() -> impl IntoResponse { - debug!("GET /styles"); - - axum_extra::response::Css(STYLING) -} - -/// GET `/htmx` - HTMX -/// -/// Idk yet how, but should be cached somehow for the future so that -/// it isn't requested on each load in full? idk -async fn htmx() -> impl IntoResponse { - debug!("GET /htmx"); - - axum_extra::response::JavaScript(HTMX) -} - -/// [`Template`] for [`index`] -#[derive(Template)] -#[template(path = "index.html", print = "none")] -struct Index { - total_listened: TimeDelta, - playcount: usize, -} -/// GET `/` -async fn index(State(state): State>) -> impl IntoResponse { - debug!("GET /"); - - let entries = state.entries.read().await; - - Index { - total_listened: gather::total_listening_time(&entries), - playcount: gather::all_plays(&entries), - } -} - -/// [`Template`] for [`artists`] -#[derive(Template)] -#[template(path = "artists.html", print = "none")] -struct Artists {} -/// GET `/artists` -/// -/// List of artists (HTML Template will call [`artists_search`] on-load) -async fn artists() -> impl IntoResponse { - debug!("GET /artists"); - - Artists {} -} - -#[derive(serde::Deserialize)] -struct ArtistsSearchForm { - search: String, -} -/// [`Template`] for [`artists_search`] -#[derive(Template)] -#[template(path = "artists_search.html", print = "none")] -struct ArtistsSearch { - artist_names: Vec>, -} -/// POST `/artists` -/// -/// List of artists -async fn artists_search( - State(state): State>, - Form(form): Form, -) -> impl IntoResponse { - debug!(search = form.search, "POST /artists"); - - let entries = state.entries.read().await; - - let lowercase_search = form.search.to_lowercase(); - - let artist_names = entries - .artists() - .into_iter() - .filter(|artist| artist.to_lowercase().contains(&lowercase_search)) - .collect(); - - ArtistsSearch { artist_names } -} - -/// To choose an artist if there are multiple with same capitalization -/// (in my dataset tia) -#[derive(serde::Deserialize)] -struct ArtistQuery { - id: usize, -} -/// [`Template`] for if there are multiple artist with different -/// capitalization in [`artist`] -#[derive(Template)] -#[template(path = "artist_selection.html", print = "none")] -struct ArtistSelection { - artists: Vec, -} -/// [`Template`] for [`artist`] -#[derive(Template)] -#[template(path = "artist.html", print = "none")] -struct ArtistPage<'a> { - artist: &'a Artist, - plays: usize, - time_played: TimeDelta, -} -/// GET `/artist/:artist_name(?id=usize)` -/// -/// Artist page -/// -/// Returns an [`ArtistPage`] with a valid `artist_name`, -/// an [`ArtistSelection`] if there are multiple artists with this name -/// but different capitalization, -/// and [`not_found`] if it's not in the dataset -async fn artist( - State(state): State>, - Path(artist_name): Path, - options: Option>, -) -> Response { - debug!( - artist_name = artist_name, - query = options.is_some(), - "GET /artist/:artist_name(?query)" - ); - - let entries = state.entries.read().await; - - let Some(artists) = entries.find().artist(&artist_name) else { - return not_found().await.into_response(); - }; - - let artist = if artists.len() == 1 { - artists.first() - } else if let Some(Query(options)) = options { - artists.get(options.id) - } else { - None - }; - - let artist = if let Some(artist) = artist { - artist - } else { - // query if multiple artists with different capitalization - return ArtistSelection { artists }.into_response(); - }; - - ArtistPage { - plays: gather::plays(&entries, artist), - time_played: gather::listening_time(&entries, artist), - artist, - } - .into_response() -} - -mod filters { - use urlencoding::encode; - - pub fn encodeurl(name: &str) -> rinja::Result { - // bc of artists like AC/DC - Ok(encode(name).to_string()) - } -} diff --git a/endsong_web/src/static.rs b/endsong_web/src/static.rs new file mode 100644 index 0000000..cb6d98b --- /dev/null +++ b/endsong_web/src/static.rs @@ -0,0 +1,30 @@ +//! Contains routes for static files + +use axum::response::IntoResponse; +use tracing::debug; + +/// Tailwind-generated CSS used on this web page +const STYLING: &str = include_str!("../static/tailwind_style.css"); + +/// HTMX code () +const HTMX: &str = include_str!("../static/htmx.min.2.0.3.js"); + +/// GET `/styles.css` - CSS +/// +/// Idk yet how, but should be cached somehow for the future so that +/// it isn't requested on each load in full? idk +pub async fn styles() -> impl IntoResponse { + debug!("GET /styles.css"); + + axum_extra::response::Css(STYLING) +} + +/// GET `/htmx.js` - HTMX +/// +/// Idk yet how, but should be cached somehow for the future so that +/// it isn't requested on each load in full? idk +pub async fn htmx() -> impl IntoResponse { + debug!("GET /htmx.js"); + + axum_extra::response::JavaScript(HTMX) +} diff --git a/endsong_web/static/tailwind_style.css b/endsong_web/static/tailwind_style.css index d5a7b01..99d3276 100644 --- a/endsong_web/static/tailwind_style.css +++ b/endsong_web/static/tailwind_style.css @@ -578,6 +578,10 @@ video { align-items: center; } +.justify-center { + justify-content: center; +} + .justify-around { justify-content: space-around; } @@ -586,6 +590,10 @@ video { justify-content: space-evenly; } +.gap-2 { + gap: 0.5rem; +} + .gap-3 { gap: 0.75rem; } @@ -598,8 +606,8 @@ video { gap: 2rem; } -.gap-2 { - gap: 0.5rem; +.gap-1 { + gap: 0.25rem; } .self-center { @@ -610,6 +618,11 @@ video { border-radius: 0.5rem; } +.rounded-s { + border-start-start-radius: 0.25rem; + border-end-start-radius: 0.25rem; +} + .border { border-width: 1px; } @@ -624,6 +637,11 @@ video { border-color: rgb(209 213 219 / var(--tw-border-opacity)); } +.border-gray-700 { + --tw-border-opacity: 1; + border-color: rgb(55 65 81 / var(--tw-border-opacity)); +} + .bg-gray-50 { --tw-bg-opacity: 1; background-color: rgb(249 250 251 / var(--tw-bg-opacity)); @@ -643,10 +661,6 @@ video { padding: 0.5rem; } -.p-4 { - padding: 1rem; -} - .p-6 { padding: 1.5rem; } @@ -655,8 +669,8 @@ video { padding: 1.75rem; } -.ps-10 { - padding-inline-start: 2.5rem; +.p-1 { + padding: 0.25rem; } .text-2xl { @@ -674,11 +688,6 @@ video { line-height: 1.75rem; } -.text-sm { - font-size: 0.875rem; - line-height: 1.25rem; -} - .text-xl { font-size: 1.25rem; line-height: 1.75rem; @@ -708,13 +717,3 @@ https://www.joshwcomeau.com/css/interactive-guide-to-flexbox/ * { min-width: 0; } - -.focus\:border-blue-500:focus { - --tw-border-opacity: 1; - border-color: rgb(59 130 246 / var(--tw-border-opacity)); -} - -.focus\:ring-blue-500:focus { - --tw-ring-opacity: 1; - --tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity)); -} diff --git a/endsong_web/templates/artists.html b/endsong_web/templates/artists.html index ef5db4d..45ad0bb 100644 --- a/endsong_web/templates/artists.html +++ b/endsong_web/templates/artists.html @@ -4,16 +4,19 @@ {% block content %}
    - + + + +