diff --git a/.gitea/workflows/push.yaml b/.gitea/workflows/push.yaml new file mode 100644 index 0000000..0fd4a4e --- /dev/null +++ b/.gitea/workflows/push.yaml @@ -0,0 +1,75 @@ +name: Push Workflows +on: push + +jobs: + build: + runs-on: libretunes-cicd + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - 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: libretunes-cicd + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Test project + run: cargo test --all-targets --all-features + + leptos-test: + runs-on: libretunes-cicd + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Run Leptos tests + run: cargo-leptos test + + docs: + runs-on: libretunes-cicd + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - 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..44b8a0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -17,6 +17,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + [[package]] name = "ahash" version = "0.8.11" @@ -145,7 +151,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http", + "http 1.1.0", "http-body", "http-body-util", "hyper", @@ -161,7 +167,7 @@ dependencies = [ "serde", "sync_wrapper 1.0.0", "tokio", - "tower", + "tower 0.4.13", "tower-layer", "tower-service", ] @@ -175,7 +181,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", + "http 1.1.0", "http-body", "http-body-util", "mime", @@ -216,7 +222,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.7.2", "object", "rustc-demangle", ] @@ -233,6 +239,29 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bindgen" +version = "0.69.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" +dependencies = [ + "bitflags 2.5.0", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.58", + "which", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -332,6 +361,15 @@ version = "1.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -378,10 +416,21 @@ dependencies = [ ] [[package]] -name = "codee" -version = "0.1.2" +name = "clang-sys" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af40247be877a1e3353fb406aa27ab3ef4bd3ff18cef91e75e667bfa3fde701d" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "codee" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3ad3122b0001c7f140cf4d605ef9a9e2c24d96ab0b4fb4347b76de2425f445" dependencies = [ "thiserror", ] @@ -471,6 +520,12 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + [[package]] name = "cpufeatures" version = "0.2.12" @@ -486,6 +541,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "crunchy" version = "0.2.2" @@ -676,6 +740,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.58", +] + [[package]] name = "dotenv" version = "0.15.0" @@ -709,6 +784,35 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fdeflate" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8090f921a24b04994d9929e204f50b498a33ea6ba559ffaa05e04f7ee7fb5ab" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.0.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" +dependencies = [ + "crc32fast", + "miniz_oxide 0.8.0", +] + [[package]] name = "flexi_logger" version = "0.28.0" @@ -913,13 +1017,14 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "gloo-net" version = "0.5.0" -source = "git+https://github.com/rustwasm/gloo.git?rev=a823fab7ecc4068e9a28bd669da5eaf3f0a56380#a823fab7ecc4068e9a28bd669da5eaf3f0a56380" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173" dependencies = [ "futures-channel", "futures-core", "futures-sink", - "gloo-utils 0.2.0 (git+https://github.com/rustwasm/gloo.git?rev=a823fab7ecc4068e9a28bd669da5eaf3f0a56380)", - "http", + "gloo-utils", + "http 0.2.12", "js-sys", "pin-project", "serde", @@ -955,18 +1060,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "gloo-utils" -version = "0.2.0" -source = "git+https://github.com/rustwasm/gloo.git?rev=a823fab7ecc4068e9a28bd669da5eaf3f0a56380#a823fab7ecc4068e9a28bd669da5eaf3f0a56380" -dependencies = [ - "js-sys", - "serde", - "serde_json", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "half" version = "2.4.0" @@ -1008,6 +1101,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "html-escape" version = "0.2.13" @@ -1017,6 +1119,17 @@ dependencies = [ "utf8-width", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.1.0" @@ -1035,7 +1148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" dependencies = [ "bytes", - "http", + "http 1.1.0", ] [[package]] @@ -1046,7 +1159,7 @@ checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" dependencies = [ "bytes", "futures-core", - "http", + "http 1.1.0", "http-body", "pin-project-lite", ] @@ -1078,7 +1191,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.1.0", "http-body", "httparse", "httpdate", @@ -1096,7 +1209,7 @@ checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" dependencies = [ "bytes", "futures-util", - "http", + "http 1.1.0", "http-body", "hyper", "pin-project-lite", @@ -1127,6 +1240,16 @@ dependencies = [ "cc", ] +[[package]] +name = "ico" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3804960be0bb5e4edb1e1ad67afd321a9ecfd875c3e65c099468fd2717d7cae" +dependencies = [ + "byteorder", + "png", +] + [[package]] name = "icondata" version = "0.3.0" @@ -1338,6 +1461,19 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image-convert" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3eb95018550c5770e1a25219a75c192fa2f6475efef2244a0d46799d3fdf6cf" +dependencies = [ + "ico", + "magick_rust", + "once_cell", + "regex", + "str-utils", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -1386,9 +1522,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" dependencies = [ "wasm-bindgen", ] @@ -1399,6 +1535,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leptos" version = "0.6.10" @@ -1421,23 +1563,22 @@ dependencies = [ [[package]] name = "leptos-use" -version = "0.11.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac79c02d0e2998569116aa36d26fd00bfa8cadbe8cb630eb771b4d1676412a16" +checksum = "3ab8914bd0ff8ab5029521540a6e15292dcc05d0f1a791a3aa8cc31a94436bfb" dependencies = [ - "async-trait", "cfg-if", "codee", "cookie", "default-struct-builder", "futures-util", "gloo-timers", - "gloo-utils 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "js-sys", "lazy_static", "leptos", "paste", "thiserror", + "unic-langid", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -1675,6 +1816,16 @@ version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "libloading" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +dependencies = [ + "cfg-if", + "windows-targets 0.52.4", +] + [[package]] name = "libretunes" version = "0.1.0" @@ -1688,8 +1839,9 @@ dependencies = [ "diesel_migrations", "dotenv", "flexi_logger", - "http", + "http 1.1.0", "icondata", + "image-convert", "lazy_static", "leptos", "leptos-use", @@ -1707,7 +1859,7 @@ dependencies = [ "thiserror", "time", "tokio", - "tower", + "tower 0.5.1", "tower-http", "tower-sessions-redis-store", "wasm-bindgen", @@ -1724,6 +1876,12 @@ dependencies = [ "serde_test", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + [[package]] name = "lock_api" version = "0.4.11" @@ -1750,6 +1908,17 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "magick_rust" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29f862f489ba66357a730356f1d48abdb2c5028707b6c17dee7842359bc6372c" +dependencies = [ + "bindgen", + "libc", + "pkg-config", +] + [[package]] name = "manyhow" version = "0.10.4" @@ -1837,6 +2006,16 @@ dependencies = [ "adler", ] +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "0.8.11" @@ -1857,7 +2036,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.1.0", "httparse", "log", "memchr", @@ -2062,6 +2241,19 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "png" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f9d46a34a05a6a57566bc2bfae066ef07585a6e3fa30fbbdff5936380623f0" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.0", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2337,6 +2529,19 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags 2.5.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustversion" version = "1.0.14" @@ -2478,7 +2683,7 @@ dependencies = [ "dashmap", "futures", "gloo-net", - "http", + "http 1.1.0", "http-body-util", "hyper", "inventory", @@ -2491,7 +2696,7 @@ dependencies = [ "serde_qs", "server_fn_macro_default", "thiserror", - "tower", + "tower 0.4.13", "tower-layer", "url", "wasm-bindgen", @@ -2536,6 +2741,18 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "slab" version = "0.4.9" @@ -2577,6 +2794,16 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "str-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60bcb3d541a8fd455189b9e022f27d255d103dafd5087a93cff4c0a156a8b597" +dependencies = [ + "cow-utils", + "unicase", +] + [[package]] name = "strsim" version = "0.10.0" @@ -2712,9 +2939,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,14 +2960,23 @@ 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", ] +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -2886,6 +3122,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 0.1.2", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-cookies" version = "0.10.0" @@ -2896,7 +3146,7 @@ dependencies = [ "axum-core", "cookie", "futures-util", - "http", + "http 1.1.0", "parking_lot", "pin-project-lite", "tower-layer", @@ -2905,14 +3155,14 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97" dependencies = [ "bitflags 2.5.0", "bytes", "futures-util", - "http", + "http 1.1.0", "http-body", "http-body-util", "http-range-header", @@ -2930,15 +3180,15 @@ dependencies = [ [[package]] name = "tower-layer" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tower-sessions" @@ -2947,7 +3197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b27326208b21807803c5f5aa1020d30ca0432b78cfe251b51a67a05e0baea102" dependencies = [ "async-trait", - "http", + "http 1.1.0", "time", "tokio", "tower-cookies", @@ -2968,7 +3218,7 @@ dependencies = [ "axum-core", "base64", "futures", - "http", + "http 1.1.0", "parking_lot", "rand", "serde", @@ -3063,6 +3313,24 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unic-langid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dd9d1e72a73b25e07123a80776aae3e7b0ec461ef94f9151eed6ec88005a44" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5422c1f65949306c99240b81de9f3f15929f5a8bfe05bb44b034cc8bf593e5" +dependencies = [ + "tinystr", +] + [[package]] name = "unicase" version = "2.7.0" @@ -3173,19 +3441,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 +3479,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 +3489,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 +3502,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" @@ -3252,14 +3521,26 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.69" +version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" dependencies = [ "js-sys", "wasm-bindgen", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index b162619..2bdcda4 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 } @@ -28,8 +28,8 @@ diesel_migrations = { version = "2.1.0", optional = true } pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true } tokio = { version = "1", optional = true, features = ["rt-multi-thread"] } axum = { version = "0.7.5", features = ["tokio", "http1"], default-features = false, optional = true } -tower = { version = "0.4.13", optional = true } -tower-http = { version = "0.5", optional = true, features = ["fs"] } +tower = { version = "0.5.1", optional = true, features = ["util"] } +tower-http = { version = "0.6.1", optional = true, features = ["fs"] } thiserror = "1.0.57" tower-sessions-redis-store = { version = "0.11", optional = true } async-trait = { version = "0.1.79", optional = true } @@ -40,10 +40,8 @@ multer = { version = "3.0.0", optional = true } log = { version = "0.4.21", optional = true } flexi_logger = { version = "0.28.0", optional = true, default-features = false } web-sys = "0.3.69" -leptos-use = "0.11.3" - -[patch.crates-io] -gloo-net = { git = "https://github.com/rustwasm/gloo.git", rev = "a823fab7ecc4068e9a28bd669da5eaf3f0a56380" } +leptos-use = "0.13.5" +image-convert = { version = "0.18.0", optional = true, default-features = false } [features] hydrate = [ @@ -76,6 +74,7 @@ ssr = [ "log", "flexi_logger", "leptos-use/ssr", + "image-convert", ] # Defines a size-optimized profile for the WASM bundle in release mode diff --git a/Dockerfile b/Dockerfile index a0390e8..5cbfa8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,37 @@ -FROM registry.mregirouard.com/libretunes/ops/docker-leptos/musl:latest as builder +FROM rust:slim as builder WORKDIR /app +RUN rustup default nightly +RUN rustup target add wasm32-unknown-unknown +RUN cargo install cargo-leptos + # Install a few dependencies RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ - npm; \ + pkg-config \ + clang \ + build-essential \ + libssl-dev \ + libpq-dev \ + wget; \ rm -rf /var/lib/apt/lists/* -RUN npm install tailwindcss@3.1.8 -g +# Install ImageMagick +RUN cd / && \ + wget https://github.com/ImageMagick/ImageMagick/archive/refs/tags/7.1.1-38.tar.gz && \ + tar xf 7.1.1-38.tar.gz && \ + rm 7.1.1-38.tar.gz && \ + cd ImageMagick-7.1.1-38 && \ + ./configure && \ + make install -j $(nproc) && \ + cd .. && \ + rm -rf ImageMagick-7.1.1-38 # Copy project dependency manifests COPY Cargo.toml Cargo.lock /app/ -# Add the target to Cargo.toml so we can statically link: -RUN echo 'bin-target-triple = "x86_64-unknown-linux-musl"' >> Cargo.toml - # Create dummy files to force cargo to build the dependencies RUN mkdir /app/src && mkdir /app/style && mkdir /app/assets && \ echo "fn main() {}" | tee /app/src/build.rs > /app/src/main.rs && \ @@ -27,15 +42,12 @@ RUN mkdir /app/src && mkdir /app/style && mkdir /app/assets && \ RUN cargo-leptos build --release --precompress RUN rm -rf /app/src /app/style /app/assets -COPY style /app/style - -# Minify CSS -RUN npx tailwindcss -i /app/style/main.scss -o /app/style/main.scss --minify COPY ascii_art.txt /app/ascii_art.txt COPY assets /app/assets COPY src /app/src COPY migrations /app/migrations +COPY style /app/style # Touch files to force rebuild RUN touch /app/src/main.rs && touch /app/src/lib.rs && touch /app/src/build.rs @@ -43,6 +55,11 @@ RUN touch /app/src/main.rs && touch /app/src/lib.rs && touch /app/src/build.rs # Actually build the binary RUN cargo-leptos build --release --precompress +# Use ldd to list all dependencies of /app/target/release/libretunes, then copy them to /app/libs +# Setting LD_LIBRARY_PATH is necessary to find the ImageMagick libraries +RUN mkdir /app/libs && LD_LIBRARY_PATH=/usr/local/lib ldd /app/target/release/libretunes | grep "=> /" | \ + awk '{print $3}' | xargs -I '{}' cp '{}' /app/libs + # Build the final image FROM scratch @@ -51,9 +68,15 @@ LABEL description="LibreTunes, an open-source browser audio player and \ library manager built for collaborative listening." # Copy the binary and the compressed assets to the "site root" -COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/libretunes /libretunes +COPY --from=builder /app/target/release/libretunes /libretunes COPY --from=builder /app/target/site /site +# Copy libraries to /lib64 +COPY --from=builder /app/libs /lib64 +COPY --from=builder /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2 + +ENV LD_LIBRARY_PATH=/lib64 + # Configure Leptos settings ENV LEPTOS_SITE_ADDR=0.0.0.0:3000 ENV LEPTOS_SITE_ROOT=/site 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/migrations/2024-05-19-163229_add_song_history/down.sql b/migrations/2024-05-19-163229_add_song_history/down.sql new file mode 100644 index 0000000..4c1a657 --- /dev/null +++ b/migrations/2024-05-19-163229_add_song_history/down.sql @@ -0,0 +1,2 @@ +DROP INDEX song_history_user_id_idx; +DROP TABLE song_history; diff --git a/migrations/2024-05-19-163229_add_song_history/up.sql b/migrations/2024-05-19-163229_add_song_history/up.sql new file mode 100644 index 0000000..42ce503 --- /dev/null +++ b/migrations/2024-05-19-163229_add_song_history/up.sql @@ -0,0 +1,8 @@ +CREATE TABLE song_history ( + id SERIAL PRIMARY KEY UNIQUE NOT NULL, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL, + date TIMESTAMP NOT NULL DEFAULT NOW(), + song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL +); + +CREATE INDEX song_history_user_id_idx ON song_history(user_id); diff --git a/migrations/2024-05-20-154208_add_friends/down.sql b/migrations/2024-05-20-154208_add_friends/down.sql new file mode 100644 index 0000000..be4a2a9 --- /dev/null +++ b/migrations/2024-05-20-154208_add_friends/down.sql @@ -0,0 +1,7 @@ +DROP INDEX friendships_friend_2_idx; +DROP INDEX friendships_friend_1_idx; +DROP TABLE friendships; + +DROP INDEX incoming_friend_requests_idx; +DROP INDEX outgoing_friend_requests_idx; +DROP TABLE friend_requests; diff --git a/migrations/2024-05-20-154208_add_friends/up.sql b/migrations/2024-05-20-154208_add_friends/up.sql new file mode 100644 index 0000000..421f223 --- /dev/null +++ b/migrations/2024-05-20-154208_add_friends/up.sql @@ -0,0 +1,19 @@ +CREATE TABLE friend_requests ( + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + from_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL, + to_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (from_id, to_id) +); + +CREATE INDEX outgoing_friend_requests_idx ON friend_requests(from_id); +CREATE INDEX incoming_friend_requests_idx ON friend_requests(to_id); + +CREATE TABLE friendships ( + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + friend_1_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL, + friend_2_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (friend_1_id, friend_2_id) +); + +CREATE INDEX friendships_friend_1_idx ON friendships(friend_1_id); +CREATE INDEX friendships_friend_2_idx ON friendships(friend_2_id); diff --git a/src/api/history.rs b/src/api/history.rs new file mode 100644 index 0000000..5f6cabb --- /dev/null +++ b/src/api/history.rs @@ -0,0 +1,44 @@ +use std::time::SystemTime; +use leptos::*; +use crate::models::HistoryEntry; +use crate::models::Song; + +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; + } +} + +/// Get the history of the current user. +#[server(endpoint = "history/get")] +pub async fn get_history(limit: Option) -> Result, ServerFnError> { + let user = get_user().await?; + let db_con = &mut get_db_conn(); + let history = user.get_history(limit, db_con) + .map_err(|e| ServerFnError::::ServerError(format!("Error getting history: {}", e)))?; + Ok(history) +} + +/// Get the listen dates and songs of the current user. +#[server(endpoint = "history/get_songs")] +pub async fn get_history_songs(limit: Option) -> Result, ServerFnError> { + let user = get_user().await?; + let db_con = &mut get_db_conn(); + let songs = user.get_history_songs(limit, db_con) + .map_err(|e| ServerFnError::::ServerError(format!("Error getting history songs: {}", e)))?; + Ok(songs) +} + +/// Add a song to the history of the current user. +#[server(endpoint = "history/add")] +pub async fn add_history(song_id: i32) -> Result<(), ServerFnError> { + let user = get_user().await?; + let db_con = &mut get_db_conn(); + user.add_history(song_id, db_con) + .map_err(|e| ServerFnError::::ServerError(format!("Error adding history: {}", e)))?; + Ok(()) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..c287a07 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,3 @@ +pub mod history; +pub mod profile; +pub mod songs; diff --git a/src/api/profile.rs b/src/api/profile.rs new file mode 100644 index 0000000..790af13 --- /dev/null +++ b/src/api/profile.rs @@ -0,0 +1,49 @@ +use leptos::*; +use server_fn::codec::{MultipartData, MultipartFormData}; + +use cfg_if::cfg_if; + +cfg_if! { + if #[cfg(feature = "ssr")] { + use crate::auth::get_user; + use server_fn::error::NoCustomError; + } +} + +/// Handle a user uploading a profile picture. Converts the image to webp and saves it to the server. +#[server(input = MultipartFormData, endpoint = "/profile/upload_picture")] +pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> { + // Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None." + let mut data = data.into_inner().unwrap(); + + let field = data.next_field().await + .map_err(|e| ServerFnError::::ServerError(format!("Error getting field: {}", e)))? + .ok_or_else(|| ServerFnError::::ServerError("No field found".to_string()))?; + + if field.name() != Some("picture") { + return Err(ServerFnError::ServerError("Field name is not 'picture'".to_string())); + } + + // Get user id from session + let user = get_user().await + .map_err(|e| ServerFnError::::ServerError(format!("Error getting user: {}", e)))?; + + let user_id = user.id.ok_or_else(|| ServerFnError::::ServerError("User has no id".to_string()))?; + + // Read the image, and convert it to webp + use image_convert::{to_webp, WEBPConfig, ImageResource}; + + let bytes = field.bytes().await + .map_err(|e| ServerFnError::::ServerError(format!("Error getting field bytes: {}", e)))?; + + let reader = std::io::Cursor::new(bytes); + let image_source = ImageResource::from_reader(reader) + .map_err(|e| ServerFnError::::ServerError(format!("Error creating image resource: {}", e)))?; + + let profile_picture_path = format!("assets/images/profile/{}.webp", user_id); + let mut image_target = ImageResource::from_path(&profile_picture_path); + to_webp(&mut image_target, &image_source, &WEBPConfig::new()) + .map_err(|e| ServerFnError::::ServerError(format!("Error converting image to webp: {}", e)))?; + + Ok(()) +} 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/app.rs b/src/app.rs index 67a6c7a..ed9fd1b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use crate::playbar::PlayBar; +use crate::playbar::CustomTitle; use crate::playstatus::PlayStatus; use crate::queue::Queue; use leptos::*; @@ -24,7 +25,7 @@ pub fn App() -> impl IntoView { // sets the document title - + <CustomTitle play_status=play_status/> // content for this welcome page <Router fallback=|| { diff --git a/src/auth.rs b/src/auth.rs index 8c33347..37f861f 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -122,6 +122,29 @@ pub async fn require_auth() -> Result<(), ServerFnError> { }) } +/// Get the current logged-in user +/// Returns a Result with the user if they are logged in +/// Returns an error if the user is not logged in, or if there is an error getting the user +/// Intended to be used in a route to get the current user: +/// ```rust +/// use leptos::*; +/// use libretunes::auth::get_user; +/// #[server(endpoint = "user_route")] +/// pub async fn user_route() -> Result<(), ServerFnError> { +/// let user = get_user().await?; +/// println!("Logged in as: {}", user.username); +/// // Do something with the user +/// Ok(()) +/// } +/// ``` +#[cfg(feature = "ssr")] +pub async fn get_user() -> Result<User, ServerFnError> { + let auth_session = extract::<AuthSession<AuthBackend>>().await + .map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?; + + auth_session.user.ok_or(ServerFnError::<NoCustomError>::ServerError("User not logged in".to_string())) +} + /// Check if a user is an admin /// Returns a Result with a boolean indicating if the user is logged in and an admin #[server(endpoint = "check_admin")] 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 51d6436..19d61b2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod search; pub mod fileserv; pub mod error_template; pub mod media_type; +pub mod api; pub mod upload; pub mod util; 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 d3e4104..b96c8d2 100644 --- a/src/models.rs +++ b/src/models.rs @@ -45,6 +45,275 @@ pub struct User { pub admin: bool, } +impl User { + /// Get the history of songs listened to by this user from the database + /// + /// The returned history will be ordered by date in descending order, + /// and a limit of N will select the N most recent entries. + /// The `id` field of this user must be present (Some) to get history + /// + /// # Arguments + /// + /// * `limit` - An optional limit on the number of history entries to return + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result<Vec<HistoryEntry>, Box<dyn Error>>` - + /// A result indicating success with a vector of history entries, or an error + /// + #[cfg(feature = "ssr")] + pub fn get_history(self: &Self, limit: Option<i64>, conn: &mut PgPooledConn) -> + Result<Vec<HistoryEntry>, Box<dyn Error>> { + use crate::schema::song_history::dsl::*; + + let my_id = self.id.ok_or("Artist id must be present (Some) to get history")?; + + let my_history = + if let Some(limit) = limit { + song_history + .filter(user_id.eq(my_id)) + .order(date.desc()) + .limit(limit) + .load(conn)? + } else { + song_history + .filter(user_id.eq(my_id)) + .load(conn)? + }; + + Ok(my_history) + } + + /// Get the history of songs listened to by this user from the database + /// + /// The returned history will be ordered by date in descending order, + /// and a limit of N will select the N most recent entries. + /// The `id` field of this user must be present (Some) to get history + /// + /// # Arguments + /// + /// * `limit` - An optional limit on the number of history entries to return + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result<Vec<(SystemTime, Song)>, Box<dyn Error>>` - + /// A result indicating success with a vector of listen dates and songs, or an error + /// + #[cfg(feature = "ssr")] + pub fn get_history_songs(self: &Self, limit: Option<i64>, conn: &mut PgPooledConn) -> + Result<Vec<(SystemTime, Song)>, Box<dyn Error>> { + use crate::schema::songs::dsl::*; + use crate::schema::song_history::dsl::*; + + let my_id = self.id.ok_or("Artist id must be present (Some) to get history")?; + + let my_history = + if let Some(limit) = limit { + song_history + .inner_join(songs) + .filter(user_id.eq(my_id)) + .order(date.desc()) + .limit(limit) + .select((date, songs::all_columns())) + .load(conn)? + } else { + song_history + .inner_join(songs) + .filter(user_id.eq(my_id)) + .order(date.desc()) + .select((date, songs::all_columns())) + .load(conn)? + }; + + Ok(my_history) + } + + /// Add a song to this user's history in the database + /// + /// The date of the history entry will be the current time + /// The `id` field of this user must be present (Some) to add history + /// + /// # Arguments + /// + /// * `song_id` - The id of the song to add to this user's history + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error + /// + #[cfg(feature = "ssr")] + pub fn add_history(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> { + use crate::schema::song_history; + + let my_id = self.id.ok_or("Artist id must be present (Some) to add history")?; + + diesel::insert_into(song_history::table) + .values((song_history::user_id.eq(my_id), song_history::song_id.eq(song_id))) + .execute(conn)?; + + Ok(()) + } + + /// Check if this user has listened to a song + /// + /// The `id` field of this user must be present (Some) to check history + /// + /// # Arguments + /// + /// * `song_id` - The id of the song to check if this user has listened to + /// * `conn` - A mutable reference to a database connection + /// + /// # Returns + /// + /// * `Result<bool, Box<dyn Error>>` - A result indicating success with a boolean value, or an error + /// + #[cfg(feature = "ssr")] + pub fn has_listened_to(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<bool, Box<dyn Error>> { + use crate::schema::song_history::{self, user_id}; + + let my_id = self.id.ok_or("Artist id must be present (Some) to check history")?; + + let has_listened = song_history::table + .filter(user_id.eq(my_id)) + .filter(song_history::song_id.eq(song_id)) + .first::<HistoryEntry>(conn) + .optional()? + .is_some(); + + 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<dyn Error>> { + 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<bool, Box<dyn Error>> { + 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<Vec<Song>, Box<dyn Error>> { + 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<dyn Error>> { + 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<bool, Box<dyn Error>> { + 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<Vec<Song>, Box<dyn Error>> { + 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 #[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable, Identifiable))] #[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::artists))] @@ -340,3 +609,20 @@ impl Song { } } } + +/// Model for a history entry +#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))] +#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::song_history))] +#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))] +#[derive(Serialize, Deserialize)] +pub struct HistoryEntry { + /// A unique id for the history entry + #[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))] + pub id: Option<i32>, + /// The id of the user who listened to the song + pub user_id: i32, + /// The date the song was listened to + pub date: SystemTime, + /// The id of the song that was listened to + pub song_id: i32, +} diff --git a/src/playbar.rs b/src/playbar.rs index 7ff2458..d113b7a 100644 --- a/src/playbar.rs +++ b/src/playbar.rs @@ -1,10 +1,14 @@ 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::*; +use leptos_meta::Title; use leptos::*; use leptos_icons::*; +use leptos_use::{utils::Pausable, use_interval_fn}; /// Width and height of the forward/backward skip buttons const SKIP_BTN_SIZE: &str = "3.5em"; @@ -20,6 +24,9 @@ const MIN_SKIP_BACK_TIME: f64 = 5.0; /// How many seconds to skip forward/backward when the user presses the arrow keys const ARROW_KEY_SKIP_TIME: f64 = 5.0; +/// Threshold in seconds for considering when the user has listened to a song, for adding it to the history +const HISTORY_LISTEN_THRESHOLD: u64 = MIN_SKIP_BACK_TIME as u64; + // TODO Handle errors better, when getting audio HTML element and when playing/pausing audio /// Get the current time and duration of the current song, if available @@ -269,13 +276,124 @@ fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView { }); view! { - <div class="media-info"> <img class="media-info-img" align="left" src={image}/> <div class="media-info-text"> {name} <br/> {artist} - {album} </div> + } +} + +/// The like and dislike buttons +#[component] +fn LikeDislike(status: RwSignal<PlayStatus>) -> 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! { + <div class="like-dislike"> + <button on:click=toggle_dislike> + <Icon class="controlbtn hmirror" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=dislike_icon /> + </button> + <button on:click=toggle_like> + <Icon class="controlbtn" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=like_icon /> + </button> </div> } } @@ -343,6 +461,21 @@ fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView { } } +/// Renders the title of the page based on the currently playing song +#[component] +pub fn CustomTitle(play_status: RwSignal<PlayStatus>) -> impl IntoView { + let title = create_memo(move |_| { + play_status.with(|play_status| { + play_status.queue.front().map_or("LibreTunes".to_string(), |song_data| { + format!("{} - {} | {}",song_data.title.clone(),Artist::display_list(&song_data.artists), "LibreTunes") + }) + }) + }); + view! { + <Title text=title /> + } +} + /// The main play bar component, containing the progress bar, media info, play controls, and play duration #[component] pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView { @@ -419,6 +552,39 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView { }); }); + let current_song_id = create_memo(move |_| { + status.with(|status| { + status.queue.front().map(|song| song.id) + }) + }); + + // Track the last song that was added to the history to prevent duplicates + let last_history_song_id = create_rw_signal(None); + + let Pausable { + is_active: hist_timeout_pending, + resume: resume_hist_timeout, + pause: pause_hist_timeout, + .. + } = use_interval_fn(move || { + if last_history_song_id.get_untracked() == current_song_id.get_untracked() { + return; + } + + if let Some(current_song_id) = current_song_id.get_untracked() { + last_history_song_id.set(Some(current_song_id)); + + spawn_local(async move { + if let Err(e) = crate::api::history::add_history(current_song_id).await { + error!("Error adding song {} to history: {}", current_song_id, e); + } + }); + } + }, HISTORY_LISTEN_THRESHOLD * 1000); + + // Initially pause the timeout, since the audio starts off paused + pause_hist_timeout(); + let on_play = move |_| { log!("Audio playing"); status.update(|status| status.playing = true); @@ -427,6 +593,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView { let on_pause = move |_| { log!("Audio paused"); status.update(|status| status.playing = false); + pause_hist_timeout(); }; let on_time_update = move |_| { @@ -444,6 +611,11 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView { error!("Unable to update time: Audio element not available"); } }); + + // If time is updated, audio is playing, so make sure the history timeout is running + if !hist_timeout_pending.get_untracked() { + resume_hist_timeout(); + } }; let on_end = move |_| { @@ -488,7 +660,10 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView { on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" /> <div class="playbar"> <ProgressBar percentage=percentage.into() status=status /> + <div class="playbar-left-group"> <MediaInfo status=status /> + <LikeDislike status=status /> + </div> <PlayControls status=status /> <PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() /> <QueueToggle status=status /> diff --git a/src/schema.rs b/src/schema.rs index e4964b9..29401e7 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -23,6 +23,22 @@ diesel::table! { } } +diesel::table! { + friend_requests (from_id, to_id) { + created_at -> Timestamp, + from_id -> Int4, + to_id -> Int4, + } +} + +diesel::table! { + friendships (friend_1_id, friend_2_id) { + created_at -> Timestamp, + friend_1_id -> Int4, + friend_2_id -> Int4, + } +} + diesel::table! { song_artists (song_id, artist_id) { song_id -> Int4, @@ -30,6 +46,29 @@ diesel::table! { } } +diesel::table! { + song_dislikes (song_id, user_id) { + song_id -> Int4, + user_id -> Int4, + } +} + +diesel::table! { + song_history (id) { + id -> Int4, + user_id -> Int4, + date -> Timestamp, + song_id -> Int4, + } +} + +diesel::table! { + song_likes (song_id, user_id) { + song_id -> Int4, + user_id -> Int4, + } +} + diesel::table! { songs (id) { id -> Int4, @@ -58,13 +97,24 @@ 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!( album_artists, albums, artists, + friend_requests, + friendships, song_artists, + song_dislikes, + song_history, + song_likes, songs, users, ); diff --git a/src/songdata.rs b/src/songdata.rs index 9e08942..99464c8 100644 --- a/src/songdata.rs +++ b/src/songdata.rs @@ -28,45 +28,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<SongData> for Song { - type Error = Box<dyn std::error::Error>; - - /// 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<SongData, Self::Error> { - 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<Song> for SongData { type Error = Box<dyn std::error::Error>; @@ -74,7 +39,7 @@ impl TryInto<Song> 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<Song, Self::Error> { 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; } }