Compare commits
206 Commits
87f5efed34
...
130-standa
Author | SHA1 | Date | |
---|---|---|---|
04cbc7bd4b
|
|||
432cb659db
|
|||
33dc7cb1a3
|
|||
92e13dfba7
|
|||
f6211cbe2e
|
|||
4a092fc78d
|
|||
d472a663ff
|
|||
8cad1816a7
|
|||
deaef81999
|
|||
7d2375698c | |||
84451e2dac
|
|||
579e7bbb48
|
|||
6e57dfc937
|
|||
85d622fdb6
|
|||
6a6bbfe8ed
|
|||
3803c20049
|
|||
544476d1ee
|
|||
1878f1feda | |||
b727137fa8
|
|||
f61507b197
|
|||
d2aebde562
|
|||
0076f4f208 | |||
ba0a531f2c
|
|||
2617ee8b95 | |||
4d1859b331
|
|||
c17aeb3822
|
|||
0e0d107d08
|
|||
463f3b744f
|
|||
28875c8669
|
|||
68778615b9
|
|||
58b5ed6d3f
|
|||
f8c0134cf2
|
|||
a31539dc8f | |||
eda4e42150
|
|||
54d629d504
|
|||
6486bbbdda
|
|||
b727832c8e
|
|||
7c4058884e
|
|||
a67bd37d11
|
|||
3f43ef2d20
|
|||
0b599f4038
|
|||
c02363c698
|
|||
9da05edcd4 | |||
f65d054612
|
|||
16cf406990 | |||
ed6cd4efcf
|
|||
4d24a9bba2 | |||
11cb502f53
|
|||
0ec9e5ed03
|
|||
7bccde7654
|
|||
bd69c46567 | |||
e2a395ae7c
|
|||
6bb6322aa4
|
|||
a82da927b0
|
|||
6f571a338f | |||
9cd1e8291a
|
|||
ff1b7401f2
|
|||
d434a514a4
|
|||
10011a8859 | |||
16bc79aef4
|
|||
297c22d832
|
|||
5fb84bd29e
|
|||
b9cbe22562
|
|||
aaec0523a4
|
|||
ff8fd283b6
|
|||
cf35961516
|
|||
976790342c
|
|||
29534a473b
|
|||
673d6e7651
|
|||
2def629dc1
|
|||
f1126c2534
|
|||
c593501572
|
|||
797fea93b2
|
|||
c6e6eb1f41
|
|||
b963849072
|
|||
c7ee25c1b4
|
|||
6a0f814cd3
|
|||
3027a1f00c
|
|||
b735b25677
|
|||
0bb8871296
|
|||
388ef55552
|
|||
fae4767313
|
|||
7b5b9fbe15
|
|||
d0e849bd0e
|
|||
ae9243e9f3
|
|||
f4f6e1e4a6
|
|||
5a71973388
|
|||
c782aa2cd4
|
|||
082c1a2128
|
|||
6572d7313a
|
|||
f1862a6bd6
|
|||
347ad39fae
|
|||
318892adc1
|
|||
1a2b7510f8
|
|||
3b452e32d8
|
|||
7d410c2419
|
|||
b55104144b
|
|||
911d375a95
|
|||
9b20395876
|
|||
ea869ce983
|
|||
b1299ca28c
|
|||
9dfc556bd0
|
|||
3a29ce4741
|
|||
7d6c1e66bc
|
|||
478f8362af
|
|||
fc8825d765
|
|||
99fac1fe8f
|
|||
cfbc84343b
|
|||
49455f5e03
|
|||
f482e06076
|
|||
56902f1ff2
|
|||
4dfc789f58
|
|||
f04ad57a5a
|
|||
fac75e1f54
|
|||
afd8f014b2
|
|||
362b8161e3
|
|||
0f48dfeada
|
|||
7a0ae4c028
|
|||
742f0e2be6
|
|||
57d7459976
|
|||
a83a051d89
|
|||
745d4c1b0a
|
|||
6666002533
|
|||
a33a891d87
|
|||
9b22a82514
|
|||
59b9db34cf
|
|||
d03eed78e7
|
|||
fc64b0cf1c
|
|||
6a52598956
|
|||
e42247ee84
|
|||
d72ed532c1
|
|||
c3bc042027
|
|||
a67e486f75
|
|||
841251639e
|
|||
0d2a83f508 | |||
2d4a9ac9fd
|
|||
be053ffa62 | |||
e7a8491653
|
|||
2116dc9058 | |||
a093068625
|
|||
0739b0026b
|
|||
698931d915
|
|||
38bc2fbe92
|
|||
d3e9c5d869
|
|||
64c37dc327
|
|||
abd0f87d41
|
|||
ec1c57a67d
|
|||
262f3634bf
|
|||
e533132273
|
|||
d89d9d3548
|
|||
2cfd698978
|
|||
57406b5940
|
|||
628684a259
|
|||
96835e684a
|
|||
aa9e26459f
|
|||
69b3066a3b
|
|||
3368d16c96
|
|||
141034eacd
|
|||
55521fd7fe
|
|||
40d6440d99
|
|||
daf8a50863
|
|||
099c1042a2
|
|||
b3748374d4
|
|||
5235854af7
|
|||
915d5ea6f7
|
|||
f5c863f2a6 | |||
ec01183dc2
|
|||
3dd040afd0
|
|||
c900cb896e
|
|||
2af8310077
|
|||
1a4112542e
|
|||
40bf99a2bf
|
|||
ebc669ecf8
|
|||
b4664bdad7
|
|||
608f18ace5
|
|||
20ff4674d4
|
|||
3de5efc27f
|
|||
b9f5867b4d
|
|||
db8dc3cd3d
|
|||
848b1afd2c
|
|||
141a27bb7e
|
|||
78d59731b0
|
|||
26a572b18a
|
|||
f6ee5feb3f
|
|||
0cd36d4b44
|
|||
3c148c36df
|
|||
4eb673a9a4
|
|||
782c9b9482
|
|||
52d60318bb
|
|||
7732b77eb5
|
|||
fe131b1ba2
|
|||
064f06d763
|
|||
900d1ca1bb
|
|||
92eb63e946
|
|||
a9c1ed7048
|
|||
a63b5d4e29
|
|||
238a24c938
|
|||
69125f71f3
|
|||
ae8a3d0ade | |||
343284a6da
|
|||
65e5de7051 | |||
219a218f92 | |||
f8534cd6f6
|
|||
01e393a77f
|
|||
481d9109eb | |||
050cab6d46
|
@ -9,3 +9,5 @@
|
||||
!/Cargo.lock
|
||||
!/Cargo.toml
|
||||
!/ascii_art.txt
|
||||
!/docs
|
||||
!/book.toml
|
||||
|
@ -36,16 +36,16 @@ jobs:
|
||||
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
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- 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
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
test:
|
||||
runs-on: libretunes-cicd
|
||||
@ -91,3 +91,71 @@ jobs:
|
||||
run: apt install -y nix-bin
|
||||
- name: Build project with Nix
|
||||
run: nix build --experimental-features 'nix-command flakes' git+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git?ref=$GITHUB_REF_NAME#default
|
||||
|
||||
clippy:
|
||||
runs-on: libretunes-cicd
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Use Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- name: Run clippy
|
||||
env:
|
||||
RUSTFLAGS: "-D warnings"
|
||||
run: cargo clippy --all-targets --all-features
|
||||
|
||||
rustfmt:
|
||||
runs-on: libretunes-cicd
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Run rustfmt
|
||||
run: cargo fmt --check
|
||||
|
||||
mdbook:
|
||||
runs-on: libretunes-cicd
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Generate mdbook
|
||||
run: mdbook build
|
||||
- name: Upload mdbook
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mdbook
|
||||
path: book
|
||||
|
||||
mdbook-server:
|
||||
runs-on: ubuntu-latest-docker
|
||||
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 }}-mdbook | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
file: Dockerfile.mdbook
|
||||
push: true
|
||||
tags: "${{ steps.get-image-name.outputs.IMAGE_NAME }}:${{ gitea.sha }}"
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Build and push Docker image with "latest" tag
|
||||
uses: docker/build-push-action@v5
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
with:
|
||||
file: Dockerfile.mdbook
|
||||
push: true
|
||||
tags: "${{ steps.get-image-name.outputs.IMAGE_NAME }}:latest"
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -35,3 +35,9 @@ playwright/.cache/
|
||||
# Nix-related files
|
||||
.direnv/
|
||||
result
|
||||
|
||||
# Old TailwindCSS config
|
||||
style/tailwind.config.js
|
||||
|
||||
# mdbook output
|
||||
book
|
||||
|
1873
Cargo.lock
generated
1873
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
78
Cargo.toml
78
Cargo.toml
@ -4,20 +4,37 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
build = "src/build.rs"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = 1
|
||||
incremental = true
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
debug = 2
|
||||
|
||||
[profile.dev.build-override]
|
||||
opt-level = 3
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "health_check"
|
||||
path = "src/health.rs"
|
||||
required-features = ["health_check"]
|
||||
|
||||
[dependencies]
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
cfg-if = "1"
|
||||
http = { version = "1.0", default-features = false }
|
||||
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.96", default-features = false, optional = true }
|
||||
leptos_icons = { version = "0.3.0" }
|
||||
icondata = { version = "0.3.0" }
|
||||
leptos = { version = "0.7.8", default-features = false, features = ["nightly"] }
|
||||
leptos_meta = { version = "0.7.8" }
|
||||
leptos_axum = { version = "0.7.8", optional = true }
|
||||
leptos_router = { version = "0.7.8", features = ["nightly"] }
|
||||
wasm-bindgen = { version = "=0.2.100", default-features = false, optional = true }
|
||||
leptos_icons = { version = "0.4.0" }
|
||||
icondata = { version = "0.5.0" }
|
||||
diesel = { version = "2.1.4", features = ["postgres", "r2d2", "chrono"], default-features = false, optional = true }
|
||||
lazy_static = { version = "1.4.0", optional = true }
|
||||
serde = { version = "1.0.195", features = ["derive"], default-features = false }
|
||||
@ -29,25 +46,28 @@ axum = { version = "0.7.5", features = ["tokio", "http1"], default-features = fa
|
||||
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 }
|
||||
tower-sessions-redis-store = { version = "0.15", optional = true }
|
||||
async-trait = { version = "0.1.79", optional = true }
|
||||
axum-login = { version = "0.14.0", optional = true }
|
||||
server_fn = { version = "0.6.11", features = ["multipart"] }
|
||||
axum-login = { version = "0.16.0", optional = true }
|
||||
server_fn = { version = "0.7.7", features = ["multipart"] }
|
||||
symphonia = { version = "0.5.4", default-features = false, features = ["mp3"], optional = true }
|
||||
multer = { version = "3.0.0", optional = true }
|
||||
multer = { version = "3.1.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.13.5"
|
||||
leptos-use = "0.15.0"
|
||||
image-convert = { version = "0.18.0", optional = true, default-features = false }
|
||||
chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] }
|
||||
dotenvy = { version = "0.15.7", optional = true }
|
||||
reqwest = { version = "0.12.9", default-features = false, optional = true }
|
||||
futures = { version = "0.3.25", default-features = false, optional = true }
|
||||
once_cell = { version = "1.20", default-features = false, optional = true }
|
||||
libretunes_macro = { git = "https://git.libretunes.xyz/LibreTunes/LibreTunes-Macro.git", branch = "main" }
|
||||
rand = { version = "0.9.1", optional = true }
|
||||
|
||||
[features]
|
||||
hydrate = [
|
||||
"leptos/hydrate",
|
||||
"leptos_meta/hydrate",
|
||||
"leptos_router/hydrate",
|
||||
"console_error_panic_hook",
|
||||
"wasm-bindgen",
|
||||
"chrono/wasmbind",
|
||||
@ -76,6 +96,23 @@ ssr = [
|
||||
"flexi_logger",
|
||||
"leptos-use/ssr",
|
||||
"image-convert",
|
||||
"rand",
|
||||
]
|
||||
reqwest_api = [
|
||||
"reqwest",
|
||||
"reqwest/cookies",
|
||||
"futures",
|
||||
"once_cell",
|
||||
|
||||
# Not needed, but fixes compile errors when building for all targets
|
||||
# (which is useful for code editors checking for errors)
|
||||
"server_fn/reqwest"
|
||||
]
|
||||
health_check = [
|
||||
"reqwest_api",
|
||||
"tokio",
|
||||
"tokio/rt",
|
||||
"tokio/macros",
|
||||
]
|
||||
|
||||
# Defines a size-optimized profile for the WASM bundle in release mode
|
||||
@ -94,8 +131,15 @@ site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "style/main.scss"
|
||||
# The tailwind input file.
|
||||
#
|
||||
# Optional, Activates the tailwind build
|
||||
tailwind-input-file = "style/main.css"
|
||||
# The tailwind config file.
|
||||
#
|
||||
# Optional, defaults to "tailwind.config.js" which if is not present
|
||||
# is generated for you
|
||||
tailwind-config-file = "style/tailwind.config.js"
|
||||
# Assets source dir. All files found here will be copied and synchronized to site-root.
|
||||
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
|
||||
#
|
||||
@ -116,6 +160,8 @@ browserquery = "defaults"
|
||||
watch = false
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
# Specify the name of the bin target
|
||||
bin-target = "libretunes"
|
||||
# The features to use when compiling the bin target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||
|
15
Dockerfile
15
Dockerfile
@ -1,10 +1,12 @@
|
||||
FROM rust:slim AS builder
|
||||
|
||||
ENV LEPTOS_TAILWIND_VERSION=v4.0.6
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN rustup default nightly
|
||||
RUN rustup target add wasm32-unknown-unknown
|
||||
RUN cargo install cargo-leptos@0.2.22
|
||||
RUN cargo install cargo-leptos@0.2.26 --locked
|
||||
|
||||
# Install a few dependencies
|
||||
RUN set -eux; \
|
||||
@ -34,12 +36,13 @@ COPY Cargo.toml Cargo.lock /app/
|
||||
|
||||
# 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 && \
|
||||
echo "fn main() {}" | tee /app/src/build.rs | tee /app/src/main.rs > /app/src/health.rs && \
|
||||
touch /app/src/lib.rs && \
|
||||
touch /app/style/main.scss
|
||||
touch /app/style/main.css
|
||||
|
||||
# Prebuild dependencies
|
||||
RUN cargo-leptos build --release --precompress
|
||||
RUN cargo build --bin health_check --features health_check --release
|
||||
|
||||
RUN rm -rf /app/src /app/style /app/assets
|
||||
|
||||
@ -50,10 +53,11 @@ 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
|
||||
RUN touch /app/src/main.rs && touch /app/src/lib.rs && touch /app/src/build.rs && touch /app/src/health.rs
|
||||
|
||||
# Actually build the binary
|
||||
RUN cargo-leptos build --release --precompress
|
||||
RUN cargo build --bin health_check --features health_check --release
|
||||
|
||||
# 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
|
||||
@ -70,6 +74,9 @@ library manager built for collaborative listening."
|
||||
# Copy the binary and the compressed assets to the "site root"
|
||||
COPY --from=builder /app/target/release/libretunes /libretunes
|
||||
COPY --from=builder /app/target/site /site
|
||||
COPY --from=builder /app/target/release/health_check /health_check
|
||||
|
||||
HEALTHCHECK CMD [ "/health_check" ]
|
||||
|
||||
# Copy libraries to /lib64
|
||||
COPY --from=builder /app/libs /lib64
|
||||
|
13
Dockerfile.mdbook
Normal file
13
Dockerfile.mdbook
Normal file
@ -0,0 +1,13 @@
|
||||
FROM rust:slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN cargo install mdbook
|
||||
|
||||
COPY book.toml /app/book.toml
|
||||
COPY docs /app/docs
|
||||
|
||||
RUN mdbook build
|
||||
|
||||
FROM nginx:alpine AS webserver
|
||||
COPY --from=builder /app/book /usr/share/nginx/html
|
4
book.toml
Normal file
4
book.toml
Normal file
@ -0,0 +1,4 @@
|
||||
[book]
|
||||
language = "en"
|
||||
src = "docs"
|
||||
title = "LibreTunes Documentation"
|
@ -1,22 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
ZONE_ID=$1
|
||||
RECORD_NAME=$2
|
||||
RECORD_COMMENT=$3
|
||||
API_TOKEN=$4
|
||||
TUNNEL_ID=$5
|
||||
|
||||
curl --request POST --silent \
|
||||
--url https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header "Authorization: Bearer $API_TOKEN" \
|
||||
--data '{
|
||||
"content": "'$TUNNEL_ID'.cfargotunnel.com",
|
||||
"name": "'$RECORD_NAME'",
|
||||
"comment": "'$RECORD_COMMENT'",
|
||||
"proxied": true,
|
||||
"type": "CNAME",
|
||||
"ttl": 1
|
||||
}' \
|
@ -1,19 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
SERVICE=$1
|
||||
HOSTNAME=$2
|
||||
TUNNEL_ID=$3
|
||||
|
||||
echo "Creating tunnel config for $HOSTNAME"
|
||||
|
||||
cat <<EOF > cloudflared-tunnel-config.yml
|
||||
tunnel: $TUNNEL_ID
|
||||
credentials-file: /etc/cloudflared/auth.json
|
||||
|
||||
ingress:
|
||||
- hostname: $HOSTNAME
|
||||
service: $SERVICE
|
||||
- service: http_status:404
|
||||
EOF
|
@ -1,55 +0,0 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
cloudflare:
|
||||
image: cloudflare/cloudflared:latest
|
||||
command: tunnel run
|
||||
volumes:
|
||||
- cloudflared-config:/etc/cloudflared:ro
|
||||
|
||||
libretunes:
|
||||
image: registry.mregirouard.com/libretunes/libretunes:${LIBRETUNES_VERSION}
|
||||
environment:
|
||||
REDIS_URL: redis://redis:6379
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_USER: libretunes
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: libretunes
|
||||
volumes:
|
||||
- libretunes-audio:/site/audio
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
volumes:
|
||||
- libretunes-redis:/data
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
environment:
|
||||
POSTGRES_USER: libretunes
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: libretunes
|
||||
volumes:
|
||||
- libretunes-postgres:/var/lib/postgresql/data
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U libretunes"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
cloudflared-config:
|
||||
libretunes-audio:
|
||||
libretunes-redis:
|
||||
libretunes-postgres:
|
@ -1,22 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
ZONE_ID=$1
|
||||
RECORD_NAME=$2
|
||||
RECORD_COMMENT=$3
|
||||
API_TOKEN=$4
|
||||
|
||||
RECORD_ID=$(
|
||||
curl --request GET --silent \
|
||||
--url "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=$RECORD_NAME&comment=$RECORD_COMMENT" \
|
||||
--header "Content-Type: application/json" \
|
||||
--header "Authorization: Bearer $API_TOKEN" \
|
||||
| jq -r '.result[0].id')
|
||||
|
||||
echo "Deleting DNS record ID $RECORD_ID"
|
||||
|
||||
curl --request DELETE --silent \
|
||||
--url "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
|
||||
--header "Content-Type: application/json" \
|
||||
--header "Authorization: Bearer $API_TOKEN"
|
1
docs/SUMMARY.md
Normal file
1
docs/SUMMARY.md
Normal file
@ -0,0 +1 @@
|
||||
# Summary
|
26
flake.lock
generated
26
flake.lock
generated
@ -3,16 +3,16 @@
|
||||
"cargo-leptos": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1732915351,
|
||||
"narHash": "sha256-QPCYKlbPHuSBmwfkKdYhcVF81Lnirf65IYao7UVxz9Q=",
|
||||
"lastModified": 1736814985,
|
||||
"narHash": "sha256-v1gNH3pq5db/swsk79nEzgtR4jy3f/xHs4QaLnVcVYU=",
|
||||
"owner": "leptos-rs",
|
||||
"repo": "cargo-leptos",
|
||||
"rev": "7d782a18e9d377be9de8d6217567ddde830fe89e",
|
||||
"rev": "87b156f4f0bc0374e7b5557d15bf79f1a12d7569",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "leptos-rs",
|
||||
"ref": "v0.2.22",
|
||||
"ref": "v0.2.26",
|
||||
"repo": "cargo-leptos",
|
||||
"type": "github"
|
||||
}
|
||||
@ -37,11 +37,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1733940404,
|
||||
"narHash": "sha256-Pj39hSoUA86ZePPF/UXiYHHM7hMIkios8TYG29kQT4g=",
|
||||
"lastModified": 1745794561,
|
||||
"narHash": "sha256-T36rUZHUART00h3dW4sV5tv4MrXKT7aWjNfHiZz7OHg=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5d67ea6b4b63378b9c13be21e2ec9d1afc921713",
|
||||
"rev": "5461b7fa65f3ca74cef60be837fd559a8918eaa0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -53,11 +53,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1728538411,
|
||||
"narHash": "sha256-f0SBJz1eZ2yOuKUr5CA9BHULGXVSn6miBuUWdTyhUhU=",
|
||||
"lastModified": 1744536153,
|
||||
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b69de56fac8c2b6f8fd27f2eca01dcda8e0a4221",
|
||||
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@ -80,11 +80,11 @@
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1734230139,
|
||||
"narHash": "sha256-zsp0Mz8VgyIAnU8UhP/YT1g+zlsl+NIJTBMAbY+RifQ=",
|
||||
"lastModified": 1745894113,
|
||||
"narHash": "sha256-dxO3caQZMv/pMtcuXdi+SnAtyki6HFbSf1IpgQPXZYc=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "150fbc8aa2bc501041810bbc1dbfe73694a861be",
|
||||
"rev": "e552fe1b16ffafd678ebe061c22b117e050769ed",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
16
flake.nix
16
flake.nix
@ -6,7 +6,7 @@
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
cargo-leptos = {
|
||||
url = "github:leptos-rs/cargo-leptos?ref=v0.2.22";
|
||||
url = "github:leptos-rs/cargo-leptos?ref=v0.2.26";
|
||||
flake = false;
|
||||
};
|
||||
};
|
||||
@ -24,7 +24,7 @@
|
||||
name = "cargo-leptos";
|
||||
buildFeatures = ["no_downloads"];
|
||||
src = cargo-leptos;
|
||||
cargoHash = "sha256-4v6sCTPRxe7bO7uV3HwUC8P1UsG8ydIvZ4rG2kU22zA=";
|
||||
cargoHash = "sha256-fyOlMagXrpfMsaLffeXolTgMldN9u6RQ08Zak9MdC4U=";
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
pkg-config
|
||||
@ -38,19 +38,21 @@
|
||||
(rust-bin.fromRustupToolchainFile ./rust-toolchain.toml)
|
||||
cargo-leptos-build
|
||||
clang
|
||||
sass
|
||||
openssl
|
||||
postgresql
|
||||
imagemagick
|
||||
pkg-config
|
||||
tailwindcss_4
|
||||
];
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
LIBCLANG_PATH = pkgs.lib.makeLibraryPath [ pkgs.llvmPackages_latest.libclang.lib ];
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.libgcc.lib ];
|
||||
|
||||
buildInputs = with pkgs; buildPkgs ++ [
|
||||
diesel-cli
|
||||
mdbook
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
@ -66,6 +68,11 @@
|
||||
|
||||
cargoLock = {
|
||||
lockFile = ./Cargo.lock;
|
||||
|
||||
# Needed because of git dependency
|
||||
outputHashes = {
|
||||
"libretunes_macro-0.1.0" = "sha256-hve1eZV6KMBK5LiW/F801qKds0hXg6ID9pd9fPvKJZY=";
|
||||
};
|
||||
};
|
||||
|
||||
LIBCLANG_PATH = pkgs.lib.makeLibraryPath [ pkgs.llvmPackages_latest.libclang.lib ];
|
||||
@ -92,7 +99,8 @@
|
||||
cp -r target/site $out/site
|
||||
|
||||
makeWrapper $out/libretunes $out/bin/libretunes \
|
||||
--set LEPTOS_SITE_ROOT $out/site
|
||||
--set LEPTOS_SITE_ROOT $out/site \
|
||||
--set LD_LIBRARY_PATH ${pkgs.libgcc.lib}
|
||||
'';
|
||||
|
||||
doCheck = false;
|
||||
|
@ -0,0 +1,4 @@
|
||||
ALTER TABLE songs
|
||||
ALTER COLUMN added_date TYPE DATE USING added_date::DATE,
|
||||
ALTER COLUMN added_date SET DEFAULT CURRENT_DATE,
|
||||
ALTER COLUMN added_date SET NOT NULL;
|
@ -0,0 +1,4 @@
|
||||
ALTER TABLE songs
|
||||
ALTER COLUMN added_date TYPE TIMESTAMP USING added_date::TIMESTAMP,
|
||||
ALTER COLUMN added_date SET DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN added_date SET NOT NULL;
|
@ -0,0 +1,4 @@
|
||||
DROP TRIGGER IF EXISTS playlist_songs_after_insert
|
||||
ON playlist_songs;
|
||||
|
||||
DROP FUNCTION IF EXISTS trg_update_playlists_updated_at();
|
@ -0,0 +1,15 @@
|
||||
CREATE OR REPLACE FUNCTION trg_update_playlists_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE playlists
|
||||
SET updated_at = NOW()
|
||||
WHERE id = NEW.playlist_id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER playlist_songs_after_insert
|
||||
AFTER INSERT ON playlist_songs
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE trg_update_playlists_updated_at();
|
@ -0,0 +1 @@
|
||||
ALTER TABLE artists DROP COLUMN image_path;
|
@ -0,0 +1 @@
|
||||
ALTER TABLE artists ADD COLUMN image_path VARCHAR;
|
@ -0,0 +1 @@
|
||||
ALTER TABLE users DROP COLUMN image_path;
|
@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN image_path VARCHAR;
|
@ -0,0 +1 @@
|
||||
ALTER TABLE playlists DROP COLUMN image_path;
|
@ -0,0 +1 @@
|
||||
ALTER TABLE playlists ADD COLUMN image_path VARCHAR;
|
@ -1,42 +0,0 @@
|
||||
use crate::models::Artist;
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
/// Holds information about an album
|
||||
///
|
||||
/// Intended to be used in the front-end
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct AlbumData {
|
||||
/// Album id
|
||||
pub id: i32,
|
||||
/// Album title
|
||||
pub title: String,
|
||||
/// Album artists
|
||||
pub artists: Vec<Artist>,
|
||||
/// Album release date
|
||||
pub release_date: Option<NaiveDate>,
|
||||
/// Path to album image, relative to the root of the web server.
|
||||
/// For example, `"/assets/images/Album.jpg"`
|
||||
pub image_path: String,
|
||||
}
|
||||
|
||||
impl DashboardTile for AlbumData {
|
||||
fn image_path(&self) -> String {
|
||||
self.image_path.clone()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn link(&self) -> String {
|
||||
format!("/album/{}", self.id)
|
||||
}
|
||||
|
||||
fn description(&self) -> Option<String> {
|
||||
Some(format!("Album • {}", Artist::display_list(&self.artists)))
|
||||
}
|
||||
}
|
198
src/api/album.rs
198
src/api/album.rs
@ -1,33 +1,181 @@
|
||||
use leptos::*;
|
||||
use crate::albumdata::AlbumData;
|
||||
use crate::songdata::SongData;
|
||||
use crate::models::frontend;
|
||||
use crate::util::serverfn_client::Client;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
use crate::database::get_db_conn;
|
||||
}
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use diesel::prelude::*;
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
use crate::util::database::get_db_conn;
|
||||
use crate::models::backend;
|
||||
}
|
||||
}
|
||||
|
||||
#[server(endpoint = "album/get")]
|
||||
pub async fn get_album(id: i32) -> Result<AlbumData, ServerFnError> {
|
||||
use crate::models::Album;
|
||||
let db_con = &mut get_db_conn();
|
||||
let album = Album::get_album_data(id,db_con)
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting album: {}", e)))?;
|
||||
Ok(album)
|
||||
#[server(endpoint = "album/get", client = Client)]
|
||||
pub async fn get_album(id: i32) -> Result<Option<frontend::Album>, ServerFnError> {
|
||||
use crate::models::backend::Album;
|
||||
use crate::schema::*;
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
|
||||
let album = albums::table
|
||||
.find(id)
|
||||
.first::<Album>(db_con)
|
||||
.optional()
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting album: {e}"))
|
||||
})?;
|
||||
|
||||
let Some(album) = album else { return Ok(None) };
|
||||
|
||||
let artists: Vec<backend::Artist> = album_artists::table
|
||||
.filter(album_artists::album_id.eq(id))
|
||||
.inner_join(artists::table.on(album_artists::artist_id.eq(artists::id)))
|
||||
.select(artists::all_columns)
|
||||
.load(db_con)?;
|
||||
|
||||
let img = album
|
||||
.image_path
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string());
|
||||
|
||||
let album = frontend::Album {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
artists,
|
||||
release_date: album.release_date,
|
||||
image_path: img,
|
||||
};
|
||||
|
||||
Ok(Some(album))
|
||||
}
|
||||
|
||||
#[server(endpoint = "album/get_songs")]
|
||||
pub async fn get_songs(id: i32) -> Result<Vec<SongData>, ServerFnError> {
|
||||
use crate::models::Album;
|
||||
use crate::auth::get_logged_in_user;
|
||||
let user = get_logged_in_user().await?;
|
||||
let db_con = &mut get_db_conn();
|
||||
// TODO: NEEDS SONG DATA QUERIES
|
||||
let songdata = Album::get_song_data(id,user,db_con)
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting song data: {}", e)))?;
|
||||
Ok(songdata)
|
||||
}
|
||||
#[server(endpoint = "album/get_songs", client = Client)]
|
||||
pub async fn get_songs(id: i32) -> Result<Vec<frontend::Song>, ServerFnError> {
|
||||
use crate::api::auth::get_logged_in_user;
|
||||
use crate::schema::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let user = get_logged_in_user().await?;
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
|
||||
let song_list = if let Some(user) = user {
|
||||
let song_list: Vec<(
|
||||
backend::Album,
|
||||
Option<backend::Song>,
|
||||
Option<backend::Artist>,
|
||||
Option<(i32, i32)>,
|
||||
Option<(i32, i32)>,
|
||||
)> = albums::table
|
||||
.find(id)
|
||||
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
|
||||
.left_join(
|
||||
song_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(songs::id.eq(song_artists::song_id)),
|
||||
)
|
||||
.left_join(
|
||||
song_likes::table.on(songs::id
|
||||
.eq(song_likes::song_id)
|
||||
.and(song_likes::user_id.eq(user.id))),
|
||||
)
|
||||
.left_join(
|
||||
song_dislikes::table.on(songs::id
|
||||
.eq(song_dislikes::song_id)
|
||||
.and(song_dislikes::user_id.eq(user.id))),
|
||||
)
|
||||
.select((
|
||||
albums::all_columns,
|
||||
songs::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable(),
|
||||
))
|
||||
.order(songs::track.asc())
|
||||
.load(db_con)?;
|
||||
song_list
|
||||
} else {
|
||||
let song_list: Vec<(
|
||||
backend::Album,
|
||||
Option<backend::Song>,
|
||||
Option<backend::Artist>,
|
||||
)> = albums::table
|
||||
.find(id)
|
||||
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
|
||||
.left_join(
|
||||
song_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(songs::id.eq(song_artists::song_id)),
|
||||
)
|
||||
.select((
|
||||
albums::all_columns,
|
||||
songs::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
))
|
||||
.order(songs::track.asc())
|
||||
.load(db_con)?;
|
||||
|
||||
let song_list: Vec<(
|
||||
backend::Album,
|
||||
Option<backend::Song>,
|
||||
Option<backend::Artist>,
|
||||
Option<(i32, i32)>,
|
||||
Option<(i32, i32)>,
|
||||
)> = song_list
|
||||
.into_iter()
|
||||
.map(|(album, song, artist)| (album, song, artist, None, None))
|
||||
.collect();
|
||||
song_list
|
||||
};
|
||||
|
||||
let mut album_songs: HashMap<i32, frontend::Song> = HashMap::with_capacity(song_list.len());
|
||||
|
||||
for (album, song, artist, like, dislike) in song_list {
|
||||
if let Some(song) = song {
|
||||
if let Some(stored_songdata) = album_songs.get_mut(&song.id) {
|
||||
// If the song is already in the map, update the artists
|
||||
if let Some(artist) = artist {
|
||||
stored_songdata.artists.push(artist);
|
||||
}
|
||||
} else {
|
||||
let like_dislike = match (like, dislike) {
|
||||
(Some(_), Some(_)) => Some((true, true)),
|
||||
(Some(_), None) => Some((true, false)),
|
||||
(None, Some(_)) => Some((false, true)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let image_path = song.image_path.unwrap_or(
|
||||
album
|
||||
.image_path
|
||||
.clone()
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
|
||||
);
|
||||
|
||||
let songdata = frontend::Song {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||
album: Some(album),
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path,
|
||||
image_path,
|
||||
like_dislike,
|
||||
added_date: song.added_date,
|
||||
};
|
||||
|
||||
album_songs.insert(song.id, songdata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the songs by date
|
||||
let mut songs: Vec<frontend::Song> = album_songs.into_values().collect();
|
||||
songs.sort_by(|a, b| a.track.cmp(&b.track));
|
||||
|
||||
Ok(songs)
|
||||
}
|
||||
|
@ -1,65 +1,64 @@
|
||||
use leptos::*;
|
||||
use crate::util::serverfn_client::Client;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::database::get_db_conn;
|
||||
use crate::util::database::get_db_conn;
|
||||
use diesel::prelude::*;
|
||||
use chrono::NaiveDate;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
///
|
||||
#[server(endpoint = "albums/add-album", client = Client)]
|
||||
pub async fn add_album(
|
||||
album_title: String,
|
||||
release_date: Option<String>,
|
||||
image_path: Option<String>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use crate::models::backend::NewAlbum;
|
||||
use crate::schema::albums::{self};
|
||||
use crate::models::Album;
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
|
||||
|
||||
let parsed_release_date = match release_date {
|
||||
Some(date) => {
|
||||
match NaiveDate::parse_from_str(&date.trim(), "%Y-%m-%d") {
|
||||
Ok(parsed_date) => Some(parsed_date),
|
||||
Err(_e) => return Err(ServerFnError::<NoCustomError>::ServerError("Invalid release date".to_string()))
|
||||
Some(date) => match NaiveDate::parse_from_str(date.trim(), "%Y-%m-%d") {
|
||||
Ok(parsed_date) => Some(parsed_date),
|
||||
Err(_e) => {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Invalid release date".to_string(),
|
||||
))
|
||||
}
|
||||
},
|
||||
None => None
|
||||
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,
|
||||
let image_path_arg = image_path.filter(|image_path| !image_path.is_empty());
|
||||
|
||||
let new_album = NewAlbum {
|
||||
title: album_title,
|
||||
release_date: parsed_release_date,
|
||||
image_path: image_path_arg
|
||||
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)))?;
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error adding album: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1,54 +1,54 @@
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
use crate::albumdata::AlbumData;
|
||||
use crate::models::Artist;
|
||||
use crate::songdata::SongData;
|
||||
use crate::models::backend::Artist;
|
||||
use crate::models::frontend;
|
||||
use crate::util::serverfn_client::Client;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::database::get_db_conn;
|
||||
use crate::util::database::get_db_conn;
|
||||
use diesel::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use server_fn::error::NoCustomError;
|
||||
use crate::models::Album;
|
||||
use crate::models::backend::Album;
|
||||
use crate::models::backend::NewArtist;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
///
|
||||
#[server(endpoint = "artists/add-artist", client = Client)]
|
||||
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,
|
||||
let new_artist = NewArtist {
|
||||
name: artist_name,
|
||||
image_path: None,
|
||||
};
|
||||
|
||||
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)))?;
|
||||
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error adding artist: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(endpoint = "artists/get")]
|
||||
#[server(endpoint = "artists/get", client = Client)]
|
||||
pub async fn get_artist_by_id(artist_id: i32) -> Result<Option<Artist>, ServerFnError> {
|
||||
use crate::schema::artists::dsl::*;
|
||||
use crate::models::Artist;
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
|
||||
let db = &mut get_db_conn();
|
||||
@ -56,161 +56,193 @@ pub async fn get_artist_by_id(artist_id: i32) -> Result<Option<Artist>, ServerFn
|
||||
.filter(id.eq(artist_id))
|
||||
.first::<Artist>(db)
|
||||
.optional()
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting artist: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting artist: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(artist)
|
||||
}
|
||||
|
||||
#[server(endpoint = "artists/top_songs")]
|
||||
pub async fn top_songs_by_artist(artist_id: i32, limit: Option<i64>, for_user_id: i32) -> Result<Vec<(SongData, i64)>, ServerFnError> {
|
||||
use crate::models::Song;
|
||||
#[server(endpoint = "artists/top_songs", client = Client)]
|
||||
pub async fn top_songs_by_artist(
|
||||
artist_id: i32,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<(frontend::Song, i64)>, ServerFnError> {
|
||||
use crate::api::auth::get_user;
|
||||
use crate::models::backend::Song;
|
||||
use crate::schema::*;
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
|
||||
use crate::schema::*;
|
||||
let user_id = get_user()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::ServerError::<NoCustomError>(format!("Error getting user: {e}"))
|
||||
})?
|
||||
.id;
|
||||
|
||||
let db = &mut get_db_conn();
|
||||
let song_play_counts: Vec<(i32, i64)> =
|
||||
if let Some(limit) = limit {
|
||||
song_history::table
|
||||
.group_by(song_history::song_id)
|
||||
.select((song_history::song_id, diesel::dsl::count(song_history::id)))
|
||||
.left_join(song_artists::table.on(song_artists::song_id.eq(song_history::song_id)))
|
||||
.filter(song_artists::artist_id.eq(artist_id))
|
||||
.order_by(diesel::dsl::count(song_history::id).desc())
|
||||
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
|
||||
.limit(limit)
|
||||
.load(db)?
|
||||
} else {
|
||||
song_history::table
|
||||
.group_by(song_history::song_id)
|
||||
.select((song_history::song_id, diesel::dsl::count(song_history::id)))
|
||||
.left_join(song_artists::table.on(song_artists::song_id.eq(song_history::song_id)))
|
||||
.filter(song_artists::artist_id.eq(artist_id))
|
||||
.order_by(diesel::dsl::count(song_history::id).desc())
|
||||
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
|
||||
.load(db)?
|
||||
};
|
||||
let song_play_counts: Vec<(i32, i64)> = if let Some(limit) = limit {
|
||||
song_history::table
|
||||
.group_by(song_history::song_id)
|
||||
.select((song_history::song_id, diesel::dsl::count(song_history::id)))
|
||||
.left_join(song_artists::table.on(song_artists::song_id.eq(song_history::song_id)))
|
||||
.filter(song_artists::artist_id.eq(artist_id))
|
||||
.order_by(diesel::dsl::count(song_history::id).desc())
|
||||
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
|
||||
.limit(limit)
|
||||
.load(db)?
|
||||
} else {
|
||||
song_history::table
|
||||
.group_by(song_history::song_id)
|
||||
.select((song_history::song_id, diesel::dsl::count(song_history::id)))
|
||||
.left_join(song_artists::table.on(song_artists::song_id.eq(song_history::song_id)))
|
||||
.filter(song_artists::artist_id.eq(artist_id))
|
||||
.order_by(diesel::dsl::count(song_history::id).desc())
|
||||
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
|
||||
.load(db)?
|
||||
};
|
||||
|
||||
let song_play_counts: HashMap<i32, i64> = song_play_counts.into_iter().collect();
|
||||
let top_song_ids: Vec<i32> = song_play_counts.iter().map(|(song_id, _)| *song_id).collect();
|
||||
let top_song_ids: Vec<i32> = song_play_counts.keys().copied().collect();
|
||||
|
||||
let top_songs: Vec<(Song, Option<Album>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)>
|
||||
= songs::table
|
||||
let top_songs: Vec<(
|
||||
Song,
|
||||
Option<Album>,
|
||||
Option<Artist>,
|
||||
Option<(i32, i32)>,
|
||||
Option<(i32, i32)>,
|
||||
)> = songs::table
|
||||
.filter(songs::id.eq_any(top_song_ids))
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
|
||||
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(for_user_id))))
|
||||
.left_join(song_dislikes::table.on(
|
||||
songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(for_user_id))))
|
||||
.select((
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable(),
|
||||
))
|
||||
.load(db)?;
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(
|
||||
song_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(songs::id.eq(song_artists::song_id)),
|
||||
)
|
||||
.left_join(
|
||||
song_likes::table.on(songs::id
|
||||
.eq(song_likes::song_id)
|
||||
.and(song_likes::user_id.eq(user_id))),
|
||||
)
|
||||
.left_join(
|
||||
song_dislikes::table.on(songs::id
|
||||
.eq(song_dislikes::song_id)
|
||||
.and(song_dislikes::user_id.eq(user_id))),
|
||||
)
|
||||
.select((
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable(),
|
||||
))
|
||||
.load(db)?;
|
||||
|
||||
let mut top_songs_map: HashMap<i32, (SongData, i64)> = HashMap::with_capacity(top_songs.len());
|
||||
let mut top_songs_map: HashMap<i32, (frontend::Song, i64)> =
|
||||
HashMap::with_capacity(top_songs.len());
|
||||
|
||||
for (song, album, artist, like, dislike) in top_songs {
|
||||
let song_id = song.id
|
||||
.ok_or(ServerFnError::ServerError::<NoCustomError>("Song id not found in database".to_string()))?;
|
||||
for (song, album, artist, like, dislike) in top_songs {
|
||||
if let Some((stored_songdata, _)) = top_songs_map.get_mut(&song.id) {
|
||||
// If the song is already in the map, update the artists
|
||||
if let Some(artist) = artist {
|
||||
stored_songdata.artists.push(artist);
|
||||
}
|
||||
} else {
|
||||
let like_dislike = match (like, dislike) {
|
||||
(Some(_), Some(_)) => Some((true, true)),
|
||||
(Some(_), None) => Some((true, false)),
|
||||
(None, Some(_)) => Some((false, true)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some((stored_songdata, _)) = top_songs_map.get_mut(&song_id) {
|
||||
// If the song is already in the map, update the artists
|
||||
if let Some(artist) = artist {
|
||||
stored_songdata.artists.push(artist);
|
||||
}
|
||||
} else {
|
||||
let like_dislike = match (like, dislike) {
|
||||
(Some(_), Some(_)) => Some((true, true)),
|
||||
(Some(_), None) => Some((true, false)),
|
||||
(None, Some(_)) => Some((false, true)),
|
||||
_ => None,
|
||||
};
|
||||
let image_path = song.image_path.unwrap_or(
|
||||
album
|
||||
.as_ref()
|
||||
.and_then(|album| album.image_path.clone())
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
|
||||
);
|
||||
|
||||
let image_path = song.image_path.unwrap_or(
|
||||
album.as_ref().map(|album| album.image_path.clone()).flatten()
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
|
||||
let songdata = frontend::Song {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||
album,
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path,
|
||||
image_path,
|
||||
like_dislike,
|
||||
added_date: song.added_date,
|
||||
};
|
||||
|
||||
let songdata = SongData {
|
||||
id: song_id,
|
||||
title: song.title,
|
||||
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||
album: album,
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path,
|
||||
image_path: image_path,
|
||||
like_dislike: like_dislike,
|
||||
added_date: song.added_date.unwrap(),
|
||||
};
|
||||
let plays = song_play_counts
|
||||
.get(&song.id)
|
||||
.ok_or(ServerFnError::ServerError::<NoCustomError>(
|
||||
"Song id not found in history counts".to_string(),
|
||||
))?;
|
||||
|
||||
let plays = song_play_counts.get(&song_id)
|
||||
.ok_or(ServerFnError::ServerError::<NoCustomError>("Song id not found in history counts".to_string()))?;
|
||||
top_songs_map.insert(song.id, (songdata, *plays));
|
||||
}
|
||||
}
|
||||
|
||||
top_songs_map.insert(song_id, (songdata, *plays));
|
||||
}
|
||||
}
|
||||
|
||||
let mut top_songs: Vec<(SongData, i64)> = top_songs_map.into_iter().map(|(_, v)| v).collect();
|
||||
let mut top_songs: Vec<(frontend::Song, i64)> = top_songs_map.into_values().collect();
|
||||
top_songs.sort_by(|(_, plays1), (_, plays2)| plays2.cmp(plays1));
|
||||
Ok(top_songs)
|
||||
}
|
||||
|
||||
#[server(endpoint = "artists/albums")]
|
||||
pub async fn albums_by_artist(artist_id: i32, limit: Option<i64>) -> Result<Vec<AlbumData>, ServerFnError> {
|
||||
#[server(endpoint = "artists/albums", client = Client)]
|
||||
pub async fn albums_by_artist(
|
||||
artist_id: i32,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<frontend::Album>, ServerFnError> {
|
||||
use crate::schema::*;
|
||||
|
||||
let db = &mut get_db_conn();
|
||||
let album_ids: Vec<i32> =
|
||||
if let Some(limit) = limit {
|
||||
albums::table
|
||||
.left_join(album_artists::table)
|
||||
.filter(album_artists::artist_id.eq(artist_id))
|
||||
.order_by(albums::release_date.desc())
|
||||
.limit(limit)
|
||||
.select(albums::id)
|
||||
.load(db)?
|
||||
} else {
|
||||
albums::table
|
||||
.left_join(album_artists::table)
|
||||
.filter(album_artists::artist_id.eq(artist_id))
|
||||
.order_by(albums::release_date.desc())
|
||||
.select(albums::id)
|
||||
.load(db)?
|
||||
};
|
||||
|
||||
let mut albums_map: HashMap<i32, AlbumData> = HashMap::with_capacity(album_ids.len());
|
||||
let album_ids = albums::table
|
||||
.left_join(album_artists::table)
|
||||
.filter(album_artists::artist_id.eq(artist_id))
|
||||
.order_by(albums::release_date.desc())
|
||||
.select(albums::id);
|
||||
|
||||
let album_ids = if let Some(limit) = limit {
|
||||
album_ids.limit(limit).into_boxed()
|
||||
} else {
|
||||
album_ids.into_boxed()
|
||||
};
|
||||
|
||||
let mut albums_map: HashMap<i32, frontend::Album> = HashMap::new();
|
||||
|
||||
let album_artists: Vec<(Album, Artist)> = albums::table
|
||||
.filter(albums::id.eq_any(album_ids))
|
||||
.inner_join(album_artists::table.inner_join(artists::table).on(albums::id.eq(album_artists::album_id)))
|
||||
.inner_join(
|
||||
album_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(albums::id.eq(album_artists::album_id)),
|
||||
)
|
||||
.select((albums::all_columns, artists::all_columns))
|
||||
.load(db)?;
|
||||
|
||||
for (album, artist) in album_artists {
|
||||
let album_id = album.id
|
||||
.ok_or(ServerFnError::ServerError::<NoCustomError>("Album id not found in database".to_string()))?;
|
||||
|
||||
if let Some(stored_album) = albums_map.get_mut(&album_id) {
|
||||
if let Some(stored_album) = albums_map.get_mut(&album.id) {
|
||||
stored_album.artists.push(artist);
|
||||
} else {
|
||||
let albumdata = AlbumData {
|
||||
id: album_id,
|
||||
let albumdata = frontend::Album {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
artists: vec![artist],
|
||||
release_date: album.release_date,
|
||||
image_path: album.image_path.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
|
||||
image_path: album
|
||||
.image_path
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
|
||||
};
|
||||
|
||||
albums_map.insert(album_id, albumdata);
|
||||
albums_map.insert(album.id, albumdata);
|
||||
}
|
||||
}
|
||||
|
||||
let mut albums: Vec<AlbumData> = albums_map.into_iter().map(|(_, v)| v).collect();
|
||||
let mut albums: Vec<frontend::Album> = albums_map.into_values().collect();
|
||||
albums.sort_by(|a1, a2| a2.release_date.cmp(&a1.release_date));
|
||||
Ok(albums)
|
||||
}
|
||||
|
221
src/api/auth.rs
Normal file
221
src/api/auth.rs
Normal file
@ -0,0 +1,221 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
use leptos_axum::extract;
|
||||
use axum_login::AuthSession;
|
||||
use crate::util::auth_backend::AuthBackend;
|
||||
}
|
||||
}
|
||||
|
||||
use crate::api::users::UserCredentials;
|
||||
use crate::models::backend::{NewUser, User};
|
||||
use crate::util::serverfn_client::Client;
|
||||
|
||||
/// Create a new user and log them in
|
||||
/// Takes in a NewUser struct, with the password in plaintext
|
||||
/// Returns a Result with the error message if the user could not be created
|
||||
#[server(endpoint = "signup", client = Client)]
|
||||
pub async fn signup(new_user: NewUser) -> Result<(), ServerFnError> {
|
||||
// Check LIBRETUNES_DISABLE_SIGNUP env var
|
||||
if std::env::var("LIBRETUNES_DISABLE_SIGNUP").is_ok_and(|v| v == "true") {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Signup is disabled".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
use crate::api::users::create_user;
|
||||
|
||||
// Ensure the user has no id, and is not a self-proclaimed admin
|
||||
let new_user = NewUser {
|
||||
admin: false,
|
||||
..new_user
|
||||
};
|
||||
|
||||
create_user(&new_user).await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {e}"))
|
||||
})?;
|
||||
|
||||
let mut auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
|
||||
})?;
|
||||
|
||||
let credentials = UserCredentials {
|
||||
username_or_email: new_user.username.clone(),
|
||||
password: new_user.password.clone().unwrap(),
|
||||
};
|
||||
|
||||
match auth_session.authenticate(credentials).await {
|
||||
Ok(Some(user)) => auth_session.login(&user).await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {e}"))
|
||||
}),
|
||||
Ok(None) => Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Error authenticating user: User not found".to_string(),
|
||||
)),
|
||||
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error authenticating user: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a user in
|
||||
/// Takes in a username or email and a password in plaintext
|
||||
/// Returns a Result with a boolean indicating if the login was successful
|
||||
#[server(endpoint = "login", client = Client)]
|
||||
pub async fn login(credentials: UserCredentials) -> Result<Option<User>, ServerFnError> {
|
||||
use crate::api::users::validate_user;
|
||||
|
||||
let mut auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
|
||||
})?;
|
||||
|
||||
let user = validate_user(credentials).await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error validating user: {e}"))
|
||||
})?;
|
||||
|
||||
if let Some(mut user) = user {
|
||||
auth_session.login(&user).await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {e}"))
|
||||
})?;
|
||||
|
||||
user.password = None;
|
||||
Ok(Some(user))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a user out
|
||||
/// Returns a Result with the error message if the user could not be logged out
|
||||
#[server(endpoint = "logout", client = Client)]
|
||||
pub async fn logout() -> Result<(), ServerFnError> {
|
||||
let mut auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
|
||||
})?;
|
||||
|
||||
auth_session.logout().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
|
||||
})?;
|
||||
|
||||
leptos_axum::redirect("/login");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a user is logged in
|
||||
/// Returns a Result with a boolean indicating if the user is logged in
|
||||
#[server(endpoint = "check_auth", client = Client)]
|
||||
pub async fn check_auth() -> Result<bool, ServerFnError> {
|
||||
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(auth_session.user.is_some())
|
||||
}
|
||||
|
||||
/// Require that a user is logged in
|
||||
/// Returns a Result with the error message if the user is not logged in
|
||||
/// Intended to be used at the start of a protected route, to ensure the user is logged in:
|
||||
/// ```rust
|
||||
/// use leptos::prelude::*;
|
||||
/// use libretunes::api::auth::require_auth;
|
||||
/// #[server(endpoint = "protected_route")]
|
||||
/// pub async fn protected_route() -> Result<(), ServerFnError> {
|
||||
/// require_auth().await?;
|
||||
/// // Continue with protected route
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn require_auth() -> Result<(), ServerFnError> {
|
||||
check_auth().await.and_then(|logged_in| {
|
||||
if logged_in {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Unauthorized".to_string(),
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 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::prelude::*;
|
||||
/// use libretunes::api::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(),
|
||||
))
|
||||
}
|
||||
|
||||
#[server(endpoint = "get_logged_in_user", client = Client)]
|
||||
pub async fn get_logged_in_user() -> Result<Option<User>, ServerFnError> {
|
||||
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
|
||||
})?;
|
||||
|
||||
let user = auth_session.user.map(|mut user| {
|
||||
user.password = None;
|
||||
user
|
||||
});
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// 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", client = Client)]
|
||||
pub async fn check_admin() -> Result<bool, ServerFnError> {
|
||||
let auth_session = extract::<AuthSession<AuthBackend>>().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(auth_session.user.as_ref().map(|u| u.admin).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Require that a user is logged in and an admin
|
||||
/// Returns a Result with the error message if the user is not logged in or is not an admin
|
||||
/// Intended to be used at the start of a protected route, to ensure the user is logged in and an admin:
|
||||
/// ```rust
|
||||
/// use leptos::prelude::*;
|
||||
/// use libretunes::api::auth::require_admin;
|
||||
/// #[server(endpoint = "protected_admin_route")]
|
||||
/// pub async fn protected_admin_route() -> Result<(), ServerFnError> {
|
||||
/// require_admin().await?;
|
||||
/// // Continue with protected route
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn require_admin() -> Result<(), ServerFnError> {
|
||||
check_admin().await.and_then(|is_admin| {
|
||||
if is_admin {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Unauthorized".to_string(),
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
23
src/api/health.rs
Normal file
23
src/api/health.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::util::serverfn_client::Client;
|
||||
|
||||
#[server(endpoint = "health", client = Client)]
|
||||
pub async fn health() -> Result<String, ServerFnError> {
|
||||
use crate::util::database::get_db_conn;
|
||||
use crate::util::redis::get_redis_conn;
|
||||
use diesel::connection::SimpleConnection;
|
||||
use server_fn::error::NoCustomError;
|
||||
use tower_sessions_redis_store::fred::interfaces::ClientLike;
|
||||
|
||||
get_db_conn()
|
||||
.batch_execute("SELECT 1")
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Database error: {e}")))?;
|
||||
|
||||
get_redis_conn()
|
||||
.ping::<()>(None)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Redis error: {e}")))?;
|
||||
|
||||
Ok("ok".to_string())
|
||||
}
|
@ -1,44 +1,50 @@
|
||||
use crate::models::backend::HistoryEntry;
|
||||
use crate::models::backend::Song;
|
||||
use crate::util::serverfn_client::Client;
|
||||
use chrono::NaiveDateTime;
|
||||
use leptos::*;
|
||||
use crate::models::HistoryEntry;
|
||||
use crate::models::Song;
|
||||
use leptos::prelude::*;
|
||||
|
||||
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;
|
||||
}
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
use crate::util::database::get_db_conn;
|
||||
use crate::api::auth::get_user;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the history of the current user.
|
||||
#[server(endpoint = "history/get")]
|
||||
#[server(endpoint = "history/get", client = Client)]
|
||||
pub async fn get_history(limit: Option<i64>) -> Result<Vec<HistoryEntry>, 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::<NoCustomError>::ServerError(format!("Error getting history: {}", e)))?;
|
||||
Ok(history)
|
||||
let user = get_user().await?;
|
||||
let db_con = &mut get_db_conn();
|
||||
let history = user.get_history(limit, db_con).map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::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<i64>) -> Result<Vec<(NaiveDateTime, Song)>, 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::<NoCustomError>::ServerError(format!("Error getting history songs: {}", e)))?;
|
||||
Ok(songs)
|
||||
#[server(endpoint = "history/get_songs", client = Client)]
|
||||
pub async fn get_history_songs(
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<(NaiveDateTime, Song)>, 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::<NoCustomError>::ServerError(format!("Error getting history songs: {e}"))
|
||||
})?;
|
||||
Ok(songs)
|
||||
}
|
||||
|
||||
/// Add a song to the history of the current user.
|
||||
#[server(endpoint = "history/add")]
|
||||
#[server(endpoint = "history/add", client = Client)]
|
||||
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::<NoCustomError>::ServerError(format!("Error adding history: {}", e)))?;
|
||||
Ok(())
|
||||
let user = get_user().await?;
|
||||
let db_con = &mut get_db_conn();
|
||||
user.add_history(song_id, db_con).map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error adding history: {e}"))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
@ -1,6 +1,12 @@
|
||||
pub mod artists;
|
||||
pub mod albums;
|
||||
pub mod history;
|
||||
pub mod profile;
|
||||
pub mod songs;
|
||||
pub mod album;
|
||||
pub mod albums;
|
||||
pub mod artists;
|
||||
pub mod auth;
|
||||
pub mod health;
|
||||
pub mod history;
|
||||
pub mod playlists;
|
||||
pub mod profile;
|
||||
pub mod search;
|
||||
pub mod songs;
|
||||
pub mod upload;
|
||||
pub mod users;
|
||||
|
457
src/api/playlists.rs
Normal file
457
src/api/playlists.rs
Normal file
@ -0,0 +1,457 @@
|
||||
use crate::models::{backend, frontend};
|
||||
use crate::util::serverfn_client::Client;
|
||||
use cfg_if::cfg_if;
|
||||
use leptos::prelude::*;
|
||||
use server_fn::codec::{MultipartData, MultipartFormData};
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::api::auth::get_user;
|
||||
use diesel::prelude::*;
|
||||
use crate::util::database::get_db_conn;
|
||||
use crate::util::extract_field::extract_field;
|
||||
use std::collections::HashMap;
|
||||
use server_fn::error::NoCustomError;
|
||||
use log::*;
|
||||
use crate::schema::*;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn user_owns_playlist(user_id: i32, playlist_id: i32) -> Result<bool, ServerFnError> {
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
let exists = playlists::table
|
||||
.find(playlist_id)
|
||||
.filter(playlists::owner_id.eq(user_id))
|
||||
.select(playlists::id)
|
||||
.first::<i32>(&mut db_conn)
|
||||
.optional()
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
|
||||
})?
|
||||
.is_some();
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
#[server(endpoint = "playlists/get_all", client = Client)]
|
||||
pub async fn get_playlists() -> Result<Vec<backend::Playlist>, ServerFnError> {
|
||||
let user_id = get_user()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?
|
||||
.id;
|
||||
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
let playlists = playlists::table
|
||||
.filter(playlists::owner_id.eq(user_id))
|
||||
.select(playlists::all_columns)
|
||||
.load::<backend::Playlist>(&mut db_conn)
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting playlists: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(playlists)
|
||||
}
|
||||
|
||||
#[server(endpoint = "playlists/get", client = Client)]
|
||||
pub async fn get_playlist(playlist_id: i32) -> Result<backend::Playlist, ServerFnError> {
|
||||
let user_id = get_user()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?
|
||||
.id;
|
||||
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
let playlist: backend::Playlist = playlists::table
|
||||
.find(playlist_id)
|
||||
.filter(playlists::owner_id.eq(user_id))
|
||||
.select(playlists::all_columns)
|
||||
.first(&mut db_conn)
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting playlist: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(playlist)
|
||||
}
|
||||
|
||||
#[server(endpoint = "playlists/get_songs", client = Client)]
|
||||
pub async fn get_playlist_songs(playlist_id: i32) -> Result<Vec<frontend::Song>, ServerFnError> {
|
||||
let user_id = get_user()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?
|
||||
.id;
|
||||
|
||||
// Check if the playlist exists and belongs to the user
|
||||
let valid_playlist = user_owns_playlist(user_id, playlist_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
|
||||
})?;
|
||||
|
||||
if !valid_playlist {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Playlist does not exist or does not belong to the user".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
let songs: Vec<(
|
||||
backend::Song,
|
||||
Option<backend::Album>,
|
||||
Option<backend::Artist>,
|
||||
Option<(i32, i32)>,
|
||||
Option<(i32, i32)>,
|
||||
)> = crate::schema::playlist_songs::table
|
||||
.filter(crate::schema::playlist_songs::playlist_id.eq(playlist_id))
|
||||
.inner_join(
|
||||
crate::schema::songs::table
|
||||
.on(crate::schema::playlist_songs::song_id.eq(crate::schema::songs::id)),
|
||||
)
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(
|
||||
song_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(songs::id.eq(song_artists::song_id)),
|
||||
)
|
||||
.left_join(
|
||||
song_likes::table.on(songs::id
|
||||
.eq(song_likes::song_id)
|
||||
.and(song_likes::user_id.eq(user_id))),
|
||||
)
|
||||
.left_join(
|
||||
song_dislikes::table.on(songs::id
|
||||
.eq(song_dislikes::song_id)
|
||||
.and(song_dislikes::user_id.eq(user_id))),
|
||||
)
|
||||
.select((
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable(),
|
||||
))
|
||||
.load(&mut db_conn)?;
|
||||
|
||||
let mut playlist_songs: HashMap<i32, frontend::Song> = HashMap::new();
|
||||
|
||||
for (song, album, artist, like, dislike) in songs {
|
||||
if let Some(stored_songdata) = playlist_songs.get_mut(&song.id) {
|
||||
if let Some(artist) = artist {
|
||||
stored_songdata.artists.push(artist);
|
||||
}
|
||||
} else {
|
||||
let like_dislike = match (like, dislike) {
|
||||
(Some(_), Some(_)) => Some((true, true)),
|
||||
(Some(_), None) => Some((true, false)),
|
||||
(None, Some(_)) => Some((false, true)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let image_path = song.image_path.unwrap_or(
|
||||
album
|
||||
.as_ref()
|
||||
.and_then(|album| album.image_path.clone())
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
|
||||
);
|
||||
|
||||
let songdata = frontend::Song {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||
album,
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path,
|
||||
image_path,
|
||||
like_dislike,
|
||||
added_date: song.added_date,
|
||||
};
|
||||
|
||||
playlist_songs.insert(song.id, songdata);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(playlist_songs.into_values().collect())
|
||||
}
|
||||
|
||||
#[server(endpoint = "playlists/add_song", client = Client)]
|
||||
pub async fn add_song_to_playlist(playlist_id: i32, song_id: i32) -> Result<(), ServerFnError> {
|
||||
use crate::schema::*;
|
||||
|
||||
let user = get_user().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?;
|
||||
|
||||
// Check if the playlist exists and belongs to the user
|
||||
let valid_playlist = user_owns_playlist(user.id, playlist_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
|
||||
})?;
|
||||
|
||||
if !valid_playlist {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Playlist does not exist or does not belong to the user".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
diesel::insert_into(crate::schema::playlist_songs::table)
|
||||
.values((
|
||||
playlist_songs::playlist_id.eq(playlist_id),
|
||||
playlist_songs::song_id.eq(song_id),
|
||||
))
|
||||
.execute(&mut db_conn)
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error adding song to playlist: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(input = MultipartFormData, endpoint = "playlists/create")]
|
||||
pub async fn create_playlist(data: MultipartData) -> Result<(), ServerFnError> {
|
||||
use crate::models::backend::NewPlaylist;
|
||||
use image_convert::{to_webp, ImageResource, WEBPConfig};
|
||||
|
||||
let user = get_user().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?;
|
||||
|
||||
// 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 mut playlist_name = None;
|
||||
let mut picture_data = None;
|
||||
|
||||
while let Ok(Some(field)) = data.next_field().await {
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
|
||||
match name.as_str() {
|
||||
"name" => {
|
||||
playlist_name = Some(extract_field(field).await?);
|
||||
}
|
||||
"picture" => {
|
||||
// Read the image
|
||||
let bytes = field.bytes().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error getting field bytes: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Check if the image is empty
|
||||
if !bytes.is_empty() {
|
||||
let reader = std::io::Cursor::new(bytes);
|
||||
let image_source = ImageResource::from_reader(reader).map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error creating image resource: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
picture_data = Some(image_source);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("Unknown playlist creation field: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap mandatory fields
|
||||
let name = playlist_name.ok_or_else(|| {
|
||||
ServerFnError::<NoCustomError>::ServerError("Missing playlist name".to_string())
|
||||
})?;
|
||||
|
||||
let new_playlist = NewPlaylist {
|
||||
name: name.clone(),
|
||||
owner_id: user.id,
|
||||
image_path: None,
|
||||
};
|
||||
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
// Create a transaction to create the playlist
|
||||
// If saving the image fails, the playlist will not be created
|
||||
db_conn.transaction(|db_conn| {
|
||||
let playlist = diesel::insert_into(playlists::table)
|
||||
.values(&new_playlist)
|
||||
.get_result::<backend::Playlist>(db_conn)
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error creating playlist: {e}"))
|
||||
})?;
|
||||
|
||||
// If a picture was provided, save it to the database
|
||||
if let Some(image_source) = picture_data {
|
||||
let image_path = format!("assets/images/playlist/{}.webp", playlist.id);
|
||||
|
||||
let mut image_target = ImageResource::from_path(&image_path);
|
||||
to_webp(&mut image_target, &image_source, &WEBPConfig::new()).map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error converting image to webp: {e}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok::<(), ServerFnError>(())
|
||||
})
|
||||
}
|
||||
|
||||
#[server(input = MultipartFormData, endpoint = "playlists/edit_image")]
|
||||
pub async fn edit_playlist_image(data: MultipartData) -> Result<(), ServerFnError> {
|
||||
use image_convert::{to_webp, ImageResource, WEBPConfig};
|
||||
|
||||
let user = get_user().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?;
|
||||
|
||||
// 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 mut playlist_id = None;
|
||||
let mut picture_data = None;
|
||||
|
||||
while let Ok(Some(field)) = data.next_field().await {
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
|
||||
match name.as_str() {
|
||||
"id" => {
|
||||
playlist_id = Some(extract_field(field).await?);
|
||||
}
|
||||
"picture" => {
|
||||
// Read the image
|
||||
let bytes = field.bytes().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error getting field bytes: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Check if the image is empty
|
||||
if !bytes.is_empty() {
|
||||
let reader = std::io::Cursor::new(bytes);
|
||||
let image_source = ImageResource::from_reader(reader).map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error creating image resource: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
picture_data = Some(image_source);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
warn!("Unknown playlist creation field: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap mandatory fields
|
||||
let playlist_id = playlist_id.ok_or_else(|| {
|
||||
ServerFnError::<NoCustomError>::ServerError("Missing playlist name".to_string())
|
||||
})?;
|
||||
|
||||
let playlist_id: i32 = playlist_id.parse().map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Invalid playlist ID: {e}"))
|
||||
})?;
|
||||
|
||||
// Make sure the playlist exists and belongs to the user
|
||||
let valid_playlist = user_owns_playlist(user.id, playlist_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
|
||||
})?;
|
||||
|
||||
if !valid_playlist {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Playlist does not exist or does not belong to the user".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// If a picture was provided, save it to the database
|
||||
if let Some(image_source) = picture_data {
|
||||
let image_path = format!("assets/images/playlist/{playlist_id}.webp");
|
||||
|
||||
let mut image_target = ImageResource::from_path(&image_path);
|
||||
to_webp(&mut image_target, &image_source, &WEBPConfig::new()).map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error converting image to webp: {e}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(endpoint = "playlists/delete", client = Client)]
|
||||
pub async fn delete_playlist(playlist_id: i32) -> Result<(), ServerFnError> {
|
||||
use crate::schema::*;
|
||||
|
||||
let user = get_user().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?;
|
||||
|
||||
// Check if the playlist exists and belongs to the user
|
||||
let valid_playlist = user_owns_playlist(user.id, playlist_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
|
||||
})?;
|
||||
|
||||
if !valid_playlist {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Playlist does not exist or does not belong to the user".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
diesel::delete(playlists::table.find(playlist_id))
|
||||
.execute(&mut db_conn)
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error deleting playlist: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server(endpoint = "playlists/rename", client = Client)]
|
||||
pub async fn rename_playlist(id: i32, new_name: String) -> Result<(), ServerFnError> {
|
||||
use crate::schema::*;
|
||||
|
||||
let user = get_user().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?;
|
||||
|
||||
// Check if the playlist exists and belongs to the user
|
||||
let valid_playlist = user_owns_playlist(user.id, id).await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error checking playlist: {e}"))
|
||||
})?;
|
||||
|
||||
if !valid_playlist {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Playlist does not exist or does not belong to the user".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
diesel::update(playlists::table.find(id))
|
||||
.set(playlists::name.eq(new_name))
|
||||
.execute(&mut db_conn)
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error renaming playlist: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
@ -1,64 +1,72 @@
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use server_fn::codec::{MultipartData, MultipartFormData};
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
use crate::songdata::SongData;
|
||||
use crate::artistdata::ArtistData;
|
||||
|
||||
use crate::models::frontend;
|
||||
use crate::util::serverfn_client::Client;
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::auth::get_user;
|
||||
use server_fn::error::NoCustomError;
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::api::auth::get_user;
|
||||
use server_fn::error::NoCustomError;
|
||||
|
||||
use crate::database::get_db_conn;
|
||||
use diesel::prelude::*;
|
||||
use diesel::dsl::count;
|
||||
use crate::models::*;
|
||||
use crate::schema::*;
|
||||
use crate::util::database::get_db_conn;
|
||||
use diesel::prelude::*;
|
||||
use diesel::dsl::count;
|
||||
use crate::models::backend::{Album, Artist, Song, HistoryEntry};
|
||||
use crate::models::backend;
|
||||
use crate::schema::*;
|
||||
|
||||
use std::collections::HashMap;
|
||||
}
|
||||
use std::collections::HashMap;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
// 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::<NoCustomError>::ServerError(format!("Error getting field: {}", e)))?
|
||||
.ok_or_else(|| ServerFnError::<NoCustomError>::ServerError("No field found".to_string()))?;
|
||||
let field = data
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting field: {e}"))
|
||||
})?
|
||||
.ok_or_else(|| ServerFnError::<NoCustomError>::ServerError("No field found".to_string()))?;
|
||||
|
||||
if field.name() != Some("picture") {
|
||||
return Err(ServerFnError::ServerError("Field name is not 'picture'".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::<NoCustomError>::ServerError(format!("Error getting user: {}", e)))?;
|
||||
// Get user id from session
|
||||
let user = get_user().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?;
|
||||
|
||||
let user_id = user.id.ok_or_else(|| ServerFnError::<NoCustomError>::ServerError("User has no id".to_string()))?;
|
||||
// Read the image, and convert it to webp
|
||||
use image_convert::{to_webp, ImageResource, WEBPConfig};
|
||||
|
||||
// Read the image, and convert it to webp
|
||||
use image_convert::{to_webp, WEBPConfig, ImageResource};
|
||||
let bytes = field.bytes().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting field bytes: {e}"))
|
||||
})?;
|
||||
|
||||
let bytes = field.bytes().await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::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::<NoCustomError>::ServerError(format!("Error creating image resource: {e}"))
|
||||
})?;
|
||||
|
||||
let reader = std::io::Cursor::new(bytes);
|
||||
let image_source = ImageResource::from_reader(reader)
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::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::<NoCustomError>::ServerError(format!("Error converting image to webp: {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::<NoCustomError>::ServerError(format!("Error converting image to webp: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a user's recent songs listened to
|
||||
@ -66,237 +74,373 @@ pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> {
|
||||
/// If not provided, all songs ever listend to are returned.
|
||||
/// Returns a list of tuples with the date the song was listened to
|
||||
/// and the song data, sorted by date (most recent first).
|
||||
#[server(endpoint = "/profile/recent_songs")]
|
||||
pub async fn recent_songs(for_user_id: i32, limit: Option<i64>) -> Result<Vec<(NaiveDateTime, SongData)>, ServerFnError> {
|
||||
let mut db_con = get_db_conn();
|
||||
#[server(endpoint = "/profile/recent_songs", client = Client)]
|
||||
pub async fn recent_songs(
|
||||
for_user_id: i32,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<(NaiveDateTime, frontend::Song)>, ServerFnError> {
|
||||
let viewing_user_id = get_user()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?
|
||||
.id;
|
||||
|
||||
// Get the ids of the most recent songs listened to
|
||||
let history_items: Vec<i32> =
|
||||
if let Some(limit) = limit {
|
||||
song_history::table
|
||||
.filter(song_history::user_id.eq(for_user_id))
|
||||
.order(song_history::date.desc())
|
||||
.limit(limit)
|
||||
.select(song_history::id)
|
||||
.load(&mut db_con)?
|
||||
} else {
|
||||
song_history::table
|
||||
.filter(song_history::user_id.eq(for_user_id))
|
||||
.order(song_history::date.desc())
|
||||
.select(song_history::id)
|
||||
.load(&mut db_con)?
|
||||
};
|
||||
let mut db_con = get_db_conn();
|
||||
|
||||
// Take the history ids and get the song data for them
|
||||
let history: Vec<(HistoryEntry, Song, Option<Album>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)>
|
||||
= song_history::table
|
||||
.filter(song_history::id.eq_any(history_items))
|
||||
.inner_join(songs::table)
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
|
||||
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(for_user_id))))
|
||||
.left_join(song_dislikes::table.on(
|
||||
songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(for_user_id))))
|
||||
.select((
|
||||
song_history::all_columns,
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable(),
|
||||
))
|
||||
.load(&mut db_con)?;
|
||||
// Create an alias for the table so it can be referenced twice in the query
|
||||
let history2 = diesel::alias!(song_history as history2);
|
||||
|
||||
// Process the history data into a map of song ids to song data
|
||||
let mut history_songs: HashMap<i32, (NaiveDateTime, SongData)> = HashMap::with_capacity(history.len());
|
||||
// Get the ids of the most recent songs listened to
|
||||
let history_ids = history2
|
||||
.filter(history2.fields(song_history::user_id).eq(for_user_id))
|
||||
.order(history2.fields(song_history::date).desc())
|
||||
.select(history2.fields(song_history::id));
|
||||
|
||||
for (history, song, album, artist, like, dislike) in history {
|
||||
let song_id = history.song_id;
|
||||
let history_ids = if let Some(limit) = limit {
|
||||
history_ids.limit(limit).into_boxed()
|
||||
} else {
|
||||
history_ids.into_boxed()
|
||||
};
|
||||
|
||||
if let Some((_, stored_songdata)) = history_songs.get_mut(&song_id) {
|
||||
// If the song is already in the map, update the artists
|
||||
if let Some(artist) = artist {
|
||||
stored_songdata.artists.push(artist);
|
||||
}
|
||||
} else {
|
||||
let like_dislike = match (like, dislike) {
|
||||
(Some(_), Some(_)) => Some((true, true)),
|
||||
(Some(_), None) => Some((true, false)),
|
||||
(None, Some(_)) => Some((false, true)),
|
||||
_ => None,
|
||||
};
|
||||
// Take the history ids and get the song data for them
|
||||
let history: Vec<(
|
||||
HistoryEntry,
|
||||
Song,
|
||||
Option<Album>,
|
||||
Option<Artist>,
|
||||
Option<(i32, i32)>,
|
||||
Option<(i32, i32)>,
|
||||
)> = song_history::table
|
||||
.filter(song_history::id.eq_any(history_ids))
|
||||
.inner_join(songs::table)
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(
|
||||
song_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(songs::id.eq(song_artists::song_id)),
|
||||
)
|
||||
.left_join(
|
||||
song_likes::table.on(songs::id
|
||||
.eq(song_likes::song_id)
|
||||
.and(song_likes::user_id.eq(viewing_user_id))),
|
||||
)
|
||||
.left_join(
|
||||
song_dislikes::table.on(songs::id
|
||||
.eq(song_dislikes::song_id)
|
||||
.and(song_dislikes::user_id.eq(viewing_user_id))),
|
||||
)
|
||||
.select((
|
||||
song_history::all_columns,
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable(),
|
||||
))
|
||||
.load(&mut db_con)?;
|
||||
|
||||
let image_path = song.image_path.unwrap_or(
|
||||
album.as_ref().map(|album| album.image_path.clone()).flatten()
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
|
||||
// Process the history data into a map of song ids to song data
|
||||
let mut history_songs: HashMap<i32, (NaiveDateTime, frontend::Song)> = HashMap::new();
|
||||
|
||||
let songdata = SongData {
|
||||
id: song_id,
|
||||
title: song.title,
|
||||
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||
album: album,
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path,
|
||||
image_path: image_path,
|
||||
like_dislike: like_dislike,
|
||||
added_date: song.added_date.unwrap(),
|
||||
};
|
||||
for (history, song, album, artist, like, dislike) in history {
|
||||
let song_id = history.song_id;
|
||||
|
||||
history_songs.insert(song_id, (history.date, songdata));
|
||||
}
|
||||
}
|
||||
if let Some((_, stored_songdata)) = history_songs.get_mut(&song_id) {
|
||||
// If the song is already in the map, update the artists
|
||||
if let Some(artist) = artist {
|
||||
stored_songdata.artists.push(artist);
|
||||
}
|
||||
} else {
|
||||
let like_dislike = match (like, dislike) {
|
||||
(Some(_), Some(_)) => Some((true, true)),
|
||||
(Some(_), None) => Some((true, false)),
|
||||
(None, Some(_)) => Some((false, true)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Sort the songs by date
|
||||
let mut history_songs: Vec<(NaiveDateTime, SongData)> = history_songs.into_values().collect();
|
||||
history_songs.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
Ok(history_songs)
|
||||
let image_path = song.image_path.unwrap_or(
|
||||
album
|
||||
.as_ref()
|
||||
.and_then(|album| album.image_path.clone())
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
|
||||
);
|
||||
|
||||
let songdata = frontend::Song {
|
||||
id: song_id,
|
||||
title: song.title,
|
||||
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||
album,
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path,
|
||||
image_path,
|
||||
like_dislike,
|
||||
added_date: song.added_date,
|
||||
};
|
||||
|
||||
history_songs.insert(song_id, (history.date, songdata));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the songs by date
|
||||
let mut history_songs: Vec<(NaiveDateTime, frontend::Song)> =
|
||||
history_songs.into_values().collect();
|
||||
history_songs.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
Ok(history_songs)
|
||||
}
|
||||
|
||||
/// Get a user's top songs by play count from a date range
|
||||
/// Optionally takes a limit parameter to limit the number of songs returned.
|
||||
/// If not provided, all songs listened to in the date range are returned.
|
||||
/// Returns a list of tuples with the play count and the song data, sorted by play count (most played first).
|
||||
#[server(endpoint = "/profile/top_songs")]
|
||||
pub async fn top_songs(for_user_id: i32, start_date: NaiveDateTime, end_date: NaiveDateTime, limit: Option<i64>)
|
||||
-> Result<Vec<(i64, SongData)>, ServerFnError>
|
||||
{
|
||||
let mut db_con = get_db_conn();
|
||||
#[server(endpoint = "/profile/top_songs", client = Client)]
|
||||
pub async fn top_songs(
|
||||
for_user_id: i32,
|
||||
start_date: NaiveDateTime,
|
||||
end_date: NaiveDateTime,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<(i64, frontend::Song)>, ServerFnError> {
|
||||
let viewing_user_id = get_user()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?
|
||||
.id;
|
||||
|
||||
// Get the play count and ids of the songs listened to in the date range
|
||||
let history_counts: Vec<(i32, i64)> =
|
||||
if let Some(limit) = limit {
|
||||
song_history::table
|
||||
.filter(song_history::date.between(start_date, end_date))
|
||||
.filter(song_history::user_id.eq(for_user_id))
|
||||
.group_by(song_history::song_id)
|
||||
.select((song_history::song_id, count(song_history::song_id)))
|
||||
.order(count(song_history::song_id).desc())
|
||||
.limit(limit)
|
||||
.load(&mut db_con)?
|
||||
} else {
|
||||
song_history::table
|
||||
.filter(song_history::date.between(start_date, end_date))
|
||||
.filter(song_history::user_id.eq(for_user_id))
|
||||
.group_by(song_history::song_id)
|
||||
.select((song_history::song_id, count(song_history::song_id)))
|
||||
.load(&mut db_con)?
|
||||
};
|
||||
let mut db_con = get_db_conn();
|
||||
|
||||
let history_counts: HashMap<i32, i64> = history_counts.into_iter().collect();
|
||||
let history_song_ids = history_counts.iter().map(|(song_id, _)| *song_id).collect::<Vec<i32>>();
|
||||
// Get the play count and ids of the songs listened to in the date range
|
||||
let history_counts: Vec<(i32, i64)> = if let Some(limit) = limit {
|
||||
song_history::table
|
||||
.filter(song_history::date.between(start_date, end_date))
|
||||
.filter(song_history::user_id.eq(for_user_id))
|
||||
.group_by(song_history::song_id)
|
||||
.select((song_history::song_id, count(song_history::song_id)))
|
||||
.order(count(song_history::song_id).desc())
|
||||
.limit(limit)
|
||||
.load(&mut db_con)?
|
||||
} else {
|
||||
song_history::table
|
||||
.filter(song_history::date.between(start_date, end_date))
|
||||
.filter(song_history::user_id.eq(for_user_id))
|
||||
.group_by(song_history::song_id)
|
||||
.select((song_history::song_id, count(song_history::song_id)))
|
||||
.load(&mut db_con)?
|
||||
};
|
||||
|
||||
// Get the song data for the songs listened to in the date range
|
||||
let history_songs: Vec<(Song, Option<Album>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)>
|
||||
= songs::table
|
||||
.filter(songs::id.eq_any(history_song_ids))
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
|
||||
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(for_user_id))))
|
||||
.left_join(song_dislikes::table.on(
|
||||
songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(for_user_id))))
|
||||
.select((
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable(),
|
||||
))
|
||||
.load(&mut db_con)?;
|
||||
let history_counts: HashMap<i32, i64> = history_counts.into_iter().collect();
|
||||
let history_song_ids = history_counts.keys().copied().collect::<Vec<i32>>();
|
||||
|
||||
// Process the history data into a map of song ids to song data
|
||||
let mut history_songs_map: HashMap<i32, (i64, SongData)> = HashMap::with_capacity(history_counts.len());
|
||||
// Get the song data for the songs listened to in the date range
|
||||
let history_songs: Vec<(
|
||||
Song,
|
||||
Option<Album>,
|
||||
Option<Artist>,
|
||||
Option<(i32, i32)>,
|
||||
Option<(i32, i32)>,
|
||||
)> = songs::table
|
||||
.filter(songs::id.eq_any(history_song_ids))
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(
|
||||
song_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(songs::id.eq(song_artists::song_id)),
|
||||
)
|
||||
.left_join(
|
||||
song_likes::table.on(songs::id
|
||||
.eq(song_likes::song_id)
|
||||
.and(song_likes::user_id.eq(viewing_user_id))),
|
||||
)
|
||||
.left_join(
|
||||
song_dislikes::table.on(songs::id
|
||||
.eq(song_dislikes::song_id)
|
||||
.and(song_dislikes::user_id.eq(viewing_user_id))),
|
||||
)
|
||||
.select((
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable(),
|
||||
))
|
||||
.load(&mut db_con)?;
|
||||
|
||||
for (song, album, artist, like, dislike) in history_songs {
|
||||
let song_id = song.id
|
||||
.ok_or(ServerFnError::ServerError::<NoCustomError>("Song id not found in database".to_string()))?;
|
||||
// Process the history data into a map of song ids to song data
|
||||
let mut history_songs_map: HashMap<i32, (i64, frontend::Song)> =
|
||||
HashMap::with_capacity(history_counts.len());
|
||||
|
||||
if let Some((_, stored_songdata)) = history_songs_map.get_mut(&song_id) {
|
||||
// If the song is already in the map, update the artists
|
||||
if let Some(artist) = artist {
|
||||
stored_songdata.artists.push(artist);
|
||||
}
|
||||
} else {
|
||||
let like_dislike = match (like, dislike) {
|
||||
(Some(_), Some(_)) => Some((true, true)),
|
||||
(Some(_), None) => Some((true, false)),
|
||||
(None, Some(_)) => Some((false, true)),
|
||||
_ => None,
|
||||
};
|
||||
for (song, album, artist, like, dislike) in history_songs {
|
||||
if let Some((_, stored_songdata)) = history_songs_map.get_mut(&song.id) {
|
||||
// If the song is already in the map, update the artists
|
||||
if let Some(artist) = artist {
|
||||
stored_songdata.artists.push(artist);
|
||||
}
|
||||
} else {
|
||||
let like_dislike = match (like, dislike) {
|
||||
(Some(_), Some(_)) => Some((true, true)),
|
||||
(Some(_), None) => Some((true, false)),
|
||||
(None, Some(_)) => Some((false, true)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let image_path = song.image_path.unwrap_or(
|
||||
album.as_ref().map(|album| album.image_path.clone()).flatten()
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
|
||||
let image_path = song.image_path.unwrap_or(
|
||||
album
|
||||
.as_ref()
|
||||
.and_then(|album| album.image_path.clone())
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
|
||||
);
|
||||
|
||||
let songdata = SongData {
|
||||
id: song_id,
|
||||
title: song.title,
|
||||
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||
album: album,
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path,
|
||||
image_path: image_path,
|
||||
like_dislike: like_dislike,
|
||||
added_date: song.added_date.unwrap(),
|
||||
};
|
||||
let songdata = frontend::Song {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||
album,
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path,
|
||||
image_path,
|
||||
like_dislike,
|
||||
added_date: song.added_date,
|
||||
};
|
||||
|
||||
let plays = history_counts.get(&song_id)
|
||||
.ok_or(ServerFnError::ServerError::<NoCustomError>("Song id not found in history counts".to_string()))?;
|
||||
let plays = history_counts
|
||||
.get(&song.id)
|
||||
.ok_or(ServerFnError::ServerError::<NoCustomError>(
|
||||
"Song id not found in history counts".to_string(),
|
||||
))?;
|
||||
|
||||
history_songs_map.insert(song_id, (*plays, songdata));
|
||||
}
|
||||
}
|
||||
history_songs_map.insert(song.id, (*plays, songdata));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the songs by play count
|
||||
let mut history_songs: Vec<(i64, SongData)> = history_songs_map.into_values().collect();
|
||||
history_songs.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
Ok(history_songs)
|
||||
// Sort the songs by play count
|
||||
let mut history_songs: Vec<(i64, frontend::Song)> = history_songs_map.into_values().collect();
|
||||
history_songs.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
Ok(history_songs)
|
||||
}
|
||||
|
||||
/// Get a user's top artists by play count from a date range
|
||||
/// Optionally takes a limit parameter to limit the number of artists returned.
|
||||
/// If not provided, all artists listened to in the date range are returned.
|
||||
/// Returns a list of tuples with the play count and the artist data, sorted by play count (most played first).
|
||||
#[server(endpoint = "/profile/top_artists")]
|
||||
pub async fn top_artists(for_user_id: i32, start_date: NaiveDateTime, end_date: NaiveDateTime, limit: Option<i64>)
|
||||
-> Result<Vec<(i64, ArtistData)>, ServerFnError>
|
||||
{
|
||||
let mut db_con = get_db_conn();
|
||||
#[server(endpoint = "/profile/top_artists", client = Client)]
|
||||
pub async fn top_artists(
|
||||
for_user_id: i32,
|
||||
start_date: NaiveDateTime,
|
||||
end_date: NaiveDateTime,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<(i64, frontend::Artist)>, ServerFnError> {
|
||||
let mut db_con = get_db_conn();
|
||||
|
||||
let artist_counts: Vec<(i64, Artist)> =
|
||||
if let Some(limit) = limit {
|
||||
song_history::table
|
||||
.filter(song_history::date.between(start_date, end_date))
|
||||
.filter(song_history::user_id.eq(for_user_id))
|
||||
.inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id)))
|
||||
.inner_join(artists::table.on(song_artists::artist_id.eq(artists::id)))
|
||||
.group_by(artists::id)
|
||||
.select((count(artists::id), artists::all_columns))
|
||||
.order(count(artists::id).desc())
|
||||
.limit(limit)
|
||||
.load(&mut db_con)?
|
||||
} else {
|
||||
song_history::table
|
||||
.filter(song_history::date.between(start_date, end_date))
|
||||
.filter(song_history::user_id.eq(for_user_id))
|
||||
.inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id)))
|
||||
.inner_join(artists::table.on(song_artists::artist_id.eq(artists::id)))
|
||||
.group_by(artists::id)
|
||||
.select((count(artists::id), artists::all_columns))
|
||||
.order(count(artists::id).desc())
|
||||
.load(&mut db_con)?
|
||||
};
|
||||
let artist_counts: Vec<(i64, Artist)> = if let Some(limit) = limit {
|
||||
song_history::table
|
||||
.filter(song_history::date.between(start_date, end_date))
|
||||
.filter(song_history::user_id.eq(for_user_id))
|
||||
.inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id)))
|
||||
.inner_join(artists::table.on(song_artists::artist_id.eq(artists::id)))
|
||||
.group_by(artists::id)
|
||||
.select((count(artists::id), artists::all_columns))
|
||||
.order(count(artists::id).desc())
|
||||
.limit(limit)
|
||||
.load(&mut db_con)?
|
||||
} else {
|
||||
song_history::table
|
||||
.filter(song_history::date.between(start_date, end_date))
|
||||
.filter(song_history::user_id.eq(for_user_id))
|
||||
.inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id)))
|
||||
.inner_join(artists::table.on(song_artists::artist_id.eq(artists::id)))
|
||||
.group_by(artists::id)
|
||||
.select((count(artists::id), artists::all_columns))
|
||||
.order(count(artists::id).desc())
|
||||
.load(&mut db_con)?
|
||||
};
|
||||
|
||||
let artist_data: Vec<(i64, ArtistData)> = artist_counts.into_iter().map(|(plays, artist)| {
|
||||
(plays, ArtistData {
|
||||
id: artist.id.unwrap(),
|
||||
name: artist.name,
|
||||
image_path: format!("/assets/images/artists/{}.webp", artist.id.unwrap()),
|
||||
})
|
||||
}).collect();
|
||||
let artist_data: Vec<(i64, frontend::Artist)> = artist_counts
|
||||
.into_iter()
|
||||
.map(|(plays, artist)| {
|
||||
(
|
||||
plays,
|
||||
frontend::Artist {
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
image_path: format!("/assets/images/artist/{}.webp", artist.id),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(artist_data)
|
||||
Ok(artist_data)
|
||||
}
|
||||
|
||||
#[server(endpoint = "/profile/liked_songs", client = Client)]
|
||||
pub async fn get_liked_songs() -> Result<Vec<frontend::Song>, ServerFnError> {
|
||||
let user_id = get_user()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?
|
||||
.id;
|
||||
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
let songs: Vec<(
|
||||
backend::Song,
|
||||
Option<backend::Album>,
|
||||
Option<backend::Artist>,
|
||||
)> = crate::schema::song_likes::table
|
||||
.filter(crate::schema::song_likes::user_id.eq(user_id))
|
||||
.inner_join(
|
||||
crate::schema::songs::table
|
||||
.on(crate::schema::song_likes::song_id.eq(crate::schema::songs::id)),
|
||||
)
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(
|
||||
song_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(songs::id.eq(song_artists::song_id)),
|
||||
)
|
||||
.select((
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
))
|
||||
.load(&mut db_conn)?;
|
||||
|
||||
let mut liked_songs: HashMap<i32, frontend::Song> = HashMap::new();
|
||||
|
||||
for (song, album, artist) in songs {
|
||||
if let Some(stored_songdata) = liked_songs.get_mut(&song.id) {
|
||||
if let Some(artist) = artist {
|
||||
stored_songdata.artists.push(artist);
|
||||
}
|
||||
} else {
|
||||
let image_path = song.image_path.unwrap_or(
|
||||
album
|
||||
.as_ref()
|
||||
.and_then(|album| album.image_path.clone())
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
|
||||
);
|
||||
|
||||
let songdata = frontend::Song {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||
album,
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path,
|
||||
image_path,
|
||||
like_dislike: Some((true, false)),
|
||||
added_date: song.added_date,
|
||||
};
|
||||
|
||||
liked_songs.insert(song.id, songdata);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(liked_songs.into_values().collect())
|
||||
}
|
||||
|
312
src/api/search.rs
Normal file
312
src/api/search.rs
Normal file
@ -0,0 +1,312 @@
|
||||
use crate::models::frontend;
|
||||
use crate::util::serverfn_client::Client;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use diesel::sql_types::*;
|
||||
use diesel::*;
|
||||
use diesel::pg::Pg;
|
||||
use diesel::expression::AsExpression;
|
||||
use std::collections::HashMap;
|
||||
use crate::models::backend;
|
||||
|
||||
use crate::util::database::get_db_conn;
|
||||
|
||||
// Define pg_trgm operators
|
||||
// Functions do not use indices for queries, so we need to use operators
|
||||
diesel::infix_operator!(Similarity, " % ", backend: Pg);
|
||||
diesel::infix_operator!(Distance, " <-> ", Float, backend: Pg);
|
||||
|
||||
// Create functions to make use of the operators in queries
|
||||
fn trgm_similar<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
|
||||
-> Similarity<T::Expression, U::Expression> {
|
||||
Similarity::new(left.as_expression(), right.as_expression())
|
||||
}
|
||||
|
||||
fn trgm_distance<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
|
||||
-> Distance<T::Expression, U::Expression> {
|
||||
Distance::new(left.as_expression(), right.as_expression())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple type for search results
|
||||
/// A vector of tuples containing the item and its score
|
||||
pub type SearchResults<T> = Vec<(T, f32)>;
|
||||
|
||||
/// Search for albums by title
|
||||
///
|
||||
/// # Arguments
|
||||
/// `query` - The search query. This will be used to perform a fuzzy search on the album titles
|
||||
/// `limit` - The maximum number of results to return
|
||||
///
|
||||
/// # Returns
|
||||
/// A Result containing a vector of albums if the search was successful, or an error if the search failed
|
||||
#[server(endpoint = "search_albums", client = Client)]
|
||||
pub async fn search_albums(
|
||||
query: String,
|
||||
limit: i64,
|
||||
) -> Result<SearchResults<frontend::Album>, ServerFnError> {
|
||||
use crate::schema::*;
|
||||
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
let album_ids = albums::table
|
||||
.filter(trgm_similar(albums::title, query.clone()))
|
||||
.order_by(trgm_distance(albums::title, query.clone()).desc())
|
||||
.limit(limit)
|
||||
.select(albums::id)
|
||||
.load::<i32>(&mut db_conn)?;
|
||||
|
||||
let mut albums_map: HashMap<i32, (frontend::Album, f32)> = HashMap::new();
|
||||
|
||||
let album_artists: Vec<(backend::Album, backend::Artist, f32)> = albums::table
|
||||
.filter(albums::id.eq_any(album_ids))
|
||||
.inner_join(
|
||||
album_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(albums::id.eq(album_artists::album_id)),
|
||||
)
|
||||
.select((
|
||||
albums::all_columns,
|
||||
artists::all_columns,
|
||||
trgm_distance(albums::title, query.clone()),
|
||||
))
|
||||
.load(&mut db_conn)?;
|
||||
|
||||
for (album, artist, score) in album_artists {
|
||||
if let Some((stored_album, _score)) = albums_map.get_mut(&album.id) {
|
||||
stored_album.artists.push(artist);
|
||||
} else {
|
||||
let albumdata = frontend::Album {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
artists: vec![artist],
|
||||
release_date: album.release_date,
|
||||
image_path: album
|
||||
.image_path
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
|
||||
};
|
||||
|
||||
albums_map.insert(album.id, (albumdata, score));
|
||||
}
|
||||
}
|
||||
|
||||
let mut albums: Vec<(frontend::Album, f32)> = albums_map.into_values().collect();
|
||||
albums.sort_by(|(_a, a_score), (_b, b_score)| b_score.total_cmp(a_score));
|
||||
|
||||
Ok(albums)
|
||||
}
|
||||
|
||||
/// Search for artists by name
|
||||
///
|
||||
/// # Arguments
|
||||
/// `query` - The search query. This will be used to perform a fuzzy search on the artist names
|
||||
/// `limit` - The maximum number of results to return
|
||||
///
|
||||
/// # Returns
|
||||
/// A Result containing a vector of artists if the search was successful, or an error if the search failed
|
||||
#[server(endpoint = "search_artists", client = Client)]
|
||||
pub async fn search_artists(
|
||||
query: String,
|
||||
limit: i64,
|
||||
) -> Result<SearchResults<frontend::Artist>, ServerFnError> {
|
||||
use crate::schema::*;
|
||||
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
let artist_list = artists::table
|
||||
.filter(trgm_similar(artists::name, query.clone()))
|
||||
.order_by(trgm_distance(artists::name, query.clone()).desc())
|
||||
.limit(limit)
|
||||
.select((artists::all_columns, trgm_distance(artists::name, query)))
|
||||
.load::<(backend::Artist, f32)>(&mut db_conn)?;
|
||||
|
||||
let artist_data = artist_list
|
||||
.into_iter()
|
||||
.map(|(artist, score)| {
|
||||
(
|
||||
frontend::Artist {
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
image_path: format!("/assets/images/artist/{}.webp", artist.id),
|
||||
},
|
||||
score,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(artist_data)
|
||||
}
|
||||
|
||||
/// Search for songs by title
|
||||
///
|
||||
/// # Arguments
|
||||
/// `query` - The search query. This will be used to perform a fuzzy search on the song titles
|
||||
/// `limit` - The maximum number of results to return
|
||||
///
|
||||
/// # Returns
|
||||
/// A Result containing a vector of songs if the search was successful, or an error if the search failed
|
||||
#[server(endpoint = "search_songs", client = Client)]
|
||||
pub async fn search_songs(
|
||||
query: String,
|
||||
limit: i64,
|
||||
) -> Result<SearchResults<frontend::Song>, ServerFnError> {
|
||||
use crate::api::auth::get_logged_in_user;
|
||||
use crate::schema::*;
|
||||
|
||||
let user = get_logged_in_user().await?;
|
||||
|
||||
let mut db_conn = get_db_conn();
|
||||
|
||||
let song_list = if let Some(user) = user {
|
||||
let song_list: Vec<(
|
||||
backend::Song,
|
||||
Option<backend::Album>,
|
||||
Option<backend::Artist>,
|
||||
Option<(i32, i32)>,
|
||||
Option<(i32, i32)>,
|
||||
f32,
|
||||
)> = songs::table
|
||||
.filter(trgm_similar(songs::title, query.clone()))
|
||||
.order_by(trgm_distance(songs::title, query.clone()).desc())
|
||||
.limit(limit)
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(
|
||||
song_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(songs::id.eq(song_artists::song_id)),
|
||||
)
|
||||
.left_join(
|
||||
song_likes::table.on(songs::id
|
||||
.eq(song_likes::song_id)
|
||||
.and(song_likes::user_id.eq(user.id))),
|
||||
)
|
||||
.left_join(
|
||||
song_dislikes::table.on(songs::id
|
||||
.eq(song_dislikes::song_id)
|
||||
.and(song_dislikes::user_id.eq(user.id))),
|
||||
)
|
||||
.select((
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable(),
|
||||
trgm_distance(songs::title, query.clone()),
|
||||
))
|
||||
.load(&mut db_conn)?;
|
||||
|
||||
song_list
|
||||
} else {
|
||||
let song_list: Vec<(
|
||||
backend::Song,
|
||||
Option<backend::Album>,
|
||||
Option<backend::Artist>,
|
||||
f32,
|
||||
)> = songs::table
|
||||
.filter(trgm_similar(songs::title, query.clone()))
|
||||
.order_by(trgm_distance(songs::title, query.clone()).desc())
|
||||
.limit(limit)
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(
|
||||
song_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(songs::id.eq(song_artists::song_id)),
|
||||
)
|
||||
.select((
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
trgm_distance(songs::title, query.clone()),
|
||||
))
|
||||
.load(&mut db_conn)?;
|
||||
|
||||
song_list
|
||||
.into_iter()
|
||||
.map(|(song, album, artist, score)| (song, album, artist, None, None, score))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut search_songs: HashMap<i32, (frontend::Song, f32)> =
|
||||
HashMap::with_capacity(song_list.len());
|
||||
|
||||
for (song, album, artist, like, dislike, score) in song_list {
|
||||
if let Some((stored_songdata, _score)) = search_songs.get_mut(&song.id) {
|
||||
// If the song is already in the map, update the artists
|
||||
if let Some(artist) = artist {
|
||||
stored_songdata.artists.push(artist);
|
||||
}
|
||||
} else {
|
||||
let like_dislike = match (like, dislike) {
|
||||
(Some(_), Some(_)) => Some((true, true)),
|
||||
(Some(_), None) => Some((true, false)),
|
||||
(None, Some(_)) => Some((false, true)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let image_path = song.image_path.unwrap_or(
|
||||
album
|
||||
.as_ref()
|
||||
.and_then(|album| album.image_path.clone())
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
|
||||
);
|
||||
|
||||
let songdata = frontend::Song {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||
album,
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path,
|
||||
image_path,
|
||||
like_dislike,
|
||||
added_date: song.added_date,
|
||||
};
|
||||
|
||||
search_songs.insert(song.id, (songdata, score));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the songs by date
|
||||
let mut songs: Vec<(frontend::Song, f32)> = search_songs.into_values().collect();
|
||||
songs.sort_by(|(_a, a_score), (_b, b_score)| b_score.total_cmp(a_score));
|
||||
|
||||
Ok(songs)
|
||||
}
|
||||
|
||||
/// Search for songs, albums, and artists by title or name
|
||||
///
|
||||
/// # Arguments
|
||||
/// `query` - The search query. This will be used to perform a fuzzy search on the
|
||||
/// song titles, album titles, and artist names
|
||||
/// `limit` - The maximum number of results to return for each type
|
||||
///
|
||||
/// # Returns
|
||||
/// A Result containing a tuple of vectors of albums, artists, and songs if the search was successful,
|
||||
#[server(endpoint = "search", client = Client)]
|
||||
pub async fn search(
|
||||
query: String,
|
||||
limit: i64,
|
||||
) -> Result<
|
||||
(
|
||||
SearchResults<frontend::Album>,
|
||||
SearchResults<frontend::Artist>,
|
||||
SearchResults<frontend::Song>,
|
||||
),
|
||||
ServerFnError,
|
||||
> {
|
||||
let albums = search_albums(query.clone(), limit);
|
||||
let artists = search_artists(query.clone(), limit);
|
||||
let songs = search_songs(query, limit);
|
||||
|
||||
use tokio::join;
|
||||
|
||||
let (albums, artists, songs) = join!(albums, artists, songs);
|
||||
Ok((albums?, artists?, songs?))
|
||||
}
|
254
src/api/songs.rs
254
src/api/songs.rs
@ -1,151 +1,193 @@
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
use crate::songdata::SongData;
|
||||
|
||||
use crate::models::frontend;
|
||||
use crate::util::serverfn_client::Client;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
use crate::database::get_db_conn;
|
||||
use crate::auth::get_user;
|
||||
use crate::models::{Song, Album, Artist};
|
||||
use diesel::prelude::*;
|
||||
}
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
use crate::util::database::get_db_conn;
|
||||
use crate::api::auth::get_user;
|
||||
use crate::models::backend::{Song, Album, Artist};
|
||||
use diesel::prelude::*;
|
||||
}
|
||||
}
|
||||
|
||||
/// Like or unlike a song
|
||||
#[server(endpoint = "songs/set_like")]
|
||||
#[server(endpoint = "songs/set_like", client = Client)]
|
||||
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();
|
||||
let user = get_user().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?;
|
||||
|
||||
user.set_like_song(song_id, like, db_con).await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error liking song: {}", 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")]
|
||||
#[server(endpoint = "songs/set_dislike", client = Client)]
|
||||
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();
|
||||
let user = get_user().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?;
|
||||
|
||||
user.set_dislike_song(song_id, dislike, db_con).await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error disliking song: {}", 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")]
|
||||
#[server(endpoint = "songs/get_like_dislike", client = Client)]
|
||||
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 user = get_user().await.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?;
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
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
|
||||
// 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)))?;
|
||||
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))
|
||||
Ok((like, dislike))
|
||||
}
|
||||
|
||||
#[server(endpoint = "songs/get")]
|
||||
pub async fn get_song_by_id(song_id: i32) -> Result<Option<SongData>, ServerFnError> {
|
||||
use crate::schema::*;
|
||||
#[server(endpoint = "songs/get", client = Client)]
|
||||
pub async fn get_song_by_id(song_id: i32) -> Result<Option<frontend::Song>, ServerFnError> {
|
||||
use crate::schema::*;
|
||||
|
||||
let user_id: i32 = get_user().await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error getting user: {}", e)))?.id.unwrap();
|
||||
let user_id: i32 = get_user()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?
|
||||
.id;
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
let db_con = &mut get_db_conn();
|
||||
|
||||
let song_parts: Vec<(Song, Option<Album>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)>
|
||||
= songs::table
|
||||
.find(song_id)
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
|
||||
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(user_id))))
|
||||
.left_join(song_dislikes::table.on(
|
||||
songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(user_id))))
|
||||
.select((
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable(),
|
||||
))
|
||||
.load(db_con)?;
|
||||
let song_parts: Vec<(
|
||||
Song,
|
||||
Option<Album>,
|
||||
Option<Artist>,
|
||||
Option<(i32, i32)>,
|
||||
Option<(i32, i32)>,
|
||||
)> = songs::table
|
||||
.find(song_id)
|
||||
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
|
||||
.left_join(
|
||||
song_artists::table
|
||||
.inner_join(artists::table)
|
||||
.on(songs::id.eq(song_artists::song_id)),
|
||||
)
|
||||
.left_join(
|
||||
song_likes::table.on(songs::id
|
||||
.eq(song_likes::song_id)
|
||||
.and(song_likes::user_id.eq(user_id))),
|
||||
)
|
||||
.left_join(
|
||||
song_dislikes::table.on(songs::id
|
||||
.eq(song_dislikes::song_id)
|
||||
.and(song_dislikes::user_id.eq(user_id))),
|
||||
)
|
||||
.select((
|
||||
songs::all_columns,
|
||||
albums::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable(),
|
||||
))
|
||||
.load(db_con)?;
|
||||
|
||||
let song = song_parts.first().cloned();
|
||||
let artists = song_parts.into_iter().map(|(_, _, artist, _, _)| artist)
|
||||
.filter_map(|artist| artist).collect::<Vec<_>>();
|
||||
let song = song_parts.first().cloned();
|
||||
let artists = song_parts
|
||||
.into_iter()
|
||||
.filter_map(|(_, _, artist, _, _)| artist)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match song {
|
||||
Some((song, album, _artist, like, dislike)) => {
|
||||
// Use song image path, or fall back to album image path, or fall back to placeholder
|
||||
let image_path = song.image_path.clone().unwrap_or_else(|| {
|
||||
album.as_ref().and_then(|album| album.image_path.clone()).unwrap_or(
|
||||
"/assets/images/placeholders/MusicPlaceholder.svg".to_string()
|
||||
)
|
||||
});
|
||||
match song {
|
||||
Some((song, album, _artist, like, dislike)) => {
|
||||
// Use song image path, or fall back to album image path, or fall back to placeholder
|
||||
let image_path = song.image_path.clone().unwrap_or_else(|| {
|
||||
album
|
||||
.as_ref()
|
||||
.and_then(|album| album.image_path.clone())
|
||||
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string())
|
||||
});
|
||||
|
||||
Ok(Some(SongData {
|
||||
id: song.id.unwrap(),
|
||||
title: song.title.clone(),
|
||||
artists: artists,
|
||||
album: album.clone().map(|album| album.into()),
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path.clone(),
|
||||
image_path: image_path,
|
||||
like_dislike: Some((like.is_some(), dislike.is_some())),
|
||||
added_date: song.added_date.unwrap(),
|
||||
}))
|
||||
},
|
||||
None => Ok(None)
|
||||
}
|
||||
Ok(Some(frontend::Song {
|
||||
id: song.id,
|
||||
title: song.title.clone(),
|
||||
artists,
|
||||
album: album.clone(),
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path.clone(),
|
||||
image_path,
|
||||
like_dislike: Some((like.is_some(), dislike.is_some())),
|
||||
added_date: song.added_date,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[server(endpoint = "songs/plays")]
|
||||
#[server(endpoint = "songs/plays", client = Client)]
|
||||
pub async fn get_song_plays(song_id: i32) -> Result<i64, ServerFnError> {
|
||||
use crate::schema::*;
|
||||
use crate::schema::*;
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
let db_con = &mut get_db_conn();
|
||||
|
||||
let plays = song_history::table
|
||||
.filter(song_history::song_id.eq(song_id))
|
||||
.count()
|
||||
.get_result::<i64>(db_con)
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error getting song plays: {}", e)))?;
|
||||
let plays = song_history::table
|
||||
.filter(song_history::song_id.eq(song_id))
|
||||
.count()
|
||||
.get_result::<i64>(db_con)
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting song plays: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(plays)
|
||||
Ok(plays)
|
||||
}
|
||||
|
||||
#[server(endpoint = "songs/my-plays")]
|
||||
#[server(endpoint = "songs/my-plays", client = Client)]
|
||||
pub async fn get_my_song_plays(song_id: i32) -> Result<i64, ServerFnError> {
|
||||
use crate::schema::*;
|
||||
use crate::schema::*;
|
||||
|
||||
let user_id: i32 = get_user().await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error getting user: {}", e)))?.id.unwrap();
|
||||
let user_id: i32 = get_user()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {e}"))
|
||||
})?
|
||||
.id;
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
let db_con = &mut get_db_conn();
|
||||
|
||||
let plays = song_history::table
|
||||
.filter(song_history::song_id.eq(song_id).and(song_history::user_id.eq(user_id)))
|
||||
.count()
|
||||
.get_result::<i64>(db_con)
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error getting song plays: {}", e)))?;
|
||||
let plays = song_history::table
|
||||
.filter(
|
||||
song_history::song_id
|
||||
.eq(song_id)
|
||||
.and(song_history::user_id.eq(user_id)),
|
||||
)
|
||||
.count()
|
||||
.get_result::<i64>(db_con)
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error getting song plays: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(plays)
|
||||
Ok(plays)
|
||||
}
|
||||
|
335
src/api/upload.rs
Normal file
335
src/api/upload.rs
Normal file
@ -0,0 +1,335 @@
|
||||
use leptos::prelude::*;
|
||||
use server_fn::codec::{MultipartData, MultipartFormData};
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use multer::Field;
|
||||
use crate::util::database::get_db_conn;
|
||||
use crate::util::extract_field::extract_field;
|
||||
use diesel::prelude::*;
|
||||
use log::*;
|
||||
use server_fn::error::NoCustomError;
|
||||
use chrono::NaiveDate;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the artist ids in a multipart field
|
||||
/// Expects a field with a comma-separated list of artist ids, and ensures each is a valid artist id in the database
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn validate_artist_ids(artist_ids: Field<'static>) -> Result<Vec<i32>, ServerFnError> {
|
||||
use crate::models::backend::Artist;
|
||||
use diesel::result::Error::NotFound;
|
||||
|
||||
// Extract the artist id from the field
|
||||
match artist_ids.text().await {
|
||||
Ok(artist_ids) => {
|
||||
let artist_ids = artist_ids.trim_end_matches(',').split(',');
|
||||
|
||||
artist_ids
|
||||
.filter(|artist_id| !artist_id.is_empty())
|
||||
.map(|artist_id| {
|
||||
// Parse the artist id as an integer
|
||||
if let Ok(artist_id) = artist_id.parse::<i32>() {
|
||||
// Check if the artist exists
|
||||
let db_con = &mut get_db_conn();
|
||||
let artist = crate::schema::artists::dsl::artists
|
||||
.find(artist_id)
|
||||
.first::<Artist>(db_con);
|
||||
|
||||
match artist {
|
||||
Ok(_) => Ok(artist_id),
|
||||
Err(NotFound) => Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Artist does not exist".to_string(),
|
||||
)),
|
||||
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error finding artist id: {e}"
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Error parsing artist id".to_string(),
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error reading artist id: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the album id in a multipart field
|
||||
/// Expects a field with an album id, and ensures it is a valid album id in the database
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn validate_album_id(album_id: Field<'static>) -> Result<Option<i32>, ServerFnError> {
|
||||
use crate::models::backend::Album;
|
||||
use diesel::result::Error::NotFound;
|
||||
|
||||
// Extract the album id from the field
|
||||
match album_id.text().await {
|
||||
Ok(album_id) => {
|
||||
if album_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Parse the album id as an integer
|
||||
if let Ok(album_id) = album_id.parse::<i32>() {
|
||||
// Check if the album exists
|
||||
let db_con = &mut get_db_conn();
|
||||
let album = crate::schema::albums::dsl::albums
|
||||
.find(album_id)
|
||||
.first::<Album>(db_con);
|
||||
|
||||
match album {
|
||||
Ok(_) => Ok(Some(album_id)),
|
||||
Err(NotFound) => Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Album does not exist".to_string(),
|
||||
)),
|
||||
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error finding album id: {e}"
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Error parsing album id".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error reading album id: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the track number in a multipart field
|
||||
/// Expects a field with a track number, and ensures it is a valid track number (non-negative integer)
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn validate_track_number(track_number: Field<'static>) -> Result<Option<i32>, ServerFnError> {
|
||||
match track_number.text().await {
|
||||
Ok(track_number) => {
|
||||
if track_number.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Ok(track_number) = track_number.parse::<i32>() {
|
||||
if track_number < 0 {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Track number must be positive or 0".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(Some(track_number))
|
||||
}
|
||||
} else {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Error parsing track number".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error reading track number: {e}"
|
||||
)))?,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the release date in a multipart field
|
||||
/// Expects a field with a release date, and ensures it is a valid date in the format [year]-[month]-[day]
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn validate_release_date(
|
||||
release_date: Field<'static>,
|
||||
) -> Result<Option<NaiveDate>, ServerFnError> {
|
||||
match release_date.text().await {
|
||||
Ok(release_date) => {
|
||||
if release_date.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let release_date = NaiveDate::parse_from_str(release_date.trim(), "%Y-%m-%d");
|
||||
|
||||
match release_date {
|
||||
Ok(release_date) => Ok(Some(release_date)),
|
||||
Err(_) => Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Invalid release date".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error reading release date: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the file upload form
|
||||
#[server(input = MultipartFormData, endpoint = "/upload")]
|
||||
pub async fn upload(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 mut title = None;
|
||||
let mut artist_ids = None;
|
||||
let mut album_id = None;
|
||||
let mut track = None;
|
||||
let mut release_date = None;
|
||||
let mut file_name = None;
|
||||
let mut duration = None;
|
||||
|
||||
// Fetch the fields from the form data
|
||||
while let Ok(Some(mut field)) = data.next_field().await {
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
|
||||
match name.as_str() {
|
||||
"title" => {
|
||||
title = Some(extract_field(field).await?);
|
||||
}
|
||||
"artist_ids" => {
|
||||
artist_ids = Some(validate_artist_ids(field).await?);
|
||||
}
|
||||
"album_id" => {
|
||||
album_id = Some(validate_album_id(field).await?);
|
||||
}
|
||||
"track_number" => {
|
||||
track = Some(validate_track_number(field).await?);
|
||||
}
|
||||
"release_date" => {
|
||||
release_date = Some(validate_release_date(field).await?);
|
||||
}
|
||||
"file" => {
|
||||
use crate::util::audio::extract_metadata;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{Seek, Write};
|
||||
use symphonia::core::codecs::CODEC_TYPE_MP3;
|
||||
|
||||
// Some logging is done here where there is high potential for bugs / failures,
|
||||
// or behavior that we may wish to change in the future
|
||||
|
||||
// Create file name
|
||||
let title = title
|
||||
.clone()
|
||||
.ok_or(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Title field required and must precede file field".to_string(),
|
||||
))?;
|
||||
|
||||
let clean_title = title.replace(" ", "_").replace("/", "_");
|
||||
let date_str = chrono::Utc::now().format("%Y-%m-%d_%H:%M:%S").to_string();
|
||||
let upload_path = format!("assets/audio/upload-{date_str}_{clean_title}.mp3");
|
||||
file_name = Some(format!("upload-{date_str}_{clean_title}.mp3"));
|
||||
|
||||
debug!("Saving uploaded file {}", upload_path);
|
||||
|
||||
// Save file to disk
|
||||
// Use these open options to create the file, write to it, then read from it
|
||||
let mut file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(upload_path.clone())?;
|
||||
|
||||
while let Some(chunk) = field.chunk().await? {
|
||||
file.write_all(&chunk)?;
|
||||
}
|
||||
|
||||
file.flush()?;
|
||||
|
||||
// Rewind the file so the duration can be measured
|
||||
file.rewind()?;
|
||||
|
||||
// Get the codec and duration of the file
|
||||
let (file_codec, file_duration) = extract_metadata(file).map_err(|e| {
|
||||
let msg = format!("Error measuring duration of audio file {upload_path}: {e}");
|
||||
warn!("{}", msg);
|
||||
ServerFnError::<NoCustomError>::ServerError(msg)
|
||||
})?;
|
||||
|
||||
if file_codec != CODEC_TYPE_MP3 {
|
||||
let msg = format!("Invalid uploaded audio file codec: {file_codec}");
|
||||
warn!("{}", msg);
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(msg));
|
||||
}
|
||||
|
||||
duration = Some(file_duration);
|
||||
}
|
||||
_ => {
|
||||
warn!("Unknown file upload field: {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap mandatory fields
|
||||
let title = title.ok_or(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Missing title".to_string(),
|
||||
))?;
|
||||
let artist_ids = artist_ids.unwrap_or(vec![]);
|
||||
let file_name = file_name.ok_or(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Missing file".to_string(),
|
||||
))?;
|
||||
let duration = duration.ok_or(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Missing duration".to_string(),
|
||||
))?;
|
||||
let duration = i32::try_from(duration).map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error converting duration to i32: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let album_id = album_id.unwrap_or(None);
|
||||
let track = track.unwrap_or(None);
|
||||
let release_date = release_date.unwrap_or(None);
|
||||
|
||||
if album_id.is_some() != track.is_some() {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(
|
||||
"Album id and track number must both be present or both be absent".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create the song
|
||||
use crate::models::backend::{NewSong, Song};
|
||||
let song = NewSong {
|
||||
title,
|
||||
album_id,
|
||||
track,
|
||||
duration,
|
||||
release_date,
|
||||
storage_path: file_name,
|
||||
image_path: None,
|
||||
};
|
||||
|
||||
// Save the song to the database
|
||||
let db_con = &mut get_db_conn();
|
||||
let song = song
|
||||
.insert_into(crate::schema::songs::table)
|
||||
.get_result::<Song>(db_con)
|
||||
.map_err(|e| {
|
||||
let msg = format!("Error saving song to database: {e}");
|
||||
warn!("{}", msg);
|
||||
ServerFnError::<NoCustomError>::ServerError(msg)
|
||||
})?;
|
||||
|
||||
// Save the song's artists to the database
|
||||
use crate::schema::song_artists;
|
||||
use diesel::ExpressionMethods;
|
||||
|
||||
let artist_ids = artist_ids
|
||||
.into_iter()
|
||||
.map(|artist_id| {
|
||||
(
|
||||
song_artists::song_id.eq(song.id),
|
||||
song_artists::artist_id.eq(artist_id),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
diesel::insert_into(crate::schema::song_artists::table)
|
||||
.values(&artist_ids)
|
||||
.execute(db_con)
|
||||
.map_err(|e| {
|
||||
let msg = format!("Error saving song artists to database: {e}");
|
||||
warn!("{}", msg);
|
||||
ServerFnError::<NoCustomError>::ServerError(msg)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
186
src/api/users.rs
Normal file
186
src/api/users.rs
Normal file
@ -0,0 +1,186 @@
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use diesel::prelude::*;
|
||||
use crate::util::database::get_db_conn;
|
||||
|
||||
use pbkdf2::{
|
||||
password_hash::{
|
||||
rand_core::OsRng,
|
||||
PasswordHasher, PasswordHash, SaltString, PasswordVerifier, Error
|
||||
},
|
||||
Pbkdf2
|
||||
};
|
||||
|
||||
use crate::models::backend::NewUser;
|
||||
}
|
||||
}
|
||||
|
||||
use crate::models::backend::User;
|
||||
use crate::util::serverfn_client::Client;
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct UserCredentials {
|
||||
pub username_or_email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Get a user from the database by username or email
|
||||
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn find_user(username_or_email: String) -> Result<Option<User>, ServerFnError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
|
||||
// Look for either a username or email that matches the input, and return an option with None if no user is found
|
||||
let db_con = &mut get_db_conn();
|
||||
let user = users
|
||||
.filter(username.eq(username_or_email.clone()))
|
||||
.or_filter(email.eq(username_or_email))
|
||||
.first::<User>(db_con)
|
||||
.optional()
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error getting user from database: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Get a user from the database by ID
|
||||
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn find_user_by_id(user_id: i32) -> Result<Option<User>, ServerFnError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
let user = users
|
||||
.filter(id.eq(user_id))
|
||||
.first::<User>(db_con)
|
||||
.optional()
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error getting user from database: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Create a new user in the database
|
||||
/// Returns an empty Result if successful, or an error if there was a problem
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn create_user(new_user: &NewUser) -> Result<(), ServerFnError> {
|
||||
use crate::schema::users::dsl::*;
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
|
||||
let new_password =
|
||||
new_user
|
||||
.password
|
||||
.clone()
|
||||
.ok_or(ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"No password provided for user {}",
|
||||
new_user.username
|
||||
)))?;
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let password_hash = Pbkdf2
|
||||
.hash_password(new_password.as_bytes(), &salt)
|
||||
.map_err(|_| {
|
||||
ServerFnError::<NoCustomError>::ServerError("Error hashing password".to_string())
|
||||
})?
|
||||
.to_string();
|
||||
|
||||
let new_user = NewUser {
|
||||
password: Some(password_hash),
|
||||
..new_user.clone()
|
||||
};
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
|
||||
diesel::insert_into(users)
|
||||
.values(&new_user)
|
||||
.execute(db_con)
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a user's credentials
|
||||
/// Returns a Result with the user if the credentials are valid, None if not valid, or an error if there was a problem
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn validate_user(credentials: UserCredentials) -> Result<Option<User>, ServerFnError> {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
|
||||
let db_user = find_user(credentials.username_or_email.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error getting user from database: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// If the user is not found, return None
|
||||
let db_user = match db_user {
|
||||
Some(user) => user,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let db_password =
|
||||
db_user
|
||||
.password
|
||||
.clone()
|
||||
.ok_or(ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"No password found for user {}",
|
||||
db_user.username
|
||||
)))?;
|
||||
|
||||
let password_hash = PasswordHash::new(&db_password).map_err(|e| {
|
||||
ServerFnError::<NoCustomError>::ServerError(format!("Error hashing supplied password: {e}"))
|
||||
})?;
|
||||
|
||||
match Pbkdf2.verify_password(credentials.password.as_bytes(), &password_hash) {
|
||||
Ok(()) => {}
|
||||
Err(Error::Password) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError(format!(
|
||||
"Error verifying password: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(db_user))
|
||||
}
|
||||
|
||||
/// Get a user from the database by username or email
|
||||
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
|
||||
#[server(endpoint = "find_user", client = Client)]
|
||||
pub async fn get_user(username_or_email: String) -> Result<Option<User>, ServerFnError> {
|
||||
let mut user = find_user(username_or_email).await?;
|
||||
|
||||
// Remove the password hash before returning the user
|
||||
if let Some(user) = user.as_mut() {
|
||||
user.password = None;
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[server(endpoint = "get_user_by_id", client = Client)]
|
||||
pub async fn get_user_by_id(user_id: i32) -> Result<Option<User>, ServerFnError> {
|
||||
let mut user = find_user_by_id(user_id).await?;
|
||||
|
||||
// Remove the password hash before returning the user
|
||||
if let Some(user) = user.as_mut() {
|
||||
user.password = None;
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
125
src/app.rs
125
src/app.rs
@ -1,17 +1,40 @@
|
||||
use crate::playbar::PlayBar;
|
||||
use crate::playbar::CustomTitle;
|
||||
use crate::queue::Queue;
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
use crate::pages::login::*;
|
||||
use crate::pages::signup::*;
|
||||
use crate::pages::profile::*;
|
||||
use crate::pages::albumpage::*;
|
||||
use crate::components::error_template::{AppError, ErrorTemplate};
|
||||
use crate::components::playbar::CustomTitle;
|
||||
use crate::components::playbar::PlayBar;
|
||||
use crate::components::queue::Queue;
|
||||
use crate::pages::album::*;
|
||||
use crate::pages::artist::*;
|
||||
use crate::pages::songpage::*;
|
||||
use crate::error_template::{AppError, ErrorTemplate};
|
||||
use crate::pages::dashboard::*;
|
||||
use crate::pages::liked_songs::*;
|
||||
use crate::pages::login::*;
|
||||
use crate::pages::playlist::*;
|
||||
use crate::pages::profile::*;
|
||||
use crate::pages::search::*;
|
||||
use crate::pages::signup::*;
|
||||
use crate::pages::song::*;
|
||||
use crate::util::state::GlobalState;
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::components::*;
|
||||
use leptos_router::*;
|
||||
|
||||
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
view! {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<AutoReload options=options.clone() />
|
||||
<HydrationScripts options/>
|
||||
<MetaTags/>
|
||||
</head>
|
||||
<body>
|
||||
<App/>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
@ -20,9 +43,9 @@ pub fn App() -> impl IntoView {
|
||||
|
||||
provide_context(GlobalState::new());
|
||||
|
||||
let upload_open = create_rw_signal(false);
|
||||
let add_artist_open = create_rw_signal(false);
|
||||
let add_album_open = create_rw_signal(false);
|
||||
let upload_open = RwSignal::new(false);
|
||||
let add_artist_open = RwSignal::new(false);
|
||||
let add_album_open = RwSignal::new(false);
|
||||
|
||||
view! {
|
||||
// injects a stylesheet into the document <head>
|
||||
@ -33,57 +56,65 @@ pub fn App() -> impl IntoView {
|
||||
<CustomTitle />
|
||||
|
||||
// content for this welcome page
|
||||
<Router fallback=|| {
|
||||
let mut outside_errors = Errors::default();
|
||||
outside_errors.insert_with_default_key(AppError::NotFound);
|
||||
view! {
|
||||
<ErrorTemplate outside_errors/>
|
||||
}
|
||||
.into_view()
|
||||
}>
|
||||
<Router>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="" view=move || view! { <HomePage upload_open=upload_open add_artist_open=add_artist_open add_album_open=add_album_open/> }>
|
||||
<Route path="" view=Dashboard />
|
||||
<Route path="dashboard" view=Dashboard />
|
||||
<Route path="search" view=Search />
|
||||
<Route path="user/:id" view=Profile />
|
||||
<Route path="user" view=Profile />
|
||||
<Route path="album/:id" view=AlbumPage />
|
||||
<Route path="artist/:id" view=ArtistPage />
|
||||
<Route path="song/:id" view=SongPage />
|
||||
</Route>
|
||||
<Route path="/login" view=Login />
|
||||
<Route path="/signup" view=Signup />
|
||||
<Routes fallback=|| {
|
||||
let mut outside_errors = Errors::default();
|
||||
outside_errors.insert_with_default_key(AppError::NotFound);
|
||||
view! {
|
||||
<ErrorTemplate outside_errors/>
|
||||
}
|
||||
.into_view()
|
||||
}>
|
||||
<ParentRoute path=path!("") view=move || view! { <HomePage upload_open=upload_open add_artist_open=add_artist_open add_album_open=add_album_open/> }>
|
||||
<Route path=path!("") view=Dashboard />
|
||||
<Route path=path!("dashboard") view=Dashboard />
|
||||
<Route path=path!("search") view=Search />
|
||||
<Route path=path!("user/:id") view=Profile />
|
||||
<Route path=path!("user") view=Profile />
|
||||
<Route path=path!("album/:id") view=AlbumPage />
|
||||
<Route path=path!("artist/:id") view=ArtistPage />
|
||||
<Route path=path!("song/:id") view=SongPage />
|
||||
<Route path=path!("playlist/:id") view=PlaylistPage />
|
||||
<Route path=path!("liked") view=LikedSongsPage />
|
||||
</ParentRoute>
|
||||
<Route path=path!("/login") view=Login />
|
||||
<Route path=path!("/signup") view=Signup />
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
use crate::components::sidebar::*;
|
||||
use crate::components::dashboard::*;
|
||||
use crate::components::search::*;
|
||||
use crate::components::personal::Personal;
|
||||
use crate::components::upload::*;
|
||||
use crate::components::add_artist::AddArtist;
|
||||
use crate::components::add_album::AddAlbum;
|
||||
use crate::components::add_artist::AddArtist;
|
||||
use crate::components::personal::Personal;
|
||||
use crate::components::sidebar::*;
|
||||
use crate::components::upload::*;
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
fn HomePage(upload_open: RwSignal<bool>, add_artist_open: RwSignal<bool>, add_album_open: RwSignal<bool>) -> impl IntoView {
|
||||
fn HomePage(
|
||||
upload_open: RwSignal<bool>,
|
||||
add_artist_open: RwSignal<bool>,
|
||||
add_album_open: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="home-container">
|
||||
<section class="bg-black h-screen flex">
|
||||
<Upload open=upload_open/>
|
||||
<AddArtist open=add_artist_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
|
||||
<Outlet />
|
||||
<div class="flex flex-col flex-grow min-w-0">
|
||||
<div class="home-card">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<Personal />
|
||||
<PlayBar />
|
||||
<Queue />
|
||||
</div>
|
||||
<PlayBar />
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,34 +0,0 @@
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
/// Holds information about an artist
|
||||
///
|
||||
/// Intended to be used in the front-end
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ArtistData {
|
||||
/// Artist id
|
||||
pub id: i32,
|
||||
/// Artist name
|
||||
pub name: String,
|
||||
/// Path to artist image, relative to the root of the web server.
|
||||
/// For example, `"/assets/images/Artist.jpg"`
|
||||
pub image_path: String,
|
||||
}
|
||||
|
||||
impl DashboardTile for ArtistData {
|
||||
fn image_path(&self) -> String {
|
||||
self.image_path.clone()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn link(&self) -> String {
|
||||
format!("/artist/{}", self.id)
|
||||
}
|
||||
|
||||
fn description(&self) -> Option<String> {
|
||||
Some("Artist".to_string())
|
||||
}
|
||||
}
|
201
src/auth.rs
201
src/auth.rs
@ -1,201 +0,0 @@
|
||||
use leptos::*;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
use leptos_axum::extract;
|
||||
use axum_login::AuthSession;
|
||||
use crate::auth_backend::AuthBackend;
|
||||
}
|
||||
}
|
||||
|
||||
use crate::models::User;
|
||||
use crate::users::UserCredentials;
|
||||
|
||||
/// Create a new user and log them in
|
||||
/// Takes in a NewUser struct, with the password in plaintext
|
||||
/// Returns a Result with the error message if the user could not be created
|
||||
#[server(endpoint = "signup")]
|
||||
pub async fn signup(new_user: User) -> Result<(), ServerFnError> {
|
||||
// Check LIBRETUNES_DISABLE_SIGNUP env var
|
||||
if std::env::var("LIBRETUNES_DISABLE_SIGNUP").is_ok_and(|v| v == "true") {
|
||||
return Err(ServerFnError::<NoCustomError>::ServerError("Signup is disabled".to_string()));
|
||||
}
|
||||
|
||||
use crate::users::create_user;
|
||||
|
||||
// Ensure the user has no id, and is not a self-proclaimed admin
|
||||
let new_user = User {
|
||||
id: None,
|
||||
admin: false,
|
||||
..new_user
|
||||
};
|
||||
|
||||
create_user(&new_user).await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {}", e)))?;
|
||||
|
||||
let mut auth_session = extract::<AuthSession<AuthBackend>>().await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
|
||||
|
||||
let credentials = UserCredentials {
|
||||
username_or_email: new_user.username.clone(),
|
||||
password: new_user.password.clone().unwrap()
|
||||
};
|
||||
|
||||
match auth_session.authenticate(credentials).await {
|
||||
Ok(Some(user)) => {
|
||||
auth_session.login(&user).await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {}", e)))
|
||||
},
|
||||
Ok(None) => {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError("Error authenticating user: User not found".to_string()))
|
||||
},
|
||||
Err(e) => {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError(format!("Error authenticating user: {}", e)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a user in
|
||||
/// Takes in a username or email and a password in plaintext
|
||||
/// Returns a Result with a boolean indicating if the login was successful
|
||||
#[server(endpoint = "login")]
|
||||
pub async fn login(credentials: UserCredentials) -> Result<Option<User>, ServerFnError> {
|
||||
use crate::users::validate_user;
|
||||
|
||||
let mut auth_session = extract::<AuthSession<AuthBackend>>().await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
|
||||
|
||||
let user = validate_user(credentials).await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error validating user: {}", e)))?;
|
||||
|
||||
if let Some(mut user) = user {
|
||||
auth_session.login(&user).await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {}", e)))?;
|
||||
|
||||
user.password = None;
|
||||
Ok(Some(user))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a user out
|
||||
/// Returns a Result with the error message if the user could not be logged out
|
||||
#[server(endpoint = "logout")]
|
||||
pub async fn logout() -> Result<(), ServerFnError> {
|
||||
let mut auth_session = extract::<AuthSession<AuthBackend>>().await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
|
||||
|
||||
auth_session.logout().await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
|
||||
|
||||
leptos_axum::redirect("/login");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a user is logged in
|
||||
/// Returns a Result with a boolean indicating if the user is logged in
|
||||
#[server(endpoint = "check_auth")]
|
||||
pub async fn check_auth() -> Result<bool, ServerFnError> {
|
||||
let auth_session = extract::<AuthSession<AuthBackend>>().await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
|
||||
|
||||
Ok(auth_session.user.is_some())
|
||||
}
|
||||
|
||||
/// Require that a user is logged in
|
||||
/// Returns a Result with the error message if the user is not logged in
|
||||
/// Intended to be used at the start of a protected route, to ensure the user is logged in:
|
||||
/// ```rust
|
||||
/// use leptos::*;
|
||||
/// use libretunes::auth::require_auth;
|
||||
/// #[server(endpoint = "protected_route")]
|
||||
/// pub async fn protected_route() -> Result<(), ServerFnError> {
|
||||
/// require_auth().await?;
|
||||
/// // Continue with protected route
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn require_auth() -> Result<(), ServerFnError> {
|
||||
check_auth().await.and_then(|logged_in| {
|
||||
if logged_in {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError(format!("Unauthorized")))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
}
|
||||
|
||||
#[server(endpoint = "get_logged_in_user")]
|
||||
pub async fn get_logged_in_user() -> Result<Option<User>, ServerFnError> {
|
||||
let auth_session = extract::<AuthSession<AuthBackend>>().await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
|
||||
|
||||
let user = auth_session.user.map(|mut user| {
|
||||
user.password = None;
|
||||
user
|
||||
});
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
pub async fn check_admin() -> Result<bool, ServerFnError> {
|
||||
let auth_session = extract::<AuthSession<AuthBackend>>().await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
|
||||
|
||||
Ok(auth_session.user.as_ref().map(|u| u.admin).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Require that a user is logged in and an admin
|
||||
/// Returns a Result with the error message if the user is not logged in or is not an admin
|
||||
/// Intended to be used at the start of a protected route, to ensure the user is logged in and an admin:
|
||||
/// ```rust
|
||||
/// use leptos::*;
|
||||
/// use libretunes::auth::require_admin;
|
||||
/// #[server(endpoint = "protected_admin_route")]
|
||||
/// pub async fn protected_admin_route() -> Result<(), ServerFnError> {
|
||||
/// require_admin().await?;
|
||||
/// // Continue with protected route
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn require_admin() -> Result<(), ServerFnError> {
|
||||
check_admin().await.and_then(|is_admin| {
|
||||
if is_admin {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ServerFnError::<NoCustomError>::ServerError(format!("Unauthorized")))
|
||||
}
|
||||
})
|
||||
}
|
@ -1,48 +0,0 @@
|
||||
use axum_login::{AuthnBackend, AuthUser, UserId};
|
||||
use crate::users::UserCredentials;
|
||||
use leptos::server_fn::error::ServerFnErrorErr;
|
||||
|
||||
use crate::models::User;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use async_trait::async_trait;
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthUser for User {
|
||||
type Id = i32;
|
||||
|
||||
// TODO: Ideally, we shouldn't have to unwrap here
|
||||
|
||||
fn id(&self) -> Self::Id {
|
||||
self.id.unwrap()
|
||||
}
|
||||
|
||||
fn session_auth_hash(&self) -> &[u8] {
|
||||
self.password.as_ref().unwrap().as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthBackend;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[async_trait]
|
||||
impl AuthnBackend for AuthBackend {
|
||||
type User = User;
|
||||
type Credentials = UserCredentials;
|
||||
type Error = ServerFnErrorErr;
|
||||
|
||||
async fn authenticate(&self, creds: Self::Credentials) -> Result<Option<Self::User>, Self::Error> {
|
||||
crate::users::validate_user(creds).await
|
||||
.map_err(|e| ServerFnErrorErr::ServerError(format!("Error validating user: {}", e)))
|
||||
}
|
||||
|
||||
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
|
||||
crate::users::find_user_by_id(*user_id).await
|
||||
.map_err(|e| ServerFnErrorErr::ServerError(format!("Error getting user: {}", e)))
|
||||
}
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
use leptos::*;
|
||||
use leptos::leptos_dom::log;
|
||||
use leptos_icons::*;
|
||||
use crate::api::albums::add_album;
|
||||
use leptos::leptos_dom::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_icons::*;
|
||||
|
||||
#[component]
|
||||
pub fn AddAlbumBtn(add_album_open: RwSignal<bool>) -> impl IntoView {
|
||||
@ -16,14 +17,14 @@ pub fn AddAlbumBtn(add_album_open: RwSignal<bool>) -> impl IntoView {
|
||||
}
|
||||
#[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 album_title = RwSignal::new("".to_string());
|
||||
let release_date = RwSignal::new("".to_string());
|
||||
let image_path = RwSignal::new("".to_string());
|
||||
|
||||
let close_dialog = move |ev: leptos::ev::MouseEvent| {
|
||||
ev.prevent_default();
|
||||
open.set(false);
|
||||
};
|
||||
ev.prevent_default();
|
||||
open.set(false);
|
||||
};
|
||||
|
||||
let on_add_album = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
@ -32,7 +33,8 @@ pub fn AddAlbum(open: RwSignal<bool>) -> impl IntoView {
|
||||
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;
|
||||
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 {
|
||||
@ -41,7 +43,7 @@ pub fn AddAlbum(open: RwSignal<bool>) -> impl IntoView {
|
||||
release_date.set("".to_string());
|
||||
image_path.set("".to_string());
|
||||
}
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
@ -50,36 +52,35 @@ pub fn AddAlbum(open: RwSignal<bool>) -> impl IntoView {
|
||||
<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="close-button" on:click=close_dialog><Icon icon={icondata::IoClose} /></div>
|
||||
<form class="create-album-form" on:submit=on_add_album>
|
||||
<div class="input-bx">
|
||||
<input type="text" required class="text-input"
|
||||
<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"
|
||||
<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>
|
||||
<div class="input-bx">
|
||||
<input type="text" class="text-input"
|
||||
<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>
|
||||
@ -88,5 +89,4 @@ pub fn AddAlbum(open: RwSignal<bool>) -> impl IntoView {
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
use leptos::*;
|
||||
use leptos::leptos_dom::log;
|
||||
use leptos_icons::*;
|
||||
use crate::api::artists::add_artist;
|
||||
use leptos::leptos_dom::log;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_icons::*;
|
||||
|
||||
#[component]
|
||||
pub fn AddArtistBtn(add_artist_open: RwSignal<bool>) -> impl IntoView {
|
||||
@ -16,12 +17,12 @@ pub fn AddArtistBtn(add_artist_open: RwSignal<bool>) -> impl IntoView {
|
||||
}
|
||||
#[component]
|
||||
pub fn AddArtist(open: RwSignal<bool>) -> impl IntoView {
|
||||
let artist_name = create_rw_signal("".to_string());
|
||||
let artist_name = RwSignal::new("".to_string());
|
||||
|
||||
let close_dialog = move |ev: leptos::ev::MouseEvent| {
|
||||
ev.prevent_default();
|
||||
open.set(false);
|
||||
};
|
||||
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();
|
||||
@ -33,7 +34,7 @@ pub fn AddArtist(open: RwSignal<bool>) -> impl IntoView {
|
||||
log!("Added artist: {:?}", artist);
|
||||
artist_name.set("".to_string());
|
||||
}
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
@ -42,15 +43,14 @@ pub fn AddArtist(open: RwSignal<bool>) -> impl IntoView {
|
||||
<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="close-button" on:click=close_dialog><Icon icon={icondata::IoClose} /></div>
|
||||
<form class="create-artist-form" on:submit=on_add_artist>
|
||||
<div class="input-bx">
|
||||
<input type="text" name="title" required class="text-input"
|
||||
<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>
|
||||
@ -59,4 +59,4 @@ pub fn AddArtist(open: RwSignal<bool>) -> impl IntoView {
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,25 +0,0 @@
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use crate::albumdata::AlbumData;
|
||||
|
||||
#[component]
|
||||
pub fn AlbumInfo(albumdata: AlbumData) -> impl IntoView {
|
||||
view! {
|
||||
<div class="album-info">
|
||||
<img class="album-image" src={albumdata.image_path} alt="dashboard-tile" />
|
||||
<div class="album-body">
|
||||
<p class="album-title">{albumdata.title}</p>
|
||||
<div class="album-artists">
|
||||
{
|
||||
albumdata.artists.iter().map(|artist| {
|
||||
view! {
|
||||
<a class="album-artist" href={format!("/artist/{}", artist.id.unwrap())}>{artist.name.clone()}</a>
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_view()
|
||||
}
|
||||
|
@ -1,10 +0,0 @@
|
||||
use leptos::*;
|
||||
|
||||
#[component]
|
||||
pub fn Dashboard() -> impl IntoView {
|
||||
view! {
|
||||
<div class="dashboard-container home-component">
|
||||
<h1 class="dashboard-header">Dashboard</h1>
|
||||
</div>
|
||||
}
|
||||
}
|
@ -1,118 +1,121 @@
|
||||
use crate::components::dashboard_tile::*;
|
||||
use leptos::html::Ul;
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use leptos_use::{use_element_size, UseElementSizeReturn, use_scroll, UseScrollReturn};
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
use leptos::prelude::*;
|
||||
use leptos::text_prop::TextProp;
|
||||
use leptos_icons::*;
|
||||
use leptos_use::{use_element_size, use_scroll, UseElementSizeReturn, UseScrollReturn};
|
||||
|
||||
/// A row of dashboard tiles, with a title
|
||||
pub struct DashboardRow {
|
||||
pub title: String,
|
||||
pub tiles: Vec<Box<dyn DashboardTile>>,
|
||||
}
|
||||
#[component]
|
||||
pub fn DashboardRow(
|
||||
#[prop(into)] title: TextProp,
|
||||
#[prop(default=vec![])] tiles: Vec<DashboardTile>,
|
||||
) -> impl IntoView {
|
||||
let list_ref = NodeRef::<Ul>::new();
|
||||
|
||||
impl DashboardRow {
|
||||
pub fn new(title: String, tiles: Vec<Box<dyn DashboardTile>>) -> Self {
|
||||
Self {
|
||||
title,
|
||||
tiles,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scroll functions attempt to align the left edge of the scroll area with the left edge of a tile
|
||||
// This is done by scrolling to the nearest multiple of the tile width, plus some for padding
|
||||
|
||||
impl IntoView for DashboardRow {
|
||||
fn into_view(self) -> View {
|
||||
let list_ref = create_node_ref::<Ul>();
|
||||
let scroll_left = move |_| {
|
||||
if let Some(scroll_element) = list_ref.get_untracked() {
|
||||
let client_width = scroll_element.client_width() as f64;
|
||||
let current_pos = scroll_element.scroll_left() as f64;
|
||||
let desired_pos = current_pos - client_width;
|
||||
|
||||
// Scroll functions attempt to align the left edge of the scroll area with the left edge of a tile
|
||||
// This is done by scrolling to the nearest multiple of the tile width, plus some for padding
|
||||
if let Some(first_tile) = scroll_element.first_element_child() {
|
||||
let tile_width = first_tile.client_width() as f64;
|
||||
let scroll_pos = desired_pos + (tile_width - (desired_pos % tile_width));
|
||||
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
|
||||
} else {
|
||||
warn!("Could not get first tile to scroll left");
|
||||
// Fall back to scrolling by the client width if we can't get the tile width
|
||||
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
|
||||
}
|
||||
} else {
|
||||
warn!("Could not get scroll element to scroll left");
|
||||
}
|
||||
};
|
||||
|
||||
let scroll_left = move |_| {
|
||||
if let Some(scroll_element) = list_ref.get_untracked() {
|
||||
let client_width = scroll_element.client_width() as f64;
|
||||
let current_pos = scroll_element.scroll_left() as f64;
|
||||
let desired_pos = current_pos - client_width;
|
||||
let scroll_right = move |_| {
|
||||
if let Some(scroll_element) = list_ref.get_untracked() {
|
||||
let client_width = scroll_element.client_width() as f64;
|
||||
let current_pos = scroll_element.scroll_left() as f64;
|
||||
let desired_pos = current_pos + client_width;
|
||||
|
||||
if let Some(first_tile) = scroll_element.first_element_child() {
|
||||
let tile_width = first_tile.client_width() as f64;
|
||||
let scroll_pos = desired_pos + (tile_width - (desired_pos % tile_width));
|
||||
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
|
||||
} else {
|
||||
warn!("Could not get first tile to scroll left");
|
||||
// Fall back to scrolling by the client width if we can't get the tile width
|
||||
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
|
||||
}
|
||||
} else {
|
||||
warn!("Could not get scroll element to scroll left");
|
||||
}
|
||||
};
|
||||
if let Some(first_tile) = scroll_element.first_element_child() {
|
||||
let tile_width = first_tile.client_width() as f64;
|
||||
let scroll_pos = desired_pos - (desired_pos % tile_width);
|
||||
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
|
||||
} else {
|
||||
warn!("Could not get first tile to scroll right");
|
||||
// Fall back to scrolling by the client width if we can't get the tile width
|
||||
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
|
||||
}
|
||||
} else {
|
||||
warn!("Could not get scroll element to scroll right");
|
||||
}
|
||||
};
|
||||
|
||||
let scroll_right = move |_| {
|
||||
if let Some(scroll_element) = list_ref.get_untracked() {
|
||||
let client_width = scroll_element.client_width() as f64;
|
||||
let current_pos = scroll_element.scroll_left() as f64;
|
||||
let desired_pos = current_pos + client_width;
|
||||
|
||||
if let Some(first_tile) = scroll_element.first_element_child() {
|
||||
let tile_width = first_tile.client_width() as f64;
|
||||
let scroll_pos = desired_pos - (desired_pos % tile_width);
|
||||
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
|
||||
} else {
|
||||
warn!("Could not get first tile to scroll right");
|
||||
// Fall back to scrolling by the client width if we can't get the tile width
|
||||
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
|
||||
}
|
||||
} else {
|
||||
warn!("Could not get scroll element to scroll right");
|
||||
}
|
||||
};
|
||||
let UseElementSizeReturn {
|
||||
width: scroll_element_width,
|
||||
..
|
||||
} = use_element_size(list_ref);
|
||||
let UseScrollReturn { x: scroll_x, .. } = use_scroll(list_ref);
|
||||
|
||||
let UseElementSizeReturn { width: scroll_element_width, .. } = use_element_size(list_ref);
|
||||
let UseScrollReturn { x: scroll_x, .. } = use_scroll(list_ref);
|
||||
let scroll_right_hidden = Signal::derive(move || {
|
||||
if let Some(scroll_element) = list_ref.get() {
|
||||
if scroll_element.scroll_width() as f64 - scroll_element_width.get() <= scroll_x.get() {
|
||||
"visibility: hidden"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
});
|
||||
|
||||
let scroll_right_hidden = Signal::derive(move || {
|
||||
if let Some(scroll_element) = list_ref.get() {
|
||||
if scroll_element.scroll_width() as f64 - scroll_element_width.get() <= scroll_x.get() {
|
||||
"visibility: hidden"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
});
|
||||
let scroll_left_hidden = Signal::derive(move || {
|
||||
if scroll_x.get() <= 0.0 {
|
||||
"visibility: hidden"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
});
|
||||
|
||||
let scroll_left_hidden = Signal::derive(move || {
|
||||
if scroll_x.get() <= 0.0 {
|
||||
"visibility: hidden"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="dashboard-tile-row">
|
||||
<div class="dashboard-tile-row-title-row">
|
||||
<h2>{self.title}</h2>
|
||||
<div class="dashboard-tile-row-scroll-btn">
|
||||
<button on:click=scroll_left tabindex=-1 style=scroll_left_hidden>
|
||||
<Icon class="dashboard-tile-row-scroll" icon=icondata::FiChevronLeft />
|
||||
</button>
|
||||
<button on:click=scroll_right tabindex=-1 style=scroll_right_hidden>
|
||||
<Icon class="dashboard-tile-row-scroll" icon=icondata::FiChevronRight />
|
||||
</button>
|
||||
</div>
|
||||
view! {
|
||||
<div>
|
||||
<div class="flex">
|
||||
<h2 class="text-xl font-bold">{move || title.get()}</h2>
|
||||
<div class="m-auto mr-0">
|
||||
<button class="control" on:click=scroll_left tabindex=-1 style=scroll_left_hidden>
|
||||
<Icon icon={icondata::FiChevronLeft} {..} class="w-7 h-7" />
|
||||
</button>
|
||||
<button class="control" on:click=scroll_right tabindex=-1 style=scroll_right_hidden>
|
||||
<Icon icon={icondata::FiChevronRight} {..} class="w-7 h-7" />
|
||||
</button>
|
||||
</div>
|
||||
<ul _ref={list_ref}>
|
||||
{self.tiles.into_iter().map(|tile_info| {
|
||||
view! {
|
||||
<li>
|
||||
{ tile_info.into_view() }
|
||||
</li>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</ul>
|
||||
</div>
|
||||
}.into_view()
|
||||
}
|
||||
<ul class="flex overflow-x-hidden scroll-smooth ps-0"
|
||||
style="mask-image: linear-gradient(90deg, black, 95%, transparent);
|
||||
-webkit-mask-image: linear-gradient(90deg, black, 95%, transparent);" node_ref={list_ref}>
|
||||
{tiles.into_iter().map(|tile| {
|
||||
view! {
|
||||
<li>
|
||||
<div class="mr-2.5">
|
||||
<a href={move || tile.link.get()}>
|
||||
<img class="w-50 h-50 max-w-none rounded-md mr-5"
|
||||
src={move || tile.image_path.get()} alt="dashboard-tile" />
|
||||
<p class="text-lg font-semibold">{move || tile.title.get()}</p>
|
||||
<p>
|
||||
{move || tile.description.as_ref().map(|desc| desc.get())}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</ul>
|
||||
</div>
|
||||
}.into_view()
|
||||
}
|
||||
|
@ -1,27 +1,14 @@
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::text_prop::TextProp;
|
||||
|
||||
pub trait DashboardTile {
|
||||
fn image_path(&self) -> String;
|
||||
fn title(&self) -> String;
|
||||
fn link(&self) -> String;
|
||||
fn description(&self) -> Option<String> { None }
|
||||
}
|
||||
|
||||
impl IntoView for &dyn DashboardTile {
|
||||
fn into_view(self) -> View {
|
||||
let link = self.link();
|
||||
|
||||
view! {
|
||||
<div class="dashboard-tile">
|
||||
<a href={link}>
|
||||
<img src={self.image_path()} alt="dashboard-tile" />
|
||||
<p class="dashboard-tile-title">{self.title()}</p>
|
||||
<p class="dashboard-tile-description">
|
||||
{self.description().unwrap_or_default()}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
}.into_view()
|
||||
}
|
||||
#[slot]
|
||||
pub struct DashboardTile {
|
||||
#[prop(into)]
|
||||
image_path: TextProp,
|
||||
#[prop(into)]
|
||||
title: TextProp,
|
||||
#[prop(into)]
|
||||
link: TextProp,
|
||||
#[prop(into, optional)]
|
||||
description: Option<TextProp>,
|
||||
}
|
||||
|
@ -1,45 +1,40 @@
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::text_prop::TextProp;
|
||||
use leptos_icons::*;
|
||||
use std::fmt::Display;
|
||||
|
||||
#[component]
|
||||
pub fn ServerError<E: Display + 'static>(
|
||||
#[prop(optional, into, default="An Error Occurred".into())]
|
||||
title: TextProp,
|
||||
#[prop(optional, into)]
|
||||
message: TextProp,
|
||||
#[prop(optional, into)]
|
||||
error: Option<ServerFnError<E>>,
|
||||
#[prop(optional, into, default="An Error Occurred".into())] title: TextProp,
|
||||
#[prop(optional, into)] message: TextProp,
|
||||
#[prop(optional, into)] error: Option<ServerFnError<E>>,
|
||||
) -> impl IntoView {
|
||||
view!{
|
||||
<div class="error-container">
|
||||
<div class="error-header">
|
||||
<Icon icon=icondata::BiErrorSolid />
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<p>{message}</p>
|
||||
<p>{error.map(|error| format!("{}", error))}</p>
|
||||
</div>
|
||||
}
|
||||
view! {
|
||||
<div class="error-container">
|
||||
<div class="error-header">
|
||||
<Icon icon={icondata::BiErrorSolid} />
|
||||
<h1>{move || title.get()}</h1>
|
||||
</div>
|
||||
<p>{move || message.get()}</p>
|
||||
<p>{error.map(|error| format!("{error}"))}</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Error<E: Display + 'static>(
|
||||
#[prop(optional, into, default="An Error Occurred".into())]
|
||||
title: TextProp,
|
||||
#[prop(optional, into)]
|
||||
message: TextProp,
|
||||
#[prop(optional, into)]
|
||||
error: Option<E>,
|
||||
#[prop(optional, into, default="An Error Occurred".into())] title: TextProp,
|
||||
#[prop(optional, into)] message: TextProp,
|
||||
#[prop(optional, into)] error: Option<E>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="error-container">
|
||||
<div class="error-header">
|
||||
<Icon icon=icondata::BiErrorSolid />
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<p>{message}</p>
|
||||
<p>{error.map(|error| format!("{}", error))}</p>
|
||||
</div>
|
||||
}
|
||||
view! {
|
||||
<div class="text-red-800">
|
||||
<div class="grid grid-cols-[max-content_1fr] gap-1">
|
||||
<Icon icon={icondata::BiErrorSolid} {..} class="self-center" />
|
||||
<h1 class="self-center">{move || title.get()}</h1>
|
||||
</div>
|
||||
<p>{move || message.get()}</p>
|
||||
<p>{error.map(|error| format!("{error}"))}</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
use http::status::StatusCode;
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
@ -12,7 +12,7 @@ pub enum AppError {
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
pub const fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
AppError::NotFound => StatusCode::NOT_FOUND,
|
||||
}
|
||||
@ -27,7 +27,7 @@ pub fn ErrorTemplate(
|
||||
#[prop(optional)] errors: Option<RwSignal<Errors>>,
|
||||
) -> impl IntoView {
|
||||
let errors = match outside_errors {
|
||||
Some(e) => create_rw_signal(e),
|
||||
Some(e) => RwSignal::new(e),
|
||||
None => match errors {
|
||||
Some(e) => e,
|
||||
None => panic!("No Errors found and we expected errors!"),
|
||||
@ -51,7 +51,7 @@ pub fn ErrorTemplate(
|
||||
response.set_status(errors[0].status_code());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
view! {
|
||||
<h1>{if errors.len() > 1 {"Errors"} else {"Error"}}</h1>
|
||||
<For
|
34
src/components/fancy_input.rs
Normal file
34
src/components/fancy_input.rs
Normal file
@ -0,0 +1,34 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos::text_prop::TextProp;
|
||||
|
||||
#[component]
|
||||
pub fn FancyInput(
|
||||
#[prop(into)] label: TextProp,
|
||||
#[prop(optional, into)] password: Signal<bool>,
|
||||
#[prop(optional)] required: bool,
|
||||
#[prop(optional)] value: RwSignal<String>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="relative mt-12 mb-3">
|
||||
<input
|
||||
class="peer text-lg w-full relative p-1 z-20 border-none outline-none bg-transparent text-white"
|
||||
type={move || if password.get() { "password" } else { "text" }}
|
||||
required={required}
|
||||
placeholder=""
|
||||
bind:value={value}
|
||||
/>
|
||||
<span
|
||||
class="absolute left-0 text-lg transition-all duration-500
|
||||
text-lg peer-[:not(:placeholder-shown)]:text-base peer-focus:text-base
|
||||
text-black peer-[:not(:placeholder-shown)]:text-neutral-700 peer-focus:text-neutral-700;
|
||||
peer-[:not(:placeholder-shown)]:translate-y-[-30px] peer-focus:translate-y-[-30px]"
|
||||
>
|
||||
{label.get()}
|
||||
</span>
|
||||
<div
|
||||
class="w-full h-[2px] rounded-md bg-accent-light absolute bottom-0 left-0
|
||||
transition-all duration-500 peer-[:not(:placeholder-shown)]:h-10 peer-focus:h-10"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
}
|
@ -1,19 +1,26 @@
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// A loading indicator
|
||||
#[component]
|
||||
pub fn Loading() -> impl IntoView {
|
||||
view! {
|
||||
<div class="loading"></div>
|
||||
}
|
||||
let dots_style = "h-2 w-2 bg-accent rounded-full animate-pulse";
|
||||
|
||||
view! {
|
||||
<div class="flex space-x-1 justify-center items-center my-2">
|
||||
<span class="sr-only">"Loading..."</span>
|
||||
<div class=dots_style style="animation-duration: 900ms; animation-delay: 0ms;" />
|
||||
<div class=dots_style style="animation-duration: 900ms; animation-delay: 300ms"/>
|
||||
<div class=dots_style style="animation-duration: 900ms; animation-delay: 600ms;" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// A full page, centered loading indicator
|
||||
#[component]
|
||||
pub fn LoadingPage() -> impl IntoView {
|
||||
view!{
|
||||
<div class="loading-page">
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
view! {
|
||||
<div class="loading-page">
|
||||
<Loading />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
98
src/components/menu.rs
Normal file
98
src/components/menu.rs
Normal file
@ -0,0 +1,98 @@
|
||||
use crate::components::upload_dropdown::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos_icons::*;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MenuEntry {
|
||||
Dashboard,
|
||||
Search,
|
||||
}
|
||||
|
||||
impl MenuEntry {
|
||||
pub const fn path(&self) -> &'static str {
|
||||
match self {
|
||||
MenuEntry::Dashboard => "/",
|
||||
MenuEntry::Search => "/search",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn icon(&self) -> icondata::Icon {
|
||||
match self {
|
||||
MenuEntry::Dashboard => icondata::OcHomeFillLg,
|
||||
MenuEntry::Search => icondata::BiSearchRegular,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn title(&self) -> &'static str {
|
||||
match self {
|
||||
MenuEntry::Dashboard => "Dashboard",
|
||||
MenuEntry::Search => "Search",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn all() -> [MenuEntry; 2] {
|
||||
[MenuEntry::Dashboard, MenuEntry::Search]
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn MenuItem(entry: MenuEntry, #[prop(into)] active: Signal<bool>) -> impl IntoView {
|
||||
view! {
|
||||
<a class="menu-btn" href={entry.path().to_string()}
|
||||
style={move || if active() {"color: var(--color-menu-active);"} else {""}}
|
||||
>
|
||||
<Icon height="1.7rem" width="1.7rem" icon={entry.icon()} {..} class="mr-2" />
|
||||
<h2>{entry.title()}</h2>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Menu(
|
||||
upload_open: RwSignal<bool>,
|
||||
add_artist_open: RwSignal<bool>,
|
||||
add_album_open: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
use leptos_router::hooks::use_location;
|
||||
let location = use_location();
|
||||
|
||||
let active_entry = Signal::derive(move || {
|
||||
let path = location.pathname.get();
|
||||
MenuEntry::all()
|
||||
.into_iter()
|
||||
.find(|entry| entry.path() == path)
|
||||
});
|
||||
|
||||
let dropdown_open = RwSignal::new(false);
|
||||
|
||||
view! {
|
||||
<div class="home-card">
|
||||
<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>
|
||||
<div class="flex">
|
||||
<h1 class="text-xl font-bold">"LibreTunes"</h1>
|
||||
<div class="upload-dropdown-container">
|
||||
<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>
|
||||
</div>
|
||||
{MenuEntry::all().into_iter().map(|entry| {
|
||||
let active = Signal::derive(move || active_entry.get() == Some(entry));
|
||||
view! { <MenuItem entry active /> }
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
@ -1,14 +1,17 @@
|
||||
pub mod sidebar;
|
||||
pub mod dashboard;
|
||||
pub mod search;
|
||||
pub mod personal;
|
||||
pub mod dashboard_tile;
|
||||
pub mod add_album;
|
||||
pub mod add_artist;
|
||||
pub mod dashboard_row;
|
||||
pub mod dashboard_tile;
|
||||
pub mod error;
|
||||
pub mod error_template;
|
||||
pub mod fancy_input;
|
||||
pub mod loading;
|
||||
pub mod menu;
|
||||
pub mod personal;
|
||||
pub mod playbar;
|
||||
pub mod queue;
|
||||
pub mod sidebar;
|
||||
pub mod song;
|
||||
pub mod song_list;
|
||||
pub mod upload;
|
||||
pub mod upload_dropdown;
|
||||
pub mod add_artist;
|
||||
pub mod add_album;
|
||||
pub mod song_list;
|
||||
pub mod loading;
|
||||
pub mod error;
|
||||
pub mod album_info;
|
@ -1,13 +1,17 @@
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use leptos_icons::*;
|
||||
use crate::auth::logout;
|
||||
use crate::api::auth::logout;
|
||||
use crate::util::state::GlobalState;
|
||||
use leptos::html::Div;
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_icons::*;
|
||||
use leptos_use::on_click_outside_with_options;
|
||||
use leptos_use::OnClickOutsideOptions;
|
||||
|
||||
#[component]
|
||||
pub fn Personal() -> impl IntoView {
|
||||
view! {
|
||||
<div class=" personal-container">
|
||||
<div class="home-card">
|
||||
<Profile />
|
||||
</div>
|
||||
}
|
||||
@ -15,101 +19,102 @@ pub fn Personal() -> impl IntoView {
|
||||
|
||||
#[component]
|
||||
pub fn Profile() -> impl IntoView {
|
||||
let (dropdown_open, set_dropdown_open) = create_signal(false);
|
||||
let user = GlobalState::logged_in_user();
|
||||
|
||||
let open_dropdown = move |_| {
|
||||
set_dropdown_open.update(|value| *value = !*value);
|
||||
};
|
||||
let dropdown_open = RwSignal::new(false);
|
||||
let user = GlobalState::logged_in_user();
|
||||
|
||||
let toggle_dropdown = move |_| dropdown_open.set(!dropdown_open.get());
|
||||
|
||||
let profile_photo = NodeRef::<Div>::new();
|
||||
let dropdown = NodeRef::<Div>::new();
|
||||
let _ = on_click_outside_with_options(
|
||||
dropdown,
|
||||
move |_| dropdown_open.set(false),
|
||||
OnClickOutsideOptions::default().ignore(profile_photo),
|
||||
);
|
||||
|
||||
let user_profile_picture = move || {
|
||||
user.get().and_then(|user| {
|
||||
if let Some(user) = user {
|
||||
if user.id.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("/assets/images/profile/{}.webp", user.id.unwrap()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
Some(format!("/assets/images/profile/{}.webp", user.id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="profile-container">
|
||||
<div class="profile-name">
|
||||
<div class="flex w-50 relative">
|
||||
<div class="text-lg self-center">
|
||||
<Suspense
|
||||
fallback=|| view!{
|
||||
<h1>Not Logged In</h1>
|
||||
}>
|
||||
<Show
|
||||
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
|
||||
fallback=|| view!{
|
||||
<h1>Not Logged In</h1>
|
||||
}>
|
||||
<h1>{move || user.get().map(|user| user.map(|user| user.username))}</h1>
|
||||
</Show>
|
||||
<Show
|
||||
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
|
||||
fallback=|| view!{
|
||||
<h1>Not Logged In</h1>
|
||||
}>
|
||||
<h1>{move || user.get().map(|user| user.map(|user| user.username))}</h1>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</div>
|
||||
<div class="profile-icon" on:click=open_dropdown>
|
||||
<Suspense fallback=|| view! { <Icon icon=icondata::CgProfile width="45" height="45"/> }>
|
||||
<Show
|
||||
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
|
||||
fallback=|| view! { <Icon icon=icondata::CgProfile width="45" height="45"/> }
|
||||
>
|
||||
<object class="profile-image" data={user_profile_picture} type="image/webp">
|
||||
<Icon class="profile-image" icon=icondata::CgProfile width="45" height="45"/>
|
||||
</object>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</div>
|
||||
<div class="dropdown-container" style={move || if dropdown_open() {"display: flex"} else {"display: none"}}>
|
||||
<Suspense
|
||||
fallback=|| view!{
|
||||
<DropDownNotLoggedIn />
|
||||
}>
|
||||
<Show
|
||||
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
|
||||
fallback=|| view!{
|
||||
<DropDownNotLoggedIn />
|
||||
}>
|
||||
<DropDownLoggedIn />
|
||||
</Show>
|
||||
</Suspense>
|
||||
<div class="self-center hover:scale-105 transition-transform cursor-pointer ml-auto"
|
||||
on:click=toggle_dropdown node_ref=profile_photo>
|
||||
<Suspense fallback=|| view! { <Icon icon={icondata::CgProfile} width="45" height="45"/> }>
|
||||
<Show
|
||||
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
|
||||
fallback=|| view! { <Icon icon={icondata::CgProfile} width="45" height="45"/> }
|
||||
>
|
||||
<object class="w-11 h-11 rounded-full pointer-events-none"
|
||||
data={user_profile_picture} type="image/webp">
|
||||
<Icon icon={icondata::CgProfile} width="45" height="45" {..} />
|
||||
</object>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</div>
|
||||
<Show when=dropdown_open >
|
||||
<div class="absolute bg-bg-light rounded-lg border-2 border-neutral-700 top-12
|
||||
right-3 p-1 text-right" node_ref=dropdown>
|
||||
<Suspense
|
||||
fallback=|| view!{
|
||||
<DropDownNotLoggedIn />
|
||||
}>
|
||||
<Show
|
||||
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
|
||||
fallback=|| view!{
|
||||
<DropDownNotLoggedIn />
|
||||
}>
|
||||
<DropDownLoggedIn />
|
||||
</Show>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
#[component]
|
||||
pub fn DropDownNotLoggedIn() -> impl IntoView {
|
||||
view! {
|
||||
<div class="dropdown-logged">
|
||||
<h1>Not Logged In</h1>
|
||||
<a href="/login"><button class="auth-button">Log In</button></a>
|
||||
<a href="/signup"><button class="auth-button">Sign Up</button></a>
|
||||
</div>
|
||||
<a href="/login"><button class="auth-button">"Log In"</button></a><br/>
|
||||
<a href="/signup"><button class="auth-button">"Sign Up"</button></a>
|
||||
}
|
||||
}
|
||||
#[component]
|
||||
pub fn DropDownLoggedIn() -> impl IntoView {
|
||||
|
||||
let logout = move |_| {
|
||||
let logout = move |_| {
|
||||
spawn_local(async move {
|
||||
let result = logout().await;
|
||||
if let Err(err) = result {
|
||||
log!("Error logging out: {:?}", err);
|
||||
} else {
|
||||
let user = GlobalState::logged_in_user();
|
||||
user.refetch();
|
||||
let user = GlobalState::logged_in_user();
|
||||
user.refetch();
|
||||
log!("Logged out successfully");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="dropdown-logged">
|
||||
<h1>"Logged In"</h1>
|
||||
<button on:click=logout class="auth-button">Log Out</button>
|
||||
</div>
|
||||
<button on:click=logout class="auth-button">"Log Out"</button>
|
||||
}
|
||||
}
|
||||
|
@ -1,22 +1,23 @@
|
||||
use crate::models::Artist;
|
||||
use crate::songdata::SongData;
|
||||
use crate::api::songs;
|
||||
use crate::models::backend::Artist;
|
||||
use crate::models::frontend;
|
||||
use crate::util::state::GlobalState;
|
||||
use leptos::ev::MouseEvent;
|
||||
use leptos::html::{Audio, Div};
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos_meta::Title;
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_icons::*;
|
||||
use leptos_use::{utils::Pausable, use_interval_fn};
|
||||
use leptos_meta::Title;
|
||||
use leptos_use::{use_interval_fn, utils::Pausable};
|
||||
|
||||
/// Width and height of the forward/backward skip buttons
|
||||
const SKIP_BTN_SIZE: &str = "3.5em";
|
||||
const SKIP_BTN_SIZE: &str = "3em";
|
||||
/// Width and height of the play/pause button
|
||||
const PLAY_BTN_SIZE: &str = "5em";
|
||||
const PLAY_BTN_SIZE: &str = "4em";
|
||||
|
||||
// Width and height of the queue button
|
||||
const QUEUE_BTN_SIZE: &str = "3.5em";
|
||||
const QUEUE_BTN_SIZE: &str = "2.5em";
|
||||
|
||||
/// Threshold in seconds for skipping to the previous song instead of skipping to the start of the current song
|
||||
const MIN_SKIP_BACK_TIME: f64 = 5.0;
|
||||
@ -30,46 +31,47 @@ 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
|
||||
///
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
///
|
||||
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
||||
///
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
///
|
||||
/// * `None` if the audio element is not available
|
||||
/// * `Some((current_time, duration))` if the audio element is available
|
||||
///
|
||||
///
|
||||
pub fn get_song_time_duration() -> Option<(f64, f64)> {
|
||||
GlobalState::play_status().with_untracked(|status| {
|
||||
if let Some(audio) = status.get_audio() {
|
||||
Some((audio.current_time(), audio.duration()))
|
||||
} else {
|
||||
error!("Unable to get current duration: Audio element not available");
|
||||
None
|
||||
}
|
||||
})
|
||||
GlobalState::play_status().with_untracked(|status| {
|
||||
if let Some(audio) = status.get_audio() {
|
||||
Some((audio.current_time(), audio.duration()))
|
||||
} else {
|
||||
error!("Unable to get current duration: Audio element not available");
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Skip to a certain time in the current song, optionally playing it
|
||||
///
|
||||
///
|
||||
/// If the given time is +/- infinity or NaN, logs an error and returns
|
||||
/// Logs an error if the audio element is not available, or if playing the song fails
|
||||
///
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
///
|
||||
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
||||
/// * `time` - The time to skip to, in seconds
|
||||
///
|
||||
///
|
||||
pub fn skip_to(time: f64) {
|
||||
if time.is_infinite() || time.is_nan() {
|
||||
error!("Unable to skip to non-finite time: {}", time);
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
GlobalState::play_status().update(|status| {
|
||||
if let Some(audio) = status.get_audio() {
|
||||
audio.set_current_time(time);
|
||||
status.playing = true;
|
||||
log!("Player skipped to time: {}", time);
|
||||
} else {
|
||||
error!("Unable to skip to time: Audio element not available");
|
||||
@ -77,63 +79,9 @@ pub fn skip_to(time: f64) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Play or pause the current song
|
||||
///
|
||||
/// Logs an error if the audio element is not available, or if playing/pausing the song fails
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
||||
/// * `play` - `true` to play the song, `false` to pause it
|
||||
///
|
||||
pub fn set_playing(play: bool) {
|
||||
GlobalState::play_status().update(|status| {
|
||||
if let Some(audio) = status.get_audio() {
|
||||
if play {
|
||||
if let Err(e) = audio.play() {
|
||||
error!("Unable to play audio: {:?}", e);
|
||||
} else {
|
||||
status.playing = true;
|
||||
log!("Successfully played audio");
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = audio.pause() {
|
||||
error!("Unable to pause audio: {:?}", e);
|
||||
} else {
|
||||
status.playing = false;
|
||||
log!("Successfully paused audio");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Unable to play/pause audio: Audio element not available");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn toggle_queue() {
|
||||
GlobalState::play_status().update(|status| {
|
||||
status.queue_open = !status.queue_open;
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// Set the source of the audio player
|
||||
///
|
||||
/// Logs an error if the audio element is not available
|
||||
///
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
|
||||
/// * `src` - The source to set the audio player to
|
||||
///
|
||||
fn set_play_src(src: String) {
|
||||
GlobalState::play_status().update(|status| {
|
||||
if let Some(audio) = status.get_audio() {
|
||||
audio.set_src(&src);
|
||||
log!("Player set src to: {}", src);
|
||||
} else {
|
||||
error!("Unable to set src: Audio element not available");
|
||||
}
|
||||
status.queue_open = !status.queue_open;
|
||||
});
|
||||
}
|
||||
|
||||
@ -160,10 +108,7 @@ fn PlayControls() -> impl IntoView {
|
||||
|
||||
if let Some(last_played_song) = last_played_song {
|
||||
// Push the popped song to the front of the queue, and play it
|
||||
let next_src = last_played_song.song_path.clone();
|
||||
status.update(|status| status.queue.push_front(last_played_song));
|
||||
set_play_src(next_src);
|
||||
set_playing(true);
|
||||
} else {
|
||||
warn!("Unable to skip back: No previous song");
|
||||
}
|
||||
@ -173,28 +118,18 @@ fn PlayControls() -> impl IntoView {
|
||||
// Default to skipping to start of current song, and playing
|
||||
log!("Skipping to start of current song");
|
||||
skip_to(0.0);
|
||||
set_playing(true);
|
||||
};
|
||||
|
||||
let skip_forward = move |_| {
|
||||
if let Some(duration) = get_song_time_duration() {
|
||||
if let Some(duration) = get_song_time_duration() {
|
||||
skip_to(duration.1);
|
||||
set_playing(true);
|
||||
} else {
|
||||
error!("Unable to skip forward: Unable to get current duration");
|
||||
}
|
||||
} else {
|
||||
error!("Unable to skip forward: Unable to get current duration");
|
||||
}
|
||||
};
|
||||
|
||||
let toggle_play = move |_| {
|
||||
let playing = status.with_untracked(|status| { status.playing });
|
||||
set_playing(!playing);
|
||||
};
|
||||
|
||||
// We use this to prevent the buttons from being focused when clicked
|
||||
// If buttons were focused on clicks, then pressing space bar to play/pause would "click" the button
|
||||
// and trigger unwanted behavior
|
||||
let prevent_focus = move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
status.update(|status| status.playing = !status.playing);
|
||||
};
|
||||
|
||||
// Change the icon based on whether the song is playing or not
|
||||
@ -209,27 +144,25 @@ fn PlayControls() -> impl IntoView {
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="playcontrols" align="center">
|
||||
<div class="flex place-content-center">
|
||||
<button class="control" on:click=skip_back>
|
||||
<Icon width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon={icondata::BsSkipStartFill} />
|
||||
</button>
|
||||
|
||||
<button on:click=skip_back on:mousedown=prevent_focus>
|
||||
<Icon class="controlbtn" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=icondata::BsSkipStartFill />
|
||||
</button>
|
||||
|
||||
<button on:click=toggle_play on:mousedown=prevent_focus>
|
||||
<Icon class="controlbtn" width=PLAY_BTN_SIZE height=PLAY_BTN_SIZE icon={icon} />
|
||||
</button>
|
||||
|
||||
<button on:click=skip_forward on:mousedown=prevent_focus>
|
||||
<Icon class="controlbtn" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=icondata::BsSkipEndFill />
|
||||
</button>
|
||||
<button class="control" on:click=toggle_play>
|
||||
<Icon width=PLAY_BTN_SIZE height=PLAY_BTN_SIZE icon={icon} />
|
||||
</button>
|
||||
|
||||
<button class="control" on:click=skip_forward>
|
||||
<Icon width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon={icondata::BsSkipEndFill} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// The elapsed time and total time of the current song
|
||||
#[component]
|
||||
fn PlayDuration(elapsed_secs: MaybeSignal<i64>, total_secs: MaybeSignal<i64>) -> impl IntoView {
|
||||
fn PlayDuration(elapsed_secs: Signal<i64>, total_secs: Signal<i64>) -> impl IntoView {
|
||||
// Create a derived signal that formats the elapsed and total seconds into a string
|
||||
let play_duration = Signal::derive(move || {
|
||||
let elapsed_mins = (elapsed_secs.get() - elapsed_secs.get() % 60) / 60;
|
||||
@ -238,12 +171,12 @@ fn PlayDuration(elapsed_secs: MaybeSignal<i64>, total_secs: MaybeSignal<i64>) ->
|
||||
let total_secs = total_secs.get() % 60;
|
||||
|
||||
// Format as "MM:SS / MM:SS"
|
||||
format!("{}:{:0>2} / {}:{:0>2}", elapsed_mins, elapsed_secs, total_mins, total_secs)
|
||||
format!("{elapsed_mins}:{elapsed_secs:0>2} / {total_mins}:{total_secs:0>2}")
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="playduration" align="right">
|
||||
{play_duration}
|
||||
<div class="text-controls p-1">
|
||||
{play_duration}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@ -254,34 +187,46 @@ fn MediaInfo() -> impl IntoView {
|
||||
let status = GlobalState::play_status();
|
||||
|
||||
let name = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
status.queue.front().map_or("No media playing".into(), |song| song.title.clone())
|
||||
})
|
||||
status.with(|status| {
|
||||
status
|
||||
.queue
|
||||
.front()
|
||||
.map_or("No media playing".into(), |song| song.title.clone())
|
||||
})
|
||||
});
|
||||
|
||||
let artist = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
status.queue.front().map_or("".into(), |song| format!("{}", Artist::display_list(&song.artists)))
|
||||
})
|
||||
});
|
||||
let artist = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
status.queue.front().map_or("".into(), |song| {
|
||||
Artist::display_list(&song.artists).to_string()
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
let album = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
status.queue.front().map_or("".into(), |song|
|
||||
song.album.as_ref().map_or("".into(), |album| album.title.clone()))
|
||||
})
|
||||
});
|
||||
let album = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
status.queue.front().map_or("".into(), |song| {
|
||||
song.album
|
||||
.as_ref()
|
||||
.map_or("".into(), |album| album.title.clone())
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
let image = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
status.queue.front().map_or("/images/placeholders/MusicPlaceholder.svg".into(),
|
||||
|song| song.image_path.clone())
|
||||
})
|
||||
});
|
||||
let image = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
status
|
||||
.queue
|
||||
.front()
|
||||
.map_or("/images/placeholders/MusicPlaceholder.svg".into(), |song| {
|
||||
song.image_path.clone()
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
view! {
|
||||
<img class="media-info-img" align="left" src={image}/>
|
||||
<div class="media-info-text">
|
||||
<img class="w-[60px] p-1" src={image}/>
|
||||
<div class="text-controls p-1">
|
||||
{name}
|
||||
<br/>
|
||||
{artist} - {album}
|
||||
@ -295,27 +240,33 @@ fn LikeDislike() -> impl IntoView {
|
||||
let status = GlobalState::play_status();
|
||||
|
||||
let like_icon = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
match status.queue.front() {
|
||||
Some(SongData { like_dislike: Some((true, _)), .. }) => icondata::TbThumbUpFilled,
|
||||
_ => icondata::TbThumbUp,
|
||||
}
|
||||
status.with(|status| match status.queue.front() {
|
||||
Some(frontend::Song {
|
||||
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,
|
||||
}
|
||||
status.with(|status| match status.queue.front() {
|
||||
Some(frontend::Song {
|
||||
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)), .. }) => {
|
||||
Some(frontend::Song {
|
||||
id,
|
||||
like_dislike: Some((liked, disliked)),
|
||||
..
|
||||
}) => {
|
||||
*liked = !*liked;
|
||||
|
||||
if *liked {
|
||||
@ -329,8 +280,10 @@ fn LikeDislike() -> impl IntoView {
|
||||
error!("Error liking song: {:?}", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
Some(SongData { id, like_dislike, .. }) => {
|
||||
}
|
||||
Some(frontend::Song {
|
||||
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.
|
||||
@ -343,10 +296,9 @@ fn LikeDislike() -> impl IntoView {
|
||||
error!("Error liking song: {:?}", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
_ => {
|
||||
log!("Unable to like song: No song in queue");
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -355,7 +307,11 @@ fn LikeDislike() -> impl IntoView {
|
||||
let toggle_dislike = move |_| {
|
||||
status.update(|status| {
|
||||
match status.queue.front_mut() {
|
||||
Some(SongData { id, like_dislike: Some((liked, disliked)), .. }) => {
|
||||
Some(frontend::Song {
|
||||
id,
|
||||
like_dislike: Some((liked, disliked)),
|
||||
..
|
||||
}) => {
|
||||
*disliked = !*disliked;
|
||||
|
||||
if *disliked {
|
||||
@ -369,36 +325,37 @@ fn LikeDislike() -> impl IntoView {
|
||||
error!("Error disliking song: {:?}", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
Some(SongData { id, like_dislike, .. }) => {
|
||||
}
|
||||
Some(frontend::Song {
|
||||
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 {
|
||||
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 />
|
||||
<div class="flex">
|
||||
<button class="control scale-x-[-1] p-1" on:click=toggle_dislike>
|
||||
<Icon 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 class="control p-1" on:click=toggle_like>
|
||||
<Icon width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon={like_icon} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@ -406,9 +363,9 @@ fn LikeDislike() -> impl IntoView {
|
||||
|
||||
/// The play progress bar, and click handler for skipping to a certain time in the song
|
||||
#[component]
|
||||
fn ProgressBar(percentage: MaybeSignal<f64>) -> impl IntoView {
|
||||
fn ProgressBar(percentage: Signal<f64>) -> impl IntoView {
|
||||
// Keep a reference to the progress bar div so we can get its width and calculate the time to skip to
|
||||
let progress_bar_ref = create_node_ref::<Div>();
|
||||
let progress_bar_ref = NodeRef::<Div>::new();
|
||||
|
||||
let progress_jump = move |e: MouseEvent| {
|
||||
let x_click_pos = e.offset_x() as f64;
|
||||
@ -418,11 +375,10 @@ fn ProgressBar(percentage: MaybeSignal<f64>) -> impl IntoView {
|
||||
let width = progress_bar.offset_width() as f64;
|
||||
let percentage = x_click_pos / width * 100.0;
|
||||
|
||||
if let Some(duration) = get_song_time_duration() {
|
||||
let time = duration.1 * percentage / 100.0;
|
||||
skip_to(time);
|
||||
set_playing(true);
|
||||
} else {
|
||||
if let Some(duration) = get_song_time_duration() {
|
||||
let time = duration.1 * percentage / 100.0;
|
||||
skip_to(time);
|
||||
} else {
|
||||
error!("Unable to skip to time: Unable to get current duration");
|
||||
}
|
||||
} else {
|
||||
@ -434,11 +390,13 @@ fn ProgressBar(percentage: MaybeSignal<f64>) -> impl IntoView {
|
||||
let bar_width_style = Signal::derive(move || format!("width: {}%;", percentage.get()));
|
||||
|
||||
view! {
|
||||
<div class="invisible-media-progress" _ref=progress_bar_ref on:click=progress_jump> // Larger click area
|
||||
<div class="media-progress"> // "Unfilled" progress bar
|
||||
<div class="media-progress-solid" style=bar_width_style> // "Filled" progress bar
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-[14px] translate-y-[50%] pt-[7px] cursor-pointer" node_ref=progress_bar_ref on:click=progress_jump> // Larger click area
|
||||
<div class="bg-controls-active h-[3px]"> // "Unfilled" progress bar
|
||||
<div class="from-play-grad-start to-play-grad-end bg-linear-90 h-[3px]"
|
||||
style=bar_width_style /> // "Filled" progress bar
|
||||
<div class="from-play-grad-start to-play-grad-end bg-linear-90 h-[3px]
|
||||
translate-y-[-3px] blur-[3px]" style=bar_width_style /> // "Filled" progress bar blur
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@ -447,36 +405,37 @@ fn ProgressBar(percentage: MaybeSignal<f64>) -> impl IntoView {
|
||||
fn QueueToggle() -> impl IntoView {
|
||||
let update_queue = move |_| {
|
||||
toggle_queue();
|
||||
log!("queue button pressed, queue status: {:?}",
|
||||
GlobalState::play_status().with_untracked(|status| status.queue_open));
|
||||
};
|
||||
|
||||
// We use this to prevent the buttons from being focused when clicked
|
||||
// If buttons were focused on clicks, then pressing space bar to play/pause would "click" the button
|
||||
// and trigger unwanted behavior
|
||||
let prevent_focus = move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
log!(
|
||||
"queue button pressed, queue status: {:?}",
|
||||
GlobalState::play_status().with_untracked(|status| status.queue_open)
|
||||
);
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="queue-toggle">
|
||||
<button on:click=update_queue on:mousedown=prevent_focus>
|
||||
<Icon class="controlbtn" width=QUEUE_BTN_SIZE height=QUEUE_BTN_SIZE icon=icondata::RiPlayListMediaFill />
|
||||
<button id="queue-toggle-btn" class="control p-1" on:click=update_queue>
|
||||
<Icon width=QUEUE_BTN_SIZE height=QUEUE_BTN_SIZE icon={icondata::RiPlayListMediaFill} />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the title of the page based on the currently playing song
|
||||
#[component]
|
||||
pub fn CustomTitle() -> impl IntoView {
|
||||
let title = create_memo(move |_| {
|
||||
let title = Memo::new(move |_| {
|
||||
GlobalState::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")
|
||||
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 />
|
||||
}
|
||||
@ -485,110 +444,151 @@ pub fn CustomTitle() -> impl IntoView {
|
||||
/// The main play bar component, containing the progress bar, media info, play controls, and play duration
|
||||
#[component]
|
||||
pub fn PlayBar() -> impl IntoView {
|
||||
use web_sys::wasm_bindgen::JsCast;
|
||||
|
||||
let status = GlobalState::play_status();
|
||||
|
||||
// Listen for key down events -- arrow keys don't seem to trigger key press events
|
||||
let _arrow_key_handle = window_event_listener(ev::keydown, move |e: ev::KeyboardEvent| {
|
||||
if e.key() == "ArrowRight" {
|
||||
e.prevent_default();
|
||||
log!("Right arrow key pressed, skipping forward by {} seconds", ARROW_KEY_SKIP_TIME);
|
||||
|
||||
if let Some(duration) = get_song_time_duration() {
|
||||
let mut time = duration.0 + ARROW_KEY_SKIP_TIME;
|
||||
time = time.clamp(0.0, duration.1);
|
||||
skip_to(time);
|
||||
set_playing(true);
|
||||
} else {
|
||||
error!("Unable to skip forward: Unable to get current duration");
|
||||
let _arrow_key_handle =
|
||||
window_event_listener(leptos::ev::keydown, move |e: leptos::ev::KeyboardEvent| {
|
||||
// Skip if the event target is an input element
|
||||
if let Some(true) = e
|
||||
.target()
|
||||
.map(|t| t.has_type::<web_sys::HtmlInputElement>())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
} else if e.key() == "ArrowLeft" {
|
||||
e.prevent_default();
|
||||
log!("Left arrow key pressed, skipping backward by {} seconds", ARROW_KEY_SKIP_TIME);
|
||||
if e.key() == "ArrowRight" {
|
||||
e.prevent_default();
|
||||
log!(
|
||||
"Right arrow key pressed, skipping forward by {} seconds",
|
||||
ARROW_KEY_SKIP_TIME
|
||||
);
|
||||
|
||||
if let Some(duration) = get_song_time_duration() {
|
||||
let mut time = duration.0 - ARROW_KEY_SKIP_TIME;
|
||||
time = time.clamp(0.0, duration.1);
|
||||
skip_to(time);
|
||||
set_playing(true);
|
||||
} else {
|
||||
error!("Unable to skip backward: Unable to get current duration");
|
||||
if let Some(duration) = get_song_time_duration() {
|
||||
let mut time = duration.0 + ARROW_KEY_SKIP_TIME;
|
||||
time = time.clamp(0.0, duration.1);
|
||||
skip_to(time);
|
||||
} else {
|
||||
error!("Unable to skip forward: Unable to get current duration");
|
||||
}
|
||||
} else if e.key() == "ArrowLeft" {
|
||||
e.prevent_default();
|
||||
log!(
|
||||
"Left arrow key pressed, skipping backward by {} seconds",
|
||||
ARROW_KEY_SKIP_TIME
|
||||
);
|
||||
|
||||
if let Some(duration) = get_song_time_duration() {
|
||||
let mut time = duration.0 - ARROW_KEY_SKIP_TIME;
|
||||
time = time.clamp(0.0, duration.1);
|
||||
skip_to(time);
|
||||
} else {
|
||||
error!("Unable to skip backward: Unable to get current duration");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for space bar presses to play/pause
|
||||
let _space_bar_handle = window_event_listener(ev::keypress, move |e: ev::KeyboardEvent| {
|
||||
if e.key() == " " {
|
||||
e.prevent_default();
|
||||
log!("Space bar pressed, toggling play/pause");
|
||||
let _space_bar_handle =
|
||||
window_event_listener(leptos::ev::keypress, move |e: leptos::ev::KeyboardEvent| {
|
||||
// Skip if the event target is an input element
|
||||
if let Some(true) = e
|
||||
.target()
|
||||
.map(|t| t.has_type::<web_sys::HtmlInputElement>())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let playing = status.with_untracked(|status| status.playing);
|
||||
set_playing(!playing);
|
||||
}
|
||||
});
|
||||
if e.key() == " " {
|
||||
e.prevent_default();
|
||||
log!("Space bar pressed, toggling play/pause");
|
||||
|
||||
status.update(|status| status.playing = !status.playing);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep a reference to the audio element so we can set its source and play/pause it
|
||||
let audio_ref = create_node_ref::<Audio>();
|
||||
let audio_ref = NodeRef::<Audio>::new();
|
||||
status.update(|status| status.audio_player = Some(audio_ref));
|
||||
|
||||
// Create signals for song time and progress
|
||||
let (elapsed_secs, set_elapsed_secs) = create_signal(0);
|
||||
let (total_secs, set_total_secs) = create_signal(0);
|
||||
let (percentage, set_percentage) = create_signal(0.0);
|
||||
|
||||
audio_ref.on_load(move |audio| {
|
||||
log!("Audio element loaded");
|
||||
|
||||
status.with_untracked(|status| {
|
||||
// Start playing the first song in the queue, if available
|
||||
if let Some(song) = status.queue.front() {
|
||||
log!("Starting playing with song: {}", song.title);
|
||||
|
||||
// Don't use the set_play_src / set_playing helper function
|
||||
// here because we already have access to the audio element
|
||||
audio.set_src(&song.song_path);
|
||||
|
||||
if let Err(e) = audio.play() {
|
||||
error!("Error playing audio on load: {:?}", e);
|
||||
} else {
|
||||
log!("Audio playing on load");
|
||||
Effect::new(move |_| {
|
||||
status.with(|status| {
|
||||
if let Some(audio) = status.get_audio() {
|
||||
if status.playing {
|
||||
if let Err(e) = audio.play() {
|
||||
log!("Unable to play audio: {:?}", e);
|
||||
}
|
||||
} else if let Err(e) = audio.pause() {
|
||||
log!("Unable to pause audio: {:?}", e);
|
||||
}
|
||||
} else {
|
||||
log!("Queue is empty, no first song to play");
|
||||
error!("Unable to play/pause audio: Audio element not available");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let current_song_id = create_memo(move |_| {
|
||||
status.with(|status| {
|
||||
status.queue.front().map(|song| song.id)
|
||||
})
|
||||
// Create signals for song time and progress
|
||||
let (elapsed_secs, set_elapsed_secs) = signal(0);
|
||||
let (total_secs, set_total_secs) = signal(0);
|
||||
let (percentage, set_percentage) = signal(0.0);
|
||||
|
||||
let current_song_id =
|
||||
Memo::new(move |_| status.with(|status| status.queue.front().map(|song| song.id)));
|
||||
|
||||
let current_song_src = Memo::new(move |_| {
|
||||
status.with(|status| status.queue.front().map(|song| song.song_path.clone()))
|
||||
});
|
||||
|
||||
Effect::new(move |_| {
|
||||
current_song_src.with(|src| {
|
||||
GlobalState::play_status().with_untracked(|status| {
|
||||
if let Some(audio) = status.get_audio() {
|
||||
if let Some(src) = src {
|
||||
audio.set_src(src);
|
||||
|
||||
if let Err(e) = audio.play() {
|
||||
error!("Error playing audio: {:?}", e);
|
||||
} else {
|
||||
log!("Audio playing");
|
||||
}
|
||||
} else {
|
||||
audio.set_src("");
|
||||
}
|
||||
} else {
|
||||
error!("Unable to set audio source: Audio element not available");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Track the last song that was added to the history to prevent duplicates
|
||||
let last_history_song_id = create_rw_signal(None);
|
||||
let last_history_song_id = RwSignal::new(None);
|
||||
|
||||
let Pausable {
|
||||
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;
|
||||
}
|
||||
} = 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));
|
||||
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);
|
||||
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();
|
||||
@ -611,7 +611,10 @@ pub fn PlayBar() -> impl IntoView {
|
||||
set_total_secs(audio.duration() as i64);
|
||||
|
||||
if elapsed_secs.get_untracked() > 0 {
|
||||
set_percentage(elapsed_secs.get_untracked() as f64 / total_secs.get_untracked() as f64 * 100.0);
|
||||
set_percentage(
|
||||
elapsed_secs.get_untracked() as f64 / total_secs.get_untracked() as f64
|
||||
* 100.0,
|
||||
);
|
||||
} else {
|
||||
set_percentage(0.0);
|
||||
}
|
||||
@ -641,40 +644,26 @@ pub fn PlayBar() -> impl IntoView {
|
||||
log!("Queue empty, no previous song to add to history");
|
||||
}
|
||||
});
|
||||
|
||||
// Get the next song to play, if available
|
||||
let next_src = status.with_untracked(|status| {
|
||||
status.queue.front().map(|song| song.song_path.clone())
|
||||
});
|
||||
|
||||
if let Some(audio) = audio_ref.get() {
|
||||
if let Some(next_src) = next_src {
|
||||
log!("Playing next song: {}", next_src);
|
||||
audio.set_src(&next_src);
|
||||
|
||||
if let Err(e) = audio.play() {
|
||||
error!("Error playing audio after song change: {:?}", e);
|
||||
} else {
|
||||
log!("Audio playing after song change");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Unable to play next song: Audio element not available");
|
||||
}
|
||||
};
|
||||
|
||||
view! {
|
||||
<audio _ref=audio_ref on:play=on_play on:pause=on_pause
|
||||
on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" />
|
||||
<div class="playbar">
|
||||
<ProgressBar percentage=percentage.into() />
|
||||
<div class="playbar-left-group">
|
||||
<MediaInfo />
|
||||
<LikeDislike />
|
||||
</div>
|
||||
<PlayControls />
|
||||
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
|
||||
<QueueToggle />
|
||||
<audio node_ref=audio_ref on:play=on_play on:pause=on_pause
|
||||
on:timeupdate=on_time_update on:ended=on_end />
|
||||
<div class="fixed bottom-0 w-full">
|
||||
<ProgressBar percentage=percentage.into() />
|
||||
<div class="flex items-center w-full bg-bg-light">
|
||||
<div class="flex-1 flex">
|
||||
<MediaInfo />
|
||||
<LikeDislike />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<PlayControls />
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-end">
|
||||
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
|
||||
<QueueToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
142
src/components/queue.rs
Normal file
142
src/components/queue.rs
Normal file
@ -0,0 +1,142 @@
|
||||
use crate::components::song::Song;
|
||||
use crate::models::backend::Artist;
|
||||
use crate::util::state::GlobalState;
|
||||
use leptos::ev::DragEvent;
|
||||
use leptos::ev::MouseEvent;
|
||||
use leptos::html::Div;
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos_icons::*;
|
||||
use leptos_use::on_click_outside_with_options;
|
||||
use leptos_use::OnClickOutsideOptions;
|
||||
|
||||
const RM_BTN_SIZE: &str = "2.5rem";
|
||||
|
||||
fn remove_song_fn(index: usize) {
|
||||
if index == 0 {
|
||||
log!("Error: Trying to remove currently playing song (index 0) from queue");
|
||||
} else {
|
||||
log!("Remove Song from Queue: Song is not currently playing, deleting song from queue and not adding to history");
|
||||
GlobalState::play_status().update(|status| {
|
||||
status.queue.remove(index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Queue() -> impl IntoView {
|
||||
let status = GlobalState::play_status();
|
||||
|
||||
let remove_song = move |index: usize| {
|
||||
remove_song_fn(index);
|
||||
log!("Removed song {}", index + 1);
|
||||
};
|
||||
|
||||
let prevent_focus = move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
};
|
||||
|
||||
let index_being_dragged = RwSignal::new(-1);
|
||||
|
||||
let index_being_hovered = RwSignal::new(-1);
|
||||
|
||||
let on_drag_start = move |_e: DragEvent, index: usize| {
|
||||
// set the index of the item being dragged
|
||||
index_being_dragged.set(index as i32);
|
||||
};
|
||||
|
||||
let on_drop = move |e: DragEvent| {
|
||||
e.prevent_default();
|
||||
// if the index of the item being dragged is not the same as the index of the item being hovered over
|
||||
if index_being_dragged.get() != index_being_hovered.get()
|
||||
&& index_being_dragged.get() > 0
|
||||
&& index_being_hovered.get() > 0
|
||||
{
|
||||
// get the index of the item being dragged
|
||||
let dragged_index = index_being_dragged.get_untracked() as usize;
|
||||
// get the index of the item being hovered over
|
||||
let hovered_index = index_being_hovered.get_untracked() as usize;
|
||||
// update the queue
|
||||
status.update(|status| {
|
||||
// remove the dragged item from the list
|
||||
let dragged_item = status.queue.remove(dragged_index);
|
||||
// insert the dragged item at the index of the item being hovered over
|
||||
status.queue.insert(hovered_index, dragged_item.unwrap());
|
||||
});
|
||||
// reset the index of the item being dragged
|
||||
index_being_dragged.set(-1);
|
||||
// reset the index of the item being hovered over
|
||||
index_being_hovered.set(-1);
|
||||
log!(
|
||||
"drag end. Moved item from index {} to index {}",
|
||||
dragged_index,
|
||||
hovered_index
|
||||
);
|
||||
} else {
|
||||
// reset the index of the item being dragged
|
||||
index_being_dragged.set(-1);
|
||||
// reset the index of the item being hovered over
|
||||
index_being_hovered.set(-1);
|
||||
}
|
||||
};
|
||||
|
||||
let on_drag_enter = move |_e: DragEvent, index: usize| {
|
||||
// set the index of the item being hovered over
|
||||
index_being_hovered.set(index as i32);
|
||||
};
|
||||
|
||||
let on_drag_over = move |e: DragEvent| {
|
||||
e.prevent_default();
|
||||
};
|
||||
|
||||
let queue = NodeRef::<Div>::new();
|
||||
let _ = on_click_outside_with_options(
|
||||
queue,
|
||||
move |_| {
|
||||
status.update(|status| {
|
||||
status.queue_open = false;
|
||||
});
|
||||
},
|
||||
OnClickOutsideOptions::default().ignore(["#queue-toggle-btn"]),
|
||||
);
|
||||
|
||||
view! {
|
||||
<Show
|
||||
when=move || status.with(|status| status.queue_open)
|
||||
fallback=|| view!{""}>
|
||||
<div class="queue" node_ref=queue>
|
||||
<div class="queue-header">
|
||||
<h2>Queue</h2>
|
||||
</div>
|
||||
<ul>
|
||||
{
|
||||
move || status.with(|status| status.queue.iter()
|
||||
.enumerate()
|
||||
.map(|(index, song)| view! {
|
||||
<div class="queue-item"
|
||||
draggable="true"
|
||||
on:dragstart=move |e: DragEvent| on_drag_start(e, index)
|
||||
on:drop=on_drop
|
||||
on:dragenter=move |e: DragEvent| on_drag_enter(e, index)
|
||||
on:dragover=on_drag_over
|
||||
>
|
||||
<Song song_image_path=song.image_path.clone() song_title=song.title.clone() song_artist=Artist::display_list(&song.artists) />
|
||||
<Show
|
||||
when=move || index != 0
|
||||
fallback=|| view!{
|
||||
<p>Playing</p>
|
||||
}>
|
||||
<button on:click=move |_| remove_song(index) on:mousedown=prevent_focus>
|
||||
<Icon width=RM_BTN_SIZE height=RM_BTN_SIZE icon={icondata::CgTrash} {..} class="remove-song" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
use leptos::*;
|
||||
|
||||
#[component]
|
||||
pub fn Search() -> impl IntoView {
|
||||
view! {
|
||||
<div class="search-container home-component">
|
||||
<h1>Searching...</h1>
|
||||
</div>
|
||||
}
|
||||
}
|
@ -1,74 +1,178 @@
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use crate::components::error::Error;
|
||||
use crate::components::loading::*;
|
||||
use crate::components::menu::*;
|
||||
use crate::util::state::GlobalState;
|
||||
use leptos::html::Div;
|
||||
use leptos::prelude::*;
|
||||
use leptos_icons::*;
|
||||
use crate::components::upload_dropdown::*;
|
||||
use leptos_router::components::{Form, A};
|
||||
use leptos_router::hooks::use_location;
|
||||
use leptos_use::{on_click_outside_with_options, OnClickOutsideOptions};
|
||||
use std::sync::Arc;
|
||||
use web_sys::Response;
|
||||
|
||||
#[component]
|
||||
pub fn Sidebar(upload_open: RwSignal<bool>, add_artist_open: RwSignal<bool>, add_album_open: RwSignal<bool>) -> impl IntoView {
|
||||
use leptos_router::use_location;
|
||||
pub fn Sidebar(
|
||||
upload_open: RwSignal<bool>,
|
||||
add_artist_open: RwSignal<bool>,
|
||||
add_album_open: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex flex-col w-[250px] min-w-[250px]">
|
||||
<Menu upload_open add_artist_open add_album_open />
|
||||
<Playlists />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn AddPlaylistDialog(open: RwSignal<bool>, node_ref: NodeRef<Div>) -> impl IntoView {
|
||||
let playlist_name = RwSignal::new("".to_string());
|
||||
let loading = RwSignal::new(false);
|
||||
let error_msg = RwSignal::new(None);
|
||||
|
||||
let handle_response = Arc::new(move |response: &Response| {
|
||||
loading.set(false);
|
||||
|
||||
if response.ok() {
|
||||
open.set(false);
|
||||
GlobalState::playlists().refetch();
|
||||
} else {
|
||||
error_msg.set(Some("Failed to create playlist".to_string()));
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<dialog class="fixed top-0 left-0 w-full h-full bg-black/50 flex items-center justify-center" class:open=open>
|
||||
<div node_ref=node_ref class="bg-neutral-800 rounded-lg p-4 w-1/3 text-white">
|
||||
<div class="flex items-center pb-3">
|
||||
<h1 class="text-2xl">"Create Playlist"</h1>
|
||||
<button id="add-playlist-dialog-btn" class="control ml-auto" on:click=move |_| open.set(false)>
|
||||
<Icon icon={icondata::IoClose} {..} class="w-7 h-7" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Form action="/api/playlists/create" on_response=handle_response.clone()
|
||||
method="POST" enctype="multipart/form-data".to_string()>
|
||||
<div class="grid grid-cols-[auto_1fr] gap-4">
|
||||
<label for="new-playlist-name">"Playlist Name"</label>
|
||||
<input id="new-playlist-name" name="name"
|
||||
class="bg-neutral-800 text-neutral-200 border border-neutral-600 rounded-lg p-2 outline-none"
|
||||
type="text" placeholder="My Playlist" bind:value=playlist_name required autocomplete="off" />
|
||||
|
||||
<label for="new-playlist-img">"Cover Image"</label>
|
||||
<input id="new-playlist-img" name="picture" type="file" accept="image/*" />
|
||||
</div>
|
||||
|
||||
{move || {
|
||||
error_msg.get().map(|error| {
|
||||
view! {
|
||||
<Error<String>
|
||||
message=error.clone()
|
||||
/>
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="control-solid" on:click=move |_| {
|
||||
error_msg.set(None);
|
||||
loading.set(true);
|
||||
}>
|
||||
"Create"
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</dialog>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Playlists() -> impl IntoView {
|
||||
let location = use_location();
|
||||
|
||||
let dropdown_open = create_rw_signal(false);
|
||||
let liked_songs_active = Signal::derive(move || location.pathname.get().ends_with("/liked"));
|
||||
|
||||
let on_dashboard = Signal::derive(
|
||||
move || location.pathname.get().starts_with("/dashboard") || location.pathname.get() == "/",
|
||||
);
|
||||
let add_playlist_open = RwSignal::new(false);
|
||||
|
||||
let on_search = Signal::derive(
|
||||
move || location.pathname.get().starts_with("/search"),
|
||||
let create_playlist = move |_| {
|
||||
leptos::logging::log!("Creating playlist");
|
||||
add_playlist_open.set(true);
|
||||
};
|
||||
|
||||
let add_playlist_dialog = NodeRef::<Div>::new();
|
||||
|
||||
let _dialog_close_handler = on_click_outside_with_options(
|
||||
add_playlist_dialog,
|
||||
move |_| add_playlist_open.set(false),
|
||||
OnClickOutsideOptions::default().ignore(["#add-playlist-dialog-btn"]),
|
||||
);
|
||||
|
||||
view! {
|
||||
<div class="sidebar-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>
|
||||
<div class="upload-dropdown-container">
|
||||
<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 {""}} >
|
||||
<Icon icon=icondata::OcHomeFillLg />
|
||||
<h1>Dashboard</h1>
|
||||
</a>
|
||||
<a class="buttons" href="/search" style={move || if on_search() {"color: #e1e3e1"} else {""}}>
|
||||
<Icon icon=icondata::BiSearchRegular />
|
||||
<h1>Search</h1>
|
||||
</a>
|
||||
</div>
|
||||
<Bottom />
|
||||
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Bottom() -> impl IntoView {
|
||||
view! {
|
||||
<div class="sidebar-bottom-container">
|
||||
<div class="heading">
|
||||
<h1 class="header">Playlists</h1>
|
||||
<button class="add-playlist">
|
||||
<div class="add-sign">
|
||||
<Icon icon=icondata::IoAddSharp />
|
||||
</div>
|
||||
New Playlist
|
||||
<div class="home-card">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="p-2 text-xl">"Playlists"</h1>
|
||||
<button class="control-solid ml-auto" on:click=create_playlist>
|
||||
<Icon icon={icondata::AiPlusOutlined} {..} class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<Transition
|
||||
fallback=move || view! { <Loading /> }
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback=|errors| {
|
||||
errors.get().into_iter().map(|(_id, error)| {
|
||||
view! {
|
||||
<Error<String>
|
||||
message=error.to_string()
|
||||
/>
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
>
|
||||
<A href={"/liked".to_string()} {..}
|
||||
style={move || if liked_songs_active() {"background-color: var(--color-neutral-700);"} else {""}}
|
||||
class="flex items-center hover:bg-neutral-700 rounded-md my-1"
|
||||
>
|
||||
<img class="w-15 h-15 rounded-xl p-2"
|
||||
src="/assets/images/placeholders/MusicPlaceholder.svg" />
|
||||
<h2 class="pr-3 my-2">"Liked Songs"</h2>
|
||||
</A>
|
||||
{move || GlobalState::playlists().get().map(|playlists| {
|
||||
playlists.map(|playlists| {
|
||||
|
||||
view! {
|
||||
{playlists.into_iter().map(|playlist| {
|
||||
let active = Signal::derive(move || {
|
||||
location.pathname.get().ends_with(&format!("/playlist/{}", playlist.id))
|
||||
});
|
||||
|
||||
view! {
|
||||
<A href={format!("/playlist/{}", playlist.id)} {..}
|
||||
style={move || if active() {"background-color: var(--color-neutral-700);"} else {""}}
|
||||
class="flex items-center hover:bg-neutral-700 rounded-md my-1" >
|
||||
<img class="w-15 h-15 rounded-xl p-2 object-cover"
|
||||
src={format!("/assets/images/playlist/{}.webp", playlist.id)}
|
||||
onerror={crate::util::img_fallback::MUSIC_IMG_FALLBACK} />
|
||||
<h2 class="pr-3 my-2">{playlist.name}</h2>
|
||||
</A>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
}
|
||||
})
|
||||
})}
|
||||
</ErrorBoundary>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when=add_playlist_open
|
||||
fallback=move || view! {}
|
||||
>
|
||||
<AddPlaylistDialog node_ref=add_playlist_dialog open=add_playlist_open />
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
14
src/components/song.rs
Normal file
14
src/components/song.rs
Normal file
@ -0,0 +1,14 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Song(song_image_path: String, song_title: String, song_artist: String) -> impl IntoView {
|
||||
view! {
|
||||
<div class="queue-song">
|
||||
<img src={song_image_path} alt={song_title.clone()} />
|
||||
<div class="queue-song-info">
|
||||
<h3>{song_title}</h3>
|
||||
<p>{song_artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
@ -1,270 +1,296 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use leptos::*;
|
||||
use leptos::either::*;
|
||||
use leptos::logging::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_icons::*;
|
||||
|
||||
use crate::api::songs::*;
|
||||
use crate::songdata::SongData;
|
||||
use crate::models::{Album, Artist};
|
||||
use crate::models::backend::{Album, Artist};
|
||||
use crate::models::frontend;
|
||||
use crate::util::state::GlobalState;
|
||||
|
||||
const LIKE_DISLIKE_BTN_SIZE: &str = "2em";
|
||||
|
||||
#[component]
|
||||
pub fn SongList(songs: Vec<SongData>) -> impl IntoView {
|
||||
__SongListInner(songs.into_iter().map(|song| (song, ())).collect::<Vec<_>>(), false)
|
||||
pub fn SongList(songs: Vec<frontend::Song>) -> impl IntoView {
|
||||
__SongListInner(
|
||||
songs.into_iter().map(|song| (song, ())).collect::<Vec<_>>(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SongListExtra<T>(songs: Vec<(SongData, T)>) -> impl IntoView where
|
||||
T: Clone + IntoView + 'static
|
||||
pub fn SongListExtra<T>(songs: Vec<(frontend::Song, T)>) -> impl IntoView
|
||||
where
|
||||
T: Clone + IntoView + 'static,
|
||||
{
|
||||
__SongListInner(songs, true)
|
||||
__SongListInner(songs, true)
|
||||
}
|
||||
|
||||
// TODO these arguments shouldn't need a leading underscore,
|
||||
// but for some reason the compiler thinks they are unused
|
||||
#[component]
|
||||
fn SongListInner<T>(_songs: Vec<(frontend::Song, T)>, _show_extra: bool) -> impl IntoView
|
||||
where
|
||||
T: Clone + IntoView + 'static,
|
||||
{
|
||||
let songs = Rc::new(_songs);
|
||||
let songs_2 = songs.clone();
|
||||
|
||||
// Signal that acts as a callback for a song list item to queue songs after it in the list
|
||||
let (handle_queue_remaining, do_queue_remaining) = signal(None);
|
||||
Effect::new(move |_| {
|
||||
let clicked_index = handle_queue_remaining.get();
|
||||
|
||||
if let Some(index) = clicked_index {
|
||||
GlobalState::play_status().update(|status| {
|
||||
let song: &(frontend::Song, T) =
|
||||
songs.get(index).expect("Invalid song list item index");
|
||||
|
||||
if status.queue.front().map(|song| song.id) == Some(song.0.id) {
|
||||
// If the clicked song is already at the front of the queue, just play it
|
||||
status.playing = true;
|
||||
} else {
|
||||
// Otherwise, add the currently playing song to the history,
|
||||
// clear the queue, and queue the clicked song and other after it
|
||||
if let Some(last_playing) = status.queue.pop_front() {
|
||||
status.history.push_back(last_playing);
|
||||
}
|
||||
|
||||
status.queue.clear();
|
||||
status
|
||||
.queue
|
||||
.extend(songs.iter().skip(index).map(|(song, _)| song.clone()));
|
||||
status.playing = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{
|
||||
songs_2.iter().enumerate().map(|(list_index, (song, extra))| {
|
||||
let song_id = song.id;
|
||||
let playing = RwSignal::new(false);
|
||||
|
||||
Effect::new(move |_| {
|
||||
GlobalState::play_status().with(|status| {
|
||||
playing.set(status.queue.front().map(|song| song.id) == Some(song_id) && status.playing);
|
||||
});
|
||||
});
|
||||
|
||||
view! {
|
||||
<SongListItem song={song.clone()} song_playing=playing.into()
|
||||
extra={if _show_extra { Some(extra.clone()) } else { None }} list_index do_queue_remaining/>
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SongListInner<T>(songs: Vec<(SongData, T)>, show_extra: bool) -> impl IntoView where
|
||||
T: Clone + IntoView + 'static
|
||||
pub fn SongListItem<T>(
|
||||
song: frontend::Song,
|
||||
song_playing: Signal<bool>,
|
||||
extra: Option<T>,
|
||||
list_index: usize,
|
||||
do_queue_remaining: WriteSignal<Option<usize>>,
|
||||
) -> impl IntoView
|
||||
where
|
||||
T: IntoView + 'static,
|
||||
{
|
||||
let songs = Rc::new(songs);
|
||||
let songs_2 = songs.clone();
|
||||
let liked = RwSignal::new(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false));
|
||||
let disliked = RwSignal::new(
|
||||
song.like_dislike
|
||||
.map(|(_, disliked)| disliked)
|
||||
.unwrap_or(false),
|
||||
);
|
||||
|
||||
// Signal that acts as a callback for a song list item to queue songs after it in the list
|
||||
let (handle_queue_remaining, do_queue_remaining) = create_signal(None);
|
||||
create_effect(move |_| {
|
||||
let clicked_index = handle_queue_remaining.get();
|
||||
|
||||
if let Some(index) = clicked_index {
|
||||
GlobalState::play_status().update(|status| {
|
||||
let song: &(SongData, T) = songs.get(index).expect("Invalid song list item index");
|
||||
|
||||
if status.queue.front().map(|song| song.id) == Some(song.0.id) {
|
||||
// If the clicked song is already at the front of the queue, just play it
|
||||
status.playing = true;
|
||||
} else {
|
||||
// Otherwise, add the currently playing song to the history,
|
||||
// clear the queue, and queue the clicked song and other after it
|
||||
if let Some(last_playing) = status.queue.pop_front() {
|
||||
status.history.push_back(last_playing);
|
||||
}
|
||||
|
||||
status.queue.clear();
|
||||
status.queue.extend(songs.iter().skip(index).map(|(song, _)| song.clone()));
|
||||
status.playing = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<table class="song-list">
|
||||
{
|
||||
songs_2.iter().enumerate().map(|(list_index, (song, extra))| {
|
||||
let song_id = song.id;
|
||||
let playing = create_rw_signal(false);
|
||||
|
||||
create_effect(move |_| {
|
||||
GlobalState::play_status().with(|status| {
|
||||
playing.set(status.queue.front().map(|song| song.id) == Some(song_id) && status.playing);
|
||||
});
|
||||
});
|
||||
|
||||
view! {
|
||||
<SongListItem song={song.clone()} song_playing=playing.into()
|
||||
extra={if show_extra { Some(extra.clone()) } else { None }} list_index do_queue_remaining/>
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: Option<T>,
|
||||
list_index: usize, do_queue_remaining: WriteSignal<Option<usize>>) -> impl IntoView where
|
||||
T: IntoView + 'static
|
||||
{
|
||||
let liked = create_rw_signal(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false));
|
||||
let disliked = create_rw_signal(song.like_dislike.map(|(_, disliked)| disliked).unwrap_or(false));
|
||||
|
||||
view! {
|
||||
<tr class="song-list-item">
|
||||
<td class="song-image"><SongImage image_path=song.image_path song_playing
|
||||
list_index do_queue_remaining /></td>
|
||||
<td class="song-title"><p>{song.title}</p></td>
|
||||
<td class="song-list-spacer"></td>
|
||||
<td class="song-artists"><SongArtists artists=song.artists /></td>
|
||||
<td class="song-list-spacer"></td>
|
||||
<td class="song-album"><SongAlbum album=song.album /></td>
|
||||
<td class="song-list-spacer-big"></td>
|
||||
<td class="song-like-dislike"><SongLikeDislike song_id=song.id liked disliked/></td>
|
||||
<td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td>
|
||||
{extra.map(|extra| view! {
|
||||
<td class="song-list-spacer"></td>
|
||||
<td>{extra}</td>
|
||||
})}
|
||||
</tr>
|
||||
}
|
||||
view! {
|
||||
<tr class="group border-b border-t border-neutral-600 last-of-type:border-b-0
|
||||
first-of-type:border-t-0 hover:bg-neutral-700 [&>*]:px-2">
|
||||
<td class="relative w-13 h-13"><SongImage image_path=song.image_path song_playing
|
||||
list_index do_queue_remaining /></td>
|
||||
<td><p>{song.title}</p></td>
|
||||
<td></td>
|
||||
<td><SongArtists artists=song.artists /></td>
|
||||
<td></td>
|
||||
<td><SongAlbum album=song.album /></td>
|
||||
<td></td>
|
||||
<td><SongLikeDislike song_id=song.id liked disliked/></td>
|
||||
<td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td>
|
||||
{extra.map(|extra| view! {
|
||||
<td></td>
|
||||
<td>{extra}</td>
|
||||
})}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
/// Display the song's image, with an overlay if the song is playing
|
||||
/// When the song list item is hovered, the overlay will show the play button
|
||||
#[component]
|
||||
pub fn SongImage(image_path: String, song_playing: MaybeSignal<bool>, list_index: usize,
|
||||
do_queue_remaining: WriteSignal<Option<usize>>) -> impl IntoView
|
||||
{
|
||||
let play_song = move |_| {
|
||||
do_queue_remaining.set(Some(list_index));
|
||||
};
|
||||
pub fn SongImage(
|
||||
image_path: String,
|
||||
song_playing: Signal<bool>,
|
||||
list_index: usize,
|
||||
do_queue_remaining: WriteSignal<Option<usize>>,
|
||||
) -> impl IntoView {
|
||||
let play_song = move |_| {
|
||||
do_queue_remaining.set(Some(list_index));
|
||||
};
|
||||
|
||||
let pause_song = move |_| {
|
||||
GlobalState::play_status().update(|status| {
|
||||
status.playing = false;
|
||||
});
|
||||
};
|
||||
let pause_song = move |_| {
|
||||
GlobalState::play_status().update(|status| {
|
||||
status.playing = false;
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<img class="song-image" src={image_path}/>
|
||||
{move || if song_playing.get() {
|
||||
view! { <Icon class="song-image-overlay song-playing-overlay"
|
||||
icon=icondata::BsPauseFill on:click=pause_song /> }.into_view()
|
||||
} else {
|
||||
view! { <Icon class="song-image-overlay hide-until-hover"
|
||||
icon=icondata::BsPlayFill on:click=play_song /> }.into_view()
|
||||
}}
|
||||
}
|
||||
view! {
|
||||
<img class="group-hover:brightness-45" src={image_path}/>
|
||||
{move || if song_playing.get() {
|
||||
Either::Left(view! { <Icon icon={icondata::BsPauseFill} on:click={pause_song}
|
||||
{..} class="w-6 h-6 absolute top-1/2 left-1/2 translate-[-50%]" /> })
|
||||
} else {
|
||||
Either::Right(view! { <Icon icon={icondata::BsPlayFill} on:click={play_song}
|
||||
{..} class="w-6 h-6 opacity-0 group-hover:opacity-100 absolute top-1/2 left-1/2 translate-[-50%]" /> })
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays a song's artists, with links to their artist pages
|
||||
#[component]
|
||||
pub fn SongArtists(artists: Vec<Artist>) -> impl IntoView {
|
||||
let num_artists = artists.len() as isize;
|
||||
let num_artists = artists.len() as isize;
|
||||
|
||||
artists.iter().enumerate().map(|(i, artist)| {
|
||||
let i = i as isize;
|
||||
|
||||
view! {
|
||||
{
|
||||
if let Some(id) = artist.id {
|
||||
view! { <a href={format!("/artist/{}", id)}>{artist.name.clone()}</a> }.into_view()
|
||||
} else {
|
||||
view! { <span>{artist.name.clone()}</span> }.into_view()
|
||||
}
|
||||
}
|
||||
{if i < num_artists - 2 { ", " } else if i == num_artists - 2 { " & " } else { "" }}
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
artists
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, artist)| {
|
||||
let i = i as isize;
|
||||
|
||||
view! {
|
||||
<a class="hover:underline active:text-controls-active"
|
||||
href={format!("/artist/{}", artist.id)}>{artist.name.clone()}</a>
|
||||
{
|
||||
use std::cmp::Ordering;
|
||||
|
||||
match i.cmp(&(num_artists - 2)) {
|
||||
Ordering::Less => ", ",
|
||||
Ordering::Equal => " & ",
|
||||
Ordering::Greater => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
/// Display a song's album, with a link to the album page
|
||||
#[component]
|
||||
pub fn SongAlbum(album: Option<Album>) -> impl IntoView {
|
||||
album.as_ref().map(|album| {
|
||||
view! {
|
||||
<span>
|
||||
{
|
||||
if let Some(id) = album.id {
|
||||
view! { <a href={format!("/album/{}", id)}>{album.title.clone()}</a> }.into_view()
|
||||
} else {
|
||||
view! { <span>{album.title.clone()}</span> }.into_view()
|
||||
}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
})
|
||||
album.as_ref().map(|album| {
|
||||
view! {
|
||||
<span>
|
||||
<a class="hover:underline active:text-controls-active"
|
||||
href={format!("/album/{}", album.id)}>{album.title.clone()}</a>
|
||||
</span>
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Display like and dislike buttons for a song, and indicate if the song is liked or disliked
|
||||
#[component]
|
||||
pub fn SongLikeDislike(
|
||||
#[prop(into)]
|
||||
song_id: MaybeSignal<i32>,
|
||||
liked: RwSignal<bool>,
|
||||
disliked: RwSignal<bool>) -> impl IntoView
|
||||
{
|
||||
let like_icon = Signal::derive(move || {
|
||||
if liked.get() {
|
||||
icondata::TbThumbUpFilled
|
||||
} else {
|
||||
icondata::TbThumbUp
|
||||
}
|
||||
});
|
||||
#[prop(into)] song_id: Signal<i32>,
|
||||
liked: RwSignal<bool>,
|
||||
disliked: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
let like_icon = Signal::derive(move || {
|
||||
if liked.get() {
|
||||
icondata::TbThumbUpFilled
|
||||
} else {
|
||||
icondata::TbThumbUp
|
||||
}
|
||||
});
|
||||
|
||||
let dislike_icon = Signal::derive(move || {
|
||||
if disliked.get() {
|
||||
icondata::TbThumbDownFilled
|
||||
} else {
|
||||
icondata::TbThumbDown
|
||||
}
|
||||
});
|
||||
let dislike_icon = Signal::derive(move || {
|
||||
if disliked.get() {
|
||||
icondata::TbThumbDownFilled
|
||||
} else {
|
||||
icondata::TbThumbDown
|
||||
}
|
||||
});
|
||||
|
||||
let like_class = MaybeProp::derive(move || {
|
||||
if liked.get() {
|
||||
Some(TextProp::from("controlbtn"))
|
||||
} else {
|
||||
Some(TextProp::from("controlbtn hide-until-hover"))
|
||||
}
|
||||
});
|
||||
let like_class = Signal::derive(move || {
|
||||
if liked.get() {
|
||||
""
|
||||
} else {
|
||||
"opacity-0 group-hover:opacity-100"
|
||||
}
|
||||
});
|
||||
|
||||
let dislike_class = MaybeProp::derive(move || {
|
||||
if disliked.get() {
|
||||
Some(TextProp::from("controlbtn hmirror"))
|
||||
} else {
|
||||
Some(TextProp::from("controlbtn hmirror hide-until-hover"))
|
||||
}
|
||||
});
|
||||
let dislike_class = Signal::derive(move || {
|
||||
if disliked.get() {
|
||||
""
|
||||
} else {
|
||||
"opacity-0 group-hover:opacity-100"
|
||||
}
|
||||
});
|
||||
|
||||
// If an error occurs, check the like/dislike status again to ensure consistency
|
||||
let check_like_dislike = move || {
|
||||
spawn_local(async move {
|
||||
match get_like_dislike_song(song_id.get_untracked()).await {
|
||||
Ok((like, dislike)) => {
|
||||
liked.set(like);
|
||||
disliked.set(dislike);
|
||||
},
|
||||
Err(_) => {}
|
||||
}
|
||||
});
|
||||
};
|
||||
// If an error occurs, check the like/dislike status again to ensure consistency
|
||||
let check_like_dislike = move || {
|
||||
spawn_local(async move {
|
||||
if let Ok((like, dislike)) = get_like_dislike_song(song_id.get_untracked()).await {
|
||||
liked.set(like);
|
||||
disliked.set(dislike);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let toggle_like = move |_| {
|
||||
let new_liked = !liked.get_untracked();
|
||||
liked.set(new_liked);
|
||||
disliked.set(disliked.get_untracked() && !liked.get_untracked());
|
||||
|
||||
spawn_local(async move {
|
||||
match set_like_song(song_id.get_untracked(), new_liked).await {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
error!("Error setting like: {}", e);
|
||||
check_like_dislike();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
let toggle_like = move |_| {
|
||||
let new_liked = !liked.get_untracked();
|
||||
liked.set(new_liked);
|
||||
disliked.set(disliked.get_untracked() && !liked.get_untracked());
|
||||
|
||||
let toggle_dislike = move |_| {
|
||||
disliked.set(!disliked.get_untracked());
|
||||
liked.set(liked.get_untracked() && !disliked.get_untracked());
|
||||
spawn_local(async move {
|
||||
match set_like_song(song_id.get_untracked(), new_liked).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("Error setting like: {}", e);
|
||||
check_like_dislike();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
spawn_local(async move {
|
||||
match set_dislike_song(song_id.get_untracked(), disliked.get_untracked()).await {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
error!("Error setting dislike: {}", e);
|
||||
check_like_dislike();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
let toggle_dislike = move |_| {
|
||||
disliked.set(!disliked.get_untracked());
|
||||
liked.set(liked.get_untracked() && !disliked.get_untracked());
|
||||
|
||||
view! {
|
||||
<button on:click=toggle_dislike>
|
||||
<Icon class=dislike_class width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon=dislike_icon />
|
||||
</button>
|
||||
<button on:click=toggle_like>
|
||||
<Icon class=like_class width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon=like_icon />
|
||||
</button>
|
||||
}
|
||||
spawn_local(async move {
|
||||
match set_dislike_song(song_id.get_untracked(), disliked.get_untracked()).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
error!("Error setting dislike: {}", e);
|
||||
check_like_dislike();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<button class="control scale-x-[-1]" on:click=toggle_dislike>
|
||||
<Icon width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon={dislike_icon} {..} class=dislike_class />
|
||||
</button>
|
||||
<button class="control" on:click=toggle_like>
|
||||
<Icon width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon={like_icon} {..} class=like_class />
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
@ -1,242 +1,263 @@
|
||||
use std::rc::Rc;
|
||||
use crate::api::search::search_albums;
|
||||
use crate::api::search::search_artists;
|
||||
use crate::models::backend::{Album, Artist};
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_icons::*;
|
||||
use leptos_router::Form;
|
||||
use leptos_router::components::Form;
|
||||
use std::sync::Arc;
|
||||
use web_sys::Response;
|
||||
use crate::search::search_artists;
|
||||
use crate::search::search_albums;
|
||||
use crate::models::Artist;
|
||||
use crate::models::Album;
|
||||
|
||||
#[component]
|
||||
pub fn UploadBtn(dialog_open: RwSignal<bool>) -> impl IntoView {
|
||||
let open_dialog = move |_| {
|
||||
dialog_open.set(true);
|
||||
};
|
||||
let open_dialog = move |_| {
|
||||
dialog_open.set(true);
|
||||
};
|
||||
|
||||
view! {
|
||||
<button class="upload-btn add-btns" on:click=open_dialog>
|
||||
Upload Song
|
||||
</button>
|
||||
}
|
||||
view! {
|
||||
<button class="upload-btn add-btns" on:click=open_dialog>
|
||||
Upload Song
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Upload(open: RwSignal<bool>) -> impl IntoView {
|
||||
// Create signals for the artist input and the filtered artists
|
||||
let (artists, set_artists) = create_signal("".to_string());
|
||||
let (filtered_artists, set_filtered_artists) = create_signal(vec![]);
|
||||
// Create signals for the artist input and the filtered artists
|
||||
let (artists, set_artists) = signal("".to_string());
|
||||
let (filtered_artists, set_filtered_artists) = signal(vec![]);
|
||||
|
||||
let (albums, set_albums) = create_signal("".to_string());
|
||||
let (filtered_albums, set_filtered_albums) = create_signal(vec![]);
|
||||
let (albums, set_albums) = signal("".to_string());
|
||||
let (filtered_albums, set_filtered_albums) = signal(vec![]);
|
||||
|
||||
let (error_msg, set_error_msg) = create_signal::<Option<String>>(None);
|
||||
let (error_msg, set_error_msg) = signal::<Option<String>>(None);
|
||||
|
||||
let close_dialog = move |ev: leptos::ev::MouseEvent| {
|
||||
ev.prevent_default();
|
||||
open.set(false);
|
||||
};
|
||||
// Create a filter function to handle filtering artists
|
||||
// Allow users to search for artists by name, converts the artist name to artist id to be handed off to backend
|
||||
let handle_filter_artists = move |ev: leptos::ev::Event| {
|
||||
ev.prevent_default();
|
||||
let close_dialog = move |ev: leptos::ev::MouseEvent| {
|
||||
ev.prevent_default();
|
||||
open.set(false);
|
||||
};
|
||||
// Create a filter function to handle filtering artists
|
||||
// Allow users to search for artists by name, converts the artist name to artist id to be handed off to backend
|
||||
let handle_filter_artists = move |ev: leptos::ev::Event| {
|
||||
ev.prevent_default();
|
||||
|
||||
let artist_input: String = event_target_value(&ev);
|
||||
let artist_input: String = event_target_value(&ev);
|
||||
|
||||
//Get the artist that we are currently searching for
|
||||
let mut all_artists: Vec<&str> = artist_input.split(",").collect();
|
||||
let search = all_artists.pop().unwrap().to_string();
|
||||
|
||||
//Update the artist signal with the input
|
||||
set_artists.update(|value: &mut String| *value = artist_input);
|
||||
//Get the artist that we are currently searching for
|
||||
let mut all_artists: Vec<&str> = artist_input.split(",").collect();
|
||||
let search = all_artists.pop().unwrap().to_string();
|
||||
|
||||
spawn_local(async move {
|
||||
let filter_results = search_artists(search, 3).await;
|
||||
if let Err(err) = filter_results {
|
||||
log!("Error filtering artists: {:?}", err);
|
||||
} else if let Ok(artists) = filter_results {
|
||||
log!("Filtered artists: {:?}", artists);
|
||||
//Update the artist signal with the input
|
||||
set_artists.update(|value: &mut String| *value = artist_input);
|
||||
|
||||
set_filtered_artists.update(|value| *value = artists);
|
||||
}
|
||||
})
|
||||
};
|
||||
// Create a filter function to handle filtering albums
|
||||
// Allow users to search for albums by title, converts the album title to album id to be handed off to backend
|
||||
let handle_filter_albums = move |ev: leptos::ev::Event| {
|
||||
ev.prevent_default();
|
||||
spawn_local(async move {
|
||||
let filter_results = search_artists(search, 3).await;
|
||||
let filter_results = filter_results.map(|artists| {
|
||||
artists
|
||||
.into_iter()
|
||||
.map(|(artist, _score)| Artist {
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
image_path: Some(artist.image_path),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let album_input: String = event_target_value(&ev);
|
||||
|
||||
//Update the album signal with the input
|
||||
set_albums.update(|value: &mut String| *value = album_input);
|
||||
if let Err(err) = filter_results {
|
||||
log!("Error filtering artists: {:?}", err);
|
||||
} else if let Ok(artists) = filter_results {
|
||||
log!("Filtered artists: {:?}", artists);
|
||||
|
||||
spawn_local(async move {
|
||||
let filter_results = search_albums(albums.get_untracked(), 3).await;
|
||||
if let Err(err) = filter_results {
|
||||
log!("Error filtering albums: {:?}", err);
|
||||
} else if let Ok(albums) = filter_results {
|
||||
log!("Filtered albums: {:?}", albums);
|
||||
set_filtered_albums.update(|value| *value = albums);
|
||||
}
|
||||
})
|
||||
};
|
||||
set_filtered_artists.update(|value| *value = artists);
|
||||
}
|
||||
});
|
||||
};
|
||||
// Create a filter function to handle filtering albums
|
||||
// Allow users to search for albums by title, converts the album title to album id to be handed off to backend
|
||||
let handle_filter_albums = move |ev: leptos::ev::Event| {
|
||||
ev.prevent_default();
|
||||
|
||||
let handle_response = Rc::new(move |response: &Response| {
|
||||
if response.ok() {
|
||||
set_error_msg.update(|value| *value = None);
|
||||
set_filtered_artists.update(|value| *value = vec![]);
|
||||
set_filtered_albums.update(|value| *value = vec![]);
|
||||
set_artists.update(|value| *value = "".to_string());
|
||||
set_albums.update(|value| *value = "".to_string());
|
||||
open.set(false);
|
||||
} else {
|
||||
// TODO: Extract error message from response
|
||||
set_error_msg.update(|value| *value = Some("Error uploading song".to_string()));
|
||||
}
|
||||
});
|
||||
let album_input: String = event_target_value(&ev);
|
||||
|
||||
view! {
|
||||
<Show when=open fallback=move || view! {}>
|
||||
<div class="upload-container" open=open>
|
||||
<div class="close-button" on:click=close_dialog><Icon icon=icondata::IoClose /></div>
|
||||
<div class="upload-header">
|
||||
<h1>Upload Song</h1>
|
||||
</div>
|
||||
<Form action="/api/upload" method="POST" enctype=String::from("multipart/form-data")
|
||||
class="upload-form" on_response=handle_response.clone()>
|
||||
<div class="input-bx">
|
||||
<input type="text" name="title" required class="text-input" required/>
|
||||
<span>Title</span>
|
||||
</div>
|
||||
<div class="artists has-search">
|
||||
<div class="input-bx">
|
||||
<input type="text" name="artist_ids" class="text-input" prop:value=artists on:input=handle_filter_artists/>
|
||||
<span>Artists</span>
|
||||
</div>
|
||||
<Show
|
||||
when=move || {filtered_artists.get().len() > 0}
|
||||
fallback=move || view! {}
|
||||
>
|
||||
<ul class="artist_results search-results">
|
||||
{
|
||||
move || filtered_artists.get().iter().enumerate().map(|(_index,filtered_artist)| view! {
|
||||
<Artist artist=filtered_artist.clone() artists=artists set_artists=set_artists set_filtered=set_filtered_artists/>
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="albums has-search">
|
||||
<div class="input-bx">
|
||||
<input type="text" name="album_id" class="text-input" prop:value=albums on:input=handle_filter_albums/>
|
||||
<span>Album ID</span>
|
||||
</div>
|
||||
<Show
|
||||
when=move || {filtered_albums.get().len() > 0}
|
||||
fallback=move || view! {}
|
||||
>
|
||||
<ul class="album_results search-results">
|
||||
{
|
||||
move || filtered_albums.get().iter().enumerate().map(|(_index,filtered_album)| view! {
|
||||
<Album album=filtered_album.clone() _albums=albums set_albums=set_albums set_filtered=set_filtered_albums/>
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="input-bx">
|
||||
<input type="number" name="track_number" class="text-input"/>
|
||||
<span>Track Number</span>
|
||||
</div>
|
||||
<div class="release-date">
|
||||
<div class="left">
|
||||
<span>Release</span>
|
||||
<span>Date</span>
|
||||
</div>
|
||||
<input class="info" type="date" name="release_date"/>
|
||||
</div>
|
||||
<div class="file">
|
||||
<span>File</span>
|
||||
<input class="info" type="file" accept=".mp3" name="file" required/>
|
||||
</div>
|
||||
<button type="submit" class="upload-button">Upload</button>
|
||||
</Form>
|
||||
<Show
|
||||
when=move || {error_msg.get().is_some()}
|
||||
fallback=move || view! {}
|
||||
>
|
||||
<div class="error-msg">
|
||||
<Icon icon=icondata::IoAlertCircleSharp />
|
||||
{error_msg.get().as_ref().unwrap()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
//Update the album signal with the input
|
||||
set_albums.update(|value: &mut String| *value = album_input);
|
||||
|
||||
spawn_local(async move {
|
||||
let filter_results = search_albums(albums.get_untracked(), 3).await;
|
||||
let filter_results = filter_results.map(|albums| {
|
||||
albums
|
||||
.into_iter()
|
||||
.map(|(album, _score)| Album {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
release_date: album.release_date,
|
||||
image_path: Some(album.image_path),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
if let Err(err) = filter_results {
|
||||
log!("Error filtering albums: {:?}", err);
|
||||
} else if let Ok(albums) = filter_results {
|
||||
log!("Filtered albums: {:?}", albums);
|
||||
set_filtered_albums.update(|value| *value = albums);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let handle_response = Arc::new(move |response: &Response| {
|
||||
if response.ok() {
|
||||
set_error_msg.update(|value| *value = None);
|
||||
set_filtered_artists.update(|value| *value = vec![]);
|
||||
set_filtered_albums.update(|value| *value = vec![]);
|
||||
set_artists.update(|value| *value = "".to_string());
|
||||
set_albums.update(|value| *value = "".to_string());
|
||||
open.set(false);
|
||||
} else {
|
||||
// TODO: Extract error message from response
|
||||
set_error_msg.update(|value| *value = Some("Error uploading song".to_string()));
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<Show when=open fallback=move || view! {}>
|
||||
<dialog class="upload-container" open=open>
|
||||
<div class="close-button" on:click=close_dialog><Icon icon={icondata::IoClose} /></div>
|
||||
<div class="upload-header">
|
||||
<h1>Upload Song</h1>
|
||||
</div>
|
||||
<Form action="/api/upload" method="POST" enctype=String::from("multipart/form-data")
|
||||
on_response=handle_response.clone() {..} class="upload-form" >
|
||||
<div class="input-bx">
|
||||
<input type="text" name="title" required class="text-input" required/>
|
||||
<span>Title</span>
|
||||
</div>
|
||||
<div class="artists has-search">
|
||||
<div class="input-bx">
|
||||
<input type="text" name="artist_ids" class="text-input" prop:value=artists on:input=handle_filter_artists/>
|
||||
<span>Artists</span>
|
||||
</div>
|
||||
<Show
|
||||
when=move || {!filtered_artists.get().is_empty()}
|
||||
fallback=move || view! {}
|
||||
>
|
||||
<ul class="artist_results search-results">
|
||||
{
|
||||
move || filtered_artists.get().iter().map(|filtered_artist| view! {
|
||||
<Artist artist=filtered_artist.clone() artists=artists set_artists=set_artists set_filtered=set_filtered_artists/>
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="albums has-search">
|
||||
<div class="input-bx">
|
||||
<input type="text" name="album_id" class="text-input" prop:value=albums on:input=handle_filter_albums/>
|
||||
<span>Album ID</span>
|
||||
</div>
|
||||
<Show
|
||||
when=move || {!filtered_albums.get().is_empty()}
|
||||
fallback=move || view! {}
|
||||
>
|
||||
<ul class="album_results search-results">
|
||||
{
|
||||
move || filtered_albums.get().iter().map(|filtered_album| view! {
|
||||
<Album album=filtered_album.clone() _albums=albums set_albums=set_albums set_filtered=set_filtered_albums/>
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="input-bx">
|
||||
<input type="number" name="track_number" class="text-input"/>
|
||||
<span>Track Number</span>
|
||||
</div>
|
||||
<div class="release-date">
|
||||
<div class="left">
|
||||
<span>Release</span>
|
||||
<span>Date</span>
|
||||
</div>
|
||||
<input class="info" type="date" name="release_date"/>
|
||||
</div>
|
||||
<div class="file">
|
||||
<span>File</span>
|
||||
<input class="info" type="file" accept=".mp3" name="file" required/>
|
||||
</div>
|
||||
<button type="submit" class="upload-button">Upload</button>
|
||||
</Form>
|
||||
<Show
|
||||
when=move || {error_msg.get().is_some()}
|
||||
fallback=move || view! {}
|
||||
>
|
||||
<div class="error-msg">
|
||||
<Icon icon={icondata::IoAlertCircleSharp} />
|
||||
{error_msg.get().unwrap()}
|
||||
</div>
|
||||
</Show>
|
||||
</dialog>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Artist(artist: Artist, artists: ReadSignal<String>, set_artists: WriteSignal<String>, set_filtered: WriteSignal<Vec<Artist>>) -> impl IntoView {
|
||||
// Converts artist name to artist id and adds it to the artist input
|
||||
let add_artist = move |_| {
|
||||
//Create an empty string to hold previous artist ids
|
||||
let mut s: String = String::from("");
|
||||
//Get the current artist input
|
||||
let all_artirts: String = artists.get();
|
||||
//Split the input into a vector of artists separated by commas
|
||||
let mut ids: Vec<&str> = all_artirts.split(",").collect();
|
||||
//If there is only one artist in the input, get their id equivalent and add it to the string
|
||||
if ids.len() == 1 {
|
||||
let value_str = match artist.id.clone() {
|
||||
Some(v) => v.to_string(),
|
||||
None => String::from("None"),
|
||||
};
|
||||
s.push_str(&value_str);
|
||||
s.push_str(",");
|
||||
set_artists.update(|value| *value = s);
|
||||
//If there are multiple artists in the input, pop the last artist by string off the vector,
|
||||
//get their id equivalent, and add it to the string
|
||||
} else {
|
||||
ids.pop();
|
||||
for id in ids {
|
||||
s.push_str(id);
|
||||
s.push_str(",");
|
||||
}
|
||||
let value_str = match artist.id.clone() {
|
||||
Some(v) => v.to_string(),
|
||||
None => String::from("None"),
|
||||
};
|
||||
s.push_str(&value_str);
|
||||
s.push_str(",");
|
||||
set_artists.update(|value| *value = s);
|
||||
}
|
||||
//Clear the search results
|
||||
set_filtered.update(|value| *value = vec![]);
|
||||
};
|
||||
pub fn Artist(
|
||||
artist: Artist,
|
||||
artists: ReadSignal<String>,
|
||||
set_artists: WriteSignal<String>,
|
||||
set_filtered: WriteSignal<Vec<Artist>>,
|
||||
) -> impl IntoView {
|
||||
// Converts artist name to artist id and adds it to the artist input
|
||||
let add_artist = move |_| {
|
||||
//Create an empty string to hold previous artist ids
|
||||
let mut s: String = String::from("");
|
||||
//Get the current artist input
|
||||
let all_artirts: String = artists.get();
|
||||
//Split the input into a vector of artists separated by commas
|
||||
let mut ids: Vec<&str> = all_artirts.split(",").collect();
|
||||
//If there is only one artist in the input, get their id equivalent and add it to the string
|
||||
if ids.len() == 1 {
|
||||
s.push_str(&artist.id.to_string());
|
||||
s.push(',');
|
||||
set_artists.update(|value| *value = s);
|
||||
//If there are multiple artists in the input, pop the last artist by string off the vector,
|
||||
//get their id equivalent, and add it to the string
|
||||
} else {
|
||||
ids.pop();
|
||||
for id in ids {
|
||||
s.push_str(id);
|
||||
s.push(',');
|
||||
}
|
||||
s.push_str(&artist.id.to_string());
|
||||
s.push(',');
|
||||
set_artists.update(|value| *value = s);
|
||||
}
|
||||
//Clear the search results
|
||||
set_filtered.update(|value| *value = vec![]);
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="artist result" on:click=add_artist>
|
||||
{artist.name.clone()}
|
||||
</div>
|
||||
}
|
||||
view! {
|
||||
<div class="artist result" on:click=add_artist>
|
||||
{artist.name.clone()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
#[component]
|
||||
pub fn Album(album: Album, _albums: ReadSignal<String>, set_albums: WriteSignal<String>, set_filtered: WriteSignal<Vec<Album>>) -> impl IntoView {
|
||||
//Converts album title to album id to upload a song
|
||||
let add_album = move |_| {
|
||||
let value_str = match album.id.clone() {
|
||||
Some(v) => v.to_string(),
|
||||
None => String::from("None"),
|
||||
};
|
||||
set_albums.update(|value| *value = value_str);
|
||||
set_filtered.update(|value| *value = vec![]);
|
||||
};
|
||||
view! {
|
||||
<div class="album result" on:click=add_album>
|
||||
{album.title.clone()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
pub fn Album(
|
||||
album: Album,
|
||||
_albums: ReadSignal<String>,
|
||||
set_albums: WriteSignal<String>,
|
||||
set_filtered: WriteSignal<Vec<Album>>,
|
||||
) -> impl IntoView {
|
||||
//Converts album title to album id to upload a song
|
||||
let add_album = move |_| {
|
||||
set_albums.update(|value| *value = album.id.to_string());
|
||||
set_filtered.update(|value| *value = vec![]);
|
||||
};
|
||||
view! {
|
||||
<div class="album result" on:click=add_album>
|
||||
{album.title.clone()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
use leptos::*;
|
||||
use leptos_icons::*;
|
||||
use crate::components::upload::*;
|
||||
use crate::components::add_artist::*;
|
||||
use crate::components::add_album::*;
|
||||
use crate::components::add_artist::*;
|
||||
use crate::components::upload::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos_icons::*;
|
||||
|
||||
#[component]
|
||||
pub fn UploadDropdownBtn(dropdown_open: RwSignal<bool>) -> impl IntoView {
|
||||
@ -11,15 +11,20 @@ pub fn UploadDropdownBtn(dropdown_open: RwSignal<bool>) -> impl IntoView {
|
||||
};
|
||||
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>
|
||||
<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 {
|
||||
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 />
|
||||
@ -27,4 +32,4 @@ pub fn UploadDropdown(dropdown_open: RwSignal<bool>, upload_open: RwSignal<bool>
|
||||
<AddAlbumBtn add_album_open=add_album_open/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,70 +0,0 @@
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! { if #[cfg(feature = "ssr")] {
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
response::IntoResponse,
|
||||
http::{Request, Response, StatusCode, Uri},
|
||||
};
|
||||
use axum::response::Response as AxumResponse;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeDir;
|
||||
use leptos::*;
|
||||
use crate::app::App;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse {
|
||||
let root = options.site_root.clone();
|
||||
let res = get_static_file(uri.clone(), &root).await.unwrap();
|
||||
|
||||
if res.status() == StatusCode::OK {
|
||||
res.into_response()
|
||||
} else {
|
||||
let handler = leptos_axum::render_app_to_stream(options.to_owned(), App);
|
||||
handler(req).await.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_static_file(uri: Uri, root: &str) -> Result<Response<Body>, (StatusCode, String)> {
|
||||
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() {
|
||||
Some(res) => Ok(res.into_response()),
|
||||
None => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AssetType {
|
||||
Audio,
|
||||
Image,
|
||||
}
|
||||
|
||||
pub async fn get_asset_file(filename: String, asset_type: AssetType) -> Result<Response<Body>, (StatusCode, String)> {
|
||||
const DEFAULT_AUDIO_PATH: &str = "assets/audio";
|
||||
const DEFAULT_IMAGE_PATH: &str = "assets/images";
|
||||
|
||||
let root = match asset_type {
|
||||
AssetType::Audio => std::env::var("LIBRETUNES_AUDIO_PATH").unwrap_or(DEFAULT_AUDIO_PATH.to_string()),
|
||||
AssetType::Image => std::env::var("LIBRETUNES_IMAGE_PATH").unwrap_or(DEFAULT_IMAGE_PATH.to_string()),
|
||||
};
|
||||
|
||||
// Create a Uri from the filename
|
||||
// ServeDir expects a leading `/`
|
||||
let uri = Uri::from_str(format!("/{}", filename).as_str());
|
||||
|
||||
match uri {
|
||||
Ok(uri) => get_static_file(uri, root.as_str()).await,
|
||||
Err(_) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Attempted to serve an invalid file"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
}}
|
21
src/health.rs
Normal file
21
src/health.rs
Normal file
@ -0,0 +1,21 @@
|
||||
use libretunes::api::health::health;
|
||||
|
||||
use server_fn::client::set_server_url;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn main() {
|
||||
let host = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or("http://localhost:3000".to_string());
|
||||
|
||||
println!("Runing health check against {host}");
|
||||
|
||||
set_server_url(Box::leak(host.into_boxed_str()));
|
||||
match health().await {
|
||||
Ok(result) => println!("Health check result: {result:?}"),
|
||||
Err(err) => {
|
||||
println!("Error: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
50
src/lib.rs
50
src/lib.rs
@ -1,29 +1,43 @@
|
||||
#![warn(
|
||||
unsafe_code,
|
||||
clippy::cognitive_complexity,
|
||||
clippy::dbg_macro,
|
||||
clippy::debug_assert_with_mut_call,
|
||||
clippy::doc_link_with_quotes,
|
||||
clippy::doc_markdown,
|
||||
clippy::empty_line_after_outer_attr,
|
||||
clippy::float_cmp,
|
||||
clippy::float_cmp_const,
|
||||
clippy::float_equality_without_abs,
|
||||
keyword_idents,
|
||||
clippy::missing_const_for_fn,
|
||||
non_ascii_idents,
|
||||
noop_method_call,
|
||||
clippy::print_stderr,
|
||||
clippy::print_stdout,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::unseparated_literal_suffix,
|
||||
clippy::suspicious_operation_groupings,
|
||||
unused_import_braces,
|
||||
clippy::unused_self,
|
||||
clippy::use_debug,
|
||||
clippy::useless_let_if_seq,
|
||||
clippy::wildcard_dependencies
|
||||
)]
|
||||
#![allow(clippy::unused_unit, clippy::unit_arg, clippy::type_complexity)]
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
pub mod api;
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod songdata;
|
||||
pub mod albumdata;
|
||||
pub mod artistdata;
|
||||
pub mod playstatus;
|
||||
pub mod playbar;
|
||||
pub mod database;
|
||||
pub mod queue;
|
||||
pub mod song;
|
||||
pub mod components;
|
||||
pub mod models;
|
||||
pub mod pages;
|
||||
pub mod components;
|
||||
pub mod users;
|
||||
pub mod search;
|
||||
pub mod fileserv;
|
||||
pub mod error_template;
|
||||
pub mod api;
|
||||
pub mod upload;
|
||||
pub mod util;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
pub mod auth_backend;
|
||||
pub mod schema;
|
||||
}
|
||||
}
|
||||
@ -39,7 +53,7 @@ if #[cfg(feature = "hydrate")] {
|
||||
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(App);
|
||||
leptos::mount::hydrate_body(App);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
68
src/main.rs
68
src/main.rs
@ -1,12 +1,4 @@
|
||||
// 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_env = "musl")]
|
||||
extern crate openssl;
|
||||
|
||||
#[cfg(target_env = "musl")]
|
||||
#[macro_use]
|
||||
extern crate diesel;
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
extern crate diesel_migrations;
|
||||
@ -14,19 +6,26 @@ extern crate diesel_migrations;
|
||||
#[cfg(feature = "ssr")]
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
use axum::{routing::get, Router, extract::Path, middleware::from_fn};
|
||||
use leptos::*;
|
||||
use axum::{extract::Path, middleware::from_fn, routing::get, Router};
|
||||
use axum_login::tower_sessions::SessionManagerLayer;
|
||||
use axum_login::AuthManagerLayerBuilder;
|
||||
use leptos::prelude::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use libretunes::app::*;
|
||||
use libretunes::util::auth_backend::AuthBackend;
|
||||
use libretunes::util::fileserv::{
|
||||
file_and_error_handler, get_asset_file, get_static_file, AssetType,
|
||||
};
|
||||
use libretunes::util::redis::get_redis_pool;
|
||||
use libretunes::util::require_auth::require_auth_middleware;
|
||||
use libretunes::fileserv::{file_and_error_handler, get_asset_file, get_static_file, AssetType};
|
||||
use axum_login::tower_sessions::SessionManagerLayer;
|
||||
use tower_sessions_redis_store::{fred::prelude::*, RedisStore};
|
||||
use axum_login::AuthManagerLayerBuilder;
|
||||
use libretunes::auth_backend::AuthBackend;
|
||||
use log::*;
|
||||
use tower_sessions_redis_store::RedisStore;
|
||||
|
||||
flexi_logger::Logger::try_with_env_or_str("debug").unwrap().format(flexi_logger::opt_format).start().unwrap();
|
||||
flexi_logger::Logger::try_with_env_or_str("debug")
|
||||
.unwrap()
|
||||
.format(flexi_logger::opt_format)
|
||||
.start()
|
||||
.unwrap();
|
||||
|
||||
info!("\n{}", include_str!("../ascii_art.txt"));
|
||||
info!("Starting Leptos server...");
|
||||
@ -37,43 +36,50 @@ async fn main() {
|
||||
debug!("Running database migrations...");
|
||||
|
||||
// Bring the database up to date
|
||||
libretunes::database::migrate();
|
||||
libretunes::util::database::migrate();
|
||||
|
||||
debug!("Connecting to Redis...");
|
||||
|
||||
let redis_url = std::env::var("REDIS_URL").expect("REDIS_URL must be set");
|
||||
let redis_config = RedisConfig::from_url(&redis_url).expect(&format!("Unable to parse Redis URL: {}", redis_url));
|
||||
let redis_pool = RedisPool::new(redis_config, None, None, None, 1).expect("Unable to create Redis pool");
|
||||
redis_pool.connect();
|
||||
redis_pool.wait_for_connect().await.expect("Unable to connect to Redis");
|
||||
|
||||
let redis_pool = get_redis_pool();
|
||||
let session_store = RedisStore::new(redis_pool);
|
||||
let session_layer = SessionManagerLayer::new(session_store);
|
||||
|
||||
let auth_backend = AuthBackend;
|
||||
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
|
||||
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
let conf = get_configuration(None).unwrap();
|
||||
let leptos_options = conf.leptos_options;
|
||||
let addr = leptos_options.site_addr;
|
||||
// Generate the list of routes in your Leptos App
|
||||
let routes = generate_route_list(App);
|
||||
|
||||
let app = Router::new()
|
||||
.leptos_routes(&leptos_options, routes, App)
|
||||
.route("/assets/audio/:song", get(|Path(song) : Path<String>| get_asset_file(song, AssetType::Audio)))
|
||||
.route("/assets/images/:image", get(|Path(image) : Path<String>| get_asset_file(image, AssetType::Image)))
|
||||
.leptos_routes(&leptos_options, routes, {
|
||||
let leptos_options = leptos_options.clone();
|
||||
move || shell(leptos_options.clone())
|
||||
})
|
||||
.route(
|
||||
"/assets/audio/:song",
|
||||
get(|Path(song): Path<String>| get_asset_file(song, AssetType::Audio)),
|
||||
)
|
||||
.route(
|
||||
"/assets/images/:image",
|
||||
get(|Path(image): Path<String>| get_asset_file(image, AssetType::Image)),
|
||||
)
|
||||
.route("/assets/*uri", get(|uri| get_static_file(uri, "")))
|
||||
.layer(from_fn(require_auth_middleware))
|
||||
.layer(auth_layer)
|
||||
.fallback(file_and_error_handler)
|
||||
.with_state(leptos_options);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.expect(&format!("Could not bind to {}", &addr));
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("Could not bind to {}", &addr));
|
||||
|
||||
info!("Listening on http://{}", &addr);
|
||||
|
||||
axum::serve(listener, app.into_make_service()).await.expect("Server failed");
|
||||
axum::serve(listener, app.into_make_service())
|
||||
.await
|
||||
.expect("Server failed");
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "ssr"))]
|
||||
|
791
src/models.rs
791
src/models.rs
@ -1,791 +0,0 @@
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use diesel::prelude::*;
|
||||
use crate::database::*;
|
||||
use std::error::Error;
|
||||
use crate::songdata::SongData;
|
||||
use crate::albumdata::AlbumData;
|
||||
}
|
||||
}
|
||||
|
||||
// These "models" are used to represent the data in the database
|
||||
// Diesel uses these models to generate the SQL queries that are used to interact with the database.
|
||||
// These types are also used for API endpoints, for consistency. Because the file must be compiled
|
||||
// for both the server and the client, we use the `cfg_attr` attribute to conditionally add
|
||||
// diesel-specific attributes to the models when compiling for the server
|
||||
|
||||
/// Model for a "User", used for querying the database
|
||||
/// Various fields are wrapped in Options, because they are not always wanted for inserts/retrieval
|
||||
/// Using deserialize_as makes Diesel use the specified type when deserializing from the database,
|
||||
/// and then call .into() to convert it into the Option
|
||||
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
|
||||
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::users))]
|
||||
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct User {
|
||||
/// A unique id for the user
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
|
||||
// #[cfg_attr(feature = "ssr", diesel(skip_insertion))] // This feature is not yet released
|
||||
pub id: Option<i32>,
|
||||
/// The user's username
|
||||
pub username: String,
|
||||
/// The user's email
|
||||
pub email: String,
|
||||
/// The user's password, stored as a hash
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = String))]
|
||||
pub password: Option<String>,
|
||||
/// The time the user was created
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
|
||||
pub created_at: Option<NaiveDateTime>,
|
||||
/// Whether the user is an admin
|
||||
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<(NaiveDateTime, 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))]
|
||||
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Artist {
|
||||
/// A unique id for the artist
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
|
||||
pub id: Option<i32>,
|
||||
/// The artist's name
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Artist {
|
||||
/// Add an album to this artist in the database
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `new_album_id` - The id of the album to add to this artist
|
||||
/// * `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_album(self: &Self, new_album_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
|
||||
use crate::schema::album_artists::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to add an album")?;
|
||||
|
||||
diesel::insert_into(album_artists)
|
||||
.values((album_id.eq(new_album_id), artist_id.eq(my_id)))
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get albums by artist from the database
|
||||
///
|
||||
/// The `id` field of this artist must be present (Some) to get albums
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<Vec<Album>, Box<dyn Error>>` - A result indicating success with a vector of albums, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn get_albums(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Album>, Box<dyn Error>> {
|
||||
use crate::schema::albums::dsl::*;
|
||||
use crate::schema::album_artists::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to get albums")?;
|
||||
|
||||
let my_albums = albums
|
||||
.inner_join(album_artists)
|
||||
.filter(artist_id.eq(my_id))
|
||||
.select(albums::all_columns())
|
||||
.load(conn)?;
|
||||
|
||||
Ok(my_albums)
|
||||
}
|
||||
|
||||
/// Add a song to this artist in the database
|
||||
///
|
||||
/// The `id` field of this artist must be present (Some) to add a song
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `new_song_id` - The id of the song to add to this artist
|
||||
/// * `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_song(self: &Self, new_song_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
|
||||
use crate::schema::song_artists::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to add an album")?;
|
||||
|
||||
diesel::insert_into(song_artists)
|
||||
.values((song_id.eq(new_song_id), artist_id.eq(my_id)))
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get songs by this artist from the database
|
||||
///
|
||||
/// The `id` field of this artist must be present (Some) to get songs
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<Vec<Song>, Box<dyn Error>>` - A result indicating success with a vector of songs, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn get_songs(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Song>, Box<dyn Error>> {
|
||||
use crate::schema::songs::dsl::*;
|
||||
use crate::schema::song_artists::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to get songs")?;
|
||||
|
||||
let my_songs = songs
|
||||
.inner_join(song_artists)
|
||||
.filter(artist_id.eq(my_id))
|
||||
.select(songs::all_columns())
|
||||
.load(conn)?;
|
||||
|
||||
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
|
||||
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable, Identifiable))]
|
||||
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::albums))]
|
||||
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Album {
|
||||
/// A unique id for the album
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
|
||||
pub id: Option<i32>,
|
||||
/// The album's title
|
||||
pub title: String,
|
||||
/// The album's release date
|
||||
pub release_date: Option<NaiveDate>,
|
||||
/// The path to the album's image file
|
||||
pub image_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Album {
|
||||
/// Add an artist to this album in the database
|
||||
///
|
||||
/// The `id` field of this album must be present (Some) to add an artist
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `new_artist_id` - The id of the artist to add to this album
|
||||
/// * `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_artist(self: &Self, new_artist_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
|
||||
use crate::schema::album_artists::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("Album id must be present (Some) to add an artist")?;
|
||||
|
||||
diesel::insert_into(album_artists)
|
||||
.values((album_id.eq(my_id), artist_id.eq(new_artist_id)))
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get songs by this album from the database
|
||||
///
|
||||
/// The `id` field of this album must be present (Some) to get songs
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<Vec<Song>, Box<dyn Error>>` - A result indicating success with a vector of songs, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn get_songs(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Song>, Box<dyn Error>> {
|
||||
use crate::schema::songs::dsl::*;
|
||||
use crate::schema::song_artists::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("Album id must be present (Some) to get songs")?;
|
||||
|
||||
let my_songs = songs
|
||||
.inner_join(song_artists)
|
||||
.filter(album_id.eq(my_id))
|
||||
.select(songs::all_columns())
|
||||
.load(conn)?;
|
||||
|
||||
Ok(my_songs)
|
||||
}
|
||||
|
||||
/// Obtain an album from its albumid
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `album_id` - The id of the album to select
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<Album, Box<dyn Error>>` - A result indicating success with the desired album, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn get_album_data(album_id: i32, conn: &mut PgPooledConn) -> Result<AlbumData, Box<dyn Error>> {
|
||||
use crate::schema::*;
|
||||
|
||||
let artist_list: Vec<Artist> = album_artists::table
|
||||
.filter(album_artists::album_id.eq(album_id))
|
||||
.inner_join(artists::table.on(album_artists::artist_id.eq(artists::id)))
|
||||
.select(
|
||||
artists::all_columns
|
||||
)
|
||||
.load(conn)?;
|
||||
|
||||
// Get info of album
|
||||
let albuminfo = albums::table
|
||||
.filter(albums::id.eq(album_id))
|
||||
.first::<Album>(conn)?;
|
||||
|
||||
let img = albuminfo.image_path.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string());
|
||||
|
||||
let albumdata = AlbumData {
|
||||
id: albuminfo.id.unwrap(),
|
||||
title: albuminfo.title,
|
||||
artists: artist_list,
|
||||
release_date: albuminfo.release_date,
|
||||
image_path: img
|
||||
};
|
||||
|
||||
Ok(albumdata)
|
||||
}
|
||||
|
||||
/// Obtain an album from its albumid
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `album_id` - The id of the album to select
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<Album, Box<dyn Error>>` - A result indicating success with the desired album, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn get_song_data(album_id: i32, user_like_dislike: Option<User>, conn: &mut PgPooledConn) -> Result<Vec<SongData>, Box<dyn Error>> {
|
||||
use crate::schema::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let song_list = if let Some(user_like_dislike) = user_like_dislike {
|
||||
let user_like_dislike_id = user_like_dislike.id.unwrap();
|
||||
let song_list: Vec<(Album, Option<Song>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)> =
|
||||
albums::table
|
||||
.find(album_id)
|
||||
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
|
||||
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
|
||||
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(user_like_dislike_id))))
|
||||
.left_join(song_dislikes::table.on(songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(user_like_dislike_id))))
|
||||
.select((
|
||||
albums::all_columns,
|
||||
songs::all_columns.nullable(),
|
||||
artists::all_columns.nullable(),
|
||||
song_likes::all_columns.nullable(),
|
||||
song_dislikes::all_columns.nullable()
|
||||
))
|
||||
.order(songs::track.asc())
|
||||
.load(conn)?;
|
||||
song_list
|
||||
} else {
|
||||
let song_list: Vec<(Album, Option<Song>, Option<Artist>)> =
|
||||
albums::table
|
||||
.find(album_id)
|
||||
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
|
||||
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
|
||||
.select((
|
||||
albums::all_columns,
|
||||
songs::all_columns.nullable(),
|
||||
artists::all_columns.nullable()
|
||||
))
|
||||
.order(songs::track.asc())
|
||||
.load(conn)?;
|
||||
|
||||
let song_list: Vec<(Album, Option<Song>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)> =
|
||||
song_list.into_iter().map( |(album, song, artist)| (album, song, artist, None, None) ).collect();
|
||||
song_list
|
||||
};
|
||||
|
||||
let mut album_songs: HashMap<i32, SongData> = HashMap::with_capacity(song_list.len());
|
||||
|
||||
for (album, song, artist, like, dislike) in song_list {
|
||||
if let Some(song) = song {
|
||||
if let Some(stored_songdata) = album_songs.get_mut(&song.id.unwrap()) {
|
||||
// If the song is already in the map, update the artists
|
||||
if let Some(artist) = artist {
|
||||
stored_songdata.artists.push(artist);
|
||||
}
|
||||
} else {
|
||||
let like_dislike = match (like, dislike) {
|
||||
(Some(_), Some(_)) => Some((true, true)),
|
||||
(Some(_), None) => Some((true, false)),
|
||||
(None, Some(_)) => Some((false, true)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let image_path = song.image_path.unwrap_or(
|
||||
album.image_path.clone().unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
|
||||
|
||||
let songdata = SongData {
|
||||
id: song.id.unwrap(),
|
||||
title: song.title,
|
||||
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
|
||||
album: Some(album),
|
||||
track: song.track,
|
||||
duration: song.duration,
|
||||
release_date: song.release_date,
|
||||
song_path: song.storage_path,
|
||||
image_path: image_path,
|
||||
like_dislike: like_dislike,
|
||||
added_date: song.added_date.unwrap(),
|
||||
};
|
||||
|
||||
album_songs.insert(song.id.unwrap(), songdata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the songs by date
|
||||
let mut songdata: Vec<SongData> = album_songs.into_values().collect();
|
||||
songdata.sort_by(|a, b| a.track.cmp(&b.track));
|
||||
Ok(songdata)
|
||||
}
|
||||
}
|
||||
|
||||
/// Model for a song
|
||||
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
|
||||
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::songs))]
|
||||
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Song {
|
||||
/// A unique id for the song
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
|
||||
pub id: Option<i32>,
|
||||
/// The song's title
|
||||
pub title: String,
|
||||
/// The album the song is from
|
||||
pub album_id: Option<i32>,
|
||||
/// 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<NaiveDate>,
|
||||
/// The path to the song's audio file
|
||||
pub storage_path: String,
|
||||
/// The path to the song's image file
|
||||
pub image_path: Option<String>,
|
||||
/// The date the song was added to the database
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDate))]
|
||||
pub added_date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
impl Song {
|
||||
/// Add an artist to this song in the database
|
||||
///
|
||||
/// The `id` field of this song must be present (Some) to add an artist
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `new_artist_id` - The id of the artist to add to this song
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<Vec<Artist>, Box<dyn Error>>` - A result indicating success with an empty value, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn get_artists(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Artist>, Box<dyn Error>> {
|
||||
use crate::schema::artists::dsl::*;
|
||||
use crate::schema::song_artists::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("Song id must be present (Some) to get artists")?;
|
||||
|
||||
let my_artists = artists
|
||||
.inner_join(song_artists)
|
||||
.filter(song_id.eq(my_id))
|
||||
.select(artists::all_columns())
|
||||
.load(conn)?;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: NaiveDateTime,
|
||||
/// The id of the song that was listened to
|
||||
pub song_id: i32,
|
||||
}
|
||||
|
||||
/// Model for a playlist
|
||||
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
|
||||
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::playlists))]
|
||||
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Playlist {
|
||||
/// A unique id for the playlist
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
|
||||
pub id: Option<i32>,
|
||||
/// The time the playlist was created
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
|
||||
pub created_at: Option<NaiveDateTime>,
|
||||
/// The time the playlist was last updated
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
|
||||
pub updated_at: Option<NaiveDateTime>,
|
||||
/// The id of the user who owns the playlist
|
||||
pub owner_id: i32,
|
||||
/// The name of the playlist
|
||||
pub name: String,
|
||||
}
|
18
src/models/backend/album.rs
Normal file
18
src/models/backend/album.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use chrono::NaiveDate;
|
||||
use libretunes_macro::db_type;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Model for an album
|
||||
#[db_type(crate::schema::albums)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Album {
|
||||
/// A unique id for the album
|
||||
#[omit_new]
|
||||
pub id: i32,
|
||||
/// The album's title
|
||||
pub title: String,
|
||||
/// The album's release date
|
||||
pub release_date: Option<NaiveDate>,
|
||||
/// The path to the album's image file
|
||||
pub image_path: Option<String>,
|
||||
}
|
37
src/models/backend/artist.rs
Normal file
37
src/models/backend/artist.rs
Normal file
@ -0,0 +1,37 @@
|
||||
use libretunes_macro::db_type;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Model for an artist
|
||||
#[db_type(crate::schema::artists)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Artist {
|
||||
/// A unique id for the artist
|
||||
#[omit_new]
|
||||
pub id: i32,
|
||||
/// The artist's name
|
||||
pub name: String,
|
||||
/// The path to the artist's image file
|
||||
pub image_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Artist {
|
||||
/// 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: &[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
|
||||
}
|
||||
}
|
18
src/models/backend/history_entry.rs
Normal file
18
src/models/backend/history_entry.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use libretunes_macro::db_type;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Model for a history entry
|
||||
#[db_type(crate::schema::song_history)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct HistoryEntry {
|
||||
/// A unique id for the history entry
|
||||
#[omit_new]
|
||||
pub id: i32,
|
||||
/// The id of the user who listened to the song
|
||||
pub user_id: i32,
|
||||
/// The date the song was listened to
|
||||
pub date: NaiveDateTime,
|
||||
/// The id of the song that was listened to
|
||||
pub song_id: i32,
|
||||
}
|
25
src/models/backend/mod.rs
Normal file
25
src/models/backend/mod.rs
Normal file
@ -0,0 +1,25 @@
|
||||
// These "models" are used to represent the data in the database
|
||||
// Diesel uses these models to generate the SQL queries that are used to interact with the database.
|
||||
// These types are also used for API endpoints, for consistency. Because the file must be compiled
|
||||
// for both the server and the client, we use the `cfg_attr` attribute to conditionally add
|
||||
// diesel-specific attributes to the models when compiling for the serverub mod user;
|
||||
|
||||
pub mod album;
|
||||
pub mod artist;
|
||||
pub mod history_entry;
|
||||
pub mod playlist;
|
||||
pub mod song;
|
||||
pub mod user;
|
||||
|
||||
pub use album::Album;
|
||||
pub use album::NewAlbum;
|
||||
pub use artist::Artist;
|
||||
pub use artist::NewArtist;
|
||||
pub use history_entry::HistoryEntry;
|
||||
pub use history_entry::NewHistoryEntry;
|
||||
pub use playlist::NewPlaylist;
|
||||
pub use playlist::Playlist;
|
||||
pub use song::NewSong;
|
||||
pub use song::Song;
|
||||
pub use user::NewUser;
|
||||
pub use user::User;
|
24
src/models/backend/playlist.rs
Normal file
24
src/models/backend/playlist.rs
Normal file
@ -0,0 +1,24 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use libretunes_macro::db_type;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Model for a playlist
|
||||
#[db_type(crate::schema::playlists)]
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Playlist {
|
||||
/// A unique id for the playlist
|
||||
#[omit_new]
|
||||
pub id: i32,
|
||||
/// The time the playlist was created
|
||||
#[omit_new]
|
||||
pub created_at: NaiveDateTime,
|
||||
/// The time the playlist was last updated
|
||||
#[omit_new]
|
||||
pub updated_at: NaiveDateTime,
|
||||
/// The id of the user who owns the playlist
|
||||
pub owner_id: i32,
|
||||
/// The name of the playlist
|
||||
pub name: String,
|
||||
/// The path to the playlist's image file
|
||||
pub image_path: Option<String>,
|
||||
}
|
28
src/models/backend/song.rs
Normal file
28
src/models/backend/song.rs
Normal file
@ -0,0 +1,28 @@
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use libretunes_macro::db_type;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[db_type(crate::schema::songs)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Song {
|
||||
/// A unique id for the song
|
||||
#[omit_new]
|
||||
pub id: i32,
|
||||
/// The song's title
|
||||
pub title: String,
|
||||
/// The album the song is from
|
||||
pub album_id: Option<i32>,
|
||||
/// 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<NaiveDate>,
|
||||
/// The path to the song's audio file
|
||||
pub storage_path: String,
|
||||
/// The path to the song's image file
|
||||
pub image_path: Option<String>,
|
||||
/// The date the song was added to the database
|
||||
#[omit_new]
|
||||
pub added_date: NaiveDateTime,
|
||||
}
|
288
src/models/backend/user.rs
Normal file
288
src/models/backend/user.rs
Normal file
@ -0,0 +1,288 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use libretunes_macro::db_type;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use diesel::prelude::*;
|
||||
use crate::util::database::*;
|
||||
use std::error::Error;
|
||||
use crate::models::backend::{Song, HistoryEntry};
|
||||
}
|
||||
}
|
||||
|
||||
// Model for a "User", used for querying the database
|
||||
/// Various fields are wrapped in Options, because they are not always wanted for inserts/retrieval
|
||||
/// Using `deserialize_as` makes Diesel use the specified type when deserializing from the database,
|
||||
/// and then call `.into()` to convert it into the Option
|
||||
#[db_type(crate::schema::users)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct User {
|
||||
/// A unique id for the user
|
||||
#[omit_new]
|
||||
pub id: i32,
|
||||
/// The user's username
|
||||
pub username: String,
|
||||
/// The user's email
|
||||
pub email: String,
|
||||
/// The user's password, stored as a hash
|
||||
#[cfg_attr(feature = "ssr", diesel(deserialize_as = String))]
|
||||
pub password: Option<String>,
|
||||
/// The time the user was created
|
||||
#[omit_new]
|
||||
pub created_at: NaiveDateTime,
|
||||
/// Whether the user is an admin
|
||||
pub admin: bool,
|
||||
/// The path to the user's profile picture file
|
||||
pub image_path: Option<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
limit: Option<i64>,
|
||||
conn: &mut PgPooledConn,
|
||||
) -> Result<Vec<HistoryEntry>, Box<dyn Error>> {
|
||||
use crate::schema::song_history::dsl::*;
|
||||
|
||||
let my_history = if let Some(limit) = limit {
|
||||
song_history
|
||||
.filter(user_id.eq(self.id))
|
||||
.order(date.desc())
|
||||
.limit(limit)
|
||||
.load(conn)?
|
||||
} else {
|
||||
song_history.filter(user_id.eq(self.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,
|
||||
limit: Option<i64>,
|
||||
conn: &mut PgPooledConn,
|
||||
) -> Result<Vec<(NaiveDateTime, Song)>, Box<dyn Error>> {
|
||||
use crate::schema::song_history::dsl::*;
|
||||
use crate::schema::songs::dsl::*;
|
||||
|
||||
let my_history = if let Some(limit) = limit {
|
||||
song_history
|
||||
.inner_join(songs)
|
||||
.filter(user_id.eq(self.id))
|
||||
.order(date.desc())
|
||||
.limit(limit)
|
||||
.select((date, songs::all_columns()))
|
||||
.load(conn)?
|
||||
} else {
|
||||
song_history
|
||||
.inner_join(songs)
|
||||
.filter(user_id.eq(self.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, song_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
|
||||
use crate::schema::song_history;
|
||||
|
||||
diesel::insert_into(song_history::table)
|
||||
.values((
|
||||
song_history::user_id.eq(self.id),
|
||||
song_history::song_id.eq(song_id),
|
||||
))
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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_dislikes;
|
||||
use crate::schema::song_likes;
|
||||
|
||||
if like {
|
||||
diesel::insert_into(song_likes::table)
|
||||
.values((
|
||||
song_likes::song_id.eq(song_id),
|
||||
song_likes::user_id.eq(self.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(self.id)),
|
||||
),
|
||||
)
|
||||
.execute(conn)?;
|
||||
} else {
|
||||
diesel::delete(
|
||||
song_likes::table.filter(
|
||||
song_likes::song_id
|
||||
.eq(song_id)
|
||||
.and(song_likes::user_id.eq(self.id)),
|
||||
),
|
||||
)
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the like status of a song for this user
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn get_like_song(
|
||||
&self,
|
||||
song_id: i32,
|
||||
conn: &mut PgPooledConn,
|
||||
) -> Result<bool, Box<dyn Error>> {
|
||||
use crate::schema::song_likes;
|
||||
|
||||
let like = song_likes::table
|
||||
.filter(
|
||||
song_likes::song_id
|
||||
.eq(song_id)
|
||||
.and(song_likes::user_id.eq(self.id)),
|
||||
)
|
||||
.first::<(i32, i32)>(conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
|
||||
Ok(like)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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_dislikes;
|
||||
use crate::schema::song_likes;
|
||||
|
||||
if dislike {
|
||||
diesel::insert_into(song_dislikes::table)
|
||||
.values((
|
||||
song_dislikes::song_id.eq(song_id),
|
||||
song_dislikes::user_id.eq(self.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(self.id)),
|
||||
),
|
||||
)
|
||||
.execute(conn)?;
|
||||
} else {
|
||||
diesel::delete(
|
||||
song_dislikes::table.filter(
|
||||
song_dislikes::song_id
|
||||
.eq(song_id)
|
||||
.and(song_dislikes::user_id.eq(self.id)),
|
||||
),
|
||||
)
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the dislike status of a song for this user
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn get_dislike_song(
|
||||
&self,
|
||||
song_id: i32,
|
||||
conn: &mut PgPooledConn,
|
||||
) -> Result<bool, Box<dyn Error>> {
|
||||
use crate::schema::song_dislikes;
|
||||
|
||||
let dislike = song_dislikes::table
|
||||
.filter(
|
||||
song_dislikes::song_id
|
||||
.eq(song_id)
|
||||
.and(song_dislikes::user_id.eq(self.id)),
|
||||
)
|
||||
.first::<(i32, i32)>(conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
|
||||
Ok(dislike)
|
||||
}
|
||||
}
|
35
src/models/frontend/album.rs
Normal file
35
src/models/frontend/album.rs
Normal file
@ -0,0 +1,35 @@
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
use crate::models::backend::Artist;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
/// Holds information about an album
|
||||
///
|
||||
/// Intended to be used in the front-end
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Album {
|
||||
/// Album id
|
||||
pub id: i32,
|
||||
/// Album title
|
||||
pub title: String,
|
||||
/// Album artists
|
||||
pub artists: Vec<Artist>,
|
||||
/// Album release date
|
||||
pub release_date: Option<NaiveDate>,
|
||||
/// Path to album image, relative to the root of the web server.
|
||||
/// For example, `"/assets/images/Album.jpg"`
|
||||
pub image_path: String,
|
||||
}
|
||||
|
||||
impl From<Album> for DashboardTile {
|
||||
fn from(val: Album) -> Self {
|
||||
DashboardTile {
|
||||
image_path: val.image_path.into(),
|
||||
title: val.title.into(),
|
||||
link: format!("/album/{}", val.id).into(),
|
||||
description: Some(format!("Album • {}", Artist::display_list(&val.artists)).into()),
|
||||
}
|
||||
}
|
||||
}
|
27
src/models/frontend/artist.rs
Normal file
27
src/models/frontend/artist.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Holds information about an artist
|
||||
///
|
||||
/// Intended to be used in the front-end
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Artist {
|
||||
/// Artist id
|
||||
pub id: i32,
|
||||
/// Artist name
|
||||
pub name: String,
|
||||
/// Path to artist image, relative to the root of the web server.
|
||||
/// For example, `"/assets/images/Artist.jpg"`
|
||||
pub image_path: String,
|
||||
}
|
||||
|
||||
impl From<Artist> for DashboardTile {
|
||||
fn from(val: Artist) -> Self {
|
||||
DashboardTile {
|
||||
image_path: val.image_path.into(),
|
||||
title: val.name.into(),
|
||||
link: format!("/artist/{}", val.id).into(),
|
||||
description: Some("Artist".into()),
|
||||
}
|
||||
}
|
||||
}
|
9
src/models/frontend/mod.rs
Normal file
9
src/models/frontend/mod.rs
Normal file
@ -0,0 +1,9 @@
|
||||
pub mod album;
|
||||
pub mod artist;
|
||||
pub mod playstatus;
|
||||
pub mod song;
|
||||
|
||||
pub use album::Album;
|
||||
pub use artist::Artist;
|
||||
pub use playstatus::PlayStatus;
|
||||
pub use song::Song;
|
65
src/models/frontend/playstatus.rs
Normal file
65
src/models/frontend/playstatus.rs
Normal file
@ -0,0 +1,65 @@
|
||||
use leptos::html::Audio;
|
||||
use leptos::prelude::*;
|
||||
use std::collections::VecDeque;
|
||||
use web_sys::HtmlAudioElement;
|
||||
|
||||
use crate::models::frontend;
|
||||
|
||||
/// Represents the global state of the audio player feature of `LibreTunes`
|
||||
pub struct PlayStatus {
|
||||
/// Whether or not the audio player is currently playing
|
||||
pub playing: bool,
|
||||
/// Whether or not the queue is open
|
||||
pub queue_open: bool,
|
||||
/// A reference to the HTML audio element
|
||||
pub audio_player: Option<NodeRef<Audio>>,
|
||||
/// A queue of songs that have been played, ordered from oldest to newest
|
||||
pub history: VecDeque<frontend::Song>,
|
||||
/// A queue of songs that have yet to be played, ordered from next up to last
|
||||
pub queue: VecDeque<frontend::Song>,
|
||||
}
|
||||
|
||||
impl PlayStatus {
|
||||
/// Returns the HTML audio element if it has been created and is present, otherwise returns None
|
||||
///
|
||||
/// Instead of:
|
||||
/// ```
|
||||
/// use leptos::prelude::*;
|
||||
/// let status = libretunes::models::frontend::PlayStatus::default();
|
||||
/// if let Some(audio) = status.audio_player {
|
||||
/// if let Some(audio) = audio.get() {
|
||||
/// let _ = audio.play();
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// You can do:
|
||||
/// ```
|
||||
/// let status = libretunes::models::frontend::PlayStatus::default();
|
||||
/// if let Some(audio) = status.get_audio() {
|
||||
/// let _ = audio.play();
|
||||
/// }
|
||||
/// ```
|
||||
pub fn get_audio(&self) -> Option<HtmlAudioElement> {
|
||||
if let Some(audio) = &self.audio_player {
|
||||
if let Some(audio) = audio.get() {
|
||||
return Some(audio);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlayStatus {
|
||||
/// Creates a paused `PlayStatus` with no audio player, no progress update handle, and empty queue/history
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
playing: false,
|
||||
queue_open: false,
|
||||
audio_player: None,
|
||||
history: VecDeque::new(),
|
||||
queue: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
77
src/models/frontend/song.rs
Normal file
77
src/models/frontend/song.rs
Normal file
@ -0,0 +1,77 @@
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
use crate::models::backend::{self, Album, Artist};
|
||||
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Holds information about a song
|
||||
///
|
||||
/// Intended to be used in the front-end, as it includes artist and album objects, rather than just their ids.
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Song {
|
||||
/// Song id
|
||||
pub id: i32,
|
||||
/// Song name
|
||||
pub title: String,
|
||||
/// Song artists
|
||||
pub artists: Vec<Artist>,
|
||||
/// Song album
|
||||
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<NaiveDate>,
|
||||
/// Path to song file, relative to the root of the web server.
|
||||
/// For example, `"/assets/audio/Song.mp3"`
|
||||
pub song_path: String,
|
||||
/// 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)>,
|
||||
/// The date the song was added to the database
|
||||
pub added_date: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl TryInto<backend::Song> for Song {
|
||||
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<backend::Song, Self::Error> {
|
||||
Ok(backend::Song {
|
||||
id: self.id,
|
||||
title: self.title,
|
||||
album_id: self.album.map(|album| album.id),
|
||||
track: self.track,
|
||||
duration: self.duration,
|
||||
release_date: self.release_date,
|
||||
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)
|
||||
},
|
||||
|
||||
added_date: self.added_date,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Song> for DashboardTile {
|
||||
fn from(val: Song) -> Self {
|
||||
DashboardTile {
|
||||
image_path: val.image_path.into(),
|
||||
title: val.title.into(),
|
||||
link: format!("/song/{}", val.id).into(),
|
||||
description: Some(format!("Song • {}", Artist::display_list(&val.artists)).into()),
|
||||
}
|
||||
}
|
||||
}
|
2
src/models/mod.rs
Normal file
2
src/models/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod backend;
|
||||
pub mod frontend;
|
@ -1,6 +0,0 @@
|
||||
pub mod login;
|
||||
pub mod signup;
|
||||
pub mod profile;
|
||||
pub mod albumpage;
|
||||
pub mod artist;
|
||||
pub mod songpage;
|
118
src/pages/album.rs
Normal file
118
src/pages/album.rs
Normal file
@ -0,0 +1,118 @@
|
||||
use crate::api::album::*;
|
||||
use crate::components::error::*;
|
||||
use crate::components::loading::*;
|
||||
use crate::components::song_list::*;
|
||||
use crate::models::frontend;
|
||||
use leptos::either::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
use server_fn::error::NoCustomError;
|
||||
|
||||
#[component]
|
||||
pub fn AlbumPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
|
||||
view! {
|
||||
{move || params.with(|params| {
|
||||
match params.get("id").map(|id| id.parse::<i32>()) {
|
||||
Some(Ok(id)) => {
|
||||
Either::Left(view! { <AlbumIdPage id /> })
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Invalid Album ID"
|
||||
error=e.to_string()
|
||||
/>
|
||||
})
|
||||
},
|
||||
None => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="No Album ID"
|
||||
message="You must specify an album ID to view its page."
|
||||
/>
|
||||
})
|
||||
}
|
||||
}
|
||||
})}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn AlbumIdPage(#[prop(into)] id: Signal<i32>) -> impl IntoView {
|
||||
let album = Resource::new(id, get_album);
|
||||
|
||||
let show_songs = RwSignal::new(false);
|
||||
|
||||
view! {
|
||||
<Transition
|
||||
fallback=move || view! { <Loading /> }
|
||||
>
|
||||
{move || album.get().map(|album| {
|
||||
match album {
|
||||
Ok(Some(album)) => {
|
||||
show_songs.set(true);
|
||||
EitherOf3::A(view! { <AlbumInfo album /> })
|
||||
},
|
||||
Ok(None) => EitherOf3::B(view! {
|
||||
<Error<String>
|
||||
title="Album Not Found"
|
||||
message=format!("Album with ID {} not found", id.get())
|
||||
/>
|
||||
}),
|
||||
Err(error) => EitherOf3::C(view! {
|
||||
<ServerError<NoCustomError>
|
||||
title="Error Getting Album"
|
||||
error
|
||||
/>
|
||||
}),
|
||||
}
|
||||
})}
|
||||
</Transition>
|
||||
<Show when=show_songs>
|
||||
<AlbumSongs id />
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn AlbumInfo(album: frontend::Album) -> impl IntoView {
|
||||
view! {
|
||||
<div class="flex">
|
||||
<img class="w-70 h-70 p-5" src={album.image_path} alt="Album Cover" />
|
||||
<div class="self-center">
|
||||
<h1 class="text-4xl">{album.title}</h1>
|
||||
<SongArtists artists=album.artists />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
.into_view()
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn AlbumSongs(#[prop(into)] id: Signal<i32>) -> impl IntoView {
|
||||
let songs = Resource::new(id, get_songs);
|
||||
|
||||
view! {
|
||||
<Transition
|
||||
fallback= move || view! { <Loading /> }
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback=|errors| view! {
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, e)| view! { <p>{e.to_string()}</p> })
|
||||
.collect_view()
|
||||
}
|
||||
}
|
||||
>
|
||||
{move || songs.get().map(|songs| {
|
||||
songs.map(|songs| {
|
||||
view! { <SongList songs=songs /> }
|
||||
})
|
||||
})}
|
||||
</ErrorBoundary>
|
||||
</Transition>
|
||||
}
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use leptos_router::*;
|
||||
use crate::components::song_list::*;
|
||||
use crate::api::album::*;
|
||||
use crate::components::album_info::*;
|
||||
|
||||
|
||||
#[derive(Params, PartialEq)]
|
||||
struct AlbumParams {
|
||||
id: i32
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AlbumPage() -> impl IntoView {
|
||||
let params = use_params::<AlbumParams>();
|
||||
|
||||
let id = move || {params.with(|params| {
|
||||
params.as_ref()
|
||||
.map(|params| params.id)
|
||||
.map_err(|e| e.clone())
|
||||
})
|
||||
};
|
||||
|
||||
let song_list = create_resource(
|
||||
id,
|
||||
|value| async move {
|
||||
match value {
|
||||
Ok(v) => {get_songs(v).await},
|
||||
Err(e) => {Err(ServerFnError::Request(format!("Error getting song data: {}", e).into()))},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let albumdata = create_resource(
|
||||
id,
|
||||
|value| async move {
|
||||
match value {
|
||||
Ok(v) => {get_album(v).await},
|
||||
Err(e) => {Err(ServerFnError::Request(format!("Error getting song data: {}", e).into()))},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
view! {
|
||||
<div class="album-page-container">
|
||||
<div class="album-header">
|
||||
<Suspense
|
||||
fallback=move || view! { <p class="loading">"Loading..."</p> }
|
||||
>
|
||||
{move || {
|
||||
albumdata.with( |albumdata| {
|
||||
match albumdata {
|
||||
Some(Ok(s)) => {
|
||||
view! { <AlbumInfo albumdata=(*s).clone() /> }
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
view! { <div class="error">{format!("Error loading album : {}",e)}</div> }.into_view()
|
||||
},
|
||||
None => {view! { }.into_view()}
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
fallback=move || view! { <p class="loading">"Loading..."</p> }
|
||||
>
|
||||
{move || {
|
||||
song_list.with( |song_list| {
|
||||
match song_list {
|
||||
Some(Ok(s)) => {
|
||||
view! { <SongList songs=(*s).clone()/> }
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
view! { <div class="error">{format!("Error loading albums: : {}",e)}</div> }.into_view()
|
||||
},
|
||||
None => {view! { }.into_view()}
|
||||
}
|
||||
})
|
||||
}}
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
use leptos::*;
|
||||
use leptos_router::use_params_map;
|
||||
use leptos::either::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos_icons::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
use server_fn::error::NoCustomError;
|
||||
|
||||
use crate::models::Artist;
|
||||
use crate::models::backend::Artist;
|
||||
|
||||
use crate::components::loading::*;
|
||||
use crate::components::error::*;
|
||||
use crate::components::loading::*;
|
||||
use crate::components::song_list::*;
|
||||
|
||||
use crate::api::artists::*;
|
||||
@ -16,41 +17,37 @@ pub fn ArtistPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
|
||||
view! {
|
||||
<div class="artist-container home-component">
|
||||
{move || params.with(|params| {
|
||||
match params.get("id").map(|id| id.parse::<i32>()) {
|
||||
Some(Ok(id)) => {
|
||||
view! { <ArtistIdProfile id /> }.into_view()
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
view! {
|
||||
<Error<String>
|
||||
title="Invalid Artist ID"
|
||||
error=e.to_string()
|
||||
/>
|
||||
}.into_view()
|
||||
},
|
||||
None => {
|
||||
view! {
|
||||
<Error<String>
|
||||
title="No Artist ID"
|
||||
message="You must specify an artist ID to view their page."
|
||||
/>
|
||||
}.into_view()
|
||||
}
|
||||
{move || params.with(|params| {
|
||||
match params.get("id").map(|id| id.parse::<i32>()) {
|
||||
Some(Ok(id)) => {
|
||||
Either::Left(view! { <ArtistIdProfile id /> })
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Invalid Artist ID"
|
||||
error=e.to_string()
|
||||
/>
|
||||
})
|
||||
},
|
||||
None => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="No Artist ID"
|
||||
message="You must specify an artist ID to view their page."
|
||||
/>
|
||||
})
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ArtistIdProfile(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
let artist_info = create_resource(move || id.get(), move |id| {
|
||||
get_artist_by_id(id)
|
||||
});
|
||||
fn ArtistIdProfile(#[prop(into)] id: Signal<i32>) -> impl IntoView {
|
||||
let artist_info = Resource::new(move || id.get(), get_artist_by_id);
|
||||
|
||||
let show_details = create_rw_signal(false);
|
||||
let show_details = RwSignal::new(false);
|
||||
|
||||
view! {
|
||||
<Transition
|
||||
@ -60,20 +57,20 @@ fn ArtistIdProfile(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
match artist {
|
||||
Ok(Some(artist)) => {
|
||||
show_details.set(true);
|
||||
view! { <ArtistProfile artist /> }.into_view()
|
||||
EitherOf3::A(view! { <ArtistProfile artist /> })
|
||||
},
|
||||
Ok(None) => view! {
|
||||
Ok(None) => EitherOf3::B(view! {
|
||||
<Error<String>
|
||||
title="Artist Not Found"
|
||||
message=format!("Artist with ID {} not found", id.get())
|
||||
/>
|
||||
}.into_view(),
|
||||
Err(error) => view! {
|
||||
}),
|
||||
Err(error) => EitherOf3::C(view! {
|
||||
<ServerError<NoCustomError>
|
||||
title="Error Getting Artist"
|
||||
error
|
||||
/>
|
||||
}.into_view(),
|
||||
}),
|
||||
}
|
||||
})}
|
||||
</Transition>
|
||||
@ -86,41 +83,46 @@ fn ArtistIdProfile(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
|
||||
#[component]
|
||||
fn ArtistProfile(artist: Artist) -> impl IntoView {
|
||||
let artist_id = artist.id.unwrap();
|
||||
let profile_image_path = format!("/assets/images/artist/{}.webp", artist_id);
|
||||
let profile_image_path = format!("/assets/images/artist/{}.webp", artist.id);
|
||||
|
||||
leptos::logging::log!("Artist name: {}", artist.name);
|
||||
|
||||
view! {
|
||||
<div class="artist-header">
|
||||
<object class="artist-image" data={profile_image_path.clone()} type="image/webp">
|
||||
<Icon class="artist-image" icon=icondata::CgProfile width="100" height="100"/>
|
||||
<div class="flex">
|
||||
<object class="w-35 h-35 rounded-full p-5" data={profile_image_path.clone()} type="image/webp">
|
||||
<Icon icon={icondata::CgProfile} width="100" height="100" {..} class="artist-image" />
|
||||
</object>
|
||||
<h1>{artist.name}</h1>
|
||||
<h1 class="text-4xl self-center">{artist.name}</h1>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TopSongsByArtist(#[prop(into)] artist_id: MaybeSignal<i32>) -> impl IntoView {
|
||||
let top_songs = create_resource(move || artist_id.get(), |artist_id| async move {
|
||||
let top_songs = top_songs_by_artist(artist_id, Some(10), 1).await;
|
||||
fn TopSongsByArtist(#[prop(into)] artist_id: Signal<i32>) -> impl IntoView {
|
||||
let top_songs = Resource::new(
|
||||
move || artist_id.get(),
|
||||
|artist_id| async move {
|
||||
let top_songs = top_songs_by_artist(artist_id, Some(10)).await;
|
||||
|
||||
top_songs.map(|top_songs| {
|
||||
top_songs.into_iter().map(|(song, plays)| {
|
||||
let plays = if plays == 1 {
|
||||
"1 play".to_string()
|
||||
} else {
|
||||
format!("{} plays", plays)
|
||||
};
|
||||
top_songs.map(|top_songs| {
|
||||
top_songs
|
||||
.into_iter()
|
||||
.map(|(song, plays)| {
|
||||
let plays = if plays == 1 {
|
||||
"1 play".to_string()
|
||||
} else {
|
||||
format!("{plays} plays")
|
||||
};
|
||||
|
||||
(song, plays)
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
(song, plays)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
view! {
|
||||
<h2>"Top Songs"</h2>
|
||||
<h2 class="text-xl font-bold">"Top Songs"</h2>
|
||||
<Transition
|
||||
fallback=move || view! { <Loading /> }
|
||||
>
|
||||
@ -144,19 +146,17 @@ fn TopSongsByArtist(#[prop(into)] artist_id: MaybeSignal<i32>) -> impl IntoView
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn AlbumsByArtist(#[prop(into)] artist_id: MaybeSignal<i32>) -> impl IntoView {
|
||||
fn AlbumsByArtist(#[prop(into)] artist_id: Signal<i32>) -> impl IntoView {
|
||||
use crate::components::dashboard_row::*;
|
||||
use crate::components::dashboard_tile::*;
|
||||
|
||||
let albums = create_resource(move || artist_id.get(), |artist_id| async move {
|
||||
let albums = albums_by_artist(artist_id, None).await;
|
||||
let albums = Resource::new(
|
||||
move || artist_id.get(),
|
||||
|artist_id| async move {
|
||||
let albums = albums_by_artist(artist_id, None).await;
|
||||
|
||||
albums.map(|albums| {
|
||||
albums.into_iter().map(|album| {
|
||||
album
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
albums.map(|albums| albums.into_iter().collect::<Vec<_>>())
|
||||
},
|
||||
);
|
||||
|
||||
view! {
|
||||
<Transition
|
||||
@ -173,9 +173,13 @@ fn AlbumsByArtist(#[prop(into)] artist_id: MaybeSignal<i32>) -> impl IntoView {
|
||||
>
|
||||
{move || albums.get().map(|albums| {
|
||||
albums.map(|albums| {
|
||||
DashboardRow::new("Albums".to_string(), albums.into_iter().map(|album| {
|
||||
Box::new(album) as Box<dyn DashboardTile>
|
||||
}).collect())
|
||||
let tiles = albums.into_iter().map(|album| {
|
||||
album.into()
|
||||
}).collect::<Vec<_>>();
|
||||
|
||||
view! {
|
||||
<DashboardRow title="Albums" tiles />
|
||||
}
|
||||
})
|
||||
})}
|
||||
</ErrorBoundary>
|
||||
|
8
src/pages/dashboard.rs
Normal file
8
src/pages/dashboard.rs
Normal file
@ -0,0 +1,8 @@
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Dashboard() -> impl IntoView {
|
||||
view! {
|
||||
<h1>"Dashboard"</h1>
|
||||
}
|
||||
}
|
38
src/pages/liked_songs.rs
Normal file
38
src/pages/liked_songs.rs
Normal file
@ -0,0 +1,38 @@
|
||||
use crate::components::error::*;
|
||||
use crate::components::loading::*;
|
||||
use crate::components::song_list::*;
|
||||
use leptos::either::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::api::profile::get_liked_songs;
|
||||
|
||||
#[component]
|
||||
pub fn LikedSongsPage() -> impl IntoView {
|
||||
let liked_songs = Resource::new(|| (), |_| get_liked_songs());
|
||||
|
||||
view! {
|
||||
<h1 class="text-4xl">"Liked Songs"</h1>
|
||||
<Transition
|
||||
fallback=move || view! { <LoadingPage /> }
|
||||
>
|
||||
{move || liked_songs.get().map(|songs| {
|
||||
match songs {
|
||||
Ok(songs) => {
|
||||
Either::Left(view! {
|
||||
<p class="text-neutral-500">{songs.len()} " liked songs"</p>
|
||||
<SongList songs />
|
||||
})
|
||||
},
|
||||
Err(e) => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Error loading liked songs"
|
||||
error=e.to_string()
|
||||
/>
|
||||
})
|
||||
}
|
||||
}
|
||||
})}
|
||||
</Transition>
|
||||
}
|
||||
}
|
@ -1,43 +1,35 @@
|
||||
use crate::auth::login;
|
||||
use crate::api::auth::login;
|
||||
use crate::api::users::UserCredentials;
|
||||
use crate::components::fancy_input::*;
|
||||
use crate::components::loading::Loading;
|
||||
use crate::util::state::GlobalState;
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_icons::*;
|
||||
use crate::users::UserCredentials;
|
||||
use crate::components::loading::Loading;
|
||||
|
||||
#[component]
|
||||
pub fn Login() -> impl IntoView {
|
||||
let (username_or_email, set_username_or_email) = create_signal("".to_string());
|
||||
let (password, set_password) = create_signal("".to_string());
|
||||
let username_or_email = RwSignal::new("".to_string());
|
||||
let password = RwSignal::new("".to_string());
|
||||
|
||||
let (show_password, set_show_password) = create_signal(false);
|
||||
|
||||
let loading = create_rw_signal(false);
|
||||
let error_msg = create_rw_signal(None);
|
||||
|
||||
let toggle_password = move |_| {
|
||||
set_show_password.update(|show_password| *show_password = !*show_password);
|
||||
log!("showing password");
|
||||
};
|
||||
let loading = RwSignal::new(false);
|
||||
let error_msg = RwSignal::new(None);
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
|
||||
let username_or_email1 = username_or_email.get();
|
||||
let password1 = password.get();
|
||||
|
||||
spawn_local(async move {
|
||||
loading.set(true);
|
||||
error_msg.set(None);
|
||||
|
||||
let user_credentials = UserCredentials {
|
||||
username_or_email: username_or_email1,
|
||||
password: password1
|
||||
username_or_email: username_or_email.get_untracked(),
|
||||
password: password.get_untracked(),
|
||||
};
|
||||
|
||||
let user = GlobalState::logged_in_user();
|
||||
|
||||
|
||||
let login_result = login(user_credentials).await;
|
||||
if let Err(err) = login_result {
|
||||
// Handle the error here, e.g., log it or display to the user
|
||||
@ -48,11 +40,11 @@ pub fn Login() -> impl IntoView {
|
||||
user.refetch();
|
||||
} else if let Ok(Some(login_user)) = login_result {
|
||||
// Manually set the user to the new user, avoiding a refetch
|
||||
user.set(Some(login_user));
|
||||
user.set(Some(Some(login_user)));
|
||||
|
||||
// Redirect to the login page
|
||||
log!("Logged in Successfully!");
|
||||
leptos_router::use_navigate()("/", Default::default());
|
||||
leptos_router::hooks::use_navigate()("/", Default::default());
|
||||
log!("Navigated to home page after login");
|
||||
} else if let Ok(None) = login_result {
|
||||
log!("Invalid username or password");
|
||||
@ -67,58 +59,36 @@ pub fn Login() -> impl IntoView {
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="auth-page-container">
|
||||
<div class="login-container">
|
||||
<a class="return" href="/"><Icon icon=icondata::IoReturnUpBackSharp /></a>
|
||||
<div class="header">
|
||||
<h1>LibreTunes</h1>
|
||||
</div>
|
||||
<form class="login-form" action="POST" on:submit=on_submit>
|
||||
<div class="input-box">
|
||||
<input class="login-info" type="text" required
|
||||
on:input = move |ev| {
|
||||
set_username_or_email(event_target_value(&ev));
|
||||
log!("username/email changed to: {}", username_or_email.get());
|
||||
}
|
||||
prop:value=username_or_email
|
||||
/>
|
||||
<span>Username/Email</span>
|
||||
<i></i>
|
||||
<section class="bg-white dark:bg-black flex items-center justify-center h-screen">
|
||||
<div class="rounded-lg shadow bg-white w-full p-12 max-w-md relative">
|
||||
<a class="hover:bg-neutral-400 transition-all duration-500
|
||||
rounded-md absolute left-5 top-5 p-1" href="/">
|
||||
<Icon icon={icondata::IoReturnUpBackSharp} height="1.5rem" width="1.5rem"/>
|
||||
</a>
|
||||
<h1 class="text-5xl font-bold text-accent text-center p-1">"LibreTunes"</h1>
|
||||
<form on:submit=on_submit>
|
||||
<FancyInput label="Username/Email" required=true value=username_or_email />
|
||||
<FancyInput label="Password" password=true required=true value=password />
|
||||
<a class="hover-link my-1">"Forgot Password?"</a>
|
||||
<div
|
||||
class="text-red-800 text-base"
|
||||
style="min-height: calc(var(--text-base--line-height) * var(--text-base));"
|
||||
>
|
||||
{ move || error_msg.get() }
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<input class="login-password" type={move || if show_password() { "text" } else { "password"} } required
|
||||
on:input = move |ev| {
|
||||
set_password(event_target_value(&ev));
|
||||
log!("password changed to: {}", password.get());
|
||||
}
|
||||
/>
|
||||
<span>Password</span>
|
||||
<i></i>
|
||||
<Show
|
||||
when=move || {show_password() == false}
|
||||
fallback=move || view!{ <button on:click=toggle_password class="login-password-visibility">
|
||||
<Icon icon=icondata::AiEyeInvisibleFilled />
|
||||
</button> /> }
|
||||
>
|
||||
<button on:click=toggle_password class="login-password-visibility">
|
||||
<Icon icon=icondata::AiEyeFilled />
|
||||
</button>
|
||||
|
||||
</Show>
|
||||
</div>
|
||||
<a href="" class="forgot-pw">Forgot Password?</a>
|
||||
<div class="error-msg" >{ move || error_msg.get() }</div>
|
||||
<Show
|
||||
when=move || !loading.get()
|
||||
fallback=move || view! { <Loading /> }
|
||||
fallback=move || view! { <div class="p-3 my-2"> <Loading /> </div> }
|
||||
>
|
||||
<input type="submit" value="Login" />
|
||||
<input class="bg-accent rounded-md text-white text-base
|
||||
w-full p-3 my-2 font-semibold cursor-pointer" type="submit" value="Login" />
|
||||
</Show>
|
||||
<span class="go-to-signup">
|
||||
New here? <a href="/signup">Create an Account</a>
|
||||
<span class="text-base text-neutral-500 my-1">
|
||||
"New here?"
|
||||
<a class="hover-link ml-2" href="/signup">"Create an Account"</a>
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
10
src/pages/mod.rs
Normal file
10
src/pages/mod.rs
Normal file
@ -0,0 +1,10 @@
|
||||
pub mod album;
|
||||
pub mod artist;
|
||||
pub mod dashboard;
|
||||
pub mod liked_songs;
|
||||
pub mod login;
|
||||
pub mod playlist;
|
||||
pub mod profile;
|
||||
pub mod search;
|
||||
pub mod signup;
|
||||
pub mod song;
|
218
src/pages/playlist.rs
Normal file
218
src/pages/playlist.rs
Normal file
@ -0,0 +1,218 @@
|
||||
use crate::api::playlists::*;
|
||||
use crate::components::error::*;
|
||||
use crate::components::loading::*;
|
||||
use crate::components::song_list::*;
|
||||
use crate::models::backend;
|
||||
use crate::util::state::GlobalState;
|
||||
use leptos::either::*;
|
||||
use leptos::ev::{keydown, KeyboardEvent};
|
||||
use leptos::html::{Button, Input};
|
||||
use leptos::logging::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_icons::*;
|
||||
use leptos_router::components::Form;
|
||||
use leptos_router::hooks::{use_navigate, use_params_map};
|
||||
use leptos_use::{on_click_outside, use_event_listener};
|
||||
use std::sync::Arc;
|
||||
use web_sys::Response;
|
||||
|
||||
#[component]
|
||||
pub fn PlaylistPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
|
||||
view! {
|
||||
{move || params.with(|params| {
|
||||
match params.get("id").map(|id| id.parse::<i32>()) {
|
||||
Some(Ok(id)) => {
|
||||
Either::Left(view! { <PlaylistIdPage id /> })
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Invalid Playlist ID"
|
||||
error=e.to_string()
|
||||
/>
|
||||
})
|
||||
},
|
||||
None => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="No Playlist ID"
|
||||
message="You must specify a playlist ID to view its page."
|
||||
/>
|
||||
})
|
||||
}
|
||||
}
|
||||
})}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PlaylistIdPage(#[prop(into)] id: Signal<i32>) -> impl IntoView {
|
||||
let playlist_songs = Resource::new(id, get_playlist_songs);
|
||||
|
||||
view! {
|
||||
<Transition
|
||||
fallback=move || view! { <LoadingPage /> }
|
||||
>
|
||||
{move || GlobalState::playlists().get().map(|playlists| {
|
||||
let playlist = playlists.map(|playlists| {
|
||||
playlists.into_iter().find(|playlist| playlist.id == id.get())
|
||||
});
|
||||
|
||||
match playlist {
|
||||
Ok(Some(playlist)) => {
|
||||
Either::Left(view! { <PlaylistInfo playlist /> })
|
||||
},
|
||||
Ok(None) => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Playlist not found"
|
||||
message="The playlist you are looking for does not exist."
|
||||
/>
|
||||
})
|
||||
}
|
||||
Err(e) => Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Error loading playlist"
|
||||
error=e.to_string()
|
||||
/>
|
||||
}),
|
||||
}
|
||||
})}
|
||||
{move || playlist_songs.get().map(|playlist_songs| {
|
||||
match playlist_songs {
|
||||
Ok(playlist_songs) => {
|
||||
Either::Left(view! { <SongList songs=playlist_songs /> })
|
||||
},
|
||||
Err(e) => Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Error loading playlist songs"
|
||||
error=e.to_string()
|
||||
/>
|
||||
}),
|
||||
}
|
||||
})}
|
||||
</Transition>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn PlaylistInfo(playlist: backend::Playlist) -> impl IntoView {
|
||||
let on_img_edit_response = Arc::new(move |response: &Response| {
|
||||
if response.ok() {
|
||||
// TODO inform browser that image has changed
|
||||
} else {
|
||||
error!("Error editing playlist image: {}", response.status());
|
||||
// TODO toast
|
||||
}
|
||||
});
|
||||
|
||||
let playing = RwSignal::new(false);
|
||||
|
||||
let editing_name = RwSignal::new(false);
|
||||
let playlist_name = RwSignal::new(playlist.name.clone());
|
||||
|
||||
let name_edit_input = NodeRef::<Input>::new();
|
||||
|
||||
let edit_complete = move || {
|
||||
editing_name.set(false);
|
||||
|
||||
spawn_local(async move {
|
||||
if let Err(e) = rename_playlist(playlist.id, playlist_name.get_untracked()).await {
|
||||
error!("Error editing playlist name: {}", e);
|
||||
// TODO toast
|
||||
} else {
|
||||
GlobalState::playlists().refetch();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let _edit_close_handler = on_click_outside(name_edit_input, move |_| edit_complete());
|
||||
|
||||
let _edit_enter_handler =
|
||||
use_event_listener(name_edit_input, keydown, move |event: KeyboardEvent| {
|
||||
if event.key() == "Enter" {
|
||||
event.prevent_default();
|
||||
edit_complete();
|
||||
}
|
||||
});
|
||||
|
||||
let on_play = move |_| {
|
||||
playing.set(!playing.get());
|
||||
};
|
||||
|
||||
let delete_btn = NodeRef::<Button>::new();
|
||||
|
||||
let confirm_delete = RwSignal::new(false);
|
||||
|
||||
let on_delete = move |_| {
|
||||
if confirm_delete.get_untracked() {
|
||||
spawn_local(async move {
|
||||
if let Err(e) = delete_playlist(playlist.id).await {
|
||||
error!("Error deleting playlist: {}", e);
|
||||
} else {
|
||||
GlobalState::playlists().refetch();
|
||||
use_navigate()("/", Default::default());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
confirm_delete.set(true);
|
||||
}
|
||||
};
|
||||
|
||||
let _delete_escape_handler = on_click_outside(delete_btn, move |_| {
|
||||
confirm_delete.set(false);
|
||||
});
|
||||
|
||||
let on_edit = move |_| {
|
||||
editing_name.set(true);
|
||||
|
||||
name_edit_input.on_load(move |input| {
|
||||
input.select();
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex items-center">
|
||||
<div class="group relative">
|
||||
<img class="w-70 h-70 p-5 rounded-4xl group-hover:brightness-45 transition-all"
|
||||
src={format!("/assets/images/playlist/{}.webp", playlist.id)} onerror={crate::util::img_fallback::MUSIC_IMG_FALLBACK} />
|
||||
<Form action="/api/playlists/edit_image" method="POST" enctype="multipart/form-data".to_string() on_response=on_img_edit_response.clone()>
|
||||
<input type="hidden" name="id" value={playlist.id} />
|
||||
<label for="edit-playlist-img">
|
||||
<Icon icon={icondata::BiPencilSolid} {..} class="absolute bottom-10 right-10 w-8 h-8 control opacity-0 group-hover:opacity-100" />
|
||||
</label>
|
||||
<input id="edit-playlist-img" type="file" accept="image/*" class="hidden" onchange="form.submit()" />
|
||||
</Form>
|
||||
</div>
|
||||
<div>
|
||||
<Show
|
||||
when=move || editing_name.get()
|
||||
fallback=move || view! {
|
||||
<h1 class="text-4xl" on:click=on_edit>{playlist_name}</h1>
|
||||
}
|
||||
>
|
||||
<input type="text" bind:value=playlist_name
|
||||
class="bg-neutral-800 text-neutral-200 border border-neutral-600 rounded-lg p-2 w-full outline-none text-4xl"
|
||||
required
|
||||
node_ref=name_edit_input autocomplete="off" />
|
||||
</Show>
|
||||
<p>{format!("Last Updated {}", playlist.updated_at.format("%B %-d %Y"))}</p>
|
||||
<div class="flex">
|
||||
<button class="control" on:click=on_play>
|
||||
{move || if playing.get() {
|
||||
Either::Left(view! { <Icon icon={icondata::BsPauseFill} {..} class="w-12 h-12" /> })
|
||||
} else {
|
||||
Either::Right(view! { <Icon icon={icondata::BsPlayFill} {..} class="w-12 h-12" /> })
|
||||
}}
|
||||
</button>
|
||||
<button node_ref=delete_btn class="control" on:click=on_delete style={move || if confirm_delete.get() { "color: red;" } else { "" }} >
|
||||
<Icon icon={icondata::AiDeleteOutlined} {..} class="w-12 h-12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
@ -1,18 +1,18 @@
|
||||
use leptos::*;
|
||||
use leptos_router::use_params_map;
|
||||
use leptos::either::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos_icons::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
use server_fn::error::NoCustomError;
|
||||
|
||||
use crate::components::dashboard_row::DashboardRow;
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
use crate::components::song_list::*;
|
||||
use crate::components::loading::*;
|
||||
use crate::components::error::*;
|
||||
use crate::components::loading::*;
|
||||
use crate::components::song_list::*;
|
||||
|
||||
use crate::api::profile::*;
|
||||
|
||||
use crate::models::User;
|
||||
use crate::users::get_user_by_id;
|
||||
use crate::api::users::get_user_by_id;
|
||||
use crate::models::backend::User;
|
||||
use crate::util::state::GlobalState;
|
||||
|
||||
/// Duration in seconds backwards from now to aggregate history data for
|
||||
@ -30,287 +30,305 @@ const TOP_ARTISTS_COUNT: i64 = 10;
|
||||
/// Shows the current user's profile if no id is specified, or a user's profile if an id is specified in the path
|
||||
#[component]
|
||||
pub fn Profile() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
let params = use_params_map();
|
||||
|
||||
view! {
|
||||
<div class="profile-container home-component">
|
||||
{move || params.with(|params| {
|
||||
match params.get("id").map(|id| id.parse::<i32>()) {
|
||||
None => {
|
||||
// No id specified, show the current user's profile
|
||||
view! { <OwnProfile /> }.into_view()
|
||||
},
|
||||
Some(Ok(id)) => {
|
||||
// Id specified, get the user and show their profile
|
||||
view! { <UserIdProfile id /> }.into_view()
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
// Invalid id, return an error
|
||||
view! {
|
||||
<Error<String>
|
||||
title="Invalid User ID"
|
||||
error=e.to_string()
|
||||
/>
|
||||
}.into_view()
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
view! {
|
||||
{move || params.with(|params| {
|
||||
match params.get("id").map(|id| id.parse::<i32>()) {
|
||||
None => {
|
||||
// No id specified, show the current user's profile
|
||||
EitherOf3::A(view! { <OwnProfile /> })
|
||||
},
|
||||
Some(Ok(id)) => {
|
||||
// Id specified, get the user and show their profile
|
||||
EitherOf3::B(view! { <UserIdProfile id /> })
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
// Invalid id, return an error
|
||||
EitherOf3::C(view! {
|
||||
<Error<String>
|
||||
title="Invalid User ID"
|
||||
error=e.to_string()
|
||||
/>
|
||||
})
|
||||
}
|
||||
}
|
||||
})}
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the logged in user's profile
|
||||
#[component]
|
||||
fn OwnProfile() -> impl IntoView {
|
||||
view! {
|
||||
<Transition
|
||||
fallback=move || view! { <LoadingPage /> }
|
||||
>
|
||||
{move || GlobalState::logged_in_user().get().map(|user| {
|
||||
match user {
|
||||
Some(user) => {
|
||||
let user_id = user.id.unwrap();
|
||||
view! {
|
||||
<UserProfile user />
|
||||
<TopSongs user_id={user_id} />
|
||||
<RecentSongs user_id={user_id} />
|
||||
<TopArtists user_id={user_id} />
|
||||
}.into_view()
|
||||
},
|
||||
None => view! {
|
||||
<Error<String>
|
||||
title="Not Logged In"
|
||||
message="You must be logged in to view your profile"
|
||||
/>
|
||||
}.into_view(),
|
||||
}
|
||||
})}
|
||||
</Transition>
|
||||
}
|
||||
view! {
|
||||
<Transition
|
||||
fallback=move || view! { <LoadingPage /> }
|
||||
>
|
||||
{move || GlobalState::logged_in_user().get().map(|user| {
|
||||
match user {
|
||||
Some(user) => {
|
||||
let user_id = user.id;
|
||||
Either::Left(view! {
|
||||
<UserProfile user />
|
||||
<TopSongs user_id={user_id} />
|
||||
<RecentSongs user_id={user_id} />
|
||||
<TopArtists user_id={user_id} />
|
||||
})
|
||||
},
|
||||
None => Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Not Logged In"
|
||||
message="You must be logged in to view your profile"
|
||||
/>
|
||||
}),
|
||||
}
|
||||
})}
|
||||
</Transition>
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a user's profile by ID
|
||||
#[component]
|
||||
fn UserIdProfile(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
let user_info = create_resource(move || id.get(), move |id| {
|
||||
get_user_by_id(id)
|
||||
});
|
||||
fn UserIdProfile(#[prop(into)] id: Signal<i32>) -> impl IntoView {
|
||||
let user_info = Resource::new(move || id.get(), get_user_by_id);
|
||||
|
||||
// Show the details if the user is found
|
||||
let show_details = create_rw_signal(false);
|
||||
// Show the details if the user is found
|
||||
let show_details = RwSignal::new(false);
|
||||
|
||||
view!{
|
||||
<Transition
|
||||
fallback=move || view! { <LoadingPage /> }
|
||||
>
|
||||
{move || user_info.get().map(|user| {
|
||||
match user {
|
||||
Ok(Some(user)) => {
|
||||
show_details.set(true);
|
||||
view! {
|
||||
<Transition
|
||||
fallback=move || view! { <LoadingPage /> }
|
||||
>
|
||||
{move || user_info.get().map(|user| {
|
||||
match user {
|
||||
Ok(Some(user)) => {
|
||||
show_details.set(true);
|
||||
|
||||
view! { <UserProfile user /> }.into_view()
|
||||
},
|
||||
Ok(None) => {
|
||||
show_details.set(false);
|
||||
EitherOf3::A(view! { <UserProfile user /> })
|
||||
},
|
||||
Ok(None) => {
|
||||
show_details.set(false);
|
||||
|
||||
view! {
|
||||
<Error<String>
|
||||
title="User Not Found"
|
||||
message=format!("User with ID {} not found", id.get())
|
||||
/>
|
||||
}.into_view()
|
||||
},
|
||||
Err(error) => {
|
||||
show_details.set(false);
|
||||
EitherOf3::B(view! {
|
||||
<Error<String>
|
||||
title="User Not Found"
|
||||
message=format!("User with ID {} not found", id.get())
|
||||
/>
|
||||
})
|
||||
},
|
||||
Err(error) => {
|
||||
show_details.set(false);
|
||||
|
||||
view! {
|
||||
<ServerError<NoCustomError>
|
||||
title="Error Getting User"
|
||||
error
|
||||
/>
|
||||
}.into_view()
|
||||
}
|
||||
}
|
||||
})}
|
||||
</Transition>
|
||||
<div hidden={move || !show_details.get()}>
|
||||
<TopSongs user_id={id} />
|
||||
<RecentSongs user_id={id} />
|
||||
<TopArtists user_id={id} />
|
||||
</div>
|
||||
}
|
||||
EitherOf3::C(view! {
|
||||
<ServerError<NoCustomError>
|
||||
title="Error Getting User"
|
||||
error
|
||||
/>
|
||||
})
|
||||
}
|
||||
}
|
||||
})}
|
||||
</Transition>
|
||||
<div hidden={move || !show_details.get()}>
|
||||
<TopSongs user_id={id} />
|
||||
<RecentSongs user_id={id} />
|
||||
<TopArtists user_id={id} />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a profile for a User object
|
||||
#[component]
|
||||
fn UserProfile(user: User) -> impl IntoView {
|
||||
let user_id = user.id.unwrap();
|
||||
let profile_image_path = format!("/assets/images/profile/{}.webp", user_id);
|
||||
let profile_image_path = format!("/assets/images/profile/{}.webp", user.id);
|
||||
|
||||
view! {
|
||||
<div class="profile-header">
|
||||
<object class="profile-image" data={profile_image_path.clone()} type="image/webp">
|
||||
<Icon class="profile-image" icon=icondata::CgProfile width="75" height="75"/>
|
||||
</object>
|
||||
<h1>{user.username}</h1>
|
||||
</div>
|
||||
<div class="profile-details">
|
||||
<p>
|
||||
{user.email}
|
||||
{
|
||||
user.created_at.map(|created_at| {
|
||||
format!(" • Joined {}", created_at.format("%B %Y"))
|
||||
})
|
||||
}
|
||||
{
|
||||
if user.admin {
|
||||
" • Admin"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
view! {
|
||||
<div class="flex">
|
||||
<object class="w-35 h-35 rounded-full p-5" data={profile_image_path.clone()} type="image/webp">
|
||||
<Icon icon={icondata::CgProfile} width="100" height="100" />
|
||||
</object>
|
||||
<h1 class="text-4xl self-center">{user.username}</h1>
|
||||
</div>
|
||||
<p class="m-2">
|
||||
{user.email}
|
||||
{format!(" • Joined {}", user.created_at.format("%B %Y"))}
|
||||
{
|
||||
if user.admin {
|
||||
" • Admin"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a list of top songs for a user
|
||||
#[component]
|
||||
fn TopSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView {
|
||||
let top_songs = create_resource(move || user_id.get(), |user_id| async move {
|
||||
use chrono::{Local, Duration};
|
||||
let now = Local::now();
|
||||
let start = now - Duration::seconds(HISTORY_SECS);
|
||||
let top_songs = top_songs(user_id, start.naive_utc(), now.naive_utc(), Some(TOP_SONGS_COUNT)).await;
|
||||
fn TopSongs(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
|
||||
let top_songs = Resource::new(
|
||||
move || user_id.get(),
|
||||
|user_id| async move {
|
||||
use chrono::{Duration, Local};
|
||||
let now = Local::now();
|
||||
let start = now - Duration::seconds(HISTORY_SECS);
|
||||
let top_songs = top_songs(
|
||||
user_id,
|
||||
start.naive_utc(),
|
||||
now.naive_utc(),
|
||||
Some(TOP_SONGS_COUNT),
|
||||
)
|
||||
.await;
|
||||
|
||||
top_songs.map(|top_songs| {
|
||||
top_songs.into_iter().map(|(plays, song)| {
|
||||
let plays = if plays == 1 {
|
||||
format!("{} Play", plays)
|
||||
} else {
|
||||
format!("{} Plays", plays)
|
||||
};
|
||||
top_songs.map(|top_songs| {
|
||||
top_songs
|
||||
.into_iter()
|
||||
.map(|(plays, song)| {
|
||||
let plays = if plays == 1 {
|
||||
format!("{plays} Play")
|
||||
} else {
|
||||
format!("{plays} Plays")
|
||||
};
|
||||
|
||||
(song, plays)
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
(song, plays)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
view! {
|
||||
<h2>{format!("Top Songs {}", HISTORY_MESSAGE)}</h2>
|
||||
<Transition
|
||||
fallback=move || view! { <Loading /> }
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback=|errors| view! {
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
|
||||
.collect_view()
|
||||
}
|
||||
}
|
||||
>
|
||||
{move ||
|
||||
top_songs.get().map(|top_songs| {
|
||||
top_songs.map(|top_songs| {
|
||||
view! {
|
||||
<SongListExtra songs=top_songs />
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
</ErrorBoundary>
|
||||
</Transition>
|
||||
}
|
||||
view! {
|
||||
|
||||
<h2 class="text-xl font-bold">{format!("Top Songs {HISTORY_MESSAGE}")}</h2>
|
||||
<Transition
|
||||
fallback=move || view! { <Loading /> }
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback=|errors| view! {
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
|
||||
.collect_view()
|
||||
}
|
||||
}
|
||||
>
|
||||
{move ||
|
||||
top_songs.get().map(|top_songs| {
|
||||
top_songs.map(|top_songs| {
|
||||
view! {
|
||||
<SongListExtra songs=top_songs />
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
</ErrorBoundary>
|
||||
</Transition>
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a list of recently played songs for a user
|
||||
#[component]
|
||||
fn RecentSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView {
|
||||
let recent_songs = create_resource(move || user_id.get(), |user_id| async move {
|
||||
let recent_songs = recent_songs(user_id, Some(RECENT_SONGS_COUNT)).await;
|
||||
fn RecentSongs(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
|
||||
let recent_songs = Resource::new(
|
||||
move || user_id.get(),
|
||||
|user_id| async move {
|
||||
let recent_songs = recent_songs(user_id, Some(RECENT_SONGS_COUNT)).await;
|
||||
|
||||
recent_songs.map(|recent_songs| {
|
||||
recent_songs.into_iter().map(|(_date, song)| {
|
||||
song
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
recent_songs.map(|recent_songs| {
|
||||
recent_songs
|
||||
.into_iter()
|
||||
.map(|(_date, song)| song)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
view! {
|
||||
<h2>"Recently Played"</h2>
|
||||
<Transition
|
||||
fallback=move || view! { <Loading /> }
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback=|errors| view! {
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
|
||||
.collect_view()
|
||||
}
|
||||
}
|
||||
>
|
||||
{move ||
|
||||
recent_songs.get().map(|recent_songs| {
|
||||
recent_songs.map(|recent_songs| {
|
||||
view! {
|
||||
<SongList songs=recent_songs />
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
</ErrorBoundary>
|
||||
</Transition>
|
||||
}
|
||||
view! {
|
||||
<h2 class="text-xl font-bold">"Recently Played"</h2>
|
||||
<Transition
|
||||
fallback=move || view! { <Loading /> }
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback=|errors| view! {
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
|
||||
.collect_view()
|
||||
}
|
||||
}
|
||||
>
|
||||
{move ||
|
||||
recent_songs.get().map(|recent_songs| {
|
||||
recent_songs.map(|recent_songs| {
|
||||
view! {
|
||||
<SongList songs=recent_songs />
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
</ErrorBoundary>
|
||||
</Transition>
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a list of top artists for a user
|
||||
#[component]
|
||||
fn TopArtists(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView {
|
||||
let top_artists = create_resource(move || user_id.get(), |user_id| async move {
|
||||
use chrono::{Local, Duration};
|
||||
fn TopArtists(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
|
||||
let top_artists = Resource::new(
|
||||
move || user_id.get(),
|
||||
|user_id| async move {
|
||||
use chrono::{Duration, Local};
|
||||
|
||||
let now = Local::now();
|
||||
let start = now - Duration::seconds(HISTORY_SECS);
|
||||
let top_artists = top_artists(user_id, start.naive_utc(), now.naive_utc(), Some(TOP_ARTISTS_COUNT)).await;
|
||||
let now = Local::now();
|
||||
let start = now - Duration::seconds(HISTORY_SECS);
|
||||
let top_artists = top_artists(
|
||||
user_id,
|
||||
start.naive_utc(),
|
||||
now.naive_utc(),
|
||||
Some(TOP_ARTISTS_COUNT),
|
||||
)
|
||||
.await;
|
||||
|
||||
top_artists.map(|top_artists| {
|
||||
top_artists.into_iter().map(|(_plays, artist)| {
|
||||
artist
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
top_artists.map(|top_artists| {
|
||||
top_artists
|
||||
.into_iter()
|
||||
.map(|(_plays, artist)| artist)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
view! {
|
||||
<Transition
|
||||
fallback=move || view! {
|
||||
<h2>{format!("Top Artists {}", HISTORY_MESSAGE)}</h2>
|
||||
<Loading />
|
||||
}
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback=|errors| view! {
|
||||
<h2>{format!("Top Artists {}", HISTORY_MESSAGE)}</h2>
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
|
||||
.collect_view()
|
||||
}
|
||||
}
|
||||
>
|
||||
{move ||
|
||||
top_artists.get().map(|top_artists| {
|
||||
top_artists.map(|top_artists| {
|
||||
let tiles = top_artists.into_iter().map(|artist| {
|
||||
Box::new(artist) as Box<dyn DashboardTile>
|
||||
}).collect::<Vec<_>>();
|
||||
view! {
|
||||
<Transition
|
||||
fallback=move || view! {
|
||||
<h2 class="text-xl font-bold">{format!("Top Artists {HISTORY_MESSAGE}")}</h2>
|
||||
<Loading />
|
||||
}
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback=|errors| view! {
|
||||
<h2 class="text-xl font-bold">{format!("Top Artists {HISTORY_MESSAGE}")}</h2>
|
||||
{move || errors.get()
|
||||
.into_iter()
|
||||
.map(|(_, e)| view! { <p>{e.to_string()}</p>})
|
||||
.collect_view()
|
||||
}
|
||||
}
|
||||
>
|
||||
{move ||
|
||||
top_artists.get().map(|top_artists| {
|
||||
top_artists.map(|top_artists| {
|
||||
let tiles = top_artists.into_iter().map(|artist| {
|
||||
artist.into()
|
||||
}).collect::<Vec<_>>();
|
||||
|
||||
DashboardRow::new(format!("Top Artists {}", HISTORY_MESSAGE), tiles)
|
||||
})
|
||||
})
|
||||
}
|
||||
</ErrorBoundary>
|
||||
</Transition>
|
||||
}
|
||||
view! {
|
||||
<DashboardRow title=format!("Top Artists {}", HISTORY_MESSAGE) tiles />
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
</ErrorBoundary>
|
||||
</Transition>
|
||||
}
|
||||
}
|
||||
|
95
src/pages/search.rs
Normal file
95
src/pages/search.rs
Normal file
@ -0,0 +1,95 @@
|
||||
use crate::api::search::search;
|
||||
use crate::components::dashboard_row::*;
|
||||
use crate::components::error::*;
|
||||
use crate::components::loading::*;
|
||||
use crate::components::song_list::*;
|
||||
use leptos::html::Input;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::query_signal;
|
||||
|
||||
#[component]
|
||||
pub fn Search() -> impl IntoView {
|
||||
// Sync the search query with the URL parameter "query"
|
||||
let (query, set_query) = query_signal::<String>("query");
|
||||
|
||||
let search_sig = RwSignal::new(query.get_untracked().unwrap_or(String::new()));
|
||||
Effect::new(move || set_query.set(Some(search_sig.get())));
|
||||
Effect::new(move || search_sig.set(query.get().unwrap_or(String::new())));
|
||||
|
||||
let search = Resource::new(
|
||||
move || search_sig.get(),
|
||||
move |search_sig| search(search_sig, 10),
|
||||
);
|
||||
|
||||
let input_ref = NodeRef::<Input>::new();
|
||||
input_ref.on_load(move |input| {
|
||||
// Select all text in the input field, or just focus it if empty
|
||||
input.select();
|
||||
});
|
||||
|
||||
view! {
|
||||
<input node_ref=input_ref
|
||||
class="bg-neutral-800 text-neutral-200 border border-neutral-600 rounded-lg p-2 w-full outline-none"
|
||||
type="text" placeholder="Search..." bind:value=search_sig autofocus />
|
||||
|
||||
<Suspense
|
||||
fallback=|| view! { <Loading /> }
|
||||
>
|
||||
<ErrorBoundary
|
||||
fallback=|errors| {
|
||||
errors.get().into_iter().map(|(_id, error)| {
|
||||
view! {
|
||||
<Error<String>
|
||||
message=error.to_string()
|
||||
/>
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
}
|
||||
>
|
||||
{move || {
|
||||
search.get().map(|results| {
|
||||
results.map(|(albums, artists, songs)| {
|
||||
view! {
|
||||
{
|
||||
(albums.is_empty() && artists.is_empty() && songs.is_empty()).then(|| {
|
||||
view! {
|
||||
<h2 class="text-xl text-neutral-500">"No Results"</h2>
|
||||
}
|
||||
})
|
||||
}
|
||||
{
|
||||
(!albums.is_empty()).then(|| {
|
||||
view! {
|
||||
<DashboardRow
|
||||
title="Albums"
|
||||
tiles=albums.into_iter().map(|(album, _score)| album.into()).collect()
|
||||
/>
|
||||
}
|
||||
})
|
||||
}
|
||||
{
|
||||
(!artists.is_empty()).then(|| {
|
||||
view! {
|
||||
<DashboardRow
|
||||
title="Artists"
|
||||
tiles=artists.into_iter().map(|(artist, _score)| artist.into()).collect()
|
||||
/>
|
||||
}
|
||||
})
|
||||
}
|
||||
{
|
||||
(!songs.is_empty()).then(|| {
|
||||
view! {
|
||||
<h2 class="text-xl font-bold">"Songs"</h2>
|
||||
<SongList songs=songs.into_iter().map(|(song, _score)| song).collect() />
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}}
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
}
|
||||
}
|
@ -1,36 +1,30 @@
|
||||
use crate::auth::signup;
|
||||
use crate::models::User;
|
||||
use crate::api::auth::signup;
|
||||
use crate::components::fancy_input::*;
|
||||
use crate::components::loading::Loading;
|
||||
use crate::models::backend::NewUser;
|
||||
use crate::util::state::GlobalState;
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use leptos_icons::*;
|
||||
use crate::components::loading::Loading;
|
||||
|
||||
#[component]
|
||||
pub fn Signup() -> impl IntoView {
|
||||
let (username, set_username) = create_signal("".to_string());
|
||||
let (email, set_email) = create_signal("".to_string());
|
||||
let (password, set_password) = create_signal("".to_string());
|
||||
let username = RwSignal::new("".to_string());
|
||||
let email = RwSignal::new("".to_string());
|
||||
let password = RwSignal::new("".to_string());
|
||||
|
||||
let (show_password, set_show_password) = create_signal(false);
|
||||
|
||||
let loading = create_rw_signal(false);
|
||||
let error_msg = create_rw_signal(None);
|
||||
|
||||
let toggle_password = move |_| {
|
||||
set_show_password.update(|show_password| *show_password = !*show_password);
|
||||
log!("showing password");
|
||||
};
|
||||
let loading = RwSignal::new(false);
|
||||
let error_msg = RwSignal::new(None);
|
||||
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
let mut new_user = User {
|
||||
id: None,
|
||||
username: username.get(),
|
||||
email: email.get(),
|
||||
password: Some(password.get()),
|
||||
created_at: None,
|
||||
let new_user = NewUser {
|
||||
username: username.get_untracked(),
|
||||
email: email.get_untracked(),
|
||||
password: Some(password.get_untracked()),
|
||||
admin: false,
|
||||
image_path: None,
|
||||
};
|
||||
log!("new user: {:?}", new_user);
|
||||
|
||||
@ -48,14 +42,12 @@ pub fn Signup() -> impl IntoView {
|
||||
// Since we're not sure what the state is, manually refetch the user
|
||||
user.refetch();
|
||||
} else {
|
||||
// Manually set the user to the new user, avoiding a refetch
|
||||
new_user.password = None;
|
||||
user.set(Some(new_user));
|
||||
user.refetch();
|
||||
|
||||
// Redirect to the login page
|
||||
log!("Signed up successfully!");
|
||||
leptos_router::use_navigate()("/", Default::default());
|
||||
log!("Navigated to home page after signup")
|
||||
leptos_router::hooks::use_navigate()("/", Default::default());
|
||||
log!("Navigated to home page after signup");
|
||||
}
|
||||
|
||||
loading.set(false);
|
||||
@ -63,64 +55,36 @@ pub fn Signup() -> impl IntoView {
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="auth-page-container">
|
||||
<div class="signup-container">
|
||||
<a class="return" href="/"><Icon icon=icondata::IoReturnUpBackSharp /></a>
|
||||
<div class="header">
|
||||
<h1>LibreTunes</h1>
|
||||
</div>
|
||||
<form class="signup-form" action="POST" on:submit=on_submit>
|
||||
<div class="input-box">
|
||||
<input class="signup-email" type="text" required
|
||||
on:input = move |ev| {
|
||||
set_email(event_target_value(&ev));
|
||||
log!("email changed to: {}", email.get());
|
||||
}
|
||||
prop:value=email
|
||||
/>
|
||||
<span>Email</span>
|
||||
<i></i>
|
||||
<section class="bg-white dark:bg-black flex items-center justify-center h-screen">
|
||||
<div class="rounded-lg shadow bg-white w-full p-12 max-w-md relative">
|
||||
<a class="hover:bg-neutral-400 transition-all duration-500
|
||||
rounded-md absolute left-5 top-5 p-1" href="/">
|
||||
<Icon icon={icondata::IoReturnUpBackSharp} height="1.5rem" width="1.5rem"/>
|
||||
</a>
|
||||
<h1 class="text-5xl font-bold text-accent text-center p-1">"LibreTunes"</h1>
|
||||
<form on:submit=on_submit>
|
||||
<FancyInput label="Email" required=true value=email />
|
||||
<FancyInput label="Username" required=true value=username />
|
||||
<FancyInput label="Password" password=true required=true value=password />
|
||||
<div
|
||||
class="text-red-800 text-base"
|
||||
style="min-height: calc(var(--text-base--line-height) * var(--text-base));"
|
||||
>
|
||||
{ move || error_msg.get() }
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<input class="signup-username" type="text" required
|
||||
on:input = move |ev| {
|
||||
set_username(event_target_value(&ev));
|
||||
log!("username changed to: {}", username.get());
|
||||
}
|
||||
/>
|
||||
<span>Username</span>
|
||||
<i></i>
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<input class="signup-password" type={move || if show_password() { "text" } else { "password"} } required style="width: 90%;"
|
||||
on:input = move |ev| {
|
||||
set_password(event_target_value(&ev));
|
||||
log!("password changed to: {}", password.get());
|
||||
}
|
||||
/>
|
||||
<span>Password</span>
|
||||
<i></i>
|
||||
<Show
|
||||
when=move || {show_password() == false}
|
||||
fallback=move || view!{ <button on:click=toggle_password class="password-visibility"> <Icon icon=icondata::AiEyeInvisibleFilled /></button> /> }
|
||||
>
|
||||
<button on:click=toggle_password class="password-visibility">
|
||||
<Icon icon=icondata::AiEyeFilled />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="error-msg">{ move || error_msg.get() }</div>
|
||||
<Show
|
||||
when=move || !loading.get()
|
||||
fallback=move || view!{ <Loading /> }
|
||||
fallback=move || view! { <div class="p-3 my-2"> <Loading /> </div> }
|
||||
>
|
||||
<input type="submit" value="Sign Up" />
|
||||
<input class="bg-accent rounded-md text-white text-base
|
||||
w-full p-3 my-2 font-semibold cursor-pointer" type="submit" value="Sign Up" />
|
||||
</Show>
|
||||
<span class="go-to-login">
|
||||
Already Have an Account? <a href="/login" class="link" >Go to Login</a>
|
||||
<span class="text-base text-neutral-500 my-1">
|
||||
"Already have an account?"
|
||||
<a class="hover-link ml-2" href="/login" >"Go to Login"</a>
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
@ -1,59 +1,54 @@
|
||||
use leptos::*;
|
||||
use leptos_router::use_params_map;
|
||||
use leptos::either::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos_icons::*;
|
||||
use leptos_router::hooks::use_params_map;
|
||||
use server_fn::error::NoCustomError;
|
||||
|
||||
use crate::api::songs;
|
||||
use crate::components::loading::*;
|
||||
use crate::components::error::*;
|
||||
use crate::components::song_list::*;
|
||||
use crate::api::songs::*;
|
||||
use crate::songdata::SongData;
|
||||
use crate::components::error::*;
|
||||
use crate::components::loading::*;
|
||||
use crate::components::song_list::*;
|
||||
use crate::models::frontend;
|
||||
use crate::util::state::GlobalState;
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::borrow::Borrow;
|
||||
|
||||
const PLAY_BTN_SIZE: &str = "3rem";
|
||||
use std::rc::Rc;
|
||||
|
||||
#[component]
|
||||
pub fn SongPage() -> impl IntoView {
|
||||
let params = use_params_map();
|
||||
|
||||
view! {
|
||||
<div class="song-container home-component">
|
||||
{move || params.with(|params| {
|
||||
match params.get("id").map(|id| id.parse::<i32>()) {
|
||||
Some(Ok(id)) => {
|
||||
view! { <SongDetails id /> }.into_view()
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
view! {
|
||||
<Error<String>
|
||||
title="Invalid Song ID"
|
||||
error=e.to_string()
|
||||
/>
|
||||
}.into_view()
|
||||
},
|
||||
None => {
|
||||
view! {
|
||||
<Error<String>
|
||||
title="No Song ID"
|
||||
message="You must specify a song ID to view its page."
|
||||
/>
|
||||
}.into_view()
|
||||
}
|
||||
{move || params.with(|params| {
|
||||
match params.get("id").map(|id| id.parse::<i32>()) {
|
||||
Some(Ok(id)) => {
|
||||
Either::Left(view! { <SongDetails id /> })
|
||||
},
|
||||
Some(Err(e)) => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="Invalid Song ID"
|
||||
error=e.to_string()
|
||||
/>
|
||||
})
|
||||
},
|
||||
None => {
|
||||
Either::Right(view! {
|
||||
<Error<String>
|
||||
title="No Song ID"
|
||||
message="You must specify a song ID to view its page."
|
||||
/>
|
||||
})
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
})}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SongDetails(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
let song_info = create_resource(move || id.get(), move |id| {
|
||||
get_song_by_id(id)
|
||||
});
|
||||
fn SongDetails(#[prop(into)] id: Signal<i32>) -> impl IntoView {
|
||||
let song_info = Resource::new(move || id.get(), get_song_by_id);
|
||||
|
||||
view! {
|
||||
<Transition
|
||||
@ -62,23 +57,23 @@ fn SongDetails(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
{move || song_info.get().map(|song| {
|
||||
match song {
|
||||
Ok(Some(song)) => {
|
||||
view! { <SongOverview song /> }.into_view()
|
||||
EitherOf3::A(view! { <SongOverview song /> })
|
||||
},
|
||||
Ok(None) => {
|
||||
view! {
|
||||
EitherOf3::B(view! {
|
||||
<Error<String>
|
||||
title="Song Not Found"
|
||||
message=format!("Song with ID {} not found", id.get())
|
||||
/>
|
||||
}.into_view()
|
||||
})
|
||||
},
|
||||
Err(error) => {
|
||||
view! {
|
||||
EitherOf3::C(view! {
|
||||
<ServerError<NoCustomError>
|
||||
title="Error Fetching Song"
|
||||
error
|
||||
/>
|
||||
}.into_view()
|
||||
})
|
||||
}
|
||||
}
|
||||
})}
|
||||
@ -89,11 +84,8 @@ fn SongDetails(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SongOverview(song: SongData) -> impl IntoView {
|
||||
let liked = create_rw_signal(song.like_dislike.map(|ld| ld.0).unwrap_or(false));
|
||||
let disliked = create_rw_signal(song.like_dislike.map(|ld| ld.1).unwrap_or(false));
|
||||
|
||||
let playing = create_rw_signal(false);
|
||||
fn SongOverview(song: frontend::Song) -> impl IntoView {
|
||||
let playing = RwSignal::new(false);
|
||||
let icon = Signal::derive(move || {
|
||||
if playing.get() {
|
||||
icondata::BsPauseFill
|
||||
@ -102,9 +94,10 @@ fn SongOverview(song: SongData) -> impl IntoView {
|
||||
}
|
||||
});
|
||||
|
||||
create_effect(move |_| {
|
||||
Effect::new(move |_| {
|
||||
GlobalState::play_status().with(|status| {
|
||||
playing.set(status.queue.front().map(|song| song.id) == Some(song.id) && status.playing);
|
||||
playing
|
||||
.set(status.queue.front().map(|song| song.id) == Some(song.id) && status.playing);
|
||||
});
|
||||
});
|
||||
|
||||
@ -120,32 +113,34 @@ fn SongOverview(song: SongData) -> impl IntoView {
|
||||
}
|
||||
|
||||
status.queue.clear();
|
||||
status.queue.push_front(<Rc<SongData> as Borrow<SongData>>::borrow(&song_rc).clone());
|
||||
status.queue.push_front(
|
||||
<Rc<frontend::Song> as Borrow<frontend::Song>>::borrow(&song_rc).clone(),
|
||||
);
|
||||
status.playing = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="song-header">
|
||||
<img class="song-image" src=song.image_path />
|
||||
<h1>{song.title}</h1>
|
||||
<div class="flex">
|
||||
<div class="relative w-35 h-35">
|
||||
<img src=song.image_path />
|
||||
<Icon icon on:click={toggle_play_song} {..}
|
||||
class="control w-15 h-15 absolute top-1/2 left-1/2 translate-[-50%]" />
|
||||
</div>
|
||||
<div class="self-center p-2">
|
||||
<h1 class="text-4xl">{song.title}</h1>
|
||||
<p><SongArtists artists=song.artists /></p>
|
||||
<p><SongAlbum album=song.album /></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="song-actions">
|
||||
<button on:click=toggle_play_song>
|
||||
<Icon class="controlbtn" width=PLAY_BTN_SIZE height=PLAY_BTN_SIZE icon />
|
||||
</button>
|
||||
<SongLikeDislike song_id=song.id liked disliked /><br/>
|
||||
</div>
|
||||
<p><SongArtists artists=song.artists /></p>
|
||||
<p><SongAlbum album=song.album /></p>
|
||||
<p>{format!("Duration: {}:{:02}", song.duration / 60, song.duration % 60)}</p>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SongPlays(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
let plays = create_resource(move || id.get(), move |id| songs::get_song_plays(id));
|
||||
fn SongPlays(#[prop(into)] id: Signal<i32>) -> impl IntoView {
|
||||
let plays = Resource::new(move || id.get(), songs::get_song_plays);
|
||||
|
||||
view! {
|
||||
<Transition
|
||||
@ -154,17 +149,17 @@ fn SongPlays(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
{move || plays.get().map(|plays| {
|
||||
match plays {
|
||||
Ok(plays) => {
|
||||
view! {
|
||||
<p>{format!("Plays: {}", plays)}</p>
|
||||
}.into_view()
|
||||
Either::Left(view! {
|
||||
<p>{format!("Plays: {plays}")}</p>
|
||||
})
|
||||
},
|
||||
Err(error) => {
|
||||
view! {
|
||||
Either::Right(view! {
|
||||
<ServerError<NoCustomError>
|
||||
title="Error fetching song plays"
|
||||
error
|
||||
/>
|
||||
}.into_view()
|
||||
})
|
||||
}
|
||||
}
|
||||
})}
|
||||
@ -173,8 +168,8 @@ fn SongPlays(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn MySongPlays(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
let plays = create_resource(move || id.get(), move |id| songs::get_my_song_plays(id));
|
||||
fn MySongPlays(#[prop(into)] id: Signal<i32>) -> impl IntoView {
|
||||
let plays = Resource::new(move || id.get(), songs::get_my_song_plays);
|
||||
|
||||
view! {
|
||||
<Transition
|
||||
@ -183,17 +178,17 @@ fn MySongPlays(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
{move || plays.get().map(|plays| {
|
||||
match plays {
|
||||
Ok(plays) => {
|
||||
view! {
|
||||
<p>{format!("My Plays: {}", plays)}</p>
|
||||
}.into_view()
|
||||
Either::Left(view! {
|
||||
<p>{format!("My Plays: {plays}")}</p>
|
||||
})
|
||||
},
|
||||
Err(error) => {
|
||||
view! {
|
||||
Either::Right(view! {
|
||||
<ServerError<NoCustomError>
|
||||
title="Error fetching my song plays"
|
||||
error
|
||||
/>
|
||||
}.into_view()
|
||||
})
|
||||
}
|
||||
}
|
||||
})}
|
@ -1,64 +0,0 @@
|
||||
use leptos::HtmlElement;
|
||||
use leptos::NodeRef;
|
||||
use leptos::html::Audio;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::songdata::SongData;
|
||||
|
||||
/// Represents the global state of the audio player feature of LibreTunes
|
||||
pub struct PlayStatus {
|
||||
/// Whether or not the audio player is currently playing
|
||||
pub playing: bool,
|
||||
/// Whether or not the queue is open
|
||||
pub queue_open: bool,
|
||||
/// A reference to the HTML audio element
|
||||
pub audio_player: Option<NodeRef<Audio>>,
|
||||
/// A queue of songs that have been played, ordered from oldest to newest
|
||||
pub history: VecDeque<SongData>,
|
||||
/// A queue of songs that have yet to be played, ordered from next up to last
|
||||
pub queue: VecDeque<SongData>,
|
||||
}
|
||||
|
||||
impl PlayStatus {
|
||||
/// Returns the HTML audio element if it has been created and is present, otherwise returns None
|
||||
///
|
||||
/// Instead of:
|
||||
/// ```
|
||||
/// let status = libretunes::playstatus::PlayStatus::default();
|
||||
/// if let Some(audio) = status.audio_player {
|
||||
/// if let Some(audio) = audio.get() {
|
||||
/// let _ = audio.play();
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// You can do:
|
||||
/// ```
|
||||
/// let status = libretunes::playstatus::PlayStatus::default();
|
||||
/// if let Some(audio) = status.get_audio() {
|
||||
/// let _ = audio.play();
|
||||
/// }
|
||||
/// ```
|
||||
pub fn get_audio(&self) -> Option<HtmlElement<Audio>> {
|
||||
if let Some(audio) = &self.audio_player {
|
||||
if let Some(audio) = audio.get() {
|
||||
return Some(audio);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlayStatus {
|
||||
/// Creates a paused PlayStatus with no audio player, no progress update handle, and empty queue/history
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
playing: false,
|
||||
queue_open: false,
|
||||
audio_player: None,
|
||||
history: VecDeque::new(),
|
||||
queue: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
122
src/queue.rs
122
src/queue.rs
@ -1,122 +0,0 @@
|
||||
use crate::models::Artist;
|
||||
use crate::song::Song;
|
||||
use crate::util::state::GlobalState;
|
||||
use leptos::ev::MouseEvent;
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
use leptos_icons::*;
|
||||
use leptos::ev::DragEvent;
|
||||
|
||||
const RM_BTN_SIZE: &str = "2.5rem";
|
||||
|
||||
fn remove_song_fn(index: usize) {
|
||||
if index == 0 {
|
||||
log!("Error: Trying to remove currently playing song (index 0) from queue");
|
||||
} else {
|
||||
log!("Remove Song from Queue: Song is not currently playing, deleting song from queue and not adding to history");
|
||||
GlobalState::play_status().update(|status| {
|
||||
status.queue.remove(index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Queue() -> impl IntoView {
|
||||
let status = GlobalState::play_status();
|
||||
|
||||
let remove_song = move |index: usize| {
|
||||
remove_song_fn(index);
|
||||
log!("Removed song {}", index + 1);
|
||||
};
|
||||
|
||||
let prevent_focus = move |e: MouseEvent| {
|
||||
e.prevent_default();
|
||||
};
|
||||
|
||||
let index_being_dragged = create_rw_signal(-1);
|
||||
|
||||
let index_being_hovered = create_rw_signal(-1);
|
||||
|
||||
let on_drag_start = move |_e: DragEvent, index: usize| {
|
||||
// set the index of the item being dragged
|
||||
index_being_dragged.set(index as i32);
|
||||
};
|
||||
|
||||
let on_drop = move |e: DragEvent| {
|
||||
e.prevent_default();
|
||||
// if the index of the item being dragged is not the same as the index of the item being hovered over
|
||||
if index_being_dragged.get() != index_being_hovered.get() && index_being_dragged.get() > 0 && index_being_hovered.get() > 0 {
|
||||
// get the index of the item being dragged
|
||||
let dragged_index = index_being_dragged.get_untracked() as usize;
|
||||
// get the index of the item being hovered over
|
||||
let hovered_index = index_being_hovered.get_untracked() as usize;
|
||||
// update the queue
|
||||
status.update(|status| {
|
||||
// remove the dragged item from the list
|
||||
let dragged_item = status.queue.remove(dragged_index);
|
||||
// insert the dragged item at the index of the item being hovered over
|
||||
status.queue.insert(hovered_index, dragged_item.unwrap());
|
||||
});
|
||||
// reset the index of the item being dragged
|
||||
index_being_dragged.set(-1);
|
||||
// reset the index of the item being hovered over
|
||||
index_being_hovered.set(-1);
|
||||
log!("drag end. Moved item from index {} to index {}", dragged_index, hovered_index);
|
||||
}
|
||||
else {
|
||||
// reset the index of the item being dragged
|
||||
index_being_dragged.set(-1);
|
||||
// reset the index of the item being hovered over
|
||||
index_being_hovered.set(-1);
|
||||
}
|
||||
};
|
||||
|
||||
let on_drag_enter = move |_e: DragEvent, index: usize| {
|
||||
// set the index of the item being hovered over
|
||||
index_being_hovered.set(index as i32);
|
||||
};
|
||||
|
||||
let on_drag_over = move |e: DragEvent| {
|
||||
e.prevent_default();
|
||||
};
|
||||
|
||||
view!{
|
||||
<Show
|
||||
when=move || status.with(|status| status.queue_open)
|
||||
fallback=|| view!{""}>
|
||||
<div class="queue">
|
||||
<div class="queue-header">
|
||||
<h2>Queue</h2>
|
||||
</div>
|
||||
<ul>
|
||||
{
|
||||
move || status.with(|status| status.queue.iter()
|
||||
.enumerate()
|
||||
.map(|(index, song)| view! {
|
||||
<div class="queue-item"
|
||||
draggable="true"
|
||||
on:dragstart=move |e: DragEvent| on_drag_start(e, index)
|
||||
on:drop=on_drop
|
||||
on:dragenter=move |e: DragEvent| on_drag_enter(e, index)
|
||||
on:dragover=on_drag_over
|
||||
>
|
||||
<Song song_image_path=song.image_path.clone() song_title=song.title.clone() song_artist=Artist::display_list(&song.artists) />
|
||||
<Show
|
||||
when=move || index != 0
|
||||
fallback=|| view!{
|
||||
<p>Playing</p>
|
||||
}>
|
||||
<button on:click=move |_| remove_song(index) on:mousedown=prevent_focus>
|
||||
<Icon class="remove-song" width=RM_BTN_SIZE height=RM_BTN_SIZE icon=icondata::CgTrash />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
}
|
||||
}
|
@ -20,6 +20,7 @@ diesel::table! {
|
||||
artists (id) {
|
||||
id -> Int4,
|
||||
name -> Varchar,
|
||||
image_path -> Nullable<Varchar>,
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +54,7 @@ diesel::table! {
|
||||
updated_at -> Timestamp,
|
||||
owner_id -> Int4,
|
||||
name -> Text,
|
||||
image_path -> Nullable<Varchar>,
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,7 +98,7 @@ diesel::table! {
|
||||
release_date -> Nullable<Date>,
|
||||
storage_path -> Varchar,
|
||||
image_path -> Nullable<Varchar>,
|
||||
added_date -> Date,
|
||||
added_date -> Timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,6 +110,7 @@ diesel::table! {
|
||||
password -> Varchar,
|
||||
created_at -> Timestamp,
|
||||
admin -> Bool,
|
||||
image_path -> Nullable<Varchar>,
|
||||
}
|
||||
}
|
||||
|
||||
|
109
src/search.rs
109
src/search.rs
@ -1,109 +0,0 @@
|
||||
use leptos::*;
|
||||
use crate::models::{Artist, Album, Song};
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use diesel::sql_types::*;
|
||||
use diesel::*;
|
||||
use diesel::pg::Pg;
|
||||
use diesel::expression::AsExpression;
|
||||
|
||||
use crate::database::get_db_conn;
|
||||
|
||||
// Define pg_trgm operators
|
||||
// Functions do not use indices for queries, so we need to use operators
|
||||
diesel::infix_operator!(Similarity, " % ", backend: Pg);
|
||||
diesel::infix_operator!(Distance, " <-> ", Float, backend: Pg);
|
||||
|
||||
// Create functions to make use of the operators in queries
|
||||
fn trgm_similar<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
|
||||
-> Similarity<T::Expression, U::Expression> {
|
||||
Similarity::new(left.as_expression(), right.as_expression())
|
||||
}
|
||||
|
||||
fn trgm_distance<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
|
||||
-> Distance<T::Expression, U::Expression> {
|
||||
Distance::new(left.as_expression(), right.as_expression())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Search for albums by title
|
||||
///
|
||||
/// # Arguments
|
||||
/// `query` - The search query. This will be used to perform a fuzzy search on the album titles
|
||||
/// `limit` - The maximum number of results to return
|
||||
///
|
||||
/// # Returns
|
||||
/// A Result containing a vector of albums if the search was successful, or an error if the search failed
|
||||
#[server(endpoint = "search_albums")]
|
||||
pub async fn search_albums(query: String, limit: i64) -> Result<Vec<Album>, ServerFnError> {
|
||||
use crate::schema::albums::dsl::*;
|
||||
|
||||
Ok(albums
|
||||
.filter(trgm_similar(title, query.clone()))
|
||||
.order_by(trgm_distance(title, query))
|
||||
.limit(limit)
|
||||
.load(&mut get_db_conn())?)
|
||||
}
|
||||
|
||||
/// Search for artists by name
|
||||
///
|
||||
/// # Arguments
|
||||
/// `query` - The search query. This will be used to perform a fuzzy search on the artist names
|
||||
/// `limit` - The maximum number of results to return
|
||||
///
|
||||
/// # Returns
|
||||
/// A Result containing a vector of artists if the search was successful, or an error if the search failed
|
||||
#[server(endpoint = "search_artists")]
|
||||
pub async fn search_artists(query: String, limit: i64) -> Result<Vec<Artist>, ServerFnError> {
|
||||
use crate::schema::artists::dsl::*;
|
||||
|
||||
Ok(artists
|
||||
.filter(trgm_similar(name, query.clone()))
|
||||
.order_by(trgm_distance(name, query))
|
||||
.limit(limit)
|
||||
.load(&mut get_db_conn())?)
|
||||
}
|
||||
|
||||
/// Search for songs by title
|
||||
///
|
||||
/// # Arguments
|
||||
/// `query` - The search query. This will be used to perform a fuzzy search on the song titles
|
||||
/// `limit` - The maximum number of results to return
|
||||
///
|
||||
/// # Returns
|
||||
/// A Result containing a vector of songs if the search was successful, or an error if the search failed
|
||||
#[server(endpoint = "search_songs")]
|
||||
pub async fn search_songs(query: String, limit: i64) -> Result<Vec<Song>, ServerFnError> {
|
||||
use crate::schema::songs::dsl::*;
|
||||
|
||||
Ok(songs
|
||||
.filter(trgm_similar(title, query.clone()))
|
||||
.order_by(trgm_distance(title, query))
|
||||
.limit(limit)
|
||||
.load(&mut get_db_conn())?)
|
||||
}
|
||||
|
||||
/// Search for songs, albums, and artists by title or name
|
||||
///
|
||||
/// # Arguments
|
||||
/// `query` - The search query. This will be used to perform a fuzzy search on the
|
||||
/// song titles, album titles, and artist names
|
||||
/// `limit` - The maximum number of results to return for each type
|
||||
///
|
||||
/// # Returns
|
||||
/// A Result containing a tuple of vectors of albums, artists, and songs if the search was successful,
|
||||
#[server(endpoint = "search")]
|
||||
pub async fn search(query: String, limit: i64) -> Result<(Vec<Album>, Vec<Artist>, Vec<Song>), ServerFnError> {
|
||||
let albums = search_albums(query.clone(), limit);
|
||||
let artists = search_artists(query.clone(), limit);
|
||||
let songs = search_songs(query.clone(), limit);
|
||||
|
||||
use tokio::join;
|
||||
|
||||
let (albums, artists, songs) = join!(albums, artists, songs);
|
||||
Ok((albums?, artists?, songs?))
|
||||
}
|
14
src/song.rs
14
src/song.rs
@ -1,14 +0,0 @@
|
||||
use leptos::*;
|
||||
|
||||
#[component]
|
||||
pub fn Song(song_image_path: String, song_title: String, song_artist: String) -> impl IntoView {
|
||||
view!{
|
||||
<div class="queue-song">
|
||||
<img src={song_image_path} alt={song_title.clone()} />
|
||||
<div class="queue-song-info">
|
||||
<h3>{song_title}</h3>
|
||||
<p>{song_artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user