diff --git a/.gitea/workflows/push.yaml b/.gitea/workflows/push.yaml new file mode 100644 index 0000000..1bb6b5e --- /dev/null +++ b/.gitea/workflows/push.yaml @@ -0,0 +1,119 @@ +name: Push Workflows +on: push + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Rust toolchain + id: setup-toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly + targets: wasm32-unknown-unknown,x86_64-unknown-linux-gnu + - name: Cache + uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ steps.setup-toolchain.outputs.cachekey }} + - name: Install cargo-leptos + run: cargo install cargo-leptos + - name: Build project + env: + RUSTFLAGS: "-D warnings" + run: cargo-leptos build + + docker-build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Gitea container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.registry }} + username: ${{ env.actions_user }} + password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }} + - name: Get Image Name + id: get-image-name + run: | + echo "IMAGE_NAME=$(echo ${{ env.registry }}/${{ gitea.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + push: true + tags: "${{ steps.get-image-name.outputs.IMAGE_NAME }}:${{ gitea.sha }}" + cache-from: type=registry,ref=${{ steps.get-image-name.outputs.IMAGE_NAME }}:${{ gitea.sha }} + cache-to: type=inline + - name: Build and push Docker image with "latest" tag + uses: docker/build-push-action@v5 + if: gitea.ref == 'refs/heads/main' + with: + push: true + tags: "${{ steps.get-image-name.outputs.IMAGE_NAME }}:latest" + cache-from: type=registry,ref=${{ steps.get-image-name.outputs.IMAGE_NAME }}:latest + cache-to: type=inline + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Rust toolchain + id: setup-toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly + targets: wasm32-unknown-unknown,x86_64-unknown-linux-gnu + - name: Cache + uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ steps.setup-toolchain.outputs.cachekey }} + - name: Test project + run: cargo test --all-targets --all-features + + leptos-test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Rust toolchain + id: setup-toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly + targets: wasm32-unknown-unknown,x86_64-unknown-linux-gnu + - name: Cache + uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ steps.setup-toolchain.outputs.cachekey }} + - name: Install cargo-leptos + run: cargo install cargo-leptos + - name: Run Leptos tests + run: cargo-leptos test + + docs: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Setup Rust toolchain + id: setup-toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: nightly + targets: wasm32-unknown-unknown,x86_64-unknown-linux-gnu + - name: Cache + uses: Swatinem/rust-cache@v2 + with: + prefix-key: ${{ steps.setup-toolchain.outputs.cachekey }} + - name: Generate docs + run: cargo doc --no-deps + - name: Upload docs + uses: actions/upload-artifact@v3 + with: + name: docs + path: target/doc diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 4954a4c..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,101 +0,0 @@ -# Build the project -build: - needs: [] - image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest - variables: - RUSTFLAGS: "-D warnings" - script: - - cargo-leptos build - -.docker: - image: docker:latest - services: - - docker:dind - tags: - - docker - before_script: - - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - -# Build the docker image and push it to the registry -docker-build: - needs: ["build"] - extends: .docker - script: - - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA . - # If running on the default branch, tag as latest - - if [ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]; then docker tag - $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA - $CI_REGISTRY_IMAGE:latest; fi - - docker push $CI_REGISTRY_IMAGE --all-tags - -# Run leptos tests -leptos-tests: - needs: ["build"] - image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest - script: - - cargo-leptos test - -# Run all tests -tests: - needs: ["build"] - image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest - script: - - cargo test --all-targets --all-features - -# Generate docs -cargo-doc: - needs: [] - image: rust:slim - script: - - cargo doc --no-deps - artifacts: - paths: - - target/doc - -# Start the review environment -start-review: - extends: .docker - rules: - - if: $CI_PIPELINE_SOURCE == "merge_request_event" - when: manual - script: - - apk add curl openssl - - cd cicd - - echo "$CLOUDFLARE_TUNNEL_AUTH_JSON" > tunnel-auth.json - - ./add-dns.sh $CLOUDFLARE_ZONE_ID review-$CI_COMMIT_SHORT_SHA libretunes-auto-review $CLOUDFLARE_API_TOKEN $CLOUDFLARE_TUNNEL_ID - - ./create-tunnel-config.sh http://libretunes:3000 review-$CI_COMMIT_SHORT_SHA.libretunes.xyz $CLOUDFLARE_TUNNEL_ID - - export COMPOSE_PROJECT_NAME=review-$CI_COMMIT_SHORT_SHA - - export POSTGRES_PASSWORD=$(openssl rand -hex 16) - - export LIBRETUNES_VERSION=$CI_COMMIT_SHORT_SHA - - docker compose --file docker-compose-cicd.yml pull - - docker compose --file docker-compose-cicd.yml create - - export CONFIG_VOL_NAME=review-${CI_COMMIT_SHORT_SHA}_cloudflared-config - - export TMP_CONTAINER_NAME=$(docker run --rm -d -v $CONFIG_VOL_NAME:/data busybox sh -c "sleep infinity") - - docker cp tunnel-auth.json $TMP_CONTAINER_NAME:/data/auth.json - - docker cp cloudflared-tunnel-config.yml $TMP_CONTAINER_NAME:/data/config.yml - - docker stop $TMP_CONTAINER_NAME - - docker compose --file docker-compose-cicd.yml up -d - environment: - name: review/$CI_COMMIT_SHORT_SHA - url: https://review-$CI_COMMIT_SHORT_SHA.libretunes.xyz - on_stop: stop-review - auto_stop_in: 1 week - -# Stop the review environment -stop-review: - needs: ["start-review"] - extends: .docker - rules: - - if: $CI_PIPELINE_SOURCE == "merge_request_event" - when: manual - allow_failure: true - script: - - apk add jq curl - - ./cicd/remove-dns.sh $CLOUDFLARE_ZONE_ID review-$CI_COMMIT_SHORT_SHA.libretunes.xyz libretunes-auto-review $CLOUDFLARE_API_TOKEN - - export COMPOSE_PROJECT_NAME=review-$CI_COMMIT_SHORT_SHA - - export LIBRETUNES_VERSION=$CI_COMMIT_SHORT_SHA - - docker compose --file cicd/docker-compose-cicd.yml down - - docker compose --file cicd/docker-compose-cicd.yml rm -f -v - environment: - name: review/$CI_COMMIT_SHORT_SHA - action: stop diff --git a/Cargo.lock b/Cargo.lock index 0a6bd5b..5ca54c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2712,9 +2712,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.34" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", @@ -2733,9 +2733,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ "num-conv", "time-core", @@ -3173,19 +3173,20 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" dependencies = [ "cfg-if", + "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" dependencies = [ "bumpalo", "log", @@ -3210,9 +3211,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3220,9 +3221,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", @@ -3233,9 +3234,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" [[package]] name = "wasm-streams" diff --git a/Cargo.toml b/Cargo.toml index b162619..c2500e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ leptos = { version = "0.6", default-features = false, features = ["nightly"] } leptos_meta = { version = "0.6", features = ["nightly"] } leptos_axum = { version = "0.6", optional = true } leptos_router = { version = "0.6", features = ["nightly"] } -wasm-bindgen = { version = "=0.2.92", default-features = false, optional = true } +wasm-bindgen = { version = "=0.2.93", default-features = false, optional = true } leptos_icons = { version = "0.3.0" } icondata = { version = "0.3.0" } dotenv = { version = "0.15.0", optional = true } diff --git a/Dockerfile b/Dockerfile index a0390e8..000173b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,20 @@ -FROM registry.mregirouard.com/libretunes/ops/docker-leptos/musl:latest as builder +FROM clux/muslrust:nightly AS builder WORKDIR /app +RUN rustup target add wasm32-unknown-unknown + # Install a few dependencies RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ + pkg-config \ + clang \ npm; \ rm -rf /var/lib/apt/lists/* +RUN cargo install cargo-leptos + RUN npm install tailwindcss@3.1.8 -g # Copy project dependency manifests diff --git a/migrations/2024-05-10-195644_create_likes_dislikes_table/down.sql b/migrations/2024-05-10-195644_create_likes_dislikes_table/down.sql new file mode 100644 index 0000000..c341129 --- /dev/null +++ b/migrations/2024-05-10-195644_create_likes_dislikes_table/down.sql @@ -0,0 +1,2 @@ +DROP TABLE song_likes; +DROP TABLE song_dislikes; diff --git a/migrations/2024-05-10-195644_create_likes_dislikes_table/up.sql b/migrations/2024-05-10-195644_create_likes_dislikes_table/up.sql new file mode 100644 index 0000000..aa8ed05 --- /dev/null +++ b/migrations/2024-05-10-195644_create_likes_dislikes_table/up.sql @@ -0,0 +1,11 @@ +CREATE TABLE song_likes ( + song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (song_id, user_id) +); + +CREATE TABLE song_dislikes ( + song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (song_id, user_id) +); diff --git a/src/api/mod.rs b/src/api/mod.rs index 2b44c34..e2e0328 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1 +1,2 @@ pub mod history; +pub mod songs; diff --git a/src/api/songs.rs b/src/api/songs.rs new file mode 100644 index 0000000..efb9209 --- /dev/null +++ b/src/api/songs.rs @@ -0,0 +1,55 @@ +use leptos::*; + +use cfg_if::cfg_if; + + +cfg_if! { + if #[cfg(feature = "ssr")] { + use leptos::server_fn::error::NoCustomError; + use crate::database::get_db_conn; + use crate::auth::get_user; + } +} + +/// Like or unlike a song +#[server(endpoint = "songs/set_like")] +pub async fn set_like_song(song_id: i32, like: bool) -> Result<(), ServerFnError> { + let user = get_user().await.map_err(|e| ServerFnError:::: + ServerError(format!("Error getting user: {}", e)))?; + + let db_con = &mut get_db_conn(); + + user.set_like_song(song_id, like, db_con).await.map_err(|e| ServerFnError:::: + ServerError(format!("Error liking song: {}", e))) +} + +/// Dislike or remove dislike from a song +#[server(endpoint = "songs/set_dislike")] +pub async fn set_dislike_song(song_id: i32, dislike: bool) -> Result<(), ServerFnError> { + let user = get_user().await.map_err(|e| ServerFnError:::: + ServerError(format!("Error getting user: {}", e)))?; + + let db_con = &mut get_db_conn(); + + user.set_dislike_song(song_id, dislike, db_con).await.map_err(|e| ServerFnError:::: + ServerError(format!("Error disliking song: {}", e))) +} + +/// Get the like and dislike status of a song +#[server(endpoint = "songs/get_like_dislike")] +pub async fn get_like_dislike_song(song_id: i32) -> Result<(bool, bool), ServerFnError> { + let user = get_user().await.map_err(|e| ServerFnError:::: + ServerError(format!("Error getting user: {}", e)))?; + + let db_con = &mut get_db_conn(); + + // TODO this could probably be done more efficiently with a tokio::try_join, but + // doing so is much more complicated than it would initially seem + + let like = user.get_like_song(song_id, db_con).await.map_err(|e| ServerFnError:::: + ServerError(format!("Error getting song liked: {}", e)))?; + let dislike = user.get_dislike_song(song_id, db_con).await.map_err(|e| ServerFnError:::: + ServerError(format!("Error getting song disliked: {}", e)))?; + + Ok((like, dislike)) +} diff --git a/src/fileserv.rs b/src/fileserv.rs index b9bebf8..4fe7a30 100644 --- a/src/fileserv.rs +++ b/src/fileserv.rs @@ -29,11 +29,11 @@ cfg_if! { if #[cfg(feature = "ssr")] { let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap(); // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` // This path is relative to the cargo root - match ServeDir::new(root).oneshot(req).await { - Ok(res) => Ok(res.into_response()), - Err(err) => Err(( + match ServeDir::new(root).oneshot(req).await.ok() { + Some(res) => Ok(res.into_response()), + None => Err(( StatusCode::INTERNAL_SERVER_ERROR, - format!("Something went wrong: {err}"), + format!("Something went wrong"), )), } } diff --git a/src/lib.rs b/src/lib.rs index 86cac32..a96a600 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod users; pub mod search; pub mod fileserv; pub mod error_template; +pub mod api; pub mod upload; pub mod util; pub mod api; diff --git a/src/main.rs b/src/main.rs index 9832704..6e50edf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,10 @@ // Needed for building in Docker container // See https://github.com/clux/muslrust?tab=readme-ov-file#diesel-and-pq-builds // See https://github.com/sgrif/pq-sys/issues/25 -#[cfg(target = "x86_64-unknown-linux-musl")] +#[cfg(target_env = "musl")] extern crate openssl; -#[cfg(target = "x86_64-unknown-linux-musl")] +#[cfg(target_env = "musl")] #[macro_use] extern crate diesel; diff --git a/src/models.rs b/src/models.rs index 83b716d..b96c8d2 100644 --- a/src/models.rs +++ b/src/models.rs @@ -185,6 +185,133 @@ impl User { Ok(has_listened) } + + /// Like or unlike a song for this user + /// If likeing a song, remove dislike if it exists + #[cfg(feature = "ssr")] + pub async fn set_like_song(self: &Self, song_id: i32, like: bool, conn: &mut PgPooledConn) -> + Result<(), Box> { + use log::*; + debug!("Setting like for song {} to {}", song_id, like); + + use crate::schema::song_likes; + use crate::schema::song_dislikes; + + let my_id = self.id.ok_or("User id must be present (Some) to like/un-like a song")?; + + if like { + diesel::insert_into(song_likes::table) + .values((song_likes::song_id.eq(song_id), song_likes::user_id.eq(my_id))) + .execute(conn)?; + + // Remove dislike if it exists + diesel::delete(song_dislikes::table.filter(song_dislikes::song_id.eq(song_id) + .and(song_dislikes::user_id.eq(my_id)))) + .execute(conn)?; + } else { + diesel::delete(song_likes::table.filter(song_likes::song_id.eq(song_id).and(song_likes::user_id.eq(my_id)))) + .execute(conn)?; + } + + Ok(()) + } + + /// Get the like status of a song for this user + #[cfg(feature = "ssr")] + pub async fn get_like_song(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result> { + use crate::schema::song_likes; + + let my_id = self.id.ok_or("User id must be present (Some) to get like status of a song")?; + + let like = song_likes::table + .filter(song_likes::song_id.eq(song_id).and(song_likes::user_id.eq(my_id))) + .first::<(i32, i32)>(conn) + .optional()? + .is_some(); + + Ok(like) + } + + /// Get songs liked by this user + #[cfg(feature = "ssr")] + pub async fn get_liked_songs(self: &Self, conn: &mut PgPooledConn) -> Result, Box> { + use crate::schema::songs::dsl::*; + use crate::schema::song_likes::dsl::*; + + let my_id = self.id.ok_or("User id must be present (Some) to get liked songs")?; + + let my_songs = songs + .inner_join(song_likes) + .filter(user_id.eq(my_id)) + .select(songs::all_columns()) + .load(conn)?; + + Ok(my_songs) + } + + /// Dislike or remove dislike from a song for this user + /// If disliking a song, remove like if it exists + #[cfg(feature = "ssr")] + pub async fn set_dislike_song(self: &Self, song_id: i32, dislike: bool, conn: &mut PgPooledConn) -> + Result<(), Box> { + use log::*; + debug!("Setting dislike for song {} to {}", song_id, dislike); + + use crate::schema::song_likes; + use crate::schema::song_dislikes; + + let my_id = self.id.ok_or("User id must be present (Some) to dislike/un-dislike a song")?; + + if dislike { + diesel::insert_into(song_dislikes::table) + .values((song_dislikes::song_id.eq(song_id), song_dislikes::user_id.eq(my_id))) + .execute(conn)?; + + // Remove like if it exists + diesel::delete(song_likes::table.filter(song_likes::song_id.eq(song_id) + .and(song_likes::user_id.eq(my_id)))) + .execute(conn)?; + } else { + diesel::delete(song_dislikes::table.filter(song_dislikes::song_id.eq(song_id) + .and(song_dislikes::user_id.eq(my_id)))) + .execute(conn)?; + } + + Ok(()) + } + + /// Get the dislike status of a song for this user + #[cfg(feature = "ssr")] + pub async fn get_dislike_song(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result> { + use crate::schema::song_dislikes; + + let my_id = self.id.ok_or("User id must be present (Some) to get dislike status of a song")?; + + let dislike = song_dislikes::table + .filter(song_dislikes::song_id.eq(song_id).and(song_dislikes::user_id.eq(my_id))) + .first::<(i32, i32)>(conn) + .optional()? + .is_some(); + + Ok(dislike) + } + + /// Get songs disliked by this user + #[cfg(feature = "ssr")] + pub async fn get_disliked_songs(self: &Self, conn: &mut PgPooledConn) -> Result, Box> { + use crate::schema::songs::dsl::*; + use crate::schema::song_likes::dsl::*; + + let my_id = self.id.ok_or("User id must be present (Some) to get disliked songs")?; + + let my_songs = songs + .inner_join(song_likes) + .filter(user_id.eq(my_id)) + .select(songs::all_columns()) + .load(conn)?; + + Ok(my_songs) + } } /// Model for an artist diff --git a/src/playbar.rs b/src/playbar.rs index 7ff2458..6ef6b7e 100644 --- a/src/playbar.rs +++ b/src/playbar.rs @@ -1,5 +1,7 @@ use crate::models::Artist; use crate::playstatus::PlayStatus; +use crate::songdata::SongData; +use crate::api::songs; use leptos::ev::MouseEvent; use leptos::html::{Audio, Div}; use leptos::leptos_dom::*; @@ -269,13 +271,124 @@ fn MediaInfo(status: RwSignal) -> impl IntoView { }); view! { -
{name}
{artist} - {album}
+ } +} + +/// The like and dislike buttons +#[component] +fn LikeDislike(status: RwSignal) -> impl IntoView { + let like_icon = Signal::derive(move || { + status.with(|status| { + match status.queue.front() { + Some(SongData { like_dislike: Some((true, _)), .. }) => icondata::TbThumbUpFilled, + _ => icondata::TbThumbUp, + } + }) + }); + + let dislike_icon = Signal::derive(move || { + status.with(|status| { + match status.queue.front() { + Some(SongData { like_dislike: Some((_, true)), .. }) => icondata::TbThumbDownFilled, + _ => icondata::TbThumbDown, + } + }) + }); + + let toggle_like = move |_| { + status.update(|status| { + match status.queue.front_mut() { + Some(SongData { id, like_dislike: Some((liked, disliked)), .. }) => { + *liked = !*liked; + + if *liked { + *disliked = false; + } + + let id = *id; + let liked = *liked; + spawn_local(async move { + if let Err(e) = songs::set_like_song(id, liked).await { + error!("Error liking song: {:?}", e); + } + }); + }, + Some(SongData { id, like_dislike, .. }) => { + // This arm should only be reached if like_dislike is None + // In this case, the buttons will show up not filled, indicating that the song is not + // liked or disliked. Therefore, clicking the like button should like the song. + + *like_dislike = Some((true, false)); + + let id = *id; + spawn_local(async move { + if let Err(e) = songs::set_like_song(id, true).await { + error!("Error liking song: {:?}", e); + } + }); + }, + _ => { + log!("Unable to like song: No song in queue"); + return; + } + } + }); + }; + + let toggle_dislike = move |_| { + status.update(|status| { + match status.queue.front_mut() { + Some(SongData { id, like_dislike: Some((liked, disliked)), .. }) => { + *disliked = !*disliked; + + if *disliked { + *liked = false; + } + + let id = *id; + let disliked = *disliked; + spawn_local(async move { + if let Err(e) = songs::set_dislike_song(id, disliked).await { + error!("Error disliking song: {:?}", e); + } + }); + }, + Some(SongData { id, like_dislike, .. }) => { + // This arm should only be reached if like_dislike is None + // In this case, the buttons will show up not filled, indicating that the song is not + // liked or disliked. Therefore, clicking the dislike button should dislike the song. + + *like_dislike = Some((false, true)); + + let id = *id; + spawn_local(async move { + if let Err(e) = songs::set_dislike_song(id, true).await { + error!("Error disliking song: {:?}", e); + } + }); + }, + _ => { + log!("Unable to dislike song: No song in queue"); + return; + } + } + }); + }; + + view! { + } } @@ -488,7 +601,10 @@ pub fn PlayBar(status: RwSignal) -> impl IntoView { on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" />
+
+ +
diff --git a/src/schema.rs b/src/schema.rs index c91a64e..3388002 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -30,6 +30,13 @@ diesel::table! { } } +diesel::table! { + song_dislikes (song_id, user_id) { + song_id -> Int4, + user_id -> Int4, + } +} + diesel::table! { song_history (id) { id -> Int4, @@ -39,6 +46,13 @@ diesel::table! { } } +diesel::table! { + song_likes (song_id, user_id) { + song_id -> Int4, + user_id -> Int4, + } +} + diesel::table! { songs (id) { id -> Int4, @@ -67,8 +81,12 @@ diesel::joinable!(album_artists -> albums (album_id)); diesel::joinable!(album_artists -> artists (artist_id)); diesel::joinable!(song_artists -> artists (artist_id)); diesel::joinable!(song_artists -> songs (song_id)); +diesel::joinable!(song_dislikes -> songs (song_id)); +diesel::joinable!(song_dislikes -> users (user_id)); diesel::joinable!(song_history -> songs (song_id)); diesel::joinable!(song_history -> users (user_id)); +diesel::joinable!(song_likes -> songs (song_id)); +diesel::joinable!(song_likes -> users (user_id)); diesel::joinable!(songs -> albums (album_id)); diesel::allow_tables_to_appear_in_same_query!( @@ -76,7 +94,9 @@ diesel::allow_tables_to_appear_in_same_query!( albums, artists, song_artists, + song_dislikes, song_history, + song_likes, songs, users, ); diff --git a/src/songdata.rs b/src/songdata.rs index 23ce415..61e263f 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -26,45 +26,10 @@ pub struct SongData { /// Path to song image, relative to the root of the web server. /// For example, `"/assets/images/Song.jpg"` pub image_path: String, + /// Whether the song is liked by the user + pub like_dislike: Option<(bool, bool)>, } -#[cfg(feature = "ssr")] -impl TryInto for Song { - type Error = Box; - - /// Convert a Song object into a SongData object - /// - /// This conversion is expensive, as it requires database queries to get the artist and album objects. - /// The SongData/Song conversions are also not truly reversible, - /// due to the way the image_path, album, and artist data is handled. - fn try_into(self) -> Result { - use crate::database; - let mut db_con = database::get_db_conn(); - - let album = self.get_album(&mut db_con)?; - - // Use the song's image path if it exists, otherwise use the album's image path, or fallback to the placeholder - let image_path = self.image_path.clone().unwrap_or_else(|| { - album - .as_ref() - .and_then(|album| album.image_path.clone()) - .unwrap_or_else(|| "/assets/images/placeholder.jpg".to_string()) - }); - - Ok(SongData { - id: self.id.ok_or("Song id must be present (Some) to convert to SongData")?, - title: self.title.clone(), - artists: self.get_artists(&mut db_con)?, - album: album, - track: self.track, - duration: self.duration, - release_date: self.release_date, - // TODO https://gitlab.mregirouard.com/libretunes/libretunes/-/issues/35 - song_path: self.storage_path, - image_path: image_path, - }) - } -} impl TryInto for SongData { type Error = Box; @@ -72,7 +37,7 @@ impl TryInto for SongData { /// Convert a SongData object into a Song object /// /// The SongData/Song conversions are also not truly reversible, - /// due to the way the image_path, album, and and artist data is handled. + /// due to the way the image_path data is handled. fn try_into(self) -> Result { Ok(Song { id: Some(self.id), diff --git a/style/playbar.scss b/style/playbar.scss index 6d91b94..522ea11 100644 --- a/style/playbar.scss +++ b/style/playbar.scss @@ -39,15 +39,12 @@ } } - .media-info { - font-size: 16; - margin-left: 10px; - + .playbar-left-group { + display: flex; position: absolute; top: 50%; transform: translateY(-50%); - display: grid; - grid-template-columns: 50px 1fr; + margin-left: 10px; .media-info-img { width: 50px; @@ -57,6 +54,10 @@ text-align: left; margin-left: 10px; } + + .like-dislike { + margin-left: 20px; + } } .playcontrols { @@ -64,23 +65,6 @@ flex-direction: row; justify-content: center; align-items: center; - - button { - .controlbtn { - color: $text-controls-color; - } - - .controlbtn:hover { - color: $controls-hover-color; - } - - .controlbtn:active { - color: $controls-click-color; - } - - background-color: transparent; - border: transparent; - } } .playduration { @@ -94,22 +78,30 @@ bottom: 13px; top: 13px; right: 90px; + } - button { - .controlbtn { - color: $text-controls-color; - } - - .controlbtn:hover { - color: $controls-hover-color; - } - - .controlbtn:active { - color: $controls-click-color; - } - - background-color: transparent; - border: transparent; + button { + .hmirror { + -moz-transform: scale(-1, 1); + -webkit-transform: scale(-1, 1); + -o-transform: scale(-1, 1); + -ms-transform: scale(-1, 1); + transform: scale(-1, 1); } + + .controlbtn { + color: $text-controls-color; + } + + .controlbtn:hover { + color: $controls-hover-color; + } + + .controlbtn:active { + color: $controls-click-color; + } + + background-color: transparent; + border: transparent; } }