Compare commits

..

40 Commits

Author SHA1 Message Date
5d1e8cb01b Setup basic audio testing environment, and setup module for audio player 2024-09-27 17:18:09 -04:00
bf89838297 Merge pull request '81_switch-from-gitlab-ci-cd-to-gitea-actions' (#91) from 81_switch-from-gitlab-ci-cd-to-gitea-actions into main
Reviewed-on: LibreTunes/LibreTunes#91
2024-09-16 02:57:38 +00:00
b7e5327903 Add Gitea Actions jobs 2024-09-15 21:49:53 -04:00
8fea5fcec7 Remove GitLab CI/CD file 2024-09-15 21:47:04 -04:00
266bba05aa Merge pull request 'Convert Infaliable Result into Option and handle' (#90) from 89_fix-warning-with-handling-infaliable-result-in-fileserv-rs into main
Reviewed-on: LibreTunes/LibreTunes#90
2024-09-16 01:44:52 +00:00
86f9b6fbb3 Convert Infaliable Result into Option and handle 2024-09-15 21:43:59 -04:00
da45a503ac Merge pull request 'Add Docker build steps from old docker-leptos/musl image' (#88) from 86_combine-old-docker-leptos-musl-image-build-into-dockerfile into main
Reviewed-on: LibreTunes/LibreTunes#88
2024-09-16 01:32:20 +00:00
074f866e5f Add Docker build steps from old docker-leptos/musl image 2024-09-15 21:31:22 -04:00
cb0936003c Merge pull request 'Change target cfg condition to target_env' (#87) from 85_fix-warnings-due-to-removal-of-target-cfg-condition into main
Reviewed-on: LibreTunes/LibreTunes#87
2024-09-16 00:20:10 +00:00
d66849c6cb Change target cfg condition to target_env 2024-09-15 20:18:02 -04:00
b54ecb5b27 Merge pull request 'Update time to v0.3.36' (#84) from 83_update-time-crate-to-fix-type-inference-build-error into main
Reviewed-on: LibreTunes/LibreTunes#84
2024-09-15 00:38:43 +00:00
0ee61f288f Update time to v0.3.36 2024-09-15 00:36:22 +00:00
8c516e3070 Merge pull request 'Update wasm-bindgen to 0.2.93' (#82) from 80_update-wasm-bindgen-to-0.2.93 into main
Reviewed-on: LibreTunes/LibreTunes#82
2024-09-15 00:30:50 +00:00
22f3749ee4 Update wasm-bindgen to 0.2.93 2024-09-15 00:23:43 +00:00
7cb556e5ef Merge branch '36-implement-likes-and-dislikes' into 'main'
Implement likes and dislikes

Closes #36

See merge request libretunes/libretunes!32
2024-07-30 20:55:52 -04:00
bab819822d Remove like/dislike API calls TODO comment 2024-07-30 19:34:52 -04:00
0c54f0aeb8 Remove leftover dislike button logging 2024-07-30 19:34:15 -04:00
afbcc65457 Implement like/dislike using SongData instead of PlayStatus 2024-07-28 22:42:04 -04:00
e5a6e8f44e Add like_dislike field to SongData 2024-07-28 22:40:52 -04:00
d45f102be7 Remove TryInto<SongData> for Song
These pre-defined conversion traits are meant to be fast, and implementing this conversion that accesses the database goes against this design.
2024-07-28 22:37:17 -04:00
74b34b1e54 Merge remote-tracking branch 'origin/31-update-songdata-for-fetching-songs-on-frontend-for-playback' into 36-implement-likes-and-dislikes 2024-07-24 17:30:05 -04:00
2ea3979a89 Merge branch '31-update-songdata-for-fetching-songs-on-frontend-for-playback' into 'main'
Resolve "Update SongData for fetching songs on frontend for playback"

Closes #31

See merge request libretunes/libretunes!31
2024-07-24 17:18:10 -04:00
21bb2d127f Fix unused crate::database import 2024-07-23 23:37:48 -04:00
76631126de Add Song/SongData conversions 2024-07-23 23:32:26 -04:00
f8bbe319bd Update SongData for frontend use 2024-07-23 23:31:48 -04:00
ffad799f72 Add function to get song album object 2024-07-23 23:30:15 -04:00
c72d4aee18 Add Arist::display_list function 2024-07-23 22:57:24 -04:00
8a474077da Merge branch '47-add-placeholder-images-for-when-no-artist-album-song-image-is-present' into 'main'
Add music placeholder image

Closes #47

See merge request libretunes/libretunes!30
2024-07-23 21:44:12 -04:00
6202b287f0 Fix music placeholder asset path 2024-07-23 20:51:12 -04:00
6b8c8d41e4 Add music placeholder image 2024-07-21 13:15:55 -04:00
e411ab9eee Add API functions for liking and disliking songs 2024-05-10 20:32:23 -04:00
775c7eff3a Add api module 2024-05-10 20:32:23 -04:00
2ed67e5545 Add various like/dislike functions for users 2024-05-10 20:32:22 -04:00
08f1b95c18 Add get_user auth function 2024-05-10 20:26:59 -04:00
7a750b60aa Update styling to fit LikeDislike component 2024-05-10 20:26:25 -04:00
2b9d849578 Add styling for horizontally mirrored button 2024-05-10 20:26:25 -04:00
28ff98ab32 Add LikeDislike component 2024-05-10 20:26:10 -04:00
a0da89204c Move MediaInfo into playbar-left-group div 2024-05-10 19:51:27 -04:00
c07237ad8a Move redundant controlbtn styling 2024-05-10 17:38:37 -04:00
727029b757 Create DB tables for likes and dislikes 2024-05-10 16:03:18 -04:00
38 changed files with 694 additions and 828 deletions

119
.gitea/workflows/push.yaml Normal file
View File

@ -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

View File

@ -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

29
Cargo.lock generated
View File

@ -2600,9 +2600,9 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.34" version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
dependencies = [ dependencies = [
"deranged", "deranged",
"itoa", "itoa",
@ -2621,9 +2621,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.17" version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
dependencies = [ dependencies = [
"num-conv", "num-conv",
"time-core", "time-core",
@ -3061,19 +3061,20 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.92" version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell",
"wasm-bindgen-macro", "wasm-bindgen-macro",
] ]
[[package]] [[package]]
name = "wasm-bindgen-backend" name = "wasm-bindgen-backend"
version = "0.2.92" version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"log", "log",
@ -3098,9 +3099,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.92" version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@ -3108,9 +3109,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.92" version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -3121,9 +3122,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.92" version = "0.2.93"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
[[package]] [[package]]
name = "wasm-streams" name = "wasm-streams"

View File

@ -15,7 +15,7 @@ leptos = { version = "0.6", default-features = false, features = ["nightly"] }
leptos_meta = { version = "0.6", features = ["nightly"] } leptos_meta = { version = "0.6", features = ["nightly"] }
leptos_axum = { version = "0.6", optional = true } leptos_axum = { version = "0.6", optional = true }
leptos_router = { version = "0.6", features = ["nightly"] } 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" } leptos_icons = { version = "0.3.0" }
icondata = { version = "0.3.0" } icondata = { version = "0.3.0" }
dotenv = { version = "0.15.0", optional = true } dotenv = { version = "0.15.0", optional = true }

View File

@ -1,14 +1,20 @@
FROM registry.mregirouard.com/libretunes/ops/docker-leptos/musl:latest as builder FROM clux/muslrust:nightly AS builder
WORKDIR /app WORKDIR /app
RUN rustup target add wasm32-unknown-unknown
# Install a few dependencies # Install a few dependencies
RUN set -eux; \ RUN set -eux; \
apt-get update; \ apt-get update; \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
pkg-config \
clang \
npm; \ npm; \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
RUN cargo install cargo-leptos
RUN npm install tailwindcss@3.1.8 -g RUN npm install tailwindcss@3.1.8 -g
# Copy project dependency manifests # Copy project dependency manifests

View File

@ -0,0 +1 @@
<svg version="1.1" viewBox="0.0 0.0 960.0 960.0" fill="none" stroke="none" stroke-linecap="square" stroke-miterlimit="10" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><clipPath id="p.0"><path d="m0 0l960.0 0l0 960.0l-960.0 0l0 -960.0z" clip-rule="nonzero"/></clipPath><g clip-path="url(#p.0)"><path fill="#4032a8" d="m0 0l960.0 0l0 960.0l-960.0 0z" fill-rule="evenodd"/><path fill="#ffffff" d="m290.11365 265.52475l35.496063 0l0 471.3386l-35.496063 0z" fill-rule="evenodd"/><path fill="#ffffff" d="m115.68951 737.6639l0 0c0 -36.946594 46.99247 -66.897644 104.960625 -66.897644l0 0c57.968155 0 104.96065 29.95105 104.96065 66.897644l0 0c0 36.946533 -46.992493 66.897644 -104.96065 66.897644l0 0c-57.968155 0 -104.960625 -29.95111 -104.960625 -66.897644z" fill-rule="evenodd"/><path fill="#ffffff" d="m723.79346 175.86597l35.496033 0l0 471.33856l-35.496033 0z" fill-rule="evenodd"/><path fill="#ffffff" d="m549.3693 648.00507l0 0c0 -36.946533 46.99243 -66.897644 104.96063 -66.897644l0 0c57.96814 0 104.96057 29.95111 104.96057 66.897644l0 0c0 36.946533 -46.99243 66.897644 -104.96057 66.897644l0 0c-57.9682 0 -104.96063 -29.95111 -104.96063 -66.897644z" fill-rule="evenodd"/><path fill="#ffffff" d="m759.2711 155.4385l-0.01727295 94.79588l-427.05206 100.14052l-42.09601 -84.920654z" fill-rule="evenodd"/><path fill="#ffffff" d="m421.49164 234.64502l21.039368 89.85828l-131.40158 30.803131l-21.039368 -89.85828z" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,2 @@
DROP TABLE song_likes;
DROP TABLE song_dislikes;

View File

@ -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)
);

View File

@ -1,66 +0,0 @@
use leptos::*;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::database::get_db_conn;
use diesel::prelude::*;
use time::Date;
}
}
/// Add an album to the database
///
/// # Arguments
///
/// * `album_title` - The name of the artist to add
/// * `release_data` - The release date of the album (Optional)
/// * `image_path` - The path to the album's image file (Optional)
///
/// # Returns
/// * `Result<(), Box<dyn Error>>` - A empty result if successful, or an error
///
#[server(endpoint = "albums/add-album")]
pub async fn add_album(album_title: String, release_date: Option<String>, image_path: Option<String>) -> Result<(), ServerFnError> {
use crate::schema::albums::{self};
use crate::models::Album;
use leptos::server_fn::error::NoCustomError;
let date_format = time::macros::format_description!("[year]-[month]-[day]");
let parsed_release_date = match release_date {
Some(date) => {
match Date::parse(&date, &date_format) {
Ok(parsed_date) => Some(parsed_date),
Err(_e) => return Err(ServerFnError::<NoCustomError>::ServerError("Invalid release date".to_string()))
}
},
None => None
};
let image_path_arg = match image_path {
Some(image_path) => {
if image_path.is_empty() {
None
} else {
Some(image_path)
}
},
None => None
};
let new_album = Album {
id: None,
title: album_title,
release_date: parsed_release_date,
image_path: image_path_arg
};
let db = &mut get_db_conn();
diesel::insert_into(albums::table)
.values(&new_album)
.execute(db)
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error adding album: {}", e)))?;
Ok(())
}

View File

@ -1,39 +0,0 @@
use leptos::*;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::database::get_db_conn;
use diesel::prelude::*;
}
}
/// Add an artist to the database
///
/// # Arguments
///
/// * `artist_name` - The name of the artist to add
///
/// # Returns
/// * `Result<(), Box<dyn Error>>` - A empty result if successful, or an error
///
#[server(endpoint = "artists/add-artist")]
pub async fn add_artist(artist_name: String) -> Result<(), ServerFnError> {
use crate::schema::artists::dsl::*;
use crate::models::Artist;
use leptos::server_fn::error::NoCustomError;
let new_artist = Artist {
id: None,
name: artist_name,
};
let db = &mut get_db_conn();
diesel::insert_into(artists)
.values(&new_artist)
.execute(db)
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error adding artist: {}", e)))?;
Ok(())
}

View File

@ -1,2 +1 @@
pub mod artists; pub mod songs;
pub mod albums;

55
src/api/songs.rs Normal file
View File

@ -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::<NoCustomError>::
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::<NoCustomError>::
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::<NoCustomError>::
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::<NoCustomError>::
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::<NoCustomError>::
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::<NoCustomError>::
ServerError(format!("Error getting song liked: {}", e)))?;
let dislike = user.get_dislike_song(song_id, db_con).await.map_err(|e| ServerFnError::<NoCustomError>::
ServerError(format!("Error getting song disliked: {}", e)))?;
Ok((like, dislike))
}

