Compare commits
103 Commits
45-impleme
...
115-create
Author | SHA1 | Date | |
---|---|---|---|
9350c74091 | |||
88cd5544fd | |||
94880ead7c | |||
837dd5ea3c | |||
86e5e733b3 | |||
8dbaaf317d | |||
d4897b4227 | |||
1f70da1747 | |||
a70de76c4d | |||
f299254d55 | |||
14aa4d236e | |||
3cfcacc9ba | |||
f39d93bd0c
|
|||
cdffd2c5eb | |||
e90ea56d14 | |||
b222b7911f | |||
0550b18d77
|
|||
36e7a5827b | |||
134b425ce6
|
|||
c678d93661
|
|||
c18bf277b9
|
|||
097b1fc491
|
|||
f02a22d805
|
|||
553e24800b
|
|||
2793391f00 | |||
cce9b946ec | |||
51858593c2 | |||
2bb9b3bdd7 | |||
a43955726a | |||
e03853929f | |||
2c71e065b7
|
|||
20a3c0c7df | |||
b2f138e7e9
|
|||
33aa285deb
|
|||
4105d0cedb
|
|||
414c1f0e20
|
|||
9927b94d59
|
|||
b9551e5bee | |||
73910a087a
|
|||
236c75492a | |||
ec76269367
|
|||
c92d68dddf
|
|||
8fd064dffc | |||
a83fb9dea5
|
|||
bf89838297 | |||
b7e5327903
|
|||
8fea5fcec7
|
|||
266bba05aa | |||
86f9b6fbb3
|
|||
da45a503ac | |||
074f866e5f
|
|||
cb0936003c | |||
d66849c6cb
|
|||
b54ecb5b27 | |||
0ee61f288f
|
|||
8c516e3070 | |||
22f3749ee4
|
|||
e55656c0e6
|
|||
18cbb0017f
|
|||
53cde3bed6
|
|||
eab011070d
|
|||
9d6013b8a4
|
|||
fa811350ae
|
|||
7f4108e1c5
|
|||
906b5bd9dd
|
|||
7cb556e5ef | |||
bab819822d
|
|||
0c54f0aeb8
|
|||
afbcc65457
|
|||
e5a6e8f44e
|
|||
d45f102be7
|
|||
74b34b1e54
|
|||
2ea3979a89 | |||
21bb2d127f
|
|||
76631126de
|
|||
f8bbe319bd
|
|||
ffad799f72
|
|||
c72d4aee18
|
|||
8a474077da | |||
6202b287f0
|
|||
6b8c8d41e4
|
|||
4aa90cdefb
|
|||
ee70b1080e
|
|||
62def56a41
|
|||
0c64865cd9
|
|||
cc11de4f3d
|
|||
81f0b9310f
|
|||
af66381f5f
|
|||
683f979bc7
|
|||
f4908ad3b3
|
|||
b1cc0f156c
|
|||
ec43ab472d
|
|||
eebd58f5a6
|
|||
e411ab9eee
|
|||
775c7eff3a
|
|||
2ed67e5545
|
|||
08f1b95c18
|
|||
7a750b60aa
|
|||
2b9d849578
|
|||
28ff98ab32
|
|||
a0da89204c
|
|||
c07237ad8a
|
|||
727029b757
|
@ -15,3 +15,6 @@ DATABASE_URL=postgresql://libretunes:password@localhost:5432/libretunes
|
||||
# POSTGRES_HOST=localhost
|
||||
# POSTGRES_PORT=5432
|
||||
# POSTGRES_DB=libretunes
|
||||
|
||||
LIBRETUNES_AUDIO_PATH=assets/audio
|
||||
LIBRETUNES_IMAGE_PATH=assets/images
|
||||
|
75
.gitea/workflows/push.yaml
Normal file
75
.gitea/workflows/push.yaml
Normal file
@ -0,0 +1,75 @@
|
||||
name: Push Workflows
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: libretunes-cicd
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Build project
|
||||
env:
|
||||
RUSTFLAGS: "-D warnings"
|
||||
run: cargo-leptos build
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Gitea container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.registry }}
|
||||
username: ${{ env.actions_user }}
|
||||
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
|
||||
- name: Get Image Name
|
||||
id: get-image-name
|
||||
run: |
|
||||
echo "IMAGE_NAME=$(echo ${{ env.registry }}/${{ gitea.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
push: true
|
||||
tags: "${{ steps.get-image-name.outputs.IMAGE_NAME }}:${{ gitea.sha }}"
|
||||
cache-from: type=registry,ref=${{ steps.get-image-name.outputs.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
cache-to: type=inline
|
||||
- name: Build and push Docker image with "latest" tag
|
||||
uses: docker/build-push-action@v5
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
with:
|
||||
push: true
|
||||
tags: "${{ steps.get-image-name.outputs.IMAGE_NAME }}:latest"
|
||||
cache-from: type=registry,ref=${{ steps.get-image-name.outputs.IMAGE_NAME }}:latest
|
||||
cache-to: type=inline
|
||||
|
||||
test:
|
||||
runs-on: libretunes-cicd
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Test project
|
||||
run: cargo test --all-targets --all-features
|
||||
|
||||
leptos-test:
|
||||
runs-on: libretunes-cicd
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Run Leptos tests
|
||||
run: cargo-leptos test
|
||||
|
||||
docs:
|
||||
runs-on: libretunes-cicd
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Generate docs
|
||||
run: cargo doc --no-deps
|
||||
- name: Upload docs
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: docs
|
||||
path: target/doc
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -24,6 +24,7 @@ playwright/.cache/
|
||||
*.jpeg
|
||||
*.png
|
||||
*.gif
|
||||
*.webp
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
101
.gitlab-ci.yml
101
.gitlab-ci.yml
@ -1,101 +0,0 @@
|
||||
# Build the project
|
||||
build:
|
||||
needs: []
|
||||
image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest
|
||||
variables:
|
||||
RUSTFLAGS: "-D warnings"
|
||||
script:
|
||||
- cargo-leptos build
|
||||
|
||||
.docker:
|
||||
image: docker:latest
|
||||
services:
|
||||
- docker:dind
|
||||
tags:
|
||||
- docker
|
||||
before_script:
|
||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
|
||||
# Build the docker image and push it to the registry
|
||||
docker-build:
|
||||
needs: ["build"]
|
||||
extends: .docker
|
||||
script:
|
||||
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
|
||||
# If running on the default branch, tag as latest
|
||||
- if [ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]; then docker tag
|
||||
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
||||
$CI_REGISTRY_IMAGE:latest; fi
|
||||
- docker push $CI_REGISTRY_IMAGE --all-tags
|
||||
|
||||
# Run leptos tests
|
||||
leptos-tests:
|
||||
needs: ["build"]
|
||||
image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest
|
||||
script:
|
||||
- cargo-leptos test
|
||||
|
||||
# Run all tests
|
||||
tests:
|
||||
needs: ["build"]
|
||||
image: $CI_REGISTRY/libretunes/ops/docker-leptos:latest
|
||||
script:
|
||||
- cargo test --all-targets --all-features
|
||||
|
||||
# Generate docs
|
||||
cargo-doc:
|
||||
needs: []
|
||||
image: rust:slim
|
||||
script:
|
||||
- cargo doc --no-deps
|
||||
artifacts:
|
||||
paths:
|
||||
- target/doc
|
||||
|
||||
# Start the review environment
|
||||
start-review:
|
||||
extends: .docker
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
when: manual
|
||||
script:
|
||||
- apk add curl openssl
|
||||
- cd cicd
|
||||
- echo "$CLOUDFLARE_TUNNEL_AUTH_JSON" > tunnel-auth.json
|
||||
- ./add-dns.sh $CLOUDFLARE_ZONE_ID review-$CI_COMMIT_SHORT_SHA libretunes-auto-review $CLOUDFLARE_API_TOKEN $CLOUDFLARE_TUNNEL_ID
|
||||
- ./create-tunnel-config.sh http://libretunes:3000 review-$CI_COMMIT_SHORT_SHA.libretunes.xyz $CLOUDFLARE_TUNNEL_ID
|
||||
- export COMPOSE_PROJECT_NAME=review-$CI_COMMIT_SHORT_SHA
|
||||
- export POSTGRES_PASSWORD=$(openssl rand -hex 16)
|
||||
- export LIBRETUNES_VERSION=$CI_COMMIT_SHORT_SHA
|
||||
- docker compose --file docker-compose-cicd.yml pull
|
||||
- docker compose --file docker-compose-cicd.yml create
|
||||
- export CONFIG_VOL_NAME=review-${CI_COMMIT_SHORT_SHA}_cloudflared-config
|
||||
- export TMP_CONTAINER_NAME=$(docker run --rm -d -v $CONFIG_VOL_NAME:/data busybox sh -c "sleep infinity")
|
||||
- docker cp tunnel-auth.json $TMP_CONTAINER_NAME:/data/auth.json
|
||||
- docker cp cloudflared-tunnel-config.yml $TMP_CONTAINER_NAME:/data/config.yml
|
||||
- docker stop $TMP_CONTAINER_NAME
|
||||
- docker compose --file docker-compose-cicd.yml up -d
|
||||
environment:
|
||||
name: review/$CI_COMMIT_SHORT_SHA
|
||||
url: https://review-$CI_COMMIT_SHORT_SHA.libretunes.xyz
|
||||
on_stop: stop-review
|
||||
auto_stop_in: 1 week
|
||||
|
||||
# Stop the review environment
|
||||
stop-review:
|
||||
needs: ["start-review"]
|
||||
extends: .docker
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
when: manual
|
||||
allow_failure: true
|
||||
script:
|
||||
- apk add jq curl
|
||||
- ./cicd/remove-dns.sh $CLOUDFLARE_ZONE_ID review-$CI_COMMIT_SHORT_SHA.libretunes.xyz libretunes-auto-review $CLOUDFLARE_API_TOKEN
|
||||
- export COMPOSE_PROJECT_NAME=review-$CI_COMMIT_SHORT_SHA
|
||||
- export LIBRETUNES_VERSION=$CI_COMMIT_SHORT_SHA
|
||||
- docker compose --file cicd/docker-compose-cicd.yml down
|
||||
- docker compose --file cicd/docker-compose-cicd.yml rm -f -v
|
||||
environment:
|
||||
name: review/$CI_COMMIT_SHORT_SHA
|
||||
action: stop
|
501
Cargo.lock
generated
501
Cargo.lock
generated
@ -1,6 +1,6 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
@ -17,6 +17,12 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.11"
|
||||
@ -145,7 +151,7 @@ dependencies = [
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
@ -161,7 +167,7 @@ dependencies = [
|
||||
"serde",
|
||||
"sync_wrapper 1.0.0",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower 0.4.13",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
@ -175,7 +181,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
@ -216,7 +222,7 @@ dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"miniz_oxide",
|
||||
"miniz_oxide 0.7.2",
|
||||
"object",
|
||||
"rustc-demangle",
|
||||
]
|
||||
@ -233,6 +239,29 @@ version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.69.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0"
|
||||
dependencies = [
|
||||
"bitflags 2.5.0",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools",
|
||||
"lazy_static",
|
||||
"lazycell",
|
||||
"log",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
"shlex",
|
||||
"syn 2.0.58",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@ -308,7 +337,7 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c878c71c2821aa2058722038a59a67583a4240524687c6028571c9b395ded61f"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.14.4",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
@ -332,6 +361,15 @@ version = "1.0.90"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
@ -377,6 +415,26 @@ dependencies = [
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codee"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d3ad3122b0001c7f140cf4d605ef9a9e2c24d96ab0b4fb4347b76de2425f445"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "collection_literals"
|
||||
version = "1.0.1"
|
||||
@ -462,6 +520,12 @@ version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
|
||||
|
||||
[[package]]
|
||||
name = "cow-utils"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.12"
|
||||
@ -477,6 +541,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.2"
|
||||
@ -499,8 +572,18 @@ version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.14.4",
|
||||
"darling_macro 0.14.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989"
|
||||
dependencies = [
|
||||
"darling_core 0.20.10",
|
||||
"darling_macro 0.20.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -513,21 +596,46 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"strsim 0.10.0",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"syn 2.0.58",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.14.4",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
|
||||
dependencies = [
|
||||
"darling_core 0.20.10",
|
||||
"quote",
|
||||
"syn 2.0.58",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "5.5.3"
|
||||
@ -541,6 +649,18 @@ dependencies = [
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "default-struct-builder"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fa90da96b8fd491f5754d1f7a731f73921e3b7aa0ce333c821a0e43666ac14"
|
||||
dependencies = [
|
||||
"darling 0.20.10",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.58",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.3.11"
|
||||
@ -620,6 +740,17 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.58",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenv"
|
||||
version = "0.15.0"
|
||||
@ -653,6 +784,35 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8090f921a24b04994d9929e204f50b498a33ea6ba559ffaa05e04f7ee7fb5ab"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.0.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flexi_logger"
|
||||
version = "0.28.0"
|
||||
@ -857,13 +1017,14 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
|
||||
[[package]]
|
||||
name = "gloo-net"
|
||||
version = "0.5.0"
|
||||
source = "git+https://github.com/rustwasm/gloo.git?rev=a823fab7ecc4068e9a28bd669da5eaf3f0a56380#a823fab7ecc4068e9a28bd669da5eaf3f0a56380"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43aaa242d1239a8822c15c645f02166398da4f8b5c4bae795c1f5b44e9eee173"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"gloo-utils",
|
||||
"http",
|
||||
"http 0.2.12",
|
||||
"js-sys",
|
||||
"pin-project",
|
||||
"serde",
|
||||
@ -874,10 +1035,23 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-timers"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-utils"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/rustwasm/gloo.git?rev=a823fab7ecc4068e9a28bd669da5eaf3f0a56380#a823fab7ecc4068e9a28bd669da5eaf3f0a56380"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
@ -927,6 +1101,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html-escape"
|
||||
version = "0.2.13"
|
||||
@ -936,6 +1119,17 @@ dependencies = [
|
||||
"utf8-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.1.0"
|
||||
@ -954,7 +1148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -965,7 +1159,7 @@ checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@ -997,7 +1191,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
@ -1015,7 +1209,7 @@ checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"pin-project-lite",
|
||||
@ -1046,6 +1240,16 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ico"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3804960be0bb5e4edb1e1ad67afd321a9ecfd875c3e65c099468fd2717d7cae"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icondata"
|
||||
version = "0.3.0"
|
||||
@ -1257,6 +1461,19 @@ dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-convert"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3eb95018550c5770e1a25219a75c192fa2f6475efef2244a0d46799d3fdf6cf"
|
||||
dependencies = [
|
||||
"ico",
|
||||
"magick_rust",
|
||||
"once_cell",
|
||||
"regex",
|
||||
"str-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.2.6"
|
||||
@ -1305,9 +1522,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.69"
|
||||
version = "0.3.70"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d"
|
||||
checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@ -1318,6 +1535,12 @@ version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
|
||||
|
||||
[[package]]
|
||||
name = "lazycell"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||
|
||||
[[package]]
|
||||
name = "leptos"
|
||||
version = "0.6.10"
|
||||
@ -1338,6 +1561,29 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos-use"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ab8914bd0ff8ab5029521540a6e15292dcc05d0f1a791a3aa8cc31a94436bfb"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"codee",
|
||||
"cookie",
|
||||
"default-struct-builder",
|
||||
"futures-util",
|
||||
"gloo-timers",
|
||||
"js-sys",
|
||||
"lazy_static",
|
||||
"leptos",
|
||||
"paste",
|
||||
"thiserror",
|
||||
"unic-langid",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leptos_axum"
|
||||
version = "0.6.10"
|
||||
@ -1570,6 +1816,16 @@ version = "0.2.153"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-targets 0.52.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libretunes"
|
||||
version = "0.1.0"
|
||||
@ -1583,10 +1839,12 @@ dependencies = [
|
||||
"diesel_migrations",
|
||||
"dotenv",
|
||||
"flexi_logger",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"icondata",
|
||||
"image-convert",
|
||||
"lazy_static",
|
||||
"leptos",
|
||||
"leptos-use",
|
||||
"leptos_axum",
|
||||
"leptos_icons",
|
||||
"leptos_meta",
|
||||
@ -1601,7 +1859,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower 0.5.1",
|
||||
"tower-http",
|
||||
"tower-sessions-redis-store",
|
||||
"wasm-bindgen",
|
||||
@ -1618,6 +1876,12 @@ dependencies = [
|
||||
"serde_test",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.11"
|
||||
@ -1644,6 +1908,17 @@ dependencies = [
|
||||
"hashbrown 0.14.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "magick_rust"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29f862f489ba66357a730356f1d48abdb2c5028707b6c17dee7842359bc6372c"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "manyhow"
|
||||
version = "0.10.4"
|
||||
@ -1731,6 +2006,16 @@ dependencies = [
|
||||
"adler",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
@ -1751,7 +2036,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"httparse",
|
||||
"log",
|
||||
"memchr",
|
||||
@ -1956,6 +2241,19 @@ version = "0.3.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52f9d46a34a05a6a57566bc2bfae066ef07585a6e3fa30fbbdff5936380623f0"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "powerfmt"
|
||||
version = "0.2.0"
|
||||
@ -2231,6 +2529,19 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
|
||||
dependencies = [
|
||||
"bitflags 2.5.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.14"
|
||||
@ -2372,7 +2683,7 @@ dependencies = [
|
||||
"dashmap",
|
||||
"futures",
|
||||
"gloo-net",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"inventory",
|
||||
@ -2385,7 +2696,7 @@ dependencies = [
|
||||
"serde_qs",
|
||||
"server_fn_macro_default",
|
||||
"thiserror",
|
||||
"tower",
|
||||
"tower 0.4.13",
|
||||
"tower-layer",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@ -2430,6 +2741,18 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.9"
|
||||
@ -2471,12 +2794,28 @@ version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "str-utils"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60bcb3d541a8fd455189b9e022f27d255d103dafd5087a93cff4c0a156a8b597"
|
||||
dependencies = [
|
||||
"cow-utils",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.5.0"
|
||||
@ -2580,18 +2919,18 @@ checksum = "384595c11a4e2969895cad5a8c4029115f5ab956a9e5ef4de79d11a426e5f20c"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.58"
|
||||
version = "1.0.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297"
|
||||
checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.58"
|
||||
version = "1.0.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7"
|
||||
checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -2600,9 +2939,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.34"
|
||||
version = "0.3.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749"
|
||||
checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
@ -2621,14 +2960,23 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.17"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774"
|
||||
checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.6.0"
|
||||
@ -2774,6 +3122,20 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper 0.1.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-cookies"
|
||||
version = "0.10.0"
|
||||
@ -2784,7 +3146,7 @@ dependencies = [
|
||||
"axum-core",
|
||||
"cookie",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
@ -2793,14 +3155,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.5.2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||
checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97"
|
||||
dependencies = [
|
||||
"bitflags 2.5.0",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
@ -2818,15 +3180,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tower-sessions"
|
||||
@ -2835,7 +3197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b27326208b21807803c5f5aa1020d30ca0432b78cfe251b51a67a05e0baea102"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower-cookies",
|
||||
@ -2856,7 +3218,7 @@ dependencies = [
|
||||
"axum-core",
|
||||
"base64",
|
||||
"futures",
|
||||
"http",
|
||||
"http 1.1.0",
|
||||
"parking_lot",
|
||||
"rand",
|
||||
"serde",
|
||||
@ -2951,6 +3313,24 @@ version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
|
||||
|
||||
[[package]]
|
||||
name = "unic-langid"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23dd9d1e72a73b25e07123a80776aae3e7b0ec461ef94f9151eed6ec88005a44"
|
||||
dependencies = [
|
||||
"unic-langid-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-langid-impl"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a5422c1f65949306c99240b81de9f3f15929f5a8bfe05bb44b034cc8bf593e5"
|
||||
dependencies = [
|
||||
"tinystr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.7.0"
|
||||
@ -3061,19 +3441,20 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.92"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8"
|
||||
checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"wasm-bindgen-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-backend"
|
||||
version = "0.2.92"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da"
|
||||
checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"log",
|
||||
@ -3098,9 +3479,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.92"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
|
||||
checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@ -3108,9 +3489,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.92"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
|
||||
checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -3121,9 +3502,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.92"
|
||||
version = "0.2.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96"
|
||||
checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
@ -3140,14 +3521,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.69"
|
||||
version = "0.3.70"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef"
|
||||
checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "4.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
|
||||
dependencies = [
|
||||
"either",
|
||||
"home",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
13
Cargo.toml
13
Cargo.toml
@ -15,7 +15,7 @@ leptos = { version = "0.6", default-features = false, features = ["nightly"] }
|
||||
leptos_meta = { version = "0.6", features = ["nightly"] }
|
||||
leptos_axum = { version = "0.6", optional = true }
|
||||
leptos_router = { version = "0.6", features = ["nightly"] }
|
||||
wasm-bindgen = { version = "=0.2.92", default-features = false, optional = true }
|
||||
wasm-bindgen = { version = "=0.2.93", default-features = false, optional = true }
|
||||
leptos_icons = { version = "0.3.0" }
|
||||
icondata = { version = "0.3.0" }
|
||||
dotenv = { version = "0.15.0", optional = true }
|
||||
@ -28,8 +28,8 @@ diesel_migrations = { version = "2.1.0", optional = true }
|
||||
pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true }
|
||||
tokio = { version = "1", optional = true, features = ["rt-multi-thread"] }
|
||||
axum = { version = "0.7.5", features = ["tokio", "http1"], default-features = false, optional = true }
|
||||
tower = { version = "0.4.13", optional = true }
|
||||
tower-http = { version = "0.5", optional = true, features = ["fs"] }
|
||||
tower = { version = "0.5.1", optional = true, features = ["util"] }
|
||||
tower-http = { version = "0.6.1", optional = true, features = ["fs"] }
|
||||
thiserror = "1.0.57"
|
||||
tower-sessions-redis-store = { version = "0.11", optional = true }
|
||||
async-trait = { version = "0.1.79", optional = true }
|
||||
@ -40,9 +40,8 @@ multer = { version = "3.0.0", optional = true }
|
||||
log = { version = "0.4.21", optional = true }
|
||||
flexi_logger = { version = "0.28.0", optional = true, default-features = false }
|
||||
web-sys = "0.3.69"
|
||||
|
||||
[patch.crates-io]
|
||||
gloo-net = { git = "https://github.com/rustwasm/gloo.git", rev = "a823fab7ecc4068e9a28bd669da5eaf3f0a56380" }
|
||||
leptos-use = "0.13.5"
|
||||
image-convert = { version = "0.18.0", optional = true, default-features = false }
|
||||
|
||||
[features]
|
||||
hydrate = [
|
||||
@ -74,6 +73,8 @@ ssr = [
|
||||
"multer",
|
||||
"log",
|
||||
"flexi_logger",
|
||||
"leptos-use/ssr",
|
||||
"image-convert",
|
||||
]
|
||||
|
||||
# Defines a size-optimized profile for the WASM bundle in release mode
|
||||
|
45
Dockerfile
45
Dockerfile
@ -1,22 +1,37 @@
|
||||
FROM registry.mregirouard.com/libretunes/ops/docker-leptos/musl:latest as builder
|
||||
FROM rust:slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN rustup default nightly
|
||||
RUN rustup target add wasm32-unknown-unknown
|
||||
RUN cargo install cargo-leptos
|
||||
|
||||
# Install a few dependencies
|
||||
RUN set -eux; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
npm; \
|
||||
pkg-config \
|
||||
clang \
|
||||
build-essential \
|
||||
libssl-dev \
|
||||
libpq-dev \
|
||||
wget; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install tailwindcss@3.1.8 -g
|
||||
# Install ImageMagick
|
||||
RUN cd / && \
|
||||
wget https://github.com/ImageMagick/ImageMagick/archive/refs/tags/7.1.1-38.tar.gz && \
|
||||
tar xf 7.1.1-38.tar.gz && \
|
||||
rm 7.1.1-38.tar.gz && \
|
||||
cd ImageMagick-7.1.1-38 && \
|
||||
./configure && \
|
||||
make install -j $(nproc) && \
|
||||
cd .. && \
|
||||
rm -rf ImageMagick-7.1.1-38
|
||||
|
||||
# Copy project dependency manifests
|
||||
COPY Cargo.toml Cargo.lock /app/
|
||||
|
||||
# Add the target to Cargo.toml so we can statically link:
|
||||
RUN echo 'bin-target-triple = "x86_64-unknown-linux-musl"' >> Cargo.toml
|
||||
|
||||
# Create dummy files to force cargo to build the dependencies
|
||||
RUN mkdir /app/src && mkdir /app/style && mkdir /app/assets && \
|
||||
echo "fn main() {}" | tee /app/src/build.rs > /app/src/main.rs && \
|
||||
@ -27,15 +42,12 @@ RUN mkdir /app/src && mkdir /app/style && mkdir /app/assets && \
|
||||
RUN cargo-leptos build --release --precompress
|
||||
|
||||
RUN rm -rf /app/src /app/style /app/assets
|
||||
COPY style /app/style
|
||||
|
||||
# Minify CSS
|
||||
RUN npx tailwindcss -i /app/style/main.scss -o /app/style/main.scss --minify
|
||||
|
||||
COPY ascii_art.txt /app/ascii_art.txt
|
||||
COPY assets /app/assets
|
||||
COPY src /app/src
|
||||
COPY migrations /app/migrations
|
||||
COPY style /app/style
|
||||
|
||||
# Touch files to force rebuild
|
||||
RUN touch /app/src/main.rs && touch /app/src/lib.rs && touch /app/src/build.rs
|
||||
@ -43,6 +55,11 @@ RUN touch /app/src/main.rs && touch /app/src/lib.rs && touch /app/src/build.rs
|
||||
# Actually build the binary
|
||||
RUN cargo-leptos build --release --precompress
|
||||
|
||||
# Use ldd to list all dependencies of /app/target/release/libretunes, then copy them to /app/libs
|
||||
# Setting LD_LIBRARY_PATH is necessary to find the ImageMagick libraries
|
||||
RUN mkdir /app/libs && LD_LIBRARY_PATH=/usr/local/lib ldd /app/target/release/libretunes | grep "=> /" | \
|
||||
awk '{print $3}' | xargs -I '{}' cp '{}' /app/libs
|
||||
|
||||
# Build the final image
|
||||
FROM scratch
|
||||
|
||||
@ -51,9 +68,15 @@ LABEL description="LibreTunes, an open-source browser audio player and \
|
||||
library manager built for collaborative listening."
|
||||
|
||||
# Copy the binary and the compressed assets to the "site root"
|
||||
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/libretunes /libretunes
|
||||
COPY --from=builder /app/target/release/libretunes /libretunes
|
||||
COPY --from=builder /app/target/site /site
|
||||
|
||||
# Copy libraries to /lib64
|
||||
COPY --from=builder /app/libs /lib64
|
||||
COPY --from=builder /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
|
||||
|
||||
ENV LD_LIBRARY_PATH=/lib64
|
||||
|
||||
# Configure Leptos settings
|
||||
ENV LEPTOS_SITE_ADDR=0.0.0.0:3000
|
||||
ENV LEPTOS_SITE_ROOT=/site
|
||||
|
1
assets/images/placeholders/MusicPlaceholder.svg
Normal file
1
assets/images/placeholders/MusicPlaceholder.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg version="1.1" viewBox="0.0 0.0 960.0 960.0" fill="none" stroke="none" stroke-linecap="square" stroke-miterlimit="10" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg"><clipPath id="p.0"><path d="m0 0l960.0 0l0 960.0l-960.0 0l0 -960.0z" clip-rule="nonzero"/></clipPath><g clip-path="url(#p.0)"><path fill="#4032a8" d="m0 0l960.0 0l0 960.0l-960.0 0z" fill-rule="evenodd"/><path fill="#ffffff" d="m290.11365 265.52475l35.496063 0l0 471.3386l-35.496063 0z" fill-rule="evenodd"/><path fill="#ffffff" d="m115.68951 737.6639l0 0c0 -36.946594 46.99247 -66.897644 104.960625 -66.897644l0 0c57.968155 0 104.96065 29.95105 104.96065 66.897644l0 0c0 36.946533 -46.992493 66.897644 -104.96065 66.897644l0 0c-57.968155 0 -104.960625 -29.95111 -104.960625 -66.897644z" fill-rule="evenodd"/><path fill="#ffffff" d="m723.79346 175.86597l35.496033 0l0 471.33856l-35.496033 0z" fill-rule="evenodd"/><path fill="#ffffff" d="m549.3693 648.00507l0 0c0 -36.946533 46.99243 -66.897644 104.96063 -66.897644l0 0c57.96814 0 104.96057 29.95111 104.96057 66.897644l0 0c0 36.946533 -46.99243 66.897644 -104.96057 66.897644l0 0c-57.9682 0 -104.96063 -29.95111 -104.96063 -66.897644z" fill-rule="evenodd"/><path fill="#ffffff" d="m759.2711 155.4385l-0.01727295 94.79588l-427.05206 100.14052l-42.09601 -84.920654z" fill-rule="evenodd"/><path fill="#ffffff" d="m421.49164 234.64502l21.039368 89.85828l-131.40158 30.803131l-21.039368 -89.85828z" fill-rule="evenodd"/></g></svg>
|
After Width: | Height: | Size: 1.4 KiB |
@ -13,8 +13,11 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
LIBRETUNES_AUDIO_PATH: /assets/audio
|
||||
LIBRETUNES_IMAGE_PATH: /assets/images
|
||||
volumes:
|
||||
- libretunes-audio:/site/audio
|
||||
- libretunes-audio:/assets/audio
|
||||
- libretunes-images:/assets/images
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
@ -50,5 +53,6 @@ services:
|
||||
|
||||
volumes:
|
||||
libretunes-audio:
|
||||
libretunes-images:
|
||||
libretunes-redis:
|
||||
libretunes-postgres:
|
||||
|
@ -0,0 +1,2 @@
|
||||
DROP TABLE song_likes;
|
||||
DROP TABLE song_dislikes;
|
@ -0,0 +1,11 @@
|
||||
CREATE TABLE song_likes (
|
||||
song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||
PRIMARY KEY (song_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE song_dislikes (
|
||||
song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||
PRIMARY KEY (song_id, user_id)
|
||||
);
|
2
migrations/2024-05-19-163229_add_song_history/down.sql
Normal file
2
migrations/2024-05-19-163229_add_song_history/down.sql
Normal file
@ -0,0 +1,2 @@
|
||||
DROP INDEX song_history_user_id_idx;
|
||||
DROP TABLE song_history;
|
8
migrations/2024-05-19-163229_add_song_history/up.sql
Normal file
8
migrations/2024-05-19-163229_add_song_history/up.sql
Normal file
@ -0,0 +1,8 @@
|
||||
CREATE TABLE song_history (
|
||||
id SERIAL PRIMARY KEY UNIQUE NOT NULL,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||
date TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
song_id INTEGER REFERENCES songs(id) ON DELETE CASCADE NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX song_history_user_id_idx ON song_history(user_id);
|
7
migrations/2024-05-20-154208_add_friends/down.sql
Normal file
7
migrations/2024-05-20-154208_add_friends/down.sql
Normal file
@ -0,0 +1,7 @@
|
||||
DROP INDEX friendships_friend_2_idx;
|
||||
DROP INDEX friendships_friend_1_idx;
|
||||
DROP TABLE friendships;
|
||||
|
||||
DROP INDEX incoming_friend_requests_idx;
|
||||
DROP INDEX outgoing_friend_requests_idx;
|
||||
DROP TABLE friend_requests;
|
19
migrations/2024-05-20-154208_add_friends/up.sql
Normal file
19
migrations/2024-05-20-154208_add_friends/up.sql
Normal file
@ -0,0 +1,19 @@
|
||||
CREATE TABLE friend_requests (
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
from_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||
to_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||
PRIMARY KEY (from_id, to_id)
|
||||
);
|
||||
|
||||
CREATE INDEX outgoing_friend_requests_idx ON friend_requests(from_id);
|
||||
CREATE INDEX incoming_friend_requests_idx ON friend_requests(to_id);
|
||||
|
||||
CREATE TABLE friendships (
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
friend_1_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||
friend_2_id INTEGER REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||
PRIMARY KEY (friend_1_id, friend_2_id)
|
||||
);
|
||||
|
||||
CREATE INDEX friendships_friend_1_idx ON friendships(friend_1_id);
|
||||
CREATE INDEX friendships_friend_2_idx ON friendships(friend_2_id);
|
39
src/albumdata.rs
Normal file
39
src/albumdata.rs
Normal file
@ -0,0 +1,39 @@
|
||||
use crate::models::Artist;
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
|
||||
use time::Date;
|
||||
|
||||
/// Holds information about an album
|
||||
///
|
||||
/// Intended to be used in the front-end
|
||||
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<Date>,
|
||||
/// 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)))
|
||||
}
|
||||
}
|
44
src/api/history.rs
Normal file
44
src/api/history.rs
Normal file
@ -0,0 +1,44 @@
|
||||
use std::time::SystemTime;
|
||||
use leptos::*;
|
||||
use crate::models::HistoryEntry;
|
||||
use crate::models::Song;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
use crate::database::get_db_conn;
|
||||
use crate::auth::get_user;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the history of the current user.
|
||||
#[server(endpoint = "history/get")]
|
||||
pub async fn get_history(limit: Option<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)
|
||||
}
|
||||
|
||||
/// 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<(SystemTime, 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")]
|
||||
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(())
|
||||
}
|
3
src/api/mod.rs
Normal file
3
src/api/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod history;
|
||||
pub mod profile;
|
||||
pub mod songs;
|
49
src/api/profile.rs
Normal file
49
src/api/profile.rs
Normal file
@ -0,0 +1,49 @@
|
||||
use leptos::*;
|
||||
use server_fn::codec::{MultipartData, MultipartFormData};
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use crate::auth::get_user;
|
||||
use server_fn::error::NoCustomError;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a user uploading a profile picture. Converts the image to webp and saves it to the server.
|
||||
#[server(input = MultipartFormData, endpoint = "/profile/upload_picture")]
|
||||
pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> {
|
||||
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
|
||||
let mut data = data.into_inner().unwrap();
|
||||
|
||||
let field = data.next_field().await
|
||||
.map_err(|e| ServerFnError::<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()));
|
||||
}
|
||||
|
||||
// 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, WEBPConfig, ImageResource};
|
||||
|
||||
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 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(())
|
||||
}
|
55
src/api/songs.rs
Normal file
55
src/api/songs.rs
Normal file
@ -0,0 +1,55 @@
|
||||
use leptos::*;
|
||||
|
||||
use cfg_if::cfg_if;
|
||||
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use leptos::server_fn::error::NoCustomError;
|
||||
use crate::database::get_db_conn;
|
||||
use crate::auth::get_user;
|
||||
}
|
||||
}
|
||||
|
||||
/// Like or unlike a song
|
||||
#[server(endpoint = "songs/set_like")]
|
||||
pub async fn set_like_song(song_id: i32, like: bool) -> Result<(), ServerFnError> {
|
||||
let user = get_user().await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error getting user: {}", e)))?;
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
|
||||
user.set_like_song(song_id, like, db_con).await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error liking song: {}", e)))
|
||||
}
|
||||
|
||||
/// Dislike or remove dislike from a song
|
||||
#[server(endpoint = "songs/set_dislike")]
|
||||
pub async fn set_dislike_song(song_id: i32, dislike: bool) -> Result<(), ServerFnError> {
|
||||
let user = get_user().await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error getting user: {}", e)))?;
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
|
||||
user.set_dislike_song(song_id, dislike, db_con).await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error disliking song: {}", e)))
|
||||
}
|
||||
|
||||
/// Get the like and dislike status of a song
|
||||
#[server(endpoint = "songs/get_like_dislike")]
|
||||
pub async fn get_like_dislike_song(song_id: i32) -> Result<(bool, bool), ServerFnError> {
|
||||
let user = get_user().await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error getting user: {}", e)))?;
|
||||
|
||||
let db_con = &mut get_db_conn();
|
||||
|
||||
// TODO this could probably be done more efficiently with a tokio::try_join, but
|
||||
// doing so is much more complicated than it would initially seem
|
||||
|
||||
let like = user.get_like_song(song_id, db_con).await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error getting song liked: {}", e)))?;
|
||||
let dislike = user.get_dislike_song(song_id, db_con).await.map_err(|e| ServerFnError::<NoCustomError>::
|
||||
ServerError(format!("Error getting song disliked: {}", e)))?;
|
||||
|
||||
Ok((like, dislike))
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
use crate::playbar::PlayBar;
|
||||
use crate::playbar::CustomTitle;
|
||||
use crate::playstatus::PlayStatus;
|
||||
use crate::queue::Queue;
|
||||
use leptos::*;
|
||||
@ -24,7 +25,7 @@ pub fn App() -> impl IntoView {
|
||||
<Stylesheet id="leptos" href="/pkg/libretunes.css"/>
|
||||
|
||||
// sets the document title
|
||||
<Title text="LibreTunes"/>
|
||||
<CustomTitle play_status=play_status/>
|
||||
|
||||
// content for this welcome page
|
||||
<Router fallback=|| {
|
||||
|
32
src/artistdata.rs
Normal file
32
src/artistdata.rs
Normal file
@ -0,0 +1,32 @@
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
|
||||
/// Holds information about an artist
|
||||
///
|
||||
/// Intended to be used in the front-end
|
||||
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())
|
||||
}
|
||||
}
|
23
src/auth.rs
23
src/auth.rs
@ -122,6 +122,29 @@ pub async fn require_auth() -> Result<(), ServerFnError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the current logged-in user
|
||||
/// Returns a Result with the user if they are logged in
|
||||
/// Returns an error if the user is not logged in, or if there is an error getting the user
|
||||
/// Intended to be used in a route to get the current user:
|
||||
/// ```rust
|
||||
/// use leptos::*;
|
||||
/// use libretunes::auth::get_user;
|
||||
/// #[server(endpoint = "user_route")]
|
||||
/// pub async fn user_route() -> Result<(), ServerFnError> {
|
||||
/// let user = get_user().await?;
|
||||
/// println!("Logged in as: {}", user.username);
|
||||
/// // Do something with the user
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn get_user() -> Result<User, ServerFnError> {
|
||||
let auth_session = extract::<AuthSession<AuthBackend>>().await
|
||||
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
|
||||
|
||||
auth_session.user.ok_or(ServerFnError::<NoCustomError>::ServerError("User not logged in".to_string()))
|
||||
}
|
||||
|
||||
/// Check if a user is an admin
|
||||
/// Returns a Result with a boolean indicating if the user is logged in and an admin
|
||||
#[server(endpoint = "check_admin")]
|
||||
|
@ -2,4 +2,7 @@ pub mod sidebar;
|
||||
pub mod dashboard;
|
||||
pub mod search;
|
||||
pub mod personal;
|
||||
pub mod dashboard_tile;
|
||||
pub mod dashboard_row;
|
||||
pub mod upload;
|
||||
pub mod song_list;
|
||||
|
118
src/components/dashboard_row.rs
Normal file
118
src/components/dashboard_row.rs
Normal file
@ -0,0 +1,118 @@
|
||||
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_icons::*;
|
||||
|
||||
/// A row of dashboard tiles, with a title
|
||||
pub struct DashboardRow {
|
||||
pub title: String,
|
||||
pub tiles: Vec<Box<dyn DashboardTile>>,
|
||||
}
|
||||
|
||||
impl DashboardRow {
|
||||
pub fn new(title: String, tiles: Vec<Box<dyn DashboardTile>>) -> Self {
|
||||
Self {
|
||||
title,
|
||||
tiles,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoView for DashboardRow {
|
||||
fn into_view(self) -> View {
|
||||
let list_ref = create_node_ref::<Ul>();
|
||||
|
||||
// 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
|
||||
|
||||
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;
|
||||
|
||||
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_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 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 {
|
||||
""
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
</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()
|
||||
}
|
||||
}
|
27
src/components/dashboard_tile.rs
Normal file
27
src/components/dashboard_tile.rs
Normal file
@ -0,0 +1,27 @@
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos::*;
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
157
src/components/song_list.rs
Normal file
157
src/components/song_list.rs
Normal file
@ -0,0 +1,157 @@
|
||||
use leptos::*;
|
||||
use leptos_icons::*;
|
||||
|
||||
use crate::songdata::SongData;
|
||||
use crate::models::{Album, Artist};
|
||||
|
||||
const LIKE_DISLIKE_BTN_SIZE: &str = "2em";
|
||||
|
||||
#[component]
|
||||
pub fn SongList(songs: MaybeSignal<Vec<SongData>>) -> impl IntoView {
|
||||
view! {
|
||||
<table class="song-list">
|
||||
{
|
||||
songs.with(|songs| {
|
||||
let mut first_song = true;
|
||||
|
||||
songs.iter().map(|song| {
|
||||
let playing = first_song.into();
|
||||
first_song = false;
|
||||
|
||||
view! {
|
||||
<SongListItem song={song.clone()} song_playing=playing />
|
||||
}
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn SongListItem(song: SongData, song_playing: MaybeSignal<bool>) -> impl IntoView {
|
||||
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 /></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 liked disliked/></td>
|
||||
<td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</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]
|
||||
fn SongImage(image_path: String, song_playing: MaybeSignal<bool>) -> impl IntoView {
|
||||
view! {
|
||||
<img class="song-image" src={image_path}/>
|
||||
{if song_playing.get() {
|
||||
view! { <Icon class="song-image-overlay song-playing-overlay" icon=icondata::BsPauseFill /> }.into_view()
|
||||
} else {
|
||||
view! { <Icon class="song-image-overlay hide-until-hover" icon=icondata::BsPlayFill /> }.into_view()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays a song's artists, with links to their artist pages
|
||||
#[component]
|
||||
fn SongArtists(artists: Vec<Artist>) -> impl IntoView {
|
||||
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<_>>()
|
||||
}
|
||||
|
||||
/// Display a song's album, with a link to the album page
|
||||
#[component]
|
||||
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>
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Display like and dislike buttons for a song, and indicate if the song is liked or disliked
|
||||
#[component]
|
||||
fn SongLikeDislike(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 like_class = MaybeProp::derive(move || {
|
||||
if liked.get() {
|
||||
Some(TextProp::from("controlbtn"))
|
||||
} else {
|
||||
Some(TextProp::from("controlbtn hide-until-hover"))
|
||||
}
|
||||
});
|
||||
|
||||
let dislike_class = MaybeProp::derive(move || {
|
||||
if disliked.get() {
|
||||
Some(TextProp::from("controlbtn hmirror"))
|
||||
} else {
|
||||
Some(TextProp::from("controlbtn hmirror hide-until-hover"))
|
||||
}
|
||||
});
|
||||
|
||||
let toggle_like = move |_| {
|
||||
liked.set(!liked.get_untracked());
|
||||
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());
|
||||
};
|
||||
|
||||
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>
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
||||
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();
|
||||
@ -27,14 +28,43 @@ cfg_if! { if #[cfg(feature = "ssr")] {
|
||||
|
||||
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(res) => Ok(res.into_response()),
|
||||
Err(err) => Err((
|
||||
match ServeDir::new(root).oneshot(req).await.ok() {
|
||||
Some(res) => Ok(res.into_response()),
|
||||
None => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Something went wrong: {err}"),
|
||||
format!("Something went wrong"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
}}
|
||||
|
@ -1,6 +1,8 @@
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod songdata;
|
||||
pub mod albumdata;
|
||||
pub mod artistdata;
|
||||
pub mod playstatus;
|
||||
pub mod playbar;
|
||||
pub mod database;
|
||||
@ -13,6 +15,7 @@ pub mod users;
|
||||
pub mod search;
|
||||
pub mod fileserv;
|
||||
pub mod error_template;
|
||||
pub mod api;
|
||||
pub mod upload;
|
||||
pub mod util;
|
||||
|
||||
|
10
src/main.rs
10
src/main.rs
@ -1,10 +1,10 @@
|
||||
// Needed for building in Docker container
|
||||
// See https://github.com/clux/muslrust?tab=readme-ov-file#diesel-and-pq-builds
|
||||
// See https://github.com/sgrif/pq-sys/issues/25
|
||||
#[cfg(target = "x86_64-unknown-linux-musl")]
|
||||
#[cfg(target_env = "musl")]
|
||||
extern crate openssl;
|
||||
|
||||
#[cfg(target = "x86_64-unknown-linux-musl")]
|
||||
#[cfg(target_env = "musl")]
|
||||
#[macro_use]
|
||||
extern crate diesel;
|
||||
|
||||
@ -14,11 +14,11 @@ extern crate diesel_migrations;
|
||||
#[cfg(feature = "ssr")]
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
use axum::{routing::get, Router};
|
||||
use axum::{routing::get, Router, extract::Path};
|
||||
use leptos::*;
|
||||
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||
use libretunes::app::*;
|
||||
use libretunes::fileserv::{file_and_error_handler, get_static_file};
|
||||
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;
|
||||
@ -60,6 +60,8 @@ async fn main() {
|
||||
|
||||
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)))
|
||||
.route("/assets/*uri", get(|uri| get_static_file(uri, "")))
|
||||
.layer(auth_layer)
|
||||
.fallback(file_and_error_handler)
|
||||
|
332
src/models.rs
332
src/models.rs
@ -45,6 +45,275 @@ pub struct User {
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Get the history of songs listened to by this user from the database
|
||||
///
|
||||
/// The returned history will be ordered by date in descending order,
|
||||
/// and a limit of N will select the N most recent entries.
|
||||
/// The `id` field of this user must be present (Some) to get history
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `limit` - An optional limit on the number of history entries to return
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<Vec<HistoryEntry>, Box<dyn Error>>` -
|
||||
/// A result indicating success with a vector of history entries, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn get_history(self: &Self, limit: Option<i64>, conn: &mut PgPooledConn) ->
|
||||
Result<Vec<HistoryEntry>, Box<dyn Error>> {
|
||||
use crate::schema::song_history::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to get history")?;
|
||||
|
||||
let my_history =
|
||||
if let Some(limit) = limit {
|
||||
song_history
|
||||
.filter(user_id.eq(my_id))
|
||||
.order(date.desc())
|
||||
.limit(limit)
|
||||
.load(conn)?
|
||||
} else {
|
||||
song_history
|
||||
.filter(user_id.eq(my_id))
|
||||
.load(conn)?
|
||||
};
|
||||
|
||||
Ok(my_history)
|
||||
}
|
||||
|
||||
/// Get the history of songs listened to by this user from the database
|
||||
///
|
||||
/// The returned history will be ordered by date in descending order,
|
||||
/// and a limit of N will select the N most recent entries.
|
||||
/// The `id` field of this user must be present (Some) to get history
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `limit` - An optional limit on the number of history entries to return
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<Vec<(SystemTime, Song)>, Box<dyn Error>>` -
|
||||
/// A result indicating success with a vector of listen dates and songs, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn get_history_songs(self: &Self, limit: Option<i64>, conn: &mut PgPooledConn) ->
|
||||
Result<Vec<(SystemTime, Song)>, Box<dyn Error>> {
|
||||
use crate::schema::songs::dsl::*;
|
||||
use crate::schema::song_history::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to get history")?;
|
||||
|
||||
let my_history =
|
||||
if let Some(limit) = limit {
|
||||
song_history
|
||||
.inner_join(songs)
|
||||
.filter(user_id.eq(my_id))
|
||||
.order(date.desc())
|
||||
.limit(limit)
|
||||
.select((date, songs::all_columns()))
|
||||
.load(conn)?
|
||||
} else {
|
||||
song_history
|
||||
.inner_join(songs)
|
||||
.filter(user_id.eq(my_id))
|
||||
.order(date.desc())
|
||||
.select((date, songs::all_columns()))
|
||||
.load(conn)?
|
||||
};
|
||||
|
||||
Ok(my_history)
|
||||
}
|
||||
|
||||
/// Add a song to this user's history in the database
|
||||
///
|
||||
/// The date of the history entry will be the current time
|
||||
/// The `id` field of this user must be present (Some) to add history
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `song_id` - The id of the song to add to this user's history
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn add_history(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
|
||||
use crate::schema::song_history;
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to add history")?;
|
||||
|
||||
diesel::insert_into(song_history::table)
|
||||
.values((song_history::user_id.eq(my_id), song_history::song_id.eq(song_id)))
|
||||
.execute(conn)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if this user has listened to a song
|
||||
///
|
||||
/// The `id` field of this user must be present (Some) to check history
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `song_id` - The id of the song to check if this user has listened to
|
||||
/// * `conn` - A mutable reference to a database connection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Result<bool, Box<dyn Error>>` - A result indicating success with a boolean value, or an error
|
||||
///
|
||||
#[cfg(feature = "ssr")]
|
||||
pub fn has_listened_to(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<bool, Box<dyn Error>> {
|
||||
use crate::schema::song_history::{self, user_id};
|
||||
|
||||
let my_id = self.id.ok_or("Artist id must be present (Some) to check history")?;
|
||||
|
||||
let has_listened = song_history::table
|
||||
.filter(user_id.eq(my_id))
|
||||
.filter(song_history::song_id.eq(song_id))
|
||||
.first::<HistoryEntry>(conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
|
||||
Ok(has_listened)
|
||||
}
|
||||
|
||||
/// Like or unlike a song for this user
|
||||
/// If likeing a song, remove dislike if it exists
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn set_like_song(self: &Self, song_id: i32, like: bool, conn: &mut PgPooledConn) ->
|
||||
Result<(), Box<dyn Error>> {
|
||||
use log::*;
|
||||
debug!("Setting like for song {} to {}", song_id, like);
|
||||
|
||||
use crate::schema::song_likes;
|
||||
use crate::schema::song_dislikes;
|
||||
|
||||
let my_id = self.id.ok_or("User id must be present (Some) to like/un-like a song")?;
|
||||
|
||||
if like {
|
||||
diesel::insert_into(song_likes::table)
|
||||
.values((song_likes::song_id.eq(song_id), song_likes::user_id.eq(my_id)))
|
||||
.execute(conn)?;
|
||||
|
||||
// Remove dislike if it exists
|
||||
diesel::delete(song_dislikes::table.filter(song_dislikes::song_id.eq(song_id)
|
||||
.and(song_dislikes::user_id.eq(my_id))))
|
||||
.execute(conn)?;
|
||||
} else {
|
||||
diesel::delete(song_likes::table.filter(song_likes::song_id.eq(song_id).and(song_likes::user_id.eq(my_id))))
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the like status of a song for this user
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn get_like_song(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<bool, Box<dyn Error>> {
|
||||
use crate::schema::song_likes;
|
||||
|
||||
let my_id = self.id.ok_or("User id must be present (Some) to get like status of a song")?;
|
||||
|
||||
let like = song_likes::table
|
||||
.filter(song_likes::song_id.eq(song_id).and(song_likes::user_id.eq(my_id)))
|
||||
.first::<(i32, i32)>(conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
|
||||
Ok(like)
|
||||
}
|
||||
|
||||
/// Get songs liked by this user
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn get_liked_songs(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Song>, Box<dyn Error>> {
|
||||
use crate::schema::songs::dsl::*;
|
||||
use crate::schema::song_likes::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("User id must be present (Some) to get liked songs")?;
|
||||
|
||||
let my_songs = songs
|
||||
.inner_join(song_likes)
|
||||
.filter(user_id.eq(my_id))
|
||||
.select(songs::all_columns())
|
||||
.load(conn)?;
|
||||
|
||||
Ok(my_songs)
|
||||
}
|
||||
|
||||
/// Dislike or remove dislike from a song for this user
|
||||
/// If disliking a song, remove like if it exists
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn set_dislike_song(self: &Self, song_id: i32, dislike: bool, conn: &mut PgPooledConn) ->
|
||||
Result<(), Box<dyn Error>> {
|
||||
use log::*;
|
||||
debug!("Setting dislike for song {} to {}", song_id, dislike);
|
||||
|
||||
use crate::schema::song_likes;
|
||||
use crate::schema::song_dislikes;
|
||||
|
||||
let my_id = self.id.ok_or("User id must be present (Some) to dislike/un-dislike a song")?;
|
||||
|
||||
if dislike {
|
||||
diesel::insert_into(song_dislikes::table)
|
||||
.values((song_dislikes::song_id.eq(song_id), song_dislikes::user_id.eq(my_id)))
|
||||
.execute(conn)?;
|
||||
|
||||
// Remove like if it exists
|
||||
diesel::delete(song_likes::table.filter(song_likes::song_id.eq(song_id)
|
||||
.and(song_likes::user_id.eq(my_id))))
|
||||
.execute(conn)?;
|
||||
} else {
|
||||
diesel::delete(song_dislikes::table.filter(song_dislikes::song_id.eq(song_id)
|
||||
.and(song_dislikes::user_id.eq(my_id))))
|
||||
.execute(conn)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the dislike status of a song for this user
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn get_dislike_song(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<bool, Box<dyn Error>> {
|
||||
use crate::schema::song_dislikes;
|
||||
|
||||
let my_id = self.id.ok_or("User id must be present (Some) to get dislike status of a song")?;
|
||||
|
||||
let dislike = song_dislikes::table
|
||||
.filter(song_dislikes::song_id.eq(song_id).and(song_dislikes::user_id.eq(my_id)))
|
||||
.first::<(i32, i32)>(conn)
|
||||
.optional()?
|
||||
.is_some();
|
||||
|
||||
Ok(dislike)
|
||||
}
|
||||
|
||||
/// Get songs disliked by this user
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn get_disliked_songs(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Song>, Box<dyn Error>> {
|
||||
use crate::schema::songs::dsl::*;
|
||||
use crate::schema::song_likes::dsl::*;
|
||||
|
||||
let my_id = self.id.ok_or("User id must be present (Some) to get disliked songs")?;
|
||||
|
||||
let my_songs = songs
|
||||
.inner_join(song_likes)
|
||||
.filter(user_id.eq(my_id))
|
||||
.select(songs::all_columns())
|
||||
.load(conn)?;
|
||||
|
||||
Ok(my_songs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Model for an artist
|
||||
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable, Identifiable))]
|
||||
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::artists))]
|
||||
@ -164,6 +433,26 @@ impl Artist {
|
||||
|
||||
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
|
||||
@ -293,4 +582,47 @@ impl Song {
|
||||
|
||||
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: SystemTime,
|
||||
/// The id of the song that was listened to
|
||||
pub song_id: i32,
|
||||
}
|
||||
|
178
src/pages/artist.rs
Normal file
178
src/pages/artist.rs
Normal file
@ -0,0 +1,178 @@
|
||||
use leptos::*;
|
||||
use leptos_router::use_params_map;
|
||||
use leptos_icons::*;
|
||||
use server_fn::error::NoCustomError;
|
||||
|
||||
use crate::components::loading::*;
|
||||
use crate::components::error::*;
|
||||
|
||||
#[component]
|
||||
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! { <ArtistProfile 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()
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ArtistProfile(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView {
|
||||
let artist_info = create_resource(move || id.get(), move |id| {
|
||||
get_artist_by_id(id)
|
||||
});
|
||||
|
||||
view! {
|
||||
<Transition
|
||||
fallback=move || view! { <LoadingPage /> }
|
||||
>
|
||||
{move || artist_info.get().map(|artist| {
|
||||
match artist {
|
||||
Ok(Some(artist)) => view! { <ArtistDetails artist /> }.into_view(),
|
||||
Ok(None) => view! {
|
||||
<Error<String>
|
||||
title="Artist Not Found"
|
||||
message=format!("Artist with ID {} not found", id.get())
|
||||
/>
|
||||
}.into_view(),
|
||||
Err(error) => view! {
|
||||
<ServerError<NoCustomError>
|
||||
title="Error Getting Artist"
|
||||
error
|
||||
/>
|
||||
}.into_view(),
|
||||
}
|
||||
})}
|
||||
</Transition>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ArtistDetails(artist: Artist) -> impl IntoView {
|
||||
let artist_id = artist.id.unwrap();
|
||||
let profile_image_path = format!("/assets/images/artist/{}.webp", artist_id);
|
||||
|
||||
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="75" height="75"/>
|
||||
</object>
|
||||
<h1>{artist.name}</h1>
|
||||
</div>
|
||||
<div class="artist-details">
|
||||
<p>{artist.bio.unwrap_or_else(|| "No bio available.".to_string())}</p>
|
||||
</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 {
|
||||
top_songs_by_artist(artist_id, Some(10)).await
|
||||
});
|
||||
|
||||
view! {
|
||||
<h2>"Top Songs"</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>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn RelatedArtists(#[prop(into)] artist_id: MaybeSignal<i32>) -> impl IntoView {
|
||||
let related_artists = create_resource(move || artist_id.get(), |artist_id| async move {
|
||||
get_related_artists(artist_id).await
|
||||
});
|
||||
|
||||
view! {
|
||||
<h2>"Related Artists"</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 || related_artists.get().map(|related_artists| {
|
||||
related_artists.map(|artists| {
|
||||
let tiles = artists.into_iter().map(|artist| {
|
||||
Box::new(artist) as Box<dyn DashboardTile>
|
||||
}).collect::<Vec<_>>();
|
||||
|
||||
DashboardRow::new("Related Artists", tiles)
|
||||
})
|
||||
})}
|
||||
</ErrorBoundary>
|
||||
</Transition>
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_artist_by_id(artist_id: i32) -> Result<Option<Artist>, NoCustomError> {
|
||||
// todo
|
||||
Ok(Some(Artist {
|
||||
id: Some(artist_id),
|
||||
name: "Example Artist".to_string(),
|
||||
bio: Some("A great artist!".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_related_artists(artist_id: i32) -> Result<Vec<Artist>, NoCustomError> {
|
||||
// todo
|
||||
Ok(vec![
|
||||
Artist {
|
||||
id: Some(artist_id + 1),
|
||||
name: "Related Artist 1".to_string(),
|
||||
bio: None,
|
||||
},
|
||||
Artist {
|
||||
id: Some(artist_id + 2),
|
||||
name: "Related Artist 2".to_string(),
|
||||
bio: None,
|
||||
},
|
||||
])
|
||||
}
|
193
src/playbar.rs
193
src/playbar.rs
@ -1,9 +1,14 @@
|
||||
use crate::models::Artist;
|
||||
use crate::playstatus::PlayStatus;
|
||||
use crate::songdata::SongData;
|
||||
use crate::api::songs;
|
||||
use leptos::ev::MouseEvent;
|
||||
use leptos::html::{Audio, Div};
|
||||
use leptos::leptos_dom::*;
|
||||
use leptos_meta::Title;
|
||||
use leptos::*;
|
||||
use leptos_icons::*;
|
||||
use leptos_use::{utils::Pausable, use_interval_fn};
|
||||
|
||||
/// Width and height of the forward/backward skip buttons
|
||||
const SKIP_BTN_SIZE: &str = "3.5em";
|
||||
@ -19,6 +24,9 @@ const MIN_SKIP_BACK_TIME: f64 = 5.0;
|
||||
/// How many seconds to skip forward/backward when the user presses the arrow keys
|
||||
const ARROW_KEY_SKIP_TIME: f64 = 5.0;
|
||||
|
||||
/// Threshold in seconds for considering when the user has listened to a song, for adding it to the history
|
||||
const HISTORY_LISTEN_THRESHOLD: u64 = MIN_SKIP_BACK_TIME as u64;
|
||||
|
||||
// TODO Handle errors better, when getting audio HTML element and when playing/pausing audio
|
||||
|
||||
/// Get the current time and duration of the current song, if available
|
||||
@ -243,37 +251,149 @@ fn PlayDuration(elapsed_secs: MaybeSignal<i64>, total_secs: MaybeSignal<i64>) ->
|
||||
fn MediaInfo(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
let name = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
status.queue.front().map_or("No media playing".into(), |song| song.name.clone())
|
||||
status.queue.front().map_or("No media playing".into(), |song| song.title.clone())
|
||||
})
|
||||
});
|
||||
|
||||
let artist = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
status.queue.front().map_or("".into(), |song| song.artist.clone())
|
||||
status.queue.front().map_or("".into(), |song| format!("{}", Artist::display_list(&song.artists)))
|
||||
})
|
||||
});
|
||||
|
||||
let album = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
status.queue.front().map_or("".into(), |song| song.album.clone())
|
||||
status.queue.front().map_or("".into(), |song|
|
||||
song.album.as_ref().map_or("".into(), |album| album.title.clone()))
|
||||
})
|
||||
});
|
||||
|
||||
let image = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
// TODO Use some default / unknown image?
|
||||
status.queue.front().map_or("".into(), |song| song.image_path.clone())
|
||||
status.queue.front().map_or("/images/placeholders/MusicPlaceholder.svg".into(),
|
||||
|song| song.image_path.clone())
|
||||
})
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="media-info">
|
||||
<img class="media-info-img" align="left" src={image}/>
|
||||
<div class="media-info-text">
|
||||
{name}
|
||||
<br/>
|
||||
{artist} - {album}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// The like and dislike buttons
|
||||
#[component]
|
||||
fn LikeDislike(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
let like_icon = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
match status.queue.front() {
|
||||
Some(SongData { like_dislike: Some((true, _)), .. }) => icondata::TbThumbUpFilled,
|
||||
_ => icondata::TbThumbUp,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let dislike_icon = Signal::derive(move || {
|
||||
status.with(|status| {
|
||||
match status.queue.front() {
|
||||
Some(SongData { like_dislike: Some((_, true)), .. }) => icondata::TbThumbDownFilled,
|
||||
_ => icondata::TbThumbDown,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let toggle_like = move |_| {
|
||||
status.update(|status| {
|
||||
match status.queue.front_mut() {
|
||||
Some(SongData { id, like_dislike: Some((liked, disliked)), .. }) => {
|
||||
*liked = !*liked;
|
||||
|
||||
if *liked {
|
||||
*disliked = false;
|
||||
}
|
||||
|
||||
let id = *id;
|
||||
let liked = *liked;
|
||||
spawn_local(async move {
|
||||
if let Err(e) = songs::set_like_song(id, liked).await {
|
||||
error!("Error liking song: {:?}", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
Some(SongData { id, like_dislike, .. }) => {
|
||||
// This arm should only be reached if like_dislike is None
|
||||
// In this case, the buttons will show up not filled, indicating that the song is not
|
||||
// liked or disliked. Therefore, clicking the like button should like the song.
|
||||
|
||||
*like_dislike = Some((true, false));
|
||||
|
||||
let id = *id;
|
||||
spawn_local(async move {
|
||||
if let Err(e) = songs::set_like_song(id, true).await {
|
||||
error!("Error liking song: {:?}", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
_ => {
|
||||
log!("Unable to like song: No song in queue");
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let toggle_dislike = move |_| {
|
||||
status.update(|status| {
|
||||
match status.queue.front_mut() {
|
||||
Some(SongData { id, like_dislike: Some((liked, disliked)), .. }) => {
|
||||
*disliked = !*disliked;
|
||||
|
||||
if *disliked {
|
||||
*liked = false;
|
||||
}
|
||||
|
||||
let id = *id;
|
||||
let disliked = *disliked;
|
||||
spawn_local(async move {
|
||||
if let Err(e) = songs::set_dislike_song(id, disliked).await {
|
||||
error!("Error disliking song: {:?}", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
Some(SongData { id, like_dislike, .. }) => {
|
||||
// This arm should only be reached if like_dislike is None
|
||||
// In this case, the buttons will show up not filled, indicating that the song is not
|
||||
// liked or disliked. Therefore, clicking the dislike button should dislike the song.
|
||||
|
||||
*like_dislike = Some((false, true));
|
||||
|
||||
let id = *id;
|
||||
spawn_local(async move {
|
||||
if let Err(e) = songs::set_dislike_song(id, true).await {
|
||||
error!("Error disliking song: {:?}", e);
|
||||
}
|
||||
});
|
||||
},
|
||||
_ => {
|
||||
log!("Unable to dislike song: No song in queue");
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="like-dislike">
|
||||
<button on:click=toggle_dislike>
|
||||
<Icon class="controlbtn hmirror" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=dislike_icon />
|
||||
</button>
|
||||
<button on:click=toggle_like>
|
||||
<Icon class="controlbtn" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=like_icon />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@ -341,6 +461,21 @@ fn QueueToggle(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the title of the page based on the currently playing song
|
||||
#[component]
|
||||
pub fn CustomTitle(play_status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
let title = create_memo(move |_| {
|
||||
play_status.with(|play_status| {
|
||||
play_status.queue.front().map_or("LibreTunes".to_string(), |song_data| {
|
||||
format!("{} - {} | {}",song_data.title.clone(),Artist::display_list(&song_data.artists), "LibreTunes")
|
||||
})
|
||||
})
|
||||
});
|
||||
view! {
|
||||
<Title text=title />
|
||||
}
|
||||
}
|
||||
|
||||
/// The main play bar component, containing the progress bar, media info, play controls, and play duration
|
||||
#[component]
|
||||
pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
@ -400,7 +535,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
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.name);
|
||||
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
|
||||
@ -417,6 +552,39 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
});
|
||||
});
|
||||
|
||||
let current_song_id = create_memo(move |_| {
|
||||
status.with(|status| {
|
||||
status.queue.front().map(|song| song.id)
|
||||
})
|
||||
});
|
||||
|
||||
// Track the last song that was added to the history to prevent duplicates
|
||||
let last_history_song_id = create_rw_signal(None);
|
||||
|
||||
let Pausable {
|
||||
is_active: hist_timeout_pending,
|
||||
resume: resume_hist_timeout,
|
||||
pause: pause_hist_timeout,
|
||||
..
|
||||
} = use_interval_fn(move || {
|
||||
if last_history_song_id.get_untracked() == current_song_id.get_untracked() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(current_song_id) = current_song_id.get_untracked() {
|
||||
last_history_song_id.set(Some(current_song_id));
|
||||
|
||||
spawn_local(async move {
|
||||
if let Err(e) = crate::api::history::add_history(current_song_id).await {
|
||||
error!("Error adding song {} to history: {}", current_song_id, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, HISTORY_LISTEN_THRESHOLD * 1000);
|
||||
|
||||
// Initially pause the timeout, since the audio starts off paused
|
||||
pause_hist_timeout();
|
||||
|
||||
let on_play = move |_| {
|
||||
log!("Audio playing");
|
||||
status.update(|status| status.playing = true);
|
||||
@ -425,6 +593,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
let on_pause = move |_| {
|
||||
log!("Audio paused");
|
||||
status.update(|status| status.playing = false);
|
||||
pause_hist_timeout();
|
||||
};
|
||||
|
||||
let on_time_update = move |_| {
|
||||
@ -442,6 +611,11 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
error!("Unable to update time: Audio element not available");
|
||||
}
|
||||
});
|
||||
|
||||
// If time is updated, audio is playing, so make sure the history timeout is running
|
||||
if !hist_timeout_pending.get_untracked() {
|
||||
resume_hist_timeout();
|
||||
}
|
||||
};
|
||||
|
||||
let on_end = move |_| {
|
||||
@ -453,7 +627,7 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
let prev_song = status.queue.pop_front();
|
||||
|
||||
if let Some(prev_song) = prev_song {
|
||||
log!("Adding song to history: {}", prev_song.name);
|
||||
log!("Adding song to history: {}", prev_song.title);
|
||||
status.history.push_back(prev_song);
|
||||
} else {
|
||||
log!("Queue empty, no previous song to add to history");
|
||||
@ -486,7 +660,10 @@ pub fn PlayBar(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" />
|
||||
<div class="playbar">
|
||||
<ProgressBar percentage=percentage.into() status=status />
|
||||
<div class="playbar-left-group">
|
||||
<MediaInfo status=status />
|
||||
<LikeDislike status=status />
|
||||
</div>
|
||||
<PlayControls status=status />
|
||||
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
|
||||
<QueueToggle status=status />
|
||||
|
@ -1,3 +1,4 @@
|
||||
use crate::models::Artist;
|
||||
use crate::playstatus::PlayStatus;
|
||||
use crate::song::Song;
|
||||
use leptos::ev::MouseEvent;
|
||||
@ -98,7 +99,7 @@ pub fn Queue(status: RwSignal<PlayStatus>) -> impl IntoView {
|
||||
on:dragenter=move |e: DragEvent| on_drag_enter(e, index)
|
||||
on:dragover=on_drag_over
|
||||
>
|
||||
<Song song_image_path=song.image_path.clone() song_title=song.name.clone() song_artist=song.artist.clone() />
|
||||
<Song song_image_path=song.image_path.clone() song_title=song.title.clone() song_artist=Artist::display_list(&song.artists) />
|
||||
<Show
|
||||
when=move || index != 0
|
||||
fallback=|| view!{
|
||||
|
@ -23,6 +23,22 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
friend_requests (from_id, to_id) {
|
||||
created_at -> Timestamp,
|
||||
from_id -> Int4,
|
||||
to_id -> Int4,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
friendships (friend_1_id, friend_2_id) {
|
||||
created_at -> Timestamp,
|
||||
friend_1_id -> Int4,
|
||||
friend_2_id -> Int4,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
song_artists (song_id, artist_id) {
|
||||
song_id -> Int4,
|
||||
@ -30,6 +46,29 @@ diesel::table! {
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
song_dislikes (song_id, user_id) {
|
||||
song_id -> Int4,
|
||||
user_id -> Int4,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
song_history (id) {
|
||||
id -> Int4,
|
||||
user_id -> Int4,
|
||||
date -> Timestamp,
|
||||
song_id -> Int4,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
song_likes (song_id, user_id) {
|
||||
song_id -> Int4,
|
||||
user_id -> Int4,
|
||||
}
|
||||
}
|
||||
|
||||
diesel::table! {
|
||||
songs (id) {
|
||||
id -> Int4,
|
||||
@ -58,13 +97,24 @@ diesel::joinable!(album_artists -> albums (album_id));
|
||||
diesel::joinable!(album_artists -> artists (artist_id));
|
||||
diesel::joinable!(song_artists -> artists (artist_id));
|
||||
diesel::joinable!(song_artists -> songs (song_id));
|
||||
diesel::joinable!(song_dislikes -> songs (song_id));
|
||||
diesel::joinable!(song_dislikes -> users (user_id));
|
||||
diesel::joinable!(song_history -> songs (song_id));
|
||||
diesel::joinable!(song_history -> users (user_id));
|
||||
diesel::joinable!(song_likes -> songs (song_id));
|
||||
diesel::joinable!(song_likes -> users (user_id));
|
||||
diesel::joinable!(songs -> albums (album_id));
|
||||
|
||||
diesel::allow_tables_to_appear_in_same_query!(
|
||||
album_artists,
|
||||
albums,
|
||||
artists,
|
||||
friend_requests,
|
||||
friendships,
|
||||
song_artists,
|
||||
song_dislikes,
|
||||
song_history,
|
||||
song_likes,
|
||||
songs,
|
||||
users,
|
||||
);
|
||||
|
@ -1,16 +1,81 @@
|
||||
use crate::models::{Album, Artist, Song};
|
||||
use crate::components::dashboard_tile::DashboardTile;
|
||||
|
||||
use time::Date;
|
||||
|
||||
/// Holds information about a song
|
||||
#[derive(Debug, Clone)]
|
||||
///
|
||||
/// Intended to be used in the front-end, as it includes artist and album objects, rather than just their ids.
|
||||
#[derive(Clone)]
|
||||
pub struct SongData {
|
||||
/// Song id
|
||||
pub id: i32,
|
||||
/// Song name
|
||||
pub name: String,
|
||||
/// Song artist
|
||||
pub artist: String,
|
||||
pub title: String,
|
||||
/// Song artists
|
||||
pub artists: Vec<Artist>,
|
||||
/// Song album
|
||||
pub album: String,
|
||||
pub album: Option<Album>,
|
||||
/// The track number of the song on the album
|
||||
pub track: Option<i32>,
|
||||
/// The duration of the song in seconds
|
||||
pub duration: i32,
|
||||
/// The song's release date
|
||||
pub release_date: Option<Date>,
|
||||
/// Path to song file, relative to the root of the web server.
|
||||
/// 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)>,
|
||||
}
|
||||
|
||||
|
||||
impl TryInto<Song> for SongData {
|
||||
type Error = Box<dyn std::error::Error>;
|
||||
|
||||
/// Convert a SongData object into a Song object
|
||||
///
|
||||
/// The SongData/Song conversions are also not truly reversible,
|
||||
/// due to the way the image_path data is handled.
|
||||
fn try_into(self) -> Result<Song, Self::Error> {
|
||||
Ok(Song {
|
||||
id: Some(self.id),
|
||||
title: self.title,
|
||||
album_id: self.album.map(|album|
|
||||
album.id.ok_or("Album id must be present (Some) to convert to Song")).transpose()?,
|
||||
track: self.track,
|
||||
duration: self.duration,
|
||||
release_date: self.release_date,
|
||||
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)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl DashboardTile for SongData {
|
||||
fn image_path(&self) -> String {
|
||||
self.image_path.clone()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn link(&self) -> String {
|
||||
format!("/song/{}", self.id)
|
||||
}
|
||||
|
||||
fn description(&self) -> Option<String> {
|
||||
Some(format!("Song • {}", Artist::display_list(&self.artists)))
|
||||
}
|
||||
}
|
||||
|
31
style/artist.scss
Normal file
31
style/artist.scss
Normal file
@ -0,0 +1,31 @@
|
||||
.artist-container {
|
||||
.artist-header {
|
||||
display: flex;
|
||||
|
||||
.artist-image {
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
border-radius: 50%;
|
||||
padding: 10px;
|
||||
margin: auto;
|
||||
|
||||
svg {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 40px;
|
||||
align-self: center;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.artist-details {
|
||||
p {
|
||||
font-size: 1rem;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
45
style/dashboard_row.scss
Normal file
45
style/dashboard_row.scss
Normal file
@ -0,0 +1,45 @@
|
||||
.dashboard-tile-row {
|
||||
.dashboard-tile-row-title-row {
|
||||
display: flex;
|
||||
|
||||
.dashboard-tile-row-scroll-btn {
|
||||
margin-left: auto;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
|
||||
button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
.dashboard-tile-row-scroll {
|
||||
color: $text-controls-color;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.dashboard-tile-row-scroll:hover {
|
||||
color: $controls-hover-color;
|
||||
}
|
||||
|
||||
.dashboard-tile-row-scroll:active {
|
||||
color: $controls-click-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
overflow-x: hidden;
|
||||
scroll-behavior: smooth;
|
||||
margin-left: 40px;
|
||||
padding-inline-start: 0;
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
-webkit-mask-image: linear-gradient(90deg, #000000 95%, transparent);
|
||||
mask-image: linear-gradient(90deg, #000000 95%, transparent);
|
||||
}
|
||||
}
|
28
style/dashboard_tile.scss
Normal file
28
style/dashboard_tile.scss
Normal file
@ -0,0 +1,28 @@
|
||||
.dashboard-tile {
|
||||
img {
|
||||
width: $dashboard-tile-size;
|
||||
height: $dashboard-tile-size;
|
||||
border-radius: 7px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: $text-controls-color;
|
||||
}
|
||||
|
||||
p.dashboard-tile-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
p.dashboard-tile-description {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
margin-right: 10px;
|
||||
}
|
@ -8,7 +8,10 @@
|
||||
@import 'home.scss';
|
||||
@import 'search.scss';
|
||||
@import 'personal.scss';
|
||||
@import 'dashboard_tile.scss';
|
||||
@import 'dashboard_row.scss';
|
||||
@import 'upload.scss';
|
||||
@import 'song_list.scss';
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
|
@ -39,15 +39,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.media-info {
|
||||
font-size: 16;
|
||||
margin-left: 10px;
|
||||
|
||||
.playbar-left-group {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr;
|
||||
margin-left: 10px;
|
||||
|
||||
.media-info-img {
|
||||
width: 50px;
|
||||
@ -57,6 +54,10 @@
|
||||
text-align: left;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.like-dislike {
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.playcontrols {
|
||||
@ -64,23 +65,6 @@
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
.controlbtn {
|
||||
color: $text-controls-color;
|
||||
}
|
||||
|
||||
.controlbtn:hover {
|
||||
color: $controls-hover-color;
|
||||
}
|
||||
|
||||
.controlbtn:active {
|
||||
color: $controls-click-color;
|
||||
}
|
||||
|
||||
background-color: transparent;
|
||||
border: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.playduration {
|
||||
@ -94,22 +78,30 @@
|
||||
bottom: 13px;
|
||||
top: 13px;
|
||||
right: 90px;
|
||||
}
|
||||
|
||||
button {
|
||||
.controlbtn {
|
||||
color: $text-controls-color;
|
||||
}
|
||||
|
||||
.controlbtn:hover {
|
||||
color: $controls-hover-color;
|
||||
}
|
||||
|
||||
.controlbtn:active {
|
||||
color: $controls-click-color;
|
||||
}
|
||||
|
||||
background-color: transparent;
|
||||
border: transparent;
|
||||
button {
|
||||
.hmirror {
|
||||
-moz-transform: scale(-1, 1);
|
||||
-webkit-transform: scale(-1, 1);
|
||||
-o-transform: scale(-1, 1);
|
||||
-ms-transform: scale(-1, 1);
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
|
||||
.controlbtn {
|
||||
color: $text-controls-color;
|
||||
}
|
||||
|
||||
.controlbtn:hover {
|
||||
color: $controls-hover-color;
|
||||
}
|
||||
|
||||
.controlbtn:active {
|
||||
color: $controls-click-color;
|
||||
}
|
||||
|
||||
background-color: transparent;
|
||||
border: transparent;
|
||||
}
|
||||
}
|
||||
|
124
style/song_list.scss
Normal file
124
style/song_list.scss
Normal file
@ -0,0 +1,124 @@
|
||||
table.song-list {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
tr.song-list-item {
|
||||
border: solid;
|
||||
border-width: 1px 0;
|
||||
border-color: #303030;
|
||||
position: relative;
|
||||
|
||||
td {
|
||||
color: $text-controls-color;
|
||||
white-space: nowrap;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: $text-controls-color;
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline $controls-hover-color;
|
||||
}
|
||||
|
||||
td.song-image {
|
||||
width: 35px;
|
||||
display: flex;
|
||||
|
||||
img.song-image {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
-ms-transform: translateY(-50%);
|
||||
transform: translateY(-50%);
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
svg.song-image-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
-ms-transform: translateY(-50%);
|
||||
transform: translateY(-50%);
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 5px;
|
||||
fill: $text-controls-color;
|
||||
}
|
||||
|
||||
svg.song-image-overlay:hover {
|
||||
fill: $controls-hover-color;
|
||||
}
|
||||
|
||||
svg.song-image-overlay:active {
|
||||
fill: $controls-click-color;
|
||||
}
|
||||
}
|
||||
|
||||
td.song-list-spacer {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
td.song-list-spacer-big {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
button {
|
||||
svg.hmirror {
|
||||
-moz-transform: scale(-1, 1);
|
||||
-webkit-transform: scale(-1, 1);
|
||||
-o-transform: scale(-1, 1);
|
||||
-ms-transform: scale(-1, 1);
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
|
||||
.controlbtn {
|
||||
color: $text-controls-color;
|
||||
}
|
||||
|
||||
.controlbtn:hover {
|
||||
color: $controls-hover-color;
|
||||
}
|
||||
|
||||
.controlbtn:active {
|
||||
color: $controls-click-color;
|
||||
}
|
||||
|
||||
background-color: transparent;
|
||||
border: transparent;
|
||||
}
|
||||
|
||||
.hide-until-hover {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.song-playing-overlay {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
tr.song-list-item:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
tr.song-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr.song-list-item:hover {
|
||||
background-color: #303030;
|
||||
|
||||
.hide-until-hover {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
td.song-image {
|
||||
svg.song-image-overlay {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -14,3 +14,5 @@ $queue-background-color: $play-bar-background-color;
|
||||
|
||||
$auth-inputs: #796dd4;
|
||||
$auth-containers: white;
|
||||
|
||||
$dashboard-tile-size: 200px;
|
||||
|
Reference in New Issue
Block a user