View File

@ -6,6 +6,7 @@ use leptos_meta::*;
use leptos_router::*; use leptos_router::*;
use crate::pages::login::*; use crate::pages::login::*;
use crate::pages::signup::*; use crate::pages::signup::*;
use crate::pages::audiotest::*;
use crate::error_template::{AppError, ErrorTemplate}; use crate::error_template::{AppError, ErrorTemplate};
@ -17,8 +18,6 @@ pub fn App() -> impl IntoView {
let play_status = PlayStatus::default(); let play_status = PlayStatus::default();
let play_status = create_rw_signal(play_status); let play_status = create_rw_signal(play_status);
let upload_open = create_rw_signal(false); let upload_open = create_rw_signal(false);
let add_artist_open = create_rw_signal(false);
let add_album_open = create_rw_signal(false);
view! { view! {
// injects a stylesheet into the document <head> // injects a stylesheet into the document <head>
@ -39,19 +38,14 @@ pub fn App() -> impl IntoView {
}> }>
<main> <main>
<Routes> <Routes>
<Route path="" view=move || <Route path="" view=move || view! { <HomePage play_status=play_status upload_open=upload_open/> }>
view! { <HomePage play_status=play_status
upload_open=upload_open
add_artist_open=add_artist_open
add_album_open=add_album_open
/>
}>
<Route path="" view=Dashboard /> <Route path="" view=Dashboard />
<Route path="dashboard" view=Dashboard /> <Route path="dashboard" view=Dashboard />
<Route path="search" view=Search /> <Route path="search" view=Search />
</Route> </Route>
<Route path="/login" view=Login /> <Route path="/login" view=Login />
<Route path="/signup" view=Signup /> <Route path="/signup" view=Signup />
<Route path="/audiotest" view=AudioTest />
</Routes> </Routes>
</main> </main>
</Router> </Router>
@ -63,18 +57,14 @@ use crate::components::dashboard::*;
use crate::components::search::*; use crate::components::search::*;
use crate::components::personal::*; use crate::components::personal::*;
use crate::components::upload::*; use crate::components::upload::*;
use crate::components::add_artist::AddArtist;
use crate::components::add_album::AddAlbum;
/// Renders the home page of your application. /// Renders the home page of your application.
#[component] #[component]
fn HomePage(play_status: RwSignal<PlayStatus>, upload_open: RwSignal<bool>, add_artist_open: RwSignal<bool>, add_album_open: RwSignal<bool>) -> impl IntoView { fn HomePage(play_status: RwSignal<PlayStatus>, upload_open: RwSignal<bool>) -> impl IntoView {
view! { view! {
<div class="home-container"> <div class="home-container">
<Upload open=upload_open/> <Upload open=upload_open/>
<AddArtist open=add_artist_open/> <Sidebar upload_open=upload_open/>
<AddAlbum open=add_album_open/>
<Sidebar upload_open=upload_open add_artist_open=add_artist_open add_album_open=add_album_open/>
// This <Outlet /> will render the child route components // This <Outlet /> will render the child route components
<Outlet /> <Outlet />
<Personal /> <Personal />

0
src/audioplayer.rs Normal file
View File

View File

@ -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 /// Check if a user is an admin
/// Returns a Result with a boolean indicating if the user is logged in and an admin /// Returns a Result with a boolean indicating if the user is logged in and an admin
#[server(endpoint = "check_admin")] #[server(endpoint = "check_admin")]

View File

@ -3,6 +3,3 @@ pub mod dashboard;
pub mod search; pub mod search;
pub mod personal; pub mod personal;
pub mod upload; pub mod upload;
pub mod upload_dropdown;
pub mod add_artist;
pub mod add_album;

View File

@ -1,92 +0,0 @@
use leptos::*;
use leptos::leptos_dom::log;
use leptos_icons::*;
use crate::api::albums::add_album;
#[component]
pub fn AddAlbumBtn(add_album_open: RwSignal<bool>) -> impl IntoView {
let open_dialog = move |_| {
add_album_open.set(true);
};
view! {
<button class="add-album-btn add-btns" on:click=open_dialog>
Add Album
</button>
}
}
#[component]
pub fn AddAlbum(open: RwSignal<bool>) -> impl IntoView {
let album_title = create_rw_signal("".to_string());
let release_date = create_rw_signal("".to_string());
let image_path = create_rw_signal("".to_string());
let close_dialog = move |ev: leptos::ev::MouseEvent| {
ev.prevent_default();
open.set(false);
};
let on_add_album = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let album_title_clone = album_title.get();
let release_date_clone = Some(release_date.get());
let image_path_clone = Some(image_path.get());
spawn_local(async move {
let add_album_result = add_album(album_title_clone, release_date_clone, image_path_clone).await;
if let Err(err) = add_album_result {
log!("Error adding album: {:?}", err);
} else if let Ok(album) = add_album_result {
log!("Added album: {:?}", album);
album_title.set("".to_string());
release_date.set("".to_string());
image_path.set("".to_string());
}
})
};
view! {
<Show when=open fallback=move|| view!{}>
<div class="add-album-container">
<div class="upload-header">
<h1>Add Album</h1>
</div>
<div class="close-button" on:click=close_dialog><Icon icon=icondata::IoClose /></div>
<form class="create-album-form" action="POST" on:submit=on_add_album>
<div class="input-bx">
<input type="text" required class="text-input"
prop:value=album_title
on:input=move |ev: leptos::ev::Event| {
album_title.set(event_target_value(&ev));
}
required
/>
<span>Album Title</span>
</div>
<div class="release-date">
<div class="left">
<span>Release</span>
<span>Date</span>
</div>
<input class="info" type="date"
prop:value=release_date
on:input=move |ev: leptos::ev::Event| {
release_date.set(event_target_value(&ev));
}
/>
</div>
<div class="input-bx">
<input type="text" class="text-input"
prop:value=image_path
on:input=move |ev: leptos::ev::Event| {
image_path.set(event_target_value(&ev));
}
/>
<span>Image Path</span>
</div>
<button type="submit" class="upload-button">Add</button>
</form>
</div>
</Show>
}
}

View File

@ -1,62 +0,0 @@
use leptos::*;
use leptos::leptos_dom::log;
use leptos_icons::*;
use crate::api::artists::add_artist;
#[component]
pub fn AddArtistBtn(add_artist_open: RwSignal<bool>) -> impl IntoView {
let open_dialog = move |_| {
add_artist_open.set(true);
};
view! {
<button class="add-artist-btn add-btns" on:click=open_dialog>
Add Artist
</button>
}
}
#[component]
pub fn AddArtist(open: RwSignal<bool>) -> impl IntoView {
let artist_name = create_rw_signal("".to_string());
let close_dialog = move |ev: leptos::ev::MouseEvent| {
ev.prevent_default();
open.set(false);
};
let on_add_artist = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let artist_name_clone = artist_name.get();
spawn_local(async move {
let add_artist_result = add_artist(artist_name_clone).await;
if let Err(err) = add_artist_result {
log!("Error adding artist: {:?}", err);
} else if let Ok(artist) = add_artist_result {
log!("Added artist: {:?}", artist);
artist_name.set("".to_string());
}
})
};
view! {
<Show when=open fallback=move|| view!{}>
<div class="add-artist-container">
<div class="upload-header">
<h1>Add Artist</h1>
</div>
<div class="close-button" on:click=close_dialog><Icon icon=icondata::IoClose /></div>
<form class="create-artist-form" action="POST" on:submit=on_add_artist>
<div class="input-bx">
<input type="text" name="title" required class="text-input"
prop:value=artist_name
on:input=move |ev: leptos::ev::Event| {
artist_name.set(event_target_value(&ev));
}
required
/>
<span>Artist Name</span>
</div>
<button type="submit" class="upload-button">Add</button>
</form>
</div>
</Show>
}
}

View File

@ -1,15 +1,13 @@
use leptos::leptos_dom::*; use leptos::leptos_dom::*;
use leptos::*; use leptos::*;
use leptos_icons::*; use leptos_icons::*;
use crate::components::upload_dropdown::*; use crate::components::upload::*;
#[component] #[component]
pub fn Sidebar(upload_open: RwSignal<bool>, add_artist_open: RwSignal<bool>, add_album_open: RwSignal<bool>) -> impl IntoView { pub fn Sidebar(upload_open: RwSignal<bool>) -> impl IntoView {
use leptos_router::use_location; use leptos_router::use_location;
let location = use_location(); let location = use_location();
let dropdown_open = create_rw_signal(false);
let on_dashboard = Signal::derive( let on_dashboard = Signal::derive(
move || location.pathname.get().starts_with("/dashboard") || location.pathname.get() == "/", move || location.pathname.get().starts_with("/dashboard") || location.pathname.get() == "/",
); );
@ -21,26 +19,8 @@ pub fn Sidebar(upload_open: RwSignal<bool>, add_artist_open: RwSignal<bool>, add
view! { view! {
<div class="sidebar-container"> <div class="sidebar-container">
<div class="sidebar-top-container"> <div class="sidebar-top-container">
<Show
when=move || {upload_open.get() || add_artist_open.get() || add_album_open.get()}
fallback=move || view! {}
>
<div class="upload-overlay" on:click=move |_| {
upload_open.set(false);
add_artist_open.set(false);
add_album_open.set(false);
}></div>
</Show>
<h2 class="header">LibreTunes</h2> <h2 class="header">LibreTunes</h2>
<div class="upload-dropdown-container"> <UploadBtn dialog_open=upload_open />
<UploadDropdownBtn dropdown_open=dropdown_open/>
<Show
when= move || dropdown_open()
fallback=move || view! {}
>
<UploadDropdown dropdown_open=dropdown_open upload_open=upload_open add_artist_open=add_artist_open add_album_open=add_album_open/>
</Show>
</div>
<a class="buttons" href="/dashboard" style={move || if on_dashboard() {"color: #e1e3e1"} else {""}} > <a class="buttons" href="/dashboard" style={move || if on_dashboard() {"color: #e1e3e1"} else {""}} >
<Icon icon=icondata::OcHomeFillLg /> <Icon icon=icondata::OcHomeFillLg />
<h1>Dashboard</h1> <h1>Dashboard</h1>

View File

@ -16,8 +16,11 @@ pub fn UploadBtn(dialog_open: RwSignal<bool>) -> impl IntoView {
}; };
view! { view! {
<button class="upload-btn add-btns" on:click=open_dialog> <button class="upload-btn" on:click=open_dialog>
Upload Song <div class="add-sign">
<Icon icon=icondata::IoAddSharp />
</div>
Upload
</button> </button>
} }
} }

View File

@ -1,30 +0,0 @@
use leptos::*;
use leptos_icons::*;
use crate::components::upload::*;
use crate::components::add_artist::*;
use crate::components::add_album::*;
#[component]
pub fn UploadDropdownBtn(dropdown_open: RwSignal<bool>) -> impl IntoView {
let open_dropdown = move |_| {
dropdown_open.set(!dropdown_open.get());
};
view! {
<button class={move || if dropdown_open() {"upload-dropdown-btn upload-dropdown-btn-active"} else {"upload-dropdown-btn"}} on:click=open_dropdown>
<div class="add-sign">
<Icon icon=icondata::IoAddSharp />
</div>
</button>
}
}
#[component]
pub fn UploadDropdown(dropdown_open: RwSignal<bool>, upload_open: RwSignal<bool>, add_artist_open: RwSignal<bool>, add_album_open: RwSignal<bool>) -> impl IntoView {
view! {
<div class="upload-dropdown" on:click=move |_| dropdown_open.set(false)>
<UploadBtn dialog_open=upload_open />
<AddArtistBtn add_artist_open=add_artist_open/>
<AddAlbumBtn add_album_open=add_album_open/>
</div>
}
}

View File

@ -29,11 +29,11 @@ cfg_if! { if #[cfg(feature = "ssr")] {
let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap(); let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root // This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await { match ServeDir::new(root).oneshot(req).await.ok() {
Ok(res) => Ok(res.into_response()), Some(res) => Ok(res.into_response()),
Err(err) => Err(( None => Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {err}"), format!("Something went wrong"),
)), )),
} }
} }

View File

@ -3,6 +3,7 @@ pub mod auth;
pub mod songdata; pub mod songdata;
pub mod playstatus; pub mod playstatus;
pub mod playbar; pub mod playbar;
pub mod audioplayer;
pub mod database; pub mod database;
pub mod queue; pub mod queue;
pub mod song; pub mod song;
@ -13,9 +14,9 @@ pub mod users;
pub mod search; pub mod search;
pub mod fileserv; pub mod fileserv;
pub mod error_template; pub mod error_template;
pub mod api;
pub mod upload; pub mod upload;
pub mod util; pub mod util;
pub mod api;
use cfg_if::cfg_if; use cfg_if::cfg_if;

View File

@ -1,10 +1,10 @@
// Needed for building in Docker container // Needed for building in Docker container
// See https://github.com/clux/muslrust?tab=readme-ov-file#diesel-and-pq-builds // See https://github.com/clux/muslrust?tab=readme-ov-file#diesel-and-pq-builds
// See https://github.com/sgrif/pq-sys/issues/25 // See https://github.com/sgrif/pq-sys/issues/25
#[cfg(target = "x86_64-unknown-linux-musl")] #[cfg(target_env = "musl")]
extern crate openssl; extern crate openssl;
#[cfg(target = "x86_64-unknown-linux-musl")] #[cfg(target_env = "musl")]
#[macro_use] #[macro_use]
extern crate diesel; extern crate diesel;

View File

@ -45,6 +45,135 @@ pub struct User {
pub admin: bool, pub admin: bool,
} }
impl User {
/// 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 /// Model for an artist
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable, Identifiable))] #[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable, Identifiable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::artists))] #[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::artists))]
@ -164,6 +293,26 @@ impl Artist {
Ok(my_songs) Ok(my_songs)
} }
/// Display a list of artists as a string.
///
/// For one artist, displays [artist1]. For two artists, displays [artist1] & [artist2].
/// For three or more artists, displays [artist1], [artist2], & [artist3].
pub fn display_list(artists: &Vec<Artist>) -> String {
let mut artist_list = String::new();
for (i, artist) in artists.iter().enumerate() {
if i == 0 {
artist_list.push_str(&artist.name);
} else if i == artists.len() - 1 {
artist_list.push_str(&format!(" & {}", artist.name));
} else {
artist_list.push_str(&format!(", {}", artist.name));
}
}
artist_list
}
} }
/// Model for an album /// Model for an album
@ -293,4 +442,30 @@ impl Song {
Ok(my_artists) Ok(my_artists)
} }
/// Get the album for this song from the database
///
/// # Arguments
///
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Option<Album>, Box<dyn Error>>` - A result indicating success with an album, or None if
/// the song does not have an album, or an error
///
#[cfg(feature = "ssr")]
pub fn get_album(self: &Self, conn: &mut PgPooledConn) -> Result<Option<Album>, Box<dyn Error>> {
use crate::schema::albums::dsl::*;
if let Some(album_id) = self.album_id {
let my_album = albums
.filter(id.eq(album_id))
.first::<Album>(conn)?;
Ok(Some(my_album))
} else {
Ok(None)
}
}
} }

View File

@ -1,2 +1,3 @@
pub mod login; pub mod login;
pub mod signup; pub mod signup;
pub mod audiotest;

9
src/pages/audiotest.rs Normal file
View File

@ -0,0 +1,9 @@
use leptos::leptos_dom::*;
use leptos::*;
#[component]
pub fn AudioTest() -> impl IntoView {
view! {
<p>Hello World</p>
}
}

View File

@ -1,4 +1,7 @@
use crate::models::Artist;
use crate::playstatus::PlayStatus; use crate::playstatus::PlayStatus;
use crate::songdata::SongData;
use crate::api::songs;
use leptos::ev::MouseEvent; use leptos::ev::MouseEvent;
use leptos::html::{Audio, Div}; use leptos::html::{Audio, Div};
use leptos::leptos_dom::*; use leptos::leptos_dom::*;
@ -243,37 +246,149 @@ fn PlayDuration(elapsed_secs: MaybeSignal<i64>, total_secs: MaybeSignal<i64>) ->
fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView { fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView {
let name = Signal::derive(move || { let name = Signal::derive(move || {
status.with(|status| { status.with(|status| {
status.queue.front().map_or("No media playing".into(), |song| song.name.clone()) status.queue.front().map_or("No media playing".into(), |song| song.title.clone())
}) })
}); });
let artist = Signal::derive(move || { let artist = Signal::derive(move || {
status.with(|status| { status.with(|status| {
status.queue.front().map_or("".into(), |song| song.artist.clone()) status.queue.front().map_or("".into(), |song| format!("{}", Artist::display_list(&song.artists)))
}) })
}); });
let album = Signal::derive(move || { let album = Signal::derive(move || {
status.with(|status| { status.with(|status| {
status.queue.front().map_or("".into(), |song| song.album.clone()) status.queue.front().map_or("".into(), |song|
song.album.as_ref().map_or("".into(), |album| album.title.clone()))
}) })
}); });
let image = Signal::derive(move || { let image = Signal::derive(move || {
status.with(|status| { status.with(|status| {
// TODO Use some default / unknown image? status.queue.front().map_or("/images/placeholders/MusicPlaceholder.svg".into(),
status.queue.front().map_or("".into(), |song| song.image_path.clone()) |song| song.image_path.clone())
}) })
}); });
view! { view! {
<div class="media-info">
<img class="media-info-img" align="left" src={image}/> <img class="media-info-img" align="left" src={image}/>
<div class="media-info-text"> <div class="media-info-text">
{name} {name}
<br/> <br/>
{artist} - {album} {artist} - {album}
</div> </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> </div>
} }
} }
@ -400,7 +515,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
status.with_untracked(|status| { status.with_untracked(|status| {
// Start playing the first song in the queue, if available // Start playing the first song in the queue, if available
if let Some(song) = status.queue.front() { if let Some(song) = status.queue.front() {
log!("Starting playing with song: {}", song.name); log!("Starting playing with song: {}", song.title);
// Don't use the set_play_src / set_playing helper function // Don't use the set_play_src / set_playing helper function
// here because we already have access to the audio element // here because we already have access to the audio element
@ -453,7 +568,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
let prev_song = status.queue.pop_front(); let prev_song = status.queue.pop_front();
if let Some(prev_song) = prev_song { if let Some(prev_song) = prev_song {
log!("Adding song to history: {}", prev_song.name); log!("Adding song to history: {}", prev_song.title);
status.history.push_back(prev_song); status.history.push_back(prev_song);
} else { } else {
log!("Queue empty, no previous song to add to history"); log!("Queue empty, no previous song to add to history");
@ -486,7 +601,10 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" /> on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" />
<div class="playbar"> <div class="playbar">
<ProgressBar percentage=percentage.into() status=status /> <ProgressBar percentage=percentage.into() status=status />
<div class="playbar-left-group">
<MediaInfo status=status /> <MediaInfo status=status />
<LikeDislike status=status />
</div>
<PlayControls status=status /> <PlayControls status=status />
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() /> <PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
<QueueToggle status=status /> <QueueToggle status=status />

View File

@ -1,3 +1,4 @@
use crate::models::Artist;
use crate::playstatus::PlayStatus; use crate::playstatus::PlayStatus;
use crate::song::Song; use crate::song::Song;
use leptos::ev::MouseEvent; use leptos::ev::MouseEvent;
@ -98,7 +99,7 @@ pub fn Queue(status: RwSignal<PlayStatus>) -> impl IntoView {
on:dragenter=move |e: DragEvent| on_drag_enter(e, index) on:dragenter=move |e: DragEvent| on_drag_enter(e, index)
on:dragover=on_drag_over on:dragover=on_drag_over
> >
<Song song_image_path=song.image_path.clone() song_title=song.name.clone() song_artist=song.artist.clone() /> <Song song_image_path=song.image_path.clone() song_title=song.title.clone() song_artist=Artist::display_list(&song.artists) />
<Show <Show
when=move || index != 0 when=move || index != 0
fallback=|| view!{ fallback=|| view!{

View File

@ -30,6 +30,20 @@ diesel::table! {
} }
} }
diesel::table! {
song_dislikes (song_id, user_id) {
song_id -> Int4,
user_id -> Int4,
}
}
diesel::table! {
song_likes (song_id, user_id) {
song_id -> Int4,
user_id -> Int4,
}
}
diesel::table! { diesel::table! {
songs (id) { songs (id) {
id -> Int4, id -> Int4,
@ -58,6 +72,10 @@ diesel::joinable!(album_artists -> albums (album_id));
diesel::joinable!(album_artists -> artists (artist_id)); diesel::joinable!(album_artists -> artists (artist_id));
diesel::joinable!(song_artists -> artists (artist_id)); diesel::joinable!(song_artists -> artists (artist_id));
diesel::joinable!(song_artists -> songs (song_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_likes -> songs (song_id));
diesel::joinable!(song_likes -> users (user_id));
diesel::joinable!(songs -> albums (album_id)); diesel::joinable!(songs -> albums (album_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
@ -65,6 +83,8 @@ diesel::allow_tables_to_appear_in_same_query!(
albums, albums,
artists, artists,
song_artists, song_artists,
song_dislikes,
song_likes,
songs, songs,
users, users,
); );

View File

@ -1,16 +1,62 @@
use crate::models::{Album, Artist, Song};
use time::Date;
/// Holds information about a song /// Holds information about a song
#[derive(Debug, Clone)] ///
/// Intended to be used in the front-end, as it includes artist and album objects, rather than just their ids.
pub struct SongData { pub struct SongData {
/// Song id
pub id: i32,
/// Song name /// Song name
pub name: String, pub title: String,
/// Song artist /// Song artists
pub artist: String, pub artists: Vec<Artist>,
/// Song album /// Song album
pub album: String, pub album: Option<Album>,
/// The track number of the song on the album
pub track: Option<i32>,
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
pub release_date: Option<Date>,
/// Path to song file, relative to the root of the web server. /// Path to song file, relative to the root of the web server.
/// For example, `"/assets/audio/Song.mp3"` /// For example, `"/assets/audio/Song.mp3"`
pub song_path: String, pub song_path: String,
/// Path to song image, relative to the root of the web server. /// Path to song image, relative to the root of the web server.
/// For example, `"/assets/images/Song.jpg"` /// For example, `"/assets/images/Song.jpg"`
pub image_path: String, pub image_path: String,
/// Whether the song is liked by the user
pub like_dislike: Option<(bool, bool)>,
}
impl TryInto<Song> for SongData {
type Error = Box<dyn std::error::Error>;
/// Convert a SongData object into a Song object
///
/// The SongData/Song conversions are also not truly reversible,
/// due to the way the image_path data is handled.
fn try_into(self) -> Result<Song, Self::Error> {
Ok(Song {
id: Some(self.id),
title: self.title,
album_id: self.album.map(|album|
album.id.ok_or("Album id must be present (Some) to convert to Song")).transpose()?,
track: self.track,
duration: self.duration,
release_date: self.release_date,
// TODO https://gitlab.mregirouard.com/libretunes/libretunes/-/issues/35
storage_path: self.song_path,
// Note that if the source of the image_path was the album, the image_path
// will be set to the album's image_path instead of None
image_path: if self.image_path == "/assets/images/placeholder.jpg" {
None
} else {
Some(self.image_path)
},
})
}
} }

View File

@ -1,135 +0,0 @@
@import "theme.scss";
.add-album-container {
position: fixed;
top: 45%;
left: 50%;
transform: translate(-50%, -50%);
width: 30rem;
height: 24rem;
border: 1px solid white;
border-radius: 5px;
padding: 1rem;
padding-top: 0;
z-index: 2;
display: flex;
flex-direction: column;
background-color: #1c1c1c;
z-index: 11;
.upload-header {
font-size: .7rem;
font-weight: 300;
padding-bottom: 0;
border-bottom: 1px solid white;
font-family: "Roboto", sans-serif;
}
.close-button {
position: absolute;
top: 5px;
right: 5px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
border-radius: 50%;
font-size: 1.6rem;
transition: all 0.3s;
border: none;
}
.close-button:hover {
transform: scale(1.1);
background-color: rgba(255, 255, 255, 0.1);
}
.close-button:active {
transform: scale(0.8);
}
.create-album-form {
width:100%;
height: 100%;
position: relative;
.input-bx{
position: relative;
margin-top: 1rem;
width: 300px;
input{
width: 100%;
padding: 10px;
border: 2px solid #7f8fa6;
border-radius: 5px;
outline: none;
font-size: 1rem;
transition: 0.6s;
background-color: transparent;
}
span{
position: absolute;
left: 0;
top: 1px;
padding: 10px;
font-size: 1rem;
color: #7f8fa6;
text-transform: uppercase;
pointer-events: none;
transition: 0.6s;
background-color: transparent;
}
input:valid ~ span,
input:focus ~ span{
color: #fff;
transform: translateX(10px) translateY(-7px);
font-size: 0.65rem;
font-weight: 600;
padding: 0 10px;
background: #1c1c1c;
letter-spacing: 0.1rem;
}
input:valid,
input:focus{
color: #fff;
border: 2px solid #fff;
}
}
.release-date {
margin-top: 1rem;
font-size: 1.2rem;
color: #7f8fa6;
font-family: "Roboto", sans-serif;
display: flex;
align-items: center;
.left {
display: flex;
flex-direction: column;
margin-left: 5px;
margin-right: 10px;
}
span {
font-size: .85rem;
}
input {
padding: 8px;
}
}
.upload-button {
position: absolute;
bottom: 5px;
margin-top: 1rem;
padding: 10px;
background-color: #7f8fa6;
color: #fff;
width: 100%;
font-size: 1rem;
font-family: "Roboto", sans-serif;
border: none;
border-radius: 5px;
cursor: pointer;
transition: 0.3s;
&:hover {
background-color: #fff;
color: #7f8fa6;
}
}
}
}

View File

@ -1,115 +0,0 @@
@import "theme.scss";
.add-artist-container {
position: fixed;
top: 45%;
left: 50%;
transform: translate(-50%, -50%);
width: 30rem;
height: 15rem;
border: 1px solid white;
border-radius: 5px;
padding: 1rem;
padding-top: 0;
z-index: 2;
display: flex;
flex-direction: column;
background-color: #1c1c1c;
z-index: 11;
.upload-header {
font-size: .7rem;
font-weight: 300;
padding-bottom: 0;
border-bottom: 1px solid white;
font-family: "Roboto", sans-serif;
}
.close-button {
position: absolute;
top: 5px;
right: 5px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
border-radius: 50%;
font-size: 1.6rem;
transition: all 0.3s;
border: none;
}
.close-button:hover {
transform: scale(1.1);
background-color: rgba(255, 255, 255, 0.1);
}
.close-button:active {
transform: scale(0.8);
}
.create-artist-form {
width:100%;
height: 100%;
position: relative;
.input-bx{
margin-top: 1rem;
width: 300px;
position: relative;
input{
width: 100%;
padding: 10px;
border: 2px solid #7f8fa6;
border-radius: 5px;
outline: none;
font-size: 1rem;
transition: 0.6s;
background-color: transparent;
}
span{
position: absolute;
left: 0;
top: 1px;
padding: 10px;
font-size: 1rem;
color: #7f8fa6;
text-transform: uppercase;
pointer-events: none;
transition: 0.6s;
background-color: transparent;
}
input:valid ~ span,
input:focus ~ span{
color: #fff;
transform: translateX(10px) translateY(-7px);
font-size: 0.65rem;
font-weight: 600;
padding: 0 10px;
background: #1c1c1c;
letter-spacing: 0.1rem;
}
input:valid,
input:focus{
color: #fff;
border: 2px solid #fff;
}
}
.upload-button {
position: absolute;
bottom: 5px;
margin-top: 1rem;
padding: 10px;
background-color: #7f8fa6;
color: #fff;
width: 100%;
font-size: 1rem;
font-family: "Roboto", sans-serif;
border: none;
border-radius: 5px;
cursor: pointer;
transition: 0.3s;
&:hover {
background-color: #fff;
color: #7f8fa6;
}
}
}
}

View File

@ -6,7 +6,6 @@
height: 100vh; height: 100vh;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
overflow: hidden;
} }
.home-component { .home-component {
background: #1c1c1c; background: #1c1c1c;

View File

@ -9,8 +9,6 @@
@import 'search.scss'; @import 'search.scss';
@import 'personal.scss'; @import 'personal.scss';
@import 'upload.scss'; @import 'upload.scss';
@import 'addArtist.scss';
@import 'addAlbum.scss';
body { body {
font-family: sans-serif; font-family: sans-serif;

View File

@ -39,15 +39,12 @@
} }
} }
.media-info { .playbar-left-group {
font-size: 16; display: flex;
margin-left: 10px;
position: absolute; position: absolute;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
display: grid; margin-left: 10px;
grid-template-columns: 50px 1fr;
.media-info-img { .media-info-img {
width: 50px; width: 50px;
@ -57,6 +54,10 @@
text-align: left; text-align: left;
margin-left: 10px; margin-left: 10px;
} }
.like-dislike {
margin-left: 20px;
}
} }
.playcontrols { .playcontrols {
@ -64,23 +65,6 @@
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
align-items: 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 { .playduration {
@ -94,8 +78,17 @@
bottom: 13px; bottom: 13px;
top: 13px; top: 13px;
right: 90px; right: 90px;
}
button { 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 { .controlbtn {
color: $text-controls-color; color: $text-controls-color;
} }
@ -111,5 +104,4 @@
background-color: transparent; background-color: transparent;
border: transparent; border: transparent;
} }
}
} }

View File

@ -11,29 +11,16 @@
margin: 3px; margin: 3px;
padding: 0.1rem 1rem 1rem 1rem; padding: 0.1rem 1rem 1rem 1rem;
position: relative; position: relative;
.upload-overlay {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 10;
}
.header { .header {
font-size: 1.2rem; font-size: 1.2rem;
} }
.upload-dropdown-container { .upload-btn {
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 7px; right: 7px;
.upload-dropdown-btn {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
justify-content: center; justify-content: center;
font-size: 0.9rem; font-size: 0.9rem;
border-radius: 50px; border-radius: 50px;
@ -42,40 +29,17 @@
padding-right: 1rem; padding-right: 1rem;
padding-left: 1rem; padding-left: 1rem;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: background-color 0.3s ease;
.add-sign { .add-sign {
font-size: 1.5rem; font-size: 1.5rem;
margin-top: auto; margin-top: auto;
margin-right: 5px;
color: white;
} }
} }
.upload-dropdown-btn:hover { .upload-btn:hover {
background-color: #9e9e9e; background-color: #9e9e9e;
} }
.upload-dropdown-btn-active {
border-radius: 12.5px 12.5px 0 0;
width: 110px;
}
.upload-dropdown {
background-color: #f0ecec;
color: black;
width: 110px;
border-radius: 0 0 5px 5px;
.add-btns {
border: none;
border-bottom: 1px solid black;
width: 100%;
padding: 0.25rem;
cursor: pointer;
}
.add-btns:first-child {
border-top: 1px solid black;
}
.add-btns:last-child {
border-radius: 0 0 5px 5px;
}
}
}
.buttons { .buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -15,7 +15,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #1c1c1c; background-color: #1c1c1c;
z-index: 11;
.close-button { .close-button {
position: absolute; position: absolute;
top: 5px; top: 5px;
@ -28,7 +27,9 @@
font-size: 1.6rem; font-size: 1.6rem;
transition: all 0.3s; transition: all 0.3s;
border: none; border: none;
} }
.close-button:hover { .close-button:hover {
transform: scale(1.1); transform: scale(1.1);
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
@ -47,9 +48,6 @@
padding: .1rem; padding: .1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%;
height: 100%;
position: relative;
.input-bx{ .input-bx{
margin-top: 1rem; margin-top: 1rem;
position: relative; position: relative;
@ -128,9 +126,6 @@
} }
} }
.upload-button { .upload-button {
position: absolute;
bottom: 5px;
width: 100%;
margin-top: 1rem; margin-top: 1rem;
padding: 10px; padding: 10px;
background-color: #7f8fa6; background-color: #7f8fa6;