347 Commits

Author SHA1 Message Date
26f6bbb28a Merge pull request '228-create-unified-config-system' (#229) from 228-create-unified-config-system into main
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / mdbook (push) Successful in 7s
Push Workflows / docker-build (push) Successful in 25s
Push Workflows / docs (push) Successful in 35s
Push Workflows / clippy (push) Successful in 45s
Push Workflows / leptos-test (push) Successful in 1m28s
Push Workflows / mdbook-server (push) Successful in 1m17s
Push Workflows / test (push) Successful in 1m38s
Push Workflows / build (push) Successful in 2m25s
Push Workflows / nix-build (push) Successful in 8m49s
Reviewed-on: #229
2025-06-28 01:13:22 +00:00
7af02947d9 Remove lazy_static dependency
All checks were successful
Push Workflows / rustfmt (push) Successful in 9s
Push Workflows / mdbook (push) Successful in 11s
Push Workflows / mdbook-server (push) Successful in 31s
Push Workflows / docs (push) Successful in 1m14s
Push Workflows / clippy (push) Successful in 1m29s
Push Workflows / leptos-test (push) Successful in 2m54s
Push Workflows / test (push) Successful in 3m9s
Push Workflows / build (push) Successful in 4m18s
Push Workflows / docker-build (push) Successful in 10m56s
Push Workflows / nix-build (push) Successful in 10m49s
Not required after BackendState implementation
2025-06-28 01:01:29 +00:00
cd1fff8a10 Remove unused imports
Some checks failed
Push Workflows / rustfmt (push) Successful in 7s
Push Workflows / mdbook (push) Successful in 9s
Push Workflows / docs (push) Successful in 50s
Push Workflows / clippy (push) Successful in 1m8s
Push Workflows / mdbook-server (push) Successful in 1m42s
Push Workflows / leptos-test (push) Successful in 2m1s
Push Workflows / test (push) Successful in 2m22s
Push Workflows / nix-build (push) Has been cancelled
Push Workflows / docker-build (push) Has been cancelled
Push Workflows / build (push) Has been cancelled
2025-06-28 00:58:07 +00:00
4b5b1209a5 Remove FromRequestParts for Config
Some checks failed
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / mdbook (push) Successful in 8s
Push Workflows / clippy (push) Failing after 34s
Push Workflows / docs (push) Successful in 42s
Push Workflows / build (push) Failing after 1m22s
Push Workflows / mdbook-server (push) Successful in 1m12s
Push Workflows / leptos-test (push) Successful in 1m30s
Push Workflows / test (push) Successful in 1m44s
Push Workflows / docker-build (push) Successful in 4m20s
Push Workflows / nix-build (push) Successful in 12m53s
2025-06-28 00:49:46 +00:00
83b56b9110 Move PG types back to util::database
Some checks failed
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / mdbook (push) Successful in 8s
Push Workflows / docs (push) Successful in 45s
Push Workflows / clippy (push) Successful in 50s
Push Workflows / mdbook-server (push) Successful in 1m18s
Push Workflows / leptos-test (push) Successful in 1m35s
Push Workflows / test (push) Successful in 1m48s
Push Workflows / build (push) Successful in 2m40s
Push Workflows / docker-build (push) Successful in 4m26s
Push Workflows / nix-build (push) Has been cancelled
2025-06-28 00:44:14 +00:00
36de234630 Add backend_state to AuthBackend 2025-06-28 00:41:10 +00:00
b43f9fae8c Add db_conn argument for functions required for auth 2025-06-28 00:39:00 +00:00
97f435c6d8 Implement Error for BackendError 2025-06-28 00:38:17 +00:00
7a79904aa4 Remove old database init/connection functions 2025-06-27 22:08:14 +00:00
7ddbee724b Use BackendState::get_db_conn() instead of database module 2025-06-27 22:08:14 +00:00
912c3b8adf Remove redis module
All functionality is now handled in BackendState
2025-06-27 22:08:14 +00:00
735f6758d7 Use BackendState for redis connection 2025-06-27 22:08:14 +00:00
e25f6ff5c4 Add function to BackendState to extract 2025-06-27 22:08:13 +00:00
8adefabc2f Initialize backend state, supply to requests instead of config 2025-06-27 22:08:13 +00:00
2795a1b754 Add BackendState 2025-06-27 22:08:13 +00:00
f25ebb85d2 Fix leptos tests using new errors
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / mdbook (push) Successful in 7s
Push Workflows / docs (push) Successful in 40s
Push Workflows / clippy (push) Successful in 1m4s
Push Workflows / mdbook-server (push) Successful in 2m21s
Push Workflows / test (push) Successful in 2m56s
Push Workflows / build (push) Successful in 3m56s
Push Workflows / leptos-test (push) Successful in 5m51s
Push Workflows / nix-build (push) Successful in 11m20s
Push Workflows / docker-build (push) Successful in 11m49s
2025-06-27 00:56:18 +00:00
368f673fd7 Rewrite error handling and display
Some checks failed
Push Workflows / rustfmt (push) Successful in 11s
Push Workflows / mdbook (push) Successful in 16s
Push Workflows / mdbook-server (push) Successful in 4m20s
Push Workflows / docs (push) Successful in 5m44s
Push Workflows / clippy (push) Successful in 7m48s
Push Workflows / test (push) Successful in 11m14s
Push Workflows / leptos-test (push) Failing after 11m33s
Push Workflows / build (push) Successful in 12m53s
Push Workflows / nix-build (push) Successful in 16m54s
Push Workflows / docker-build (push) Successful in 17m40s
2025-06-26 00:01:49 +00:00
0541b77b66 Add more BackendError types
Remove "Error" from enum variant names
Add functions to create BackendErrors
Change Contextualize types
Implement conversion from all sub error types
2025-06-26 00:01:35 +00:00
f8a774f389 Add BackendResult type 2025-06-25 23:59:34 +00:00
a6d57a84ce Remove unnecesary feature 2025-06-25 23:59:11 +00:00
cd39ec7252 Create a type for different backend errors 2025-06-16 15:27:08 +00:00
9181b12c01 Load Config at start of main, supply to requests with middleware 2025-06-16 15:27:08 +00:00
40909bfdb0 Implement FromRequestParts for Config 2025-06-10 02:07:34 +00:00
c7154f5008 Update CustomClient for websocket support 2025-06-10 01:37:47 +00:00
60f82fbc74 Add tokio-tungstenite crate 2025-06-10 01:30:52 +00:00
13111e3567 Fix axum route syntax 2025-06-08 21:14:52 +00:00
627746c0b3 Update leptos-use 2025-06-08 21:14:26 +00:00
a738341c5f Update axum-login 2025-06-08 21:13:54 +00:00
5cf2d5e6d9 Update tower-sessions-redis-store 2025-06-08 21:13:28 +00:00
20e2b03b14 Update axum 2025-06-08 21:13:05 +00:00
306d760f06 Update leptos_icons 2025-06-08 21:12:32 +00:00
7cec212c32 Update leptos crates 2025-06-08 20:34:16 +00:00
5cbeba5dbe Add config module 2025-06-08 20:10:14 +00:00
f43013a568 Add clap crate 2025-06-08 18:07:19 +00:00
deaef81999 Fix lints
All checks were successful
Push Workflows / rustfmt (push) Successful in 7s
Push Workflows / docs (push) Successful in 45s
Push Workflows / mdbook (push) Successful in 10s
Push Workflows / clippy (push) Successful in 1m3s
Push Workflows / mdbook-server (push) Successful in 42s
Push Workflows / leptos-test (push) Successful in 2m33s
Push Workflows / test (push) Successful in 3m8s
Push Workflows / build (push) Successful in 4m34s
Push Workflows / docker-build (push) Successful in 11m49s
Push Workflows / nix-build (push) Successful in 11m57s
2025-05-30 17:03:43 +00:00
7d2375698c Merge pull request 'mdbook' (#227) from mdbook into main
Some checks failed
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / mdbook (push) Successful in 7s
Push Workflows / docs (push) Successful in 46s
Push Workflows / mdbook-server (push) Successful in 35s
Push Workflows / leptos-test (push) Successful in 2m16s
Push Workflows / clippy (push) Failing after 2m39s
Push Workflows / test (push) Successful in 2m48s
Push Workflows / build (push) Successful in 3m34s
Push Workflows / docker-build (push) Successful in 11m34s
Push Workflows / nix-build (push) Successful in 12m6s
Reviewed-on: #227
2025-05-28 03:07:17 +00:00
84451e2dac Set path to mdbook Dockerfile
Some checks failed
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / mdbook (push) Successful in 5s
Push Workflows / docker-build (push) Successful in 27s
Push Workflows / docs (push) Successful in 36s
Push Workflows / leptos-test (push) Successful in 1m48s
Push Workflows / test (push) Successful in 1m58s
Push Workflows / mdbook-server (push) Successful in 1m45s
Push Workflows / clippy (push) Failing after 1m58s
Push Workflows / build (push) Successful in 2m45s
Push Workflows / nix-build (push) Successful in 13m16s
2025-05-28 02:57:38 +00:00
579e7bbb48 Add CICD job to build mdbook
Some checks failed
Push Workflows / rustfmt (push) Successful in 44s
Push Workflows / mdbook (push) Successful in 1m28s
Push Workflows / docs (push) Successful in 7m51s
Push Workflows / clippy (push) Failing after 8m28s
Push Workflows / test (push) Successful in 13m15s
Push Workflows / leptos-test (push) Successful in 14m20s
Push Workflows / build (push) Successful in 15m5s
Push Workflows / docker-build (push) Successful in 18m32s
Push Workflows / mdbook-server (push) Successful in 18m37s
Push Workflows / nix-build (push) Successful in 19m9s
2025-05-28 02:40:21 +00:00
6e57dfc937 Add CICD job to build mdbook server 2025-05-28 02:40:11 +00:00
85d622fdb6 Add Dockerfile for mdbook 2025-05-28 02:29:04 +00:00
6a6bbfe8ed Add mdbook to flake 2025-05-28 01:54:24 +00:00
3803c20049 Initialize mdbook 2025-05-28 01:53:45 +00:00
544476d1ee Fix missing libgomp library in flake
All checks were successful
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / docker-build (push) Successful in 24s
Push Workflows / docs (push) Successful in 49s
Push Workflows / clippy (push) Successful in 49s
Push Workflows / leptos-test (push) Successful in 1m38s
Push Workflows / test (push) Successful in 1m44s
Push Workflows / build (push) Successful in 2m30s
Push Workflows / nix-build (push) Successful in 8m30s
2025-05-06 05:31:05 +00:00
1878f1feda Merge pull request 'Add liked songs page' (#224) from 222-add-liked-songs-page into main
All checks were successful
Push Workflows / rustfmt (push) Successful in 7s
Push Workflows / docs (push) Successful in 49s
Push Workflows / clippy (push) Successful in 47s
Push Workflows / leptos-test (push) Successful in 1m48s
Push Workflows / test (push) Successful in 2m7s
Push Workflows / nix-build (push) Successful in 12m26s
Push Workflows / build (push) Successful in 2m19s
Push Workflows / docker-build (push) Successful in 20s
Reviewed-on: #224
2025-05-06 03:31:04 +00:00
b727137fa8 Show liked songs at top of playlists
Some checks failed
Push Workflows / rustfmt (push) Successful in 8s
Push Workflows / docs (push) Successful in 54s
Push Workflows / clippy (push) Successful in 54s
Push Workflows / leptos-test (push) Successful in 2m51s
Push Workflows / test (push) Successful in 3m9s
Push Workflows / build (push) Successful in 4m25s
Push Workflows / docker-build (push) Failing after 12m59s
Push Workflows / nix-build (push) Successful in 16m11s
2025-05-06 03:17:47 +00:00
f61507b197 Add liked songs page 2025-05-06 03:17:35 +00:00
d2aebde562 Add API endpoint to get liked songs 2025-05-06 03:14:57 +00:00
0076f4f208 Merge pull request 'Fix typing space in add album/artist/song dialog' (#223) from 180-fix-typing-space into main
Some checks failed
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / docs (push) Successful in 39s
Push Workflows / clippy (push) Successful in 37s
Push Workflows / leptos-test (push) Successful in 1m21s
Push Workflows / test (push) Successful in 1m50s
Push Workflows / build (push) Successful in 2m53s
Push Workflows / docker-build (push) Failing after 13m21s
Push Workflows / nix-build (push) Successful in 16m37s
Reviewed-on: #223
2025-05-06 03:14:00 +00:00
ba0a531f2c Ignore space and arrow key events on input fields
Some checks failed
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / docs (push) Successful in 39s
Push Workflows / clippy (push) Successful in 36s
Push Workflows / leptos-test (push) Successful in 1m29s
Push Workflows / test (push) Successful in 1m50s
Push Workflows / build (push) Successful in 2m47s
Push Workflows / docker-build (push) Failing after 9m52s
Push Workflows / nix-build (push) Successful in 12m32s
2025-05-06 02:41:15 +00:00
2617ee8b95 Merge pull request 'Implement Playlists' (#219) from playlists-implementation into main
Some checks failed
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / docs (push) Successful in 39s
Push Workflows / clippy (push) Successful in 36s
Push Workflows / leptos-test (push) Successful in 1m14s
Push Workflows / test (push) Successful in 1m38s
Push Workflows / build (push) Successful in 2m35s
Push Workflows / docker-build (push) Failing after 9m57s
Push Workflows / nix-build (push) Successful in 12m20s
Reviewed-on: #219
2025-05-06 02:04:11 +00:00
4d1859b331 Add playlist page
Some checks failed
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / docs (push) Successful in 41s
Push Workflows / clippy (push) Successful in 37s
Push Workflows / leptos-test (push) Successful in 1m18s
Push Workflows / test (push) Successful in 1m39s
Push Workflows / build (push) Successful in 2m43s
Push Workflows / docker-build (push) Failing after 9m22s
Push Workflows / nix-build (push) Successful in 12m39s
2025-05-06 01:34:53 +00:00
c17aeb3822 Display playlists on sidebar 2025-05-06 01:34:44 +00:00
0e0d107d08 Include playlist resource in global state 2025-05-06 01:34:28 +00:00
463f3b744f Add styling for control-solid 2025-05-06 01:33:59 +00:00
28875c8669 Write playlist API functions 2025-05-06 01:33:21 +00:00
68778615b9 Move extract_field to util 2025-05-06 01:33:20 +00:00
58b5ed6d3f Add image fallback handler 2025-05-06 01:33:20 +00:00
f8c0134cf2 Add trigger to update playlist updated_at 2025-05-05 04:29:24 +00:00
a31539dc8f Merge pull request 'Separate types for inserting and fetching' (#218) from 217-separate-insert-fetch-types into main
Some checks failed
Push Workflows / rustfmt (push) Successful in 9s
Push Workflows / docs (push) Successful in 38s
Push Workflows / clippy (push) Successful in 36s
Push Workflows / leptos-test (push) Successful in 1m13s
Push Workflows / test (push) Successful in 1m30s
Push Workflows / build (push) Successful in 2m32s
Push Workflows / docker-build (push) Failing after 9m20s
Push Workflows / nix-build (push) Successful in 12m12s
Reviewed-on: #218
2025-05-05 02:32:46 +00:00
eda4e42150 Update flake.nix to use libretunes_macro git dependency
Some checks failed
Push Workflows / docs (push) Successful in 1m14s
Push Workflows / rustfmt (push) Successful in 10s
Push Workflows / clippy (push) Successful in 1m40s
Push Workflows / leptos-test (push) Successful in 3m0s
Push Workflows / test (push) Successful in 3m11s
Push Workflows / build (push) Successful in 4m6s
Push Workflows / docker-build (push) Failing after 10m51s
Push Workflows / nix-build (push) Successful in 13m11s
2025-05-05 02:08:36 +00:00
54d629d504 Use db_type for User
Some checks failed
Push Workflows / rustfmt (push) Successful in 8s
Push Workflows / nix-build (push) Failing after 45s
Push Workflows / docs (push) Successful in 3m20s
Push Workflows / clippy (push) Successful in 4m24s
Push Workflows / test (push) Successful in 5m58s
Push Workflows / leptos-test (push) Successful in 6m59s
Push Workflows / build (push) Successful in 7m53s
Push Workflows / docker-build (push) Failing after 11m47s
2025-05-05 01:25:20 +00:00
6486bbbdda Use db_type for Playlist 2025-05-05 01:10:17 +00:00
b727832c8e Use db_type for HistoryEntry 2025-05-05 01:07:07 +00:00
7c4058884e Use db_type for Artist 2025-05-05 01:05:20 +00:00
a67bd37d11 Use db_type for Album 2025-05-05 00:53:57 +00:00
3f43ef2d20 Use libretunes_macro::db_type instead of manual Song/NewSong structs 2025-05-05 00:38:37 +00:00
0b599f4038 Add libretunes_macro dependency 2025-05-05 00:38:01 +00:00
c02363c698 Create NewSong type 2025-05-04 21:36:34 +00:00
9da05edcd4 Merge pull request 'Create Search Bar Component' (#216) from 148-create-search-bar-component-2 into main
Some checks failed
Push Workflows / rustfmt (push) Successful in 4s
Push Workflows / docs (push) Successful in 30s
Push Workflows / clippy (push) Successful in 27s
Push Workflows / leptos-test (push) Successful in 59s
Push Workflows / test (push) Successful in 1m9s
Push Workflows / build (push) Successful in 2m7s
Push Workflows / docker-build (push) Failing after 9m20s
Push Workflows / nix-build (push) Successful in 12m8s
Reviewed-on: #216
2025-05-04 03:50:15 +00:00
f65d054612 Create search page
Some checks failed
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / clippy (push) Successful in 27s
Push Workflows / docs (push) Successful in 33s
Push Workflows / leptos-test (push) Successful in 1m8s
Push Workflows / test (push) Successful in 1m19s
Push Workflows / build (push) Successful in 2m26s
Push Workflows / docker-build (push) Failing after 9m13s
Push Workflows / nix-build (push) Successful in 12m18s
2025-05-04 03:35:12 +00:00
16cf406990 Merge pull request 'Return query match score for all search API results' (#215) from 26-return-query-match-score into main
Some checks failed
Push Workflows / rustfmt (push) Successful in 8s
Push Workflows / clippy (push) Successful in 40s
Push Workflows / docs (push) Successful in 49s
Push Workflows / leptos-test (push) Successful in 1m20s
Push Workflows / test (push) Successful in 1m39s
Push Workflows / build (push) Successful in 3m13s
Push Workflows / docker-build (push) Failing after 11m34s
Push Workflows / nix-build (push) Successful in 13m59s
Reviewed-on: #215
2025-05-03 18:28:40 +00:00
ed6cd4efcf Return query match score for all search results
Some checks failed
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / clippy (push) Successful in 28s
Push Workflows / docs (push) Successful in 33s
Push Workflows / leptos-test (push) Successful in 1m1s
Push Workflows / test (push) Successful in 1m9s
Push Workflows / build (push) Successful in 2m3s
Push Workflows / docker-build (push) Failing after 12m0s
Push Workflows / nix-build (push) Successful in 15m20s
Add SearchRersult<T> type
Apply temporary fixes to upload page
2025-05-03 18:24:30 +00:00
4d24a9bba2 Merge pull request 'Create health check binary' (#214) from 207-create-health-check-binary into main
Some checks failed
Push Workflows / docs (push) Successful in 50s
Push Workflows / clippy (push) Successful in 57s
Push Workflows / rustfmt (push) Successful in 12s
Push Workflows / leptos-test (push) Successful in 2m14s
Push Workflows / test (push) Successful in 2m30s
Push Workflows / build (push) Successful in 3m47s
Push Workflows / docker-build (push) Failing after 11m24s
Push Workflows / nix-build (push) Successful in 13m52s
Reviewed-on: #214
2025-05-03 06:32:09 +00:00
11cb502f53 Add healthcheck to Docker image
Some checks failed
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / docs (push) Successful in 37s
Push Workflows / clippy (push) Successful in 33s
Push Workflows / leptos-test (push) Successful in 1m10s
Push Workflows / test (push) Successful in 1m21s
Push Workflows / build (push) Successful in 2m39s
Push Workflows / docker-build (push) Failing after 12m51s
Push Workflows / nix-build (push) Successful in 15m52s
2025-05-03 06:27:38 +00:00
0ec9e5ed03 Include health_check binary in Docker image 2025-05-03 06:27:38 +00:00
7bccde7654 Create health check bin 2025-05-03 06:27:38 +00:00
bd69c46567 Merge pull request 'Create health check endpoint' (#213) from 206-create-health-check-endpoint into main
Some checks failed
Push Workflows / rustfmt (push) Successful in 8s
Push Workflows / docs (push) Successful in 43s
Push Workflows / clippy (push) Successful in 40s
Push Workflows / leptos-test (push) Successful in 1m36s
Push Workflows / test (push) Successful in 2m6s
Push Workflows / build (push) Successful in 3m35s
Push Workflows / docker-build (push) Failing after 10m49s
Push Workflows / nix-build (push) Successful in 14m0s
Reviewed-on: #213
2025-05-03 05:34:04 +00:00
e2a395ae7c Don't require auth for health check endpoint
Some checks failed
Push Workflows / rustfmt (push) Successful in 5s
Push Workflows / docs (push) Successful in 32s
Push Workflows / clippy (push) Successful in 28s
Push Workflows / leptos-test (push) Successful in 59s
Push Workflows / test (push) Successful in 1m7s
Push Workflows / build (push) Successful in 2m6s
Push Workflows / docker-build (push) Failing after 11m21s
Push Workflows / nix-build (push) Successful in 15m21s
2025-05-03 05:30:47 +00:00
6bb6322aa4 Create health check endpoint 2025-05-03 05:30:34 +00:00
a82da927b0 Move redis connection to util/redis.rs 2025-05-03 05:30:17 +00:00
6f571a338f Merge pull request 'Allow server function calls from non-WASM' (#212) from 211-allow-server-function-calls-from-non-wasm into main
Some checks failed
Push Workflows / rustfmt (push) Successful in 6s
Push Workflows / docs (push) Successful in 36s
Push Workflows / clippy (push) Successful in 33s
Push Workflows / leptos-test (push) Successful in 1m9s
Push Workflows / test (push) Successful in 1m20s
Push Workflows / build (push) Successful in 2m33s
Push Workflows / docker-build (push) Failing after 8m53s
Push Workflows / nix-build (push) Successful in 12m3s
Reviewed-on: #212
2025-05-03 04:38:01 +00:00
9cd1e8291a Use feature-specific Client in server functions
Some checks failed
Push Workflows / rustfmt (push) Successful in 10s
Push Workflows / docs (push) Successful in 1m43s
Push Workflows / clippy (push) Successful in 2m12s
Push Workflows / test (push) Successful in 4m17s
Push Workflows / leptos-test (push) Successful in 4m24s
Push Workflows / build (push) Successful in 6m2s
Push Workflows / docker-build (push) Failing after 12m7s
Push Workflows / nix-build (push) Successful in 15m46s
2025-05-03 04:24:28 +00:00
ff1b7401f2 Add custom client for non-browser RPC 2025-05-03 03:51:55 +00:00
d434a514a4 Create reqwest_api feature 2025-05-03 03:25:33 +00:00
10011a8859 Merge pull request '6-general-src-restructuring' (#210) from 6-general-src-restructuring into main
Some checks failed
Push Workflows / rustfmt (push) Successful in 10s
Push Workflows / clippy (push) Successful in 33s
Push Workflows / docs (push) Successful in 39s
Push Workflows / leptos-test (push) Successful in 1m17s
Push Workflows / test (push) Successful in 1m30s
Push Workflows / build (push) Successful in 2m41s
Push Workflows / docker-build (push) Failing after 9m20s
Push Workflows / nix-build (push) Successful in 12m26s
Reviewed-on: #210
2025-04-29 23:14:30 +00:00
16bc79aef4 Fix redundant closures (Clippy)
Some checks failed
Push Workflows / rustfmt (push) Successful in 4s
Push Workflows / docs (push) Successful in 29s
Push Workflows / clippy (push) Successful in 25s
Push Workflows / leptos-test (push) Successful in 54s
Push Workflows / test (push) Successful in 1m2s
Push Workflows / build (push) Successful in 1m52s
Push Workflows / docker-build (push) Failing after 8m18s
Push Workflows / nix-build (push) Successful in 13m53s
2025-04-29 22:41:47 +00:00
297c22d832 Run rustfmt
Some checks failed
Push Workflows / docs (push) Successful in 1m35s
Push Workflows / rustfmt (push) Successful in 11s
Push Workflows / clippy (push) Failing after 58s
Push Workflows / leptos-test (push) Successful in 3m4s
Push Workflows / test (push) Successful in 3m22s
Push Workflows / build (push) Successful in 4m43s
Push Workflows / docker-build (push) Failing after 14m42s
Push Workflows / nix-build (push) Successful in 17m22s
2025-04-29 22:27:24 +00:00
5fb84bd29e Add rustfmt CICD job
Some checks failed
Push Workflows / rustfmt (push) Failing after 5s
Push Workflows / clippy (push) Successful in 32s
Push Workflows / docs (push) Successful in 38s
Push Workflows / leptos-test (push) Successful in 1m40s
Push Workflows / test (push) Successful in 2m13s
Push Workflows / build (push) Successful in 4m7s
Push Workflows / docker-build (push) Failing after 15m19s
Push Workflows / nix-build (push) Successful in 18m33s
2025-04-29 22:25:44 +00:00
b9cbe22562 Use contains instead of .iter().any() (Clippy)
Some checks failed
Push Workflows / test (push) Successful in 49s
Push Workflows / build (push) Successful in 1m39s
Push Workflows / leptos-test (push) Successful in 1m15s
Push Workflows / docs (push) Successful in 37s
Push Workflows / clippy (push) Successful in 2m1s
Push Workflows / docker-build (push) Failing after 8m53s
Push Workflows / nix-build (push) Successful in 11m19s
2025-04-29 21:33:01 +00:00
aaec0523a4 Combine else + if into else if (Clippy) 2025-04-29 21:32:47 +00:00
ff8fd283b6 Fix Clippy format string lints 2025-04-29 21:32:18 +00:00
cf35961516 Automatically start playing in skip_to 2025-04-29 21:29:15 +00:00
976790342c Call audio play/pause when PlayStatus playing updated 2025-04-29 21:26:22 +00:00
29534a473b Rename songpage.rs to song.rs
Some checks failed
Push Workflows / build (push) Successful in 1m51s
Push Workflows / test (push) Successful in 3m51s
Push Workflows / leptos-test (push) Successful in 52s
Push Workflows / docs (push) Successful in 24s
Push Workflows / clippy (push) Failing after 1m53s
Push Workflows / docker-build (push) Failing after 9m31s
Push Workflows / nix-build (push) Successful in 10m55s
2025-04-29 20:51:09 +00:00
673d6e7651 Rename albumpage.rs to album.rs 2025-04-29 20:49:46 +00:00
2def629dc1 Update song page to use TailwindCSS
Some checks failed
Push Workflows / build (push) Successful in 1m50s
Push Workflows / leptos-test (push) Successful in 1m12s
Push Workflows / docs (push) Successful in 28s
Push Workflows / clippy (push) Has been cancelled
Push Workflows / nix-build (push) Has been cancelled
Push Workflows / docker-build (push) Has been cancelled
Push Workflows / test (push) Has been cancelled
2025-04-29 20:48:22 +00:00
f1126c2534 Update flake 2025-04-29 20:36:39 +00:00
c593501572 Refactor profile page to use TailwindCSS 2025-04-29 20:09:28 +00:00
797fea93b2 Fix artist image path in profile API 2025-04-29 20:02:37 +00:00
c6e6eb1f41 Increase recursion limit in lib target 2025-04-29 19:57:27 +00:00
b963849072 Move Queue before PlayBar
Some checks failed
Push Workflows / test (push) Failing after 4m24s
Push Workflows / build (push) Successful in 7m24s
Push Workflows / docs (push) Successful in 1m42s
Push Workflows / leptos-test (push) Successful in 5m2s
Push Workflows / docker-build (push) Failing after 10m50s
Push Workflows / clippy (push) Failing after 1m48s
Push Workflows / nix-build (push) Failing after 7m37s
2025-04-29 19:17:55 +00:00
c7ee25c1b4 Close queue when clicked away 2025-04-29 19:17:55 +00:00
6a0f814cd3 Update to leptos 0.7.8
Signed-off-by: Ethan Girouard <ethan@girouard.com>
2025-04-29 19:03:22 +00:00
3027a1f00c Change Dashboard menu to / instead of /dashboard
Some checks failed
Push Workflows / test (push) Failing after 8m8s
Push Workflows / docs (push) Successful in 2m47s
Push Workflows / clippy (push) Successful in 2m18s
Push Workflows / build (push) Successful in 6m50s
Push Workflows / docker-build (push) Failing after 9m17s
Push Workflows / leptos-test (push) Successful in 4m49s
Push Workflows / nix-build (push) Successful in 12m56s
2025-04-05 13:59:47 -04:00
b735b25677 Use tailwindcss for Personal component
Some checks failed
Push Workflows / test (push) Failing after 7m47s
Push Workflows / docker-build (push) Successful in 9m49s
Push Workflows / docs (push) Successful in 2m48s
Push Workflows / build (push) Successful in 15m54s
Push Workflows / clippy (push) Successful in 2m11s
Push Workflows / leptos-test (push) Successful in 12m24s
Push Workflows / nix-build (push) Successful in 31m57s
Close login/out buttons when clicking away
2025-03-26 17:09:59 -04:00
0bb8871296 Use tailwindcss for Error component 2025-03-26 14:00:23 -04:00
388ef55552 Refactor album page
Use tailwindcss classes
Refactor API endpoints
2025-03-26 13:53:44 -04:00
fae4767313 Remove white background from loading indicator 2025-03-26 13:49:45 -04:00
7b5b9fbe15 Move AlbumInfo component into album page
Some checks failed
Push Workflows / build (push) Failing after 20s
Push Workflows / leptos-test (push) Failing after 19s
Push Workflows / docs (push) Successful in 3m27s
Push Workflows / test (push) Failing after 8m33s
Push Workflows / clippy (push) Successful in 4m42s
Push Workflows / docker-build (push) Successful in 27m40s
Push Workflows / nix-build (push) Successful in 44m34s
2025-03-21 22:04:53 -04:00
d0e849bd0e Update artist page to use tailwind
Some checks failed
Push Workflows / build (push) Successful in 6m6s
Push Workflows / test (push) Failing after 7m12s
Push Workflows / docker-build (push) Successful in 8m40s
Push Workflows / docs (push) Successful in 2m22s
Push Workflows / leptos-test (push) Successful in 4m38s
Push Workflows / clippy (push) Successful in 2m23s
Push Workflows / nix-build (push) Successful in 34m40s
2025-03-16 18:08:20 -04:00
ae9243e9f3 Update DashboardRow to use tailwind 2025-03-16 17:40:00 -04:00
f4f6e1e4a6 Prevent main page component from extending beyond page 2025-03-16 16:49:09 -04:00
5a71973388 Remove old dashboard-container and home-component wrappers 2025-03-15 23:17:59 -04:00
c782aa2cd4 Increase recursion limit to fix compile error
Some checks failed
Push Workflows / test (push) Failing after 12m18s
Push Workflows / build (push) Successful in 24m34s
Push Workflows / leptos-test (push) Successful in 19m43s
Push Workflows / docs (push) Successful in 7m49s
Push Workflows / docker-build (push) Successful in 35m51s
Push Workflows / clippy (push) Successful in 7m12s
Push Workflows / nix-build (push) Successful in 44m21s
2025-03-15 23:14:32 -04:00
082c1a2128 Update SongList to use tailwind 2025-03-15 23:12:08 -04:00
6572d7313a Move sidebar navigation to separate module
Some checks failed
Push Workflows / test (push) Failing after 9m50s
Push Workflows / build (push) Failing after 16m49s
Push Workflows / docker-build (push) Failing after 21m36s
Push Workflows / docs (push) Successful in 4m49s
Push Workflows / leptos-test (push) Successful in 14m36s
Push Workflows / clippy (push) Successful in 5m9s
Push Workflows / nix-build (push) Failing after 26m50s
2025-03-07 23:26:11 -05:00
f1862a6bd6 Rename sidebar Bottom component to Playlists 2025-03-07 23:15:56 -05:00
347ad39fae Make sidebar Bottom component a home-card 2025-03-07 23:14:48 -05:00
318892adc1 Make Personal component a home-card 2025-03-07 23:11:38 -05:00
1a2b7510f8 Remove "extern crate" used for musl builds 2025-03-07 22:57:04 -05:00
3b452e32d8 Switch home-container div to section, use tailwind 2025-02-18 22:49:55 -05:00
7d410c2419 Add home-card style class 2025-02-18 22:49:29 -05:00
b55104144b Convert playbar to using tailwindcss
All checks were successful
Push Workflows / docs (push) Successful in 1m5s
Push Workflows / clippy (push) Successful in 1m26s
Push Workflows / leptos-test (push) Successful in 2m26s
Push Workflows / test (push) Successful in 2m30s
Push Workflows / build (push) Successful in 4m48s
Push Workflows / docker-build (push) Successful in 7m21s
Push Workflows / nix-build (push) Successful in 25m44s
2025-02-18 19:17:21 -05:00
911d375a95 Rewrite signup page using tailwindcss
All checks were successful
Push Workflows / clippy (push) Successful in 1m14s
Push Workflows / docs (push) Successful in 1m54s
Push Workflows / leptos-test (push) Successful in 2m20s
Push Workflows / test (push) Successful in 2m25s
Push Workflows / build (push) Successful in 5m45s
Push Workflows / docker-build (push) Successful in 7m25s
Push Workflows / nix-build (push) Successful in 24m4s
2025-02-18 15:14:33 -05:00
9b20395876 Rewrite login page using tailwindcss 2025-02-18 15:04:54 -05:00
ea869ce983 Ignore automatically generated but unused tailwind config 2025-02-18 14:59:24 -05:00
b1299ca28c Remove action="POST" from forms with submit handler
All checks were successful
Push Workflows / docs (push) Successful in 9m6s
Push Workflows / docker-build (push) Successful in 10m38s
Push Workflows / test (push) Successful in 12m40s
Push Workflows / clippy (push) Successful in 10m38s
Push Workflows / build (push) Successful in 18m18s
Push Workflows / leptos-test (push) Successful in 19m43s
Push Workflows / nix-build (push) Successful in 30m19s
2025-02-18 00:22:21 -05:00
9dfc556bd0 Ensure cargo-leptos uses tailwindcss v4 in Docker build
Some checks failed
Push Workflows / docs (push) Successful in 1m37s
Push Workflows / clippy (push) Successful in 2m3s
Push Workflows / build (push) Failing after 2m32s
Push Workflows / test (push) Successful in 3m17s
Push Workflows / leptos-test (push) Successful in 3m33s
Push Workflows / docker-build (push) Successful in 19m23s
Push Workflows / nix-build (push) Successful in 26m14s
2025-02-17 23:06:28 -05:00
3a29ce4741 Use new tailwindcss configuration method 2025-02-17 22:38:37 -05:00
7d6c1e66bc Update tailwindcss to v4 2025-02-17 22:37:00 -05:00
478f8362af Update flake 2025-02-17 22:36:09 -05:00
fc8825d765 Update tower-sessions-redis-store and axum-login
All checks were successful
Push Workflows / docs (push) Successful in 8m10s
Push Workflows / clippy (push) Successful in 9m24s
Push Workflows / build (push) Successful in 12m20s
Push Workflows / test (push) Successful in 14m25s
Push Workflows / leptos-test (push) Successful in 16m24s
Push Workflows / docker-build (push) Successful in 22m53s
Push Workflows / nix-build (push) Successful in 32m6s
2025-02-17 20:15:39 -05:00
99fac1fe8f Upgrade leptos to 0.7.7 2025-02-12 21:37:36 -05:00
cfbc84343b Add FancyInput component for forms
Some checks failed
Push Workflows / clippy (push) Successful in 55s
Push Workflows / docs (push) Successful in 1m4s
Push Workflows / leptos-test (push) Successful in 5m46s
Push Workflows / nix-build (push) Successful in 21m58s
Push Workflows / test (push) Successful in 1m37s
Push Workflows / build (push) Has been cancelled
Push Workflows / docker-build (push) Has been cancelled
2025-02-12 18:57:52 -05:00
49455f5e03 Convert loading to use Tailwind styling 2025-02-10 17:25:57 -05:00
f482e06076 Switch to Tailwind CSS
All checks were successful
Push Workflows / docs (push) Successful in 48s
Push Workflows / clippy (push) Successful in 58s
Push Workflows / leptos-test (push) Successful in 1m47s
Push Workflows / test (push) Successful in 1m59s
Push Workflows / build (push) Successful in 2m56s
Push Workflows / docker-build (push) Successful in 13m34s
Push Workflows / nix-build (push) Successful in 26m40s
2025-02-07 10:55:22 -05:00
56902f1ff2 Move dashboard to pages
All checks were successful
Push Workflows / clippy (push) Successful in 58s
Push Workflows / leptos-test (push) Successful in 1m28s
Push Workflows / test (push) Successful in 2m2s
Push Workflows / docs (push) Successful in 2m26s
Push Workflows / build (push) Successful in 2m52s
Push Workflows / docker-build (push) Successful in 8m39s
Push Workflows / nix-build (push) Successful in 22m8s
2025-02-06 11:21:12 -05:00
4dfc789f58 Move search to pages 2025-02-06 11:20:22 -05:00
f04ad57a5a Move error_template to components
Some checks failed
Push Workflows / clippy (push) Successful in 1m7s
Push Workflows / docs (push) Successful in 1m17s
Push Workflows / test (push) Successful in 1m39s
Push Workflows / leptos-test (push) Successful in 1m44s
Push Workflows / build (push) Successful in 3m35s
Push Workflows / docker-build (push) Successful in 7m5s
Push Workflows / nix-build (push) Has been cancelled
2025-02-06 11:11:19 -05:00
fac75e1f54 Move auth_backend to util 2025-02-06 11:09:36 -05:00
afd8f014b2 Move users to api 2025-02-06 11:03:47 -05:00
362b8161e3 Fix doc comment indentation
All checks were successful
Push Workflows / leptos-test (push) Successful in 1m42s
Push Workflows / test (push) Successful in 2m6s
Push Workflows / clippy (push) Successful in 2m30s
Push Workflows / build (push) Successful in 6m19s
Push Workflows / docs (push) Successful in 6m26s
Push Workflows / docker-build (push) Successful in 8m44s
Push Workflows / nix-build (push) Successful in 23m52s
2025-02-06 10:22:54 -05:00
0f48dfeada Fix clippy lint errors
Some checks failed
Push Workflows / test (push) Successful in 1m33s
Push Workflows / docs (push) Successful in 1m45s
Push Workflows / clippy (push) Failing after 2m25s
Push Workflows / build (push) Successful in 4m0s
Push Workflows / leptos-test (push) Successful in 6m33s
Push Workflows / docker-build (push) Successful in 7m44s
Push Workflows / nix-build (push) Successful in 19m55s
2025-02-05 22:56:11 -05:00
7a0ae4c028 Configure clippy lints 2025-02-05 22:55:22 -05:00
742f0e2be6 Update doctests to reflect moved modules
Some checks failed
Push Workflows / docs (push) Successful in 1m16s
Push Workflows / leptos-test (push) Successful in 1m59s
Push Workflows / clippy (push) Failing after 2m4s
Push Workflows / test (push) Successful in 2m7s
Push Workflows / build (push) Successful in 2m46s
Push Workflows / docker-build (push) Successful in 7m28s
Push Workflows / nix-build (push) Successful in 25m13s
2025-02-05 22:06:43 -05:00
57d7459976 Add clippy CICD job
Some checks failed
Push Workflows / docker-build (push) Successful in 55s
Push Workflows / docs (push) Successful in 58s
Push Workflows / leptos-test (push) Failing after 1m21s
Push Workflows / clippy (push) Failing after 2m19s
Push Workflows / test (push) Successful in 2m31s
Push Workflows / build (push) Successful in 3m27s
Push Workflows / nix-build (push) Has been cancelled
2025-02-05 21:58:25 -05:00
a83a051d89 Remove unused backend models functions
Some checks failed
Push Workflows / leptos-test (push) Failing after 1m2s
Push Workflows / docs (push) Successful in 1m7s
Push Workflows / test (push) Successful in 1m36s
Push Workflows / build (push) Successful in 5m37s
Push Workflows / docker-build (push) Successful in 7m53s
Push Workflows / nix-build (push) Successful in 18m58s
2025-02-05 21:21:14 -05:00
745d4c1b0a Move auth to api 2025-02-05 21:05:45 -05:00
6666002533 Move upload to api 2025-02-05 21:04:26 -05:00
a33a891d87 Move song to components 2025-02-05 21:04:26 -05:00
9b22a82514 Move search to api 2025-02-05 20:58:00 -05:00
59b9db34cf Move playbar to components 2025-02-05 20:56:07 -05:00
d03eed78e7 Move queue to components 2025-02-05 20:53:25 -05:00
fc64b0cf1c Combine artist albums DB queries into single query 2025-02-05 15:20:03 -05:00
6a52598956 Combine user profile history DB queries into single query 2025-02-05 15:18:32 -05:00
e42247ee84 Move data types into models/frontend and models/backend 2025-02-05 12:34:48 -05:00
d72ed532c1 Move database to util 2025-02-04 22:54:06 -05:00
c3bc042027 Move pages.rs into pages/mod.rs
All checks were successful
Push Workflows / docs (push) Successful in 1m1s
Push Workflows / leptos-test (push) Successful in 1m45s
Push Workflows / test (push) Successful in 2m1s
Push Workflows / build (push) Successful in 4m8s
Push Workflows / docker-build (push) Successful in 18m29s
Push Workflows / nix-build (push) Successful in 20m30s
2025-02-04 22:47:43 -05:00
a67e486f75 Move components.rs into components/mod.rs 2025-02-04 22:47:18 -05:00
841251639e Move fileserv into util 2025-02-04 22:46:39 -05:00
0d2a83f508 Merge pull request 'Update audio source when status is updated' (#204) from 198-update-audio-source-when-status-is into main
All checks were successful
Push Workflows / docs (push) Successful in 1m4s
Push Workflows / docker-build (push) Successful in 1m7s
Push Workflows / leptos-test (push) Successful in 1m28s
Push Workflows / test (push) Successful in 2m6s
Push Workflows / build (push) Successful in 6m36s
Push Workflows / nix-build (push) Successful in 25m2s
Reviewed-on: #204
2025-02-03 03:04:14 +00:00
2d4a9ac9fd Merge branch 'main' into 198-update-audio-source-when-status-is
All checks were successful
Push Workflows / docs (push) Successful in 46s
Push Workflows / test (push) Successful in 1m38s
Push Workflows / leptos-test (push) Successful in 1m46s
Push Workflows / build (push) Successful in 2m52s
Push Workflows / docker-build (push) Successful in 8m31s
Push Workflows / nix-build (push) Successful in 18m23s
2025-02-02 22:01:03 -05:00
be053ffa62 Merge pull request 'Use timestamp instead of date for song added_date' (#203) from 201-use-timestamp-instead-of-date-for into main
All checks were successful
Push Workflows / docs (push) Successful in 44s
Push Workflows / docker-build (push) Successful in 1m17s
Push Workflows / leptos-test (push) Successful in 1m36s
Push Workflows / test (push) Successful in 1m44s
Push Workflows / build (push) Successful in 4m56s
Push Workflows / nix-build (push) Successful in 20m48s
Reviewed-on: #203
2025-02-02 22:17:10 +00:00
e7a8491653 Use timestamp instead of date for added_date column in songs table
All checks were successful
Push Workflows / docs (push) Successful in 56s
Push Workflows / test (push) Successful in 2m23s
Push Workflows / leptos-test (push) Successful in 2m31s
Push Workflows / build (push) Successful in 3m24s
Push Workflows / docker-build (push) Successful in 8m12s
Push Workflows / nix-build (push) Successful in 18m0s
2025-02-02 16:42:24 -05:00
2116dc9058 Merge pull request 'Update to leptos 0.7' (#202) from 171-update-to-leptos-07 into main
All checks were successful
Push Workflows / nix-build (push) Successful in 21m38s
Push Workflows / build (push) Successful in 3m45s
Push Workflows / docker-build (push) Successful in 1m14s
Push Workflows / docs (push) Successful in 2m3s
Push Workflows / leptos-test (push) Successful in 3m51s
Push Workflows / test (push) Successful in 6m32s
Reviewed-on: #202
2025-02-02 21:24:51 +00:00
a093068625 Add --locked to cargo-leptos install command
All checks were successful
Push Workflows / docs (push) Successful in 50s
Push Workflows / leptos-test (push) Successful in 1m29s
Push Workflows / test (push) Successful in 1m47s
Push Workflows / nix-build (push) Successful in 21m37s
Push Workflows / docker-build (push) Successful in 22m2s
Push Workflows / build (push) Successful in 6m53s
2025-02-02 15:47:25 -05:00
0739b0026b Update cargo-leptos to 0.2.26
Some checks failed
Push Workflows / docs (push) Successful in 1m46s
Push Workflows / test (push) Successful in 2m25s
Push Workflows / leptos-test (push) Successful in 3m30s
Push Workflows / docker-build (push) Failing after 4m58s
Push Workflows / nix-build (push) Has been cancelled
Push Workflows / build (push) Successful in 7m5s
Update wasm-bindgen to 0.2.100
2025-02-02 15:37:19 -05:00
698931d915 Update to leptos 0.7.5
Some checks failed
Push Workflows / docs (push) Successful in 1m47s
Push Workflows / leptos-test (push) Successful in 4m0s
Push Workflows / test (push) Successful in 4m41s
Push Workflows / build (push) Failing after 6m40s
Push Workflows / docker-build (push) Failing after 13m6s
Push Workflows / nix-build (push) Successful in 20m4s
2025-02-02 15:14:46 -05:00
38bc2fbe92 Use effect to set audio source when PlayStatus changes
All checks were successful
Push Workflows / docs (push) Successful in 39s
Push Workflows / leptos-test (push) Successful in 1m7s
Push Workflows / build (push) Successful in 2m7s
Push Workflows / test (push) Successful in 2m48s
Push Workflows / nix-build (push) Successful in 17m57s
Push Workflows / docker-build (push) Successful in 3m31s
2025-01-07 15:13:07 -05:00
d3e9c5d869 Fix broken get_audio test
Some checks failed
Push Workflows / docs (push) Successful in 48s
Push Workflows / test (push) Successful in 1m45s
Push Workflows / build (push) Failing after 2m31s
Push Workflows / leptos-test (push) Successful in 3m51s
Push Workflows / docker-build (push) Successful in 8m0s
Push Workflows / nix-build (push) Successful in 21m7s
2024-12-28 16:37:09 -05:00
64c37dc327 Add underscores to fix (incorrect?) unused warming
Some checks failed
Push Workflows / docs (push) Successful in 43s
Push Workflows / test (push) Successful in 1m34s
Push Workflows / build (push) Failing after 2m1s
Push Workflows / leptos-test (push) Failing after 3m12s
Push Workflows / docker-build (push) Successful in 8m0s
Push Workflows / nix-build (push) Successful in 20m41s
2024-12-28 16:11:39 -05:00
abd0f87d41 Use Signal instead of MaybeSignal 2024-12-28 16:09:53 -05:00
ec1c57a67d Use Memo::new instead of create_memo 2024-12-28 16:06:52 -05:00
262f3634bf Use NodeRef::new instead of create_node_ref 2024-12-28 16:05:27 -05:00
e533132273 Use Effect::new instead of create_effect 2024-12-28 16:02:27 -05:00
d89d9d3548 Use signal instead of create_signal 2024-12-28 16:01:32 -05:00
2cfd698978 Remove unused imports 2024-12-28 16:00:18 -05:00
57406b5940 Use RwSignal::new instead of create_rw_signal 2024-12-28 15:47:06 -05:00
628684a259 Use dialog instead of div for upload
Some checks failed
Push Workflows / build (push) Failing after 1m55s
Push Workflows / docs (push) Successful in 2m14s
Push Workflows / leptos-test (push) Failing after 3m39s
Push Workflows / test (push) Successful in 4m2s
Push Workflows / docker-build (push) Successful in 18m33s
Push Workflows / nix-build (push) Successful in 20m7s
2024-12-28 15:40:29 -05:00
96835e684a Use node_ref instead of _ref in DashboardRow 2024-12-28 15:38:31 -05:00
aa9e26459f Remove .await for loading config 2024-12-28 15:38:09 -05:00
69b3066a3b Add HTML boilerplate in shell 2024-12-28 15:37:49 -05:00
3368d16c96 Use .get() on TextProp 2024-12-28 15:24:07 -05:00
141034eacd Remove type prop from <audio> 2024-12-28 12:36:42 -05:00
55521fd7fe Remove align prop from playbar divs 2024-12-28 12:36:21 -05:00
40d6440d99 Use hydrate_body instead of mount_to_body 2024-12-28 12:28:29 -05:00
daf8a50863 Use new HTML types for getting audio component 2024-12-28 12:28:17 -05:00
099c1042a2 Use raw strings instead of TextProp for classes in SongList 2024-12-28 12:27:03 -05:00
b3748374d4 Fix type of setting logged in user resource 2024-12-28 12:26:25 -05:00
5235854af7 Use tbody for table in SongList 2024-12-28 12:24:37 -05:00
915d5ea6f7 Remove options arg from render_app_to_stream call 2024-12-28 12:20:23 -05:00
f5c863f2a6 Merge pull request 'Turn DashboardRow and DashboardTile into components' (#194) from 193-run-dashboardrow-and-dashboardtile-into-components into main
Some checks failed
Push Workflows / docs (push) Successful in 3m6s
Push Workflows / test (push) Successful in 3m51s
Push Workflows / leptos-test (push) Successful in 4m52s
Push Workflows / build (push) Successful in 7m16s
Push Workflows / docker-build (push) Has been cancelled
Push Workflows / nix-build (push) Has been cancelled
Reviewed-on: #194
2024-12-26 17:30:39 +00:00
ec01183dc2 Use Arc for response handler instead of Rc 2024-12-24 16:54:07 -05:00
3dd040afd0 Merge remote-tracking branch 'origin/193-run-dashboardrow-and-dashboardtile-into-components' into 171-update-to-leptos-07 2024-12-24 16:50:45 -05:00
c900cb896e Use new DashboardRow / DashboardTile in artist and profile pages
All checks were successful
Push Workflows / test (push) Successful in 47s
Push Workflows / docs (push) Successful in 1m8s
Push Workflows / leptos-test (push) Successful in 1m12s
Push Workflows / build (push) Successful in 1m37s
Push Workflows / docker-build (push) Successful in 4m49s
Push Workflows / nix-build (push) Successful in 17m1s
2024-12-24 16:48:49 -05:00
2af8310077 Implement Into<DashboardTile> instead of implementing old trait DashboardTile 2024-12-24 16:48:48 -05:00
1a4112542e Convert DashboardRow to component 2024-12-24 16:48:48 -05:00
40bf99a2bf Use spread syntax for Form class 2024-12-24 15:09:58 -05:00
ebc669ecf8 Use new Router setup 2024-12-23 21:58:34 -05:00
b4664bdad7 Fix bad path for mount_to_body 2024-12-23 21:51:31 -05:00
608f18ace5 Manually import Params and use_params 2024-12-23 21:49:34 -05:00
20ff4674d4 Fix path to use_navigate 2024-12-23 21:49:34 -05:00
3de5efc27f Specify leptos::ev instead of ev 2024-12-23 21:49:34 -05:00
b9f5867b4d Fix Resource type signature 2024-12-23 21:49:34 -05:00
db8dc3cd3d Manually import TextProp 2024-12-23 21:49:33 -05:00
848b1afd2c Use node_ref instead of _ref 2024-12-23 21:33:07 -05:00
141a27bb7e Fix bad import path for use_location 2024-12-23 21:25:23 -05:00
78d59731b0 Manually import spawn_local 2024-12-23 21:24:26 -05:00
26a572b18a Fix bad import path for Form 2024-12-23 21:17:46 -05:00
f6ee5feb3f Update leptos-use to 0.15 2024-12-23 21:15:31 -05:00
0cd36d4b44 Remove duplicate "required" prop 2024-12-23 21:11:57 -05:00
3c148c36df Update server_fn to 0.7 2024-12-23 21:10:21 -05:00
4eb673a9a4 Fix bad import path for NodeRef 2024-12-23 21:06:28 -05:00
782c9b9482 Use correct use_params_map import path 2024-12-23 21:04:20 -05:00
52d60318bb Render String instead of &String from error_msg 2024-12-23 21:01:39 -05:00
7732b77eb5 Use leptos::either to handle mismatched return types instead of into_view() 2024-12-23 20:58:53 -05:00
fe131b1ba2 Use spread syntax for Icon class 2024-12-23 20:47:41 -05:00
064f06d763 Update icons 2024-12-23 20:47:28 -05:00
900d1ca1bb Use new way of creating resources 2024-12-23 20:34:28 -05:00
92eb63e946 Use new leptos::predude module 2024-12-23 20:33:42 -05:00
a9c1ed7048 Upgrade to wasm-bindgen 0.2.99 2024-12-23 20:32:29 -05:00
a63b5d4e29 Remove hydrate flag from leptos_router 2024-12-23 20:31:31 -05:00
238a24c938 Remove nightly and hydrate flags from leptos_meta 2024-12-23 20:01:50 -05:00
69125f71f3 Update leptos_ crates to 0.7 2024-12-23 20:00:37 -05:00
ae8a3d0ade Merge pull request 'Remove old cicd utils' (#192) from 191-remove-old-cicd-utils into main
Some checks failed
Push Workflows / docker-build (push) Successful in 51s
Push Workflows / leptos-test (push) Successful in 57s
Push Workflows / docs (push) Successful in 1m0s
Push Workflows / build (push) Successful in 1m18s
Push Workflows / nix-build (push) Has been cancelled
Push Workflows / test (push) Has been cancelled
Reviewed-on: #192
2024-12-20 20:05:24 +00:00
343284a6da Remove cicd/
All checks were successful
Push Workflows / docs (push) Successful in 39s
Push Workflows / test (push) Successful in 46s
Push Workflows / leptos-test (push) Successful in 1m6s
Push Workflows / docker-build (push) Successful in 1m6s
Push Workflows / build (push) Successful in 1m10s
Push Workflows / nix-build (push) Successful in 29m5s
2024-12-20 15:00:12 -05:00
65e5de7051 Merge pull request 'Display like/dislike for client instead of viewed user on profile page' (#189) from 140-display-likedislike-for-client-instead-of into main
Some checks failed
Push Workflows / build (push) Has been cancelled
Push Workflows / leptos-test (push) Has been cancelled
Push Workflows / test (push) Has been cancelled
Push Workflows / nix-build (push) Has been cancelled
Push Workflows / docker-build (push) Has been cancelled
Push Workflows / docs (push) Has been cancelled
Reviewed-on: #189
2024-12-20 19:37:38 +00:00
219a218f92 Merge pull request 'Fix hardcoded for_user_id in artist page' (#190) from 185-fix-hardcoded-foruserid-in-artist-page into main
Some checks failed
Push Workflows / docker-build (push) Has been cancelled
Push Workflows / build (push) Has been cancelled
Push Workflows / docs (push) Has been cancelled
Push Workflows / test (push) Has been cancelled
Push Workflows / nix-build (push) Has been cancelled
Push Workflows / leptos-test (push) Has been cancelled
Reviewed-on: #190
2024-12-20 19:37:28 +00:00
f8534cd6f6 Return like/dislike data for user viewing page
All checks were successful
Push Workflows / leptos-test (push) Successful in 1m27s
Push Workflows / build (push) Successful in 1m41s
Push Workflows / test (push) Successful in 1m44s
Push Workflows / docs (push) Successful in 1m43s
Push Workflows / docker-build (push) Successful in 5m25s
Push Workflows / nix-build (push) Successful in 17m45s
2024-12-20 14:23:06 -05:00
01e393a77f Return like/dislike data for user viewing page
All checks were successful
Push Workflows / docs (push) Successful in 35s
Push Workflows / test (push) Successful in 45s
Push Workflows / leptos-test (push) Successful in 1m1s
Push Workflows / build (push) Successful in 2m52s
Push Workflows / docker-build (push) Successful in 5m52s
Push Workflows / nix-build (push) Successful in 19m17s
2024-12-20 14:21:22 -05:00
481d9109eb Merge pull request 'Fix docker-build caching' (#188) from 186-fix-dockerbuild-caching into main
All checks were successful
Push Workflows / nix-build (push) Successful in 20m7s
Push Workflows / docs (push) Successful in 39s
Push Workflows / test (push) Successful in 44s
Push Workflows / leptos-test (push) Successful in 1m3s
Push Workflows / build (push) Successful in 1m46s
Push Workflows / docker-build (push) Successful in 1m29s
Reviewed-on: #188
2024-12-20 19:09:41 +00:00
050cab6d46 Use GitHub Actions cache
All checks were successful
Push Workflows / test (push) Successful in 59s
Push Workflows / docs (push) Successful in 1m0s
Push Workflows / build (push) Successful in 1m17s
Push Workflows / leptos-test (push) Successful in 1m42s
Push Workflows / nix-build (push) Successful in 17m42s
Push Workflows / docker-build (push) Successful in 21m34s
2024-12-20 13:46:36 -05:00
87f5efed34 Merge pull request 'Create song page' (#187) from 144-create-song-page-2 into main
All checks were successful
Push Workflows / test (push) Successful in 46s
Push Workflows / leptos-test (push) Successful in 56s
Push Workflows / docs (push) Successful in 1m2s
Push Workflows / build (push) Successful in 1m27s
Push Workflows / nix-build (push) Successful in 16m22s
Push Workflows / docker-build (push) Successful in 15m40s
Reviewed-on: #187
2024-12-20 18:39:26 +00:00
525be5615c Add CSS for song page
All checks were successful
Push Workflows / test (push) Successful in 43s
Push Workflows / leptos-test (push) Successful in 1m0s
Push Workflows / docs (push) Successful in 2m7s
Push Workflows / build (push) Successful in 5m25s
Push Workflows / docker-build (push) Successful in 12m4s
Push Workflows / nix-build (push) Successful in 20m17s
2024-12-20 13:26:05 -05:00
28b71df7e6 Finish song page 2024-12-20 13:25:51 -05:00
560fe0355d Make some parts of SongList public 2024-12-20 13:25:39 -05:00
0e64131fa0 Add route for song page 2024-12-20 13:25:23 -05:00
f3f123d8f6 Add module for song page 2024-12-20 13:25:15 -05:00
15087e86b5 Add API endpoints for song page 2024-12-20 13:24:44 -05:00
c77699b3a1 Merge remote-tracking branch 'origin/main' into 144-create-song-page-2 2024-12-19 19:20:59 -05:00
e55f5d973e Merge pull request 'Create artist page' (#184) from 115-create-artist-page into main
All checks were successful
Push Workflows / docs (push) Successful in 40s
Push Workflows / test (push) Successful in 46s
Push Workflows / leptos-test (push) Successful in 50s
Push Workflows / docker-build (push) Successful in 53s
Push Workflows / build (push) Successful in 1m20s
Push Workflows / nix-build (push) Successful in 16m27s
Reviewed-on: #184
2024-12-20 00:12:42 +00:00
3586df650f Fix unused import warnings
Some checks failed
Push Workflows / docs (push) Successful in 47s
Push Workflows / leptos-test (push) Successful in 53s
Push Workflows / test (push) Successful in 54s
Push Workflows / build (push) Successful in 1m16s
Push Workflows / nix-build (push) Successful in 17m40s
Push Workflows / docker-build (push) Has been cancelled
2024-12-19 18:53:10 -05:00
579e764994 Finish artist page
Some checks failed
Push Workflows / test (push) Successful in 43s
Push Workflows / build (push) Failing after 50s
Push Workflows / docs (push) Successful in 1m26s
Push Workflows / nix-build (push) Has been cancelled
Push Workflows / docker-build (push) Has been cancelled
Push Workflows / leptos-test (push) Has been cancelled
2024-12-19 18:48:38 -05:00
fb86e2e229 Add API endpoints for artist page 2024-12-19 18:48:21 -05:00
0ff594aaec Increase artist image padding, remove margin 2024-12-19 18:48:05 -05:00
6cc5f60c5a Increase artist image size 2024-12-19 18:47:52 -05:00
ce9e16f376 Add route for artist page 2024-12-19 18:44:43 -05:00
e915e1ab44 Add module for artist page 2024-12-19 18:44:28 -05:00
7e7480d02b Add artist page CSS to main 2024-12-19 18:43:46 -05:00
dcdfee27a3 Merge remote-tracking branch 'origin/main' into 115-create-artist-page 2024-12-19 14:18:18 -05:00
8061bb9f5e Merge pull request 'Add caching to Rust CICD jobs' (#183) from 182-add-caching-to-rust-cicd-jobs into main
All checks were successful
Push Workflows / nix-build (push) Successful in 30m29s
Push Workflows / test (push) Successful in 36s
Push Workflows / docs (push) Successful in 36s
Push Workflows / leptos-test (push) Successful in 54s
Push Workflows / build (push) Successful in 6m41s
Push Workflows / docker-build (push) Successful in 11m38s
Reviewed-on: #183
2024-12-19 18:14:51 +00:00
cff1327b8a Use rust-cache action for build, test, docs, and leptos-test jobs
All checks were successful
Push Workflows / docker-build (push) Successful in 39s
Push Workflows / test (push) Successful in 1m5s
Push Workflows / docs (push) Successful in 1m7s
Push Workflows / leptos-test (push) Successful in 1m28s
Push Workflows / build (push) Successful in 1m43s
Push Workflows / nix-build (push) Successful in 16m54s
2024-12-19 11:19:39 -05:00
a5a679a74e Merge pull request 'Resolve "implement artist/album creation"' (#76) from 45-implement-artist-album-creation into main
Some checks failed
Push Workflows / docker-build (push) Successful in 30s
Push Workflows / docs (push) Successful in 58s
Push Workflows / leptos-test (push) Has been cancelled
Push Workflows / test (push) Has been cancelled
Push Workflows / nix-build (push) Has been cancelled
Push Workflows / build (push) Has been cancelled
Reviewed-on: #76
Reviewed-by: Ethan Girouard <ethan@girouard.com>
2024-12-19 05:33:45 +00:00
6042ec209c Switch to chrono instead of time
All checks were successful
Push Workflows / nix-build (push) Successful in 19m24s
Push Workflows / docker-build (push) Successful in 12m48s
Push Workflows / docs (push) Successful in 1m17s
Push Workflows / test (push) Successful in 2m13s
Push Workflows / leptos-test (push) Successful in 2m50s
Push Workflows / build (push) Successful in 4m55s
2024-12-19 00:13:52 -05:00
08a2322eb8 Merge remote-tracking branch 'origin/main' into 45-implement-artist-album-creation 2024-12-19 00:07:19 -05:00
36ffb33b02 Merge pull request 'Use different CICD image for docker-build job' (#179) from 178-use-different-cicd-image-for-dockerbuild into main
All checks were successful
Push Workflows / docs (push) Successful in 1m37s
Push Workflows / test (push) Successful in 2m33s
Push Workflows / leptos-test (push) Successful in 5m44s
Push Workflows / build (push) Successful in 6m1s
Push Workflows / docker-build (push) Successful in 12m53s
Push Workflows / nix-build (push) Successful in 19m30s
Reviewed-on: #179
2024-12-19 03:15:34 +00:00
42beaad659 Use ubuntu-latest-docker for docker-build job
All checks were successful
Push Workflows / docs (push) Successful in 1m55s
Push Workflows / test (push) Successful in 3m3s
Push Workflows / leptos-test (push) Successful in 3m50s
Push Workflows / build (push) Successful in 6m28s
Push Workflows / docker-build (push) Successful in 13m32s
Push Workflows / nix-build (push) Successful in 20m0s
2024-12-18 21:53:09 -05:00
bef240e2b2 Merge pull request 'Pin cargo-leptos version in Dockerfile' (#176) from 175-pin-cargoleptos-version-in-dockerfile into main
Some checks failed
Push Workflows / test (push) Waiting to run
Push Workflows / docker-build (push) Failing after 2m23s
Push Workflows / leptos-test (push) Successful in 2m52s
Push Workflows / nix-build (push) Has been cancelled
Push Workflows / docs (push) Has been cancelled
Push Workflows / build (push) Has been cancelled
Reviewed-on: #176
2024-12-18 06:52:37 +00:00
05074230a9 Pin cargo-leptos to 0.2.22 in Dockerfile
All checks were successful
Push Workflows / build (push) Successful in 14m41s
Push Workflows / test (push) Successful in 9m46s
Push Workflows / docs (push) Successful in 3m54s
Push Workflows / leptos-test (push) Successful in 10m45s
Push Workflows / docker-build (push) Successful in 27m57s
Push Workflows / nix-build (push) Successful in 25m33s
2024-12-18 00:36:19 -05:00
ecc5b35cd0 Merge pull request 'Add NixOS environment file' (#174) from 170-add-nixos-environment-file into main
Some checks failed
Push Workflows / docs (push) Successful in 1m30s
Push Workflows / docker-build (push) Failing after 1m30s
Push Workflows / test (push) Successful in 3m52s
Push Workflows / leptos-test (push) Successful in 6m31s
Push Workflows / build (push) Successful in 7m2s
Push Workflows / nix-build (push) Successful in 26m17s
Reviewed-on: #174
2024-12-18 05:35:17 +00:00
3536ad7343 Build flake from git URL
Some checks failed
Push Workflows / docs (push) Successful in 5m18s
Push Workflows / test (push) Successful in 9m29s
Push Workflows / leptos-test (push) Successful in 10m34s
Push Workflows / build (push) Successful in 11m24s
Push Workflows / docker-build (push) Failing after 18m17s
Push Workflows / nix-build (push) Successful in 29m38s
2024-12-17 23:44:56 -05:00
f1c94bd8a8 Update cargo-leptos to 0.2.22 in flake
Some checks failed
Push Workflows / docs (push) Successful in 6m41s
Push Workflows / test (push) Successful in 9m23s
Push Workflows / leptos-test (push) Successful in 11m18s
Push Workflows / build (push) Successful in 12m26s
Push Workflows / docker-build (push) Failing after 16m4s
Push Workflows / nix-build (push) Successful in 31m15s
2024-12-17 22:21:50 -05:00
59e97c4a79 Merge branch '172-fix-unexpected-cfg-condition-name-wasmbindgenunstabletestcoverage' into 170-add-nixos-environment-file
Some checks failed
Push Workflows / docs (push) Successful in 3m53s
Push Workflows / test (push) Successful in 6m31s
Push Workflows / leptos-test (push) Successful in 9m48s
Push Workflows / build (push) Successful in 10m15s
Push Workflows / docker-build (push) Failing after 17m18s
Push Workflows / nix-build (push) Failing after 26m27s
2024-12-17 21:46:47 -05:00
96e6b67c6e Add Nix build CICD job
Some checks failed
Push Workflows / docs (push) Successful in 2m39s
Push Workflows / build (push) Failing after 3m25s
Push Workflows / test (push) Successful in 5m22s
Push Workflows / leptos-test (push) Successful in 6m49s
Push Workflows / nix-build (push) Has been cancelled
Push Workflows / docker-build (push) Has been cancelled
2024-12-17 21:42:01 -05:00
5548992c57 Ignore some Nix files 2024-12-17 21:41:18 -05:00
414f507ef9 Merge pull request 'Add added_date column to songs table #100' (#118) from 100-add-addeddate-column-to-songs-table into main
Some checks failed
Push Workflows / docs (push) Successful in 2m9s
Push Workflows / test (push) Successful in 5m12s
Push Workflows / leptos-test (push) Successful in 6m59s
Push Workflows / build (push) Successful in 7m56s
Push Workflows / docker-build (push) Failing after 13m27s
Reviewed-on: #118
2024-12-15 23:57:20 +00:00
ec65d099f1 Merge branch '172-fix-unexpected-cfg-condition-name-wasmbindgenunstabletestcoverage' into 100-add-addeddate-column-to-songs-table
Some checks failed
Push Workflows / docs (push) Successful in 2m49s
Push Workflows / test (push) Successful in 7m19s
Push Workflows / leptos-test (push) Successful in 8m53s
Push Workflows / build (push) Successful in 9m36s
Push Workflows / docker-build (push) Failing after 15m33s
2024-12-15 18:07:59 -05:00
65aa296493 Merge pull request 'Fix unexpected cfg condition name wasm_bindgen_unstable_test_coverage warning' (#173) from 172-fix-unexpected-cfg-condition-name-wasmbindgenunstabletestcoverage into main
Some checks failed
Push Workflows / docs (push) Successful in 2m37s
Push Workflows / test (push) Successful in 5m54s
Push Workflows / leptos-test (push) Successful in 7m8s
Push Workflows / build (push) Successful in 8m34s
Push Workflows / docker-build (push) Failing after 14m6s
Reviewed-on: #173
2024-12-15 22:55:09 +00:00
d42ae8a227 Update wasm-bindgen to 0.2.96
Some checks failed
Push Workflows / docs (push) Successful in 3m47s
Push Workflows / test (push) Successful in 7m30s
Push Workflows / leptos-test (push) Successful in 8m42s
Push Workflows / build (push) Successful in 9m36s
Push Workflows / docker-build (push) Failing after 14m43s
2024-12-15 17:34:47 -05:00
b7b6406c2d Add added_date field to Song and SongData
Some checks failed
Push Workflows / docs (push) Successful in 2m8s
Push Workflows / build (push) Failing after 2m53s
Push Workflows / test (push) Successful in 5m7s
Push Workflows / leptos-test (push) Successful in 6m16s
Push Workflows / docker-build (push) Failing after 14m38s
2024-12-15 17:20:18 -05:00
9f39c9b3fd Merge branch 'main' into 100-add-addeddate-column-to-songs-table 2024-12-15 17:09:44 -05:00
8fbc733b6b Rename add_songs_added_date migration 2024-12-15 17:08:31 -05:00
3ec25881b9 Add Nix flake 2024-12-15 14:42:48 -05:00
5cb0f4a17b Add wasm32-unknown-unknown target to toolchain file 2024-12-15 14:42:32 -05:00
5967918642 Added css for songpage components
Some checks failed
Push Workflows / docs (push) Successful in 4m48s
Push Workflows / build (push) Failing after 5m49s
Push Workflows / test (push) Successful in 7m54s
Push Workflows / leptos-test (push) Successful in 11m48s
Push Workflows / docker-build (push) Failing after 32m19s
2024-12-11 04:38:08 +00:00
84371bb586 Added song overview component for the song's metadata 2024-12-11 04:37:51 +00:00
186821d838 Added songdetails component 2024-12-11 04:37:28 +00:00
4c46f78135 Added songpage component with basic structure and css file 2024-12-11 04:37:11 +00:00
9350c74091 Added mock api functions, need to implement later
Some checks failed
Push Workflows / docs (push) Successful in 3m8s
Push Workflows / build (push) Failing after 3m45s
Push Workflows / test (push) Successful in 4m57s
Push Workflows / leptos-test (push) Successful in 6m33s
Push Workflows / docker-build (push) Failing after 18m25s
2024-12-11 03:47:03 +00:00
88cd5544fd Added basic artist css 2024-12-11 03:46:07 +00:00
94880ead7c Added related artists component 2024-12-11 03:45:35 +00:00
837dd5ea3c Added top songs component that shows the top songs by an artist 2024-12-11 03:45:15 +00:00
86e5e733b3 Add artist detail component for name/bio/image 2024-12-11 03:44:43 +00:00
8dbaaf317d Added ArtistProfile component to get artist info based on id 2024-12-11 03:44:13 +00:00
d4897b4227 Added basic artist page
Some checks failed
Push Workflows / docs (push) Successful in 2m38s
Push Workflows / build (push) Failing after 3m32s
Push Workflows / docker-build (push) Has been cancelled
Push Workflows / test (push) Has been cancelled
Push Workflows / leptos-test (push) Has been cancelled
2024-12-11 03:42:32 +00:00
9fb3cd745b Merge pull request 'Fix Album Artists Displayed Wrong' (#165) from 164-album-artists-displayed-wrong into main
Some checks failed
Push Workflows / docs (push) Successful in 3m59s
Push Workflows / build (push) Failing after 4m9s
Push Workflows / test (push) Has been cancelled
Push Workflows / docker-build (push) Has been cancelled
Push Workflows / leptos-test (push) Has been cancelled
Reviewed-on: #165
Reviewed-by: Ethan Girouard <ethan@girouard.com>
2024-12-04 05:54:37 +00:00
a7905624a6 Bugfixes
Some checks failed
Push Workflows / docs (push) Successful in 3m4s
Push Workflows / test (push) Successful in 4m58s
Push Workflows / leptos-test (push) Successful in 7m7s
Push Workflows / build (push) Successful in 7m53s
Push Workflows / docker-build (push) Failing after 13m32s
2024-12-04 05:29:16 +00:00
aaa9db93fb Merge pull request 'Require login to fetch audio and image assets' (#160) from 110-require-login-to-fetch-audio-and into main
Some checks failed
Push Workflows / docs (push) Successful in 2m56s
Push Workflows / test (push) Successful in 5m8s
Push Workflows / leptos-test (push) Successful in 7m24s
Push Workflows / build (push) Successful in 8m23s
Push Workflows / docker-build (push) Failing after 14m21s
Reviewed-on: #160
2024-11-24 21:35:41 +00:00
fac33bb1f1 Merge pull request 'Add environment variable to disable signup' (#159) from 157-add-environment-variable-to-disable-signup into main
Some checks failed
Push Workflows / docker-build (push) Has been cancelled
Push Workflows / leptos-test (push) Has been cancelled
Push Workflows / build (push) Has been cancelled
Push Workflows / test (push) Has been cancelled
Push Workflows / docs (push) Has been cancelled
Reviewed-on: #159
2024-11-24 21:34:32 +00:00
97f50b38c5 Add example usage of LIBRETUNES_DISABLE_SIGNUP env var
All checks were successful
Push Workflows / docs (push) Successful in 1m12s
Push Workflows / test (push) Successful in 2m47s
Push Workflows / build (push) Successful in 3m22s
Push Workflows / leptos-test (push) Successful in 7m7s
Push Workflows / docker-build (push) Successful in 15m33s
2024-11-24 14:37:10 -05:00
5ecb71ce9d Return error on /api/signup if signup disabled 2024-11-24 14:36:56 -05:00
d1c8615105 Add router layer to require authentication
All checks were successful
Push Workflows / docs (push) Successful in 1m13s
Push Workflows / test (push) Successful in 2m14s
Push Workflows / build (push) Successful in 3m8s
Push Workflows / leptos-test (push) Successful in 5m26s
Push Workflows / docker-build (push) Successful in 19m16s
2024-11-24 14:28:06 -05:00
6592d66f87 Merge pull request 'Fixed casing warning' (#158) from 155-fix-dockerfile-fromas-casing-warning into main
All checks were successful
Push Workflows / docker-build (push) Successful in 19s
Push Workflows / leptos-test (push) Successful in 2m17s
Push Workflows / docs (push) Successful in 2m53s
Push Workflows / test (push) Successful in 4m9s
Push Workflows / build (push) Successful in 7m47s
Reviewed-on: #158
2024-11-24 19:04:06 +00:00
51a9e8c4b3 Fixed casing warning
All checks were successful
Push Workflows / docs (push) Successful in 2m34s
Push Workflows / test (push) Successful in 3m56s
Push Workflows / leptos-test (push) Successful in 5m5s
Push Workflows / build (push) Successful in 5m40s
Push Workflows / docker-build (push) Successful in 6m16s
2024-11-24 04:29:53 +00:00
1a1516ff92 Merge pull request 'Set new container registry url in docker-compose' (#156) from 112-set-new-container-registry-url-in into main
Some checks failed
Push Workflows / docker-build (push) Successful in 11s
Push Workflows / docs (push) Successful in 1m12s
Push Workflows / build (push) Has been cancelled
Push Workflows / test (push) Has been cancelled
Push Workflows / leptos-test (push) Has been cancelled
Reviewed-on: #156
2024-11-23 07:16:40 +00:00
c9d3053c5a Change registry URL to Gitea registry
All checks were successful
Push Workflows / test (push) Successful in 3m41s
Push Workflows / build (push) Successful in 5m22s
Push Workflows / docs (push) Successful in 1m17s
Push Workflows / leptos-test (push) Successful in 4m31s
Push Workflows / docker-build (push) Successful in 13m13s
2024-11-23 02:02:53 -05:00
aced8723c2 Merge pull request 'Indicate loading and errors on login/signup pages' (#154) from 147-indicate-loading-and-errors-on-loginsignup into main
All checks were successful
Push Workflows / docs (push) Successful in 1m50s
Push Workflows / test (push) Successful in 2m31s
Push Workflows / build (push) Successful in 3m33s
Push Workflows / leptos-test (push) Successful in 4m14s
Push Workflows / docker-build (push) Successful in 12m11s
Reviewed-on: #154
2024-11-23 06:15:25 +00:00
ede248d961 Add loading and error messages to signup page
All checks were successful
Push Workflows / docs (push) Successful in 1m56s
Push Workflows / test (push) Successful in 3m11s
Push Workflows / leptos-test (push) Successful in 4m16s
Push Workflows / build (push) Successful in 4m43s
Push Workflows / docker-build (push) Successful in 8m23s
2024-11-23 00:57:29 -05:00
930618dcad Add loading and error messages to login page 2024-11-23 00:46:30 -05:00
9b48fc0204 Merge pull request '95-fix-home-screen-account-button-ui' (#124) from 95-fix-home-screen-account-button-ui into main
All checks were successful
Push Workflows / docker-build (push) Successful in 15s
Push Workflows / docs (push) Successful in 2m3s
Push Workflows / leptos-test (push) Successful in 2m49s
Push Workflows / test (push) Successful in 3m10s
Push Workflows / build (push) Successful in 4m47s
Reviewed-on: #124
2024-11-22 22:32:53 +00:00
dfbb217988 Merge pull request '114-create-album-page' (#149) from 114-create-album-page into main
Some checks failed
Push Workflows / docs (push) Has been cancelled
Push Workflows / build (push) Has been cancelled
Push Workflows / test (push) Has been cancelled
Push Workflows / leptos-test (push) Has been cancelled
Push Workflows / docker-build (push) Has been cancelled
Reviewed-on: #149
2024-11-22 22:31:27 +00:00
954cc0edce Artist links and styling
All checks were successful
Push Workflows / docs (push) Successful in 2m5s
Push Workflows / test (push) Successful in 3m3s
Push Workflows / leptos-test (push) Successful in 3m21s
Push Workflows / build (push) Successful in 4m31s
Push Workflows / docker-build (push) Successful in 6m42s
2024-11-22 22:13:43 +00:00
e5ce0eab76 Fix Build Warnings
All checks were successful
Push Workflows / docs (push) Successful in 1m55s
Push Workflows / test (push) Successful in 2m57s
Push Workflows / leptos-test (push) Successful in 3m18s
Push Workflows / build (push) Successful in 4m32s
Push Workflows / docker-build (push) Successful in 7m13s
2024-11-22 21:55:59 +00:00
45eb7191f0 Bugfix and small changes to styling
Some checks failed
Push Workflows / docs (push) Successful in 1m55s
Push Workflows / build (push) Failing after 2m23s
Push Workflows / test (push) Successful in 2m50s
Push Workflows / leptos-test (push) Successful in 3m10s
Push Workflows / docker-build (push) Successful in 6m49s
2024-11-22 21:47:24 +00:00
21a17a8eb5 Album Page Styling
Some checks failed
Push Workflows / docs (push) Successful in 3m19s
Push Workflows / build (push) Failing after 3m41s
Push Workflows / test (push) Successful in 4m22s
Push Workflows / leptos-test (push) Successful in 5m22s
Push Workflows / docker-build (push) Successful in 9m39s
2024-11-22 21:34:21 +00:00
fcd987d433 change unwrap or default to unwrap or false for readability
All checks were successful
Push Workflows / docs (push) Successful in 1m58s
Push Workflows / test (push) Successful in 3m33s
Push Workflows / leptos-test (push) Successful in 3m45s
Push Workflows / build (push) Successful in 6m47s
Push Workflows / docker-build (push) Successful in 9m16s
2024-11-22 16:31:55 -05:00
dd14aa0b4d AlbumData Query, API Endpoint, and Integration into AlbumPage
Some checks failed
Push Workflows / build (push) Failing after 1m8s
Push Workflows / docs (push) Successful in 1m4s
Push Workflows / test (push) Successful in 1m47s
Push Workflows / leptos-test (push) Successful in 4m47s
Push Workflows / docker-build (push) Successful in 12m16s
2024-11-20 04:43:53 +00:00
3b6035dd71 Album pages for users not signed in
Some checks failed
Push Workflows / docs (push) Successful in 2m14s
Push Workflows / build (push) Failing after 2m26s
Push Workflows / test (push) Successful in 2m50s
Push Workflows / leptos-test (push) Successful in 4m13s
Push Workflows / docker-build (push) Successful in 9m21s
2024-11-20 02:12:09 +00:00
22cee4a265 Merge remote-tracking branch 'origin/main' into 114-create-album-page
Some checks failed
Push Workflows / docs (push) Successful in 4m10s
Push Workflows / build (push) Failing after 4m47s
Push Workflows / test (push) Successful in 6m57s
Push Workflows / leptos-test (push) Successful in 10m51s
Push Workflows / docker-build (push) Successful in 23m59s
2024-11-19 22:21:46 +00:00
b25cb4549c Revert get_user to not be server function
All checks were successful
Push Workflows / docs (push) Successful in 1m19s
Push Workflows / test (push) Successful in 2m48s
Push Workflows / build (push) Successful in 4m1s
Push Workflows / leptos-test (push) Successful in 5m3s
Push Workflows / docker-build (push) Successful in 14m58s
2024-11-19 16:21:35 -05:00
b1a62ee284 Merge pull request 'Switch from dotenv to dotenvy' (#146) from 127-switch-from-dotenv-to-dotenvy into main
All checks were successful
Push Workflows / docker-build (push) Successful in 13s
Push Workflows / docs (push) Successful in 2m5s
Push Workflows / leptos-test (push) Successful in 2m46s
Push Workflows / test (push) Successful in 4m29s
Push Workflows / build (push) Successful in 9m28s
Reviewed-on: #146
2024-11-19 00:44:27 +00:00
0c1bab9fbb Switch from dotenv to dotenvy
All checks were successful
Push Workflows / docs (push) Successful in 2m0s
Push Workflows / test (push) Successful in 3m9s
Push Workflows / build (push) Successful in 4m39s
Push Workflows / leptos-test (push) Successful in 5m55s
Push Workflows / docker-build (push) Successful in 14m10s
2024-11-18 18:34:20 -05:00
190df0edfd Merge pull request 'Implement play/pause for songs in SongList' (#145) from 122-implement-playpause-for-songs-in-songlist into main
Some checks failed
Push Workflows / docker-build (push) Successful in 23s
Push Workflows / test (push) Has been cancelled
Push Workflows / build (push) Has been cancelled
Push Workflows / docs (push) Has been cancelled
Push Workflows / leptos-test (push) Has been cancelled
Reviewed-on: #145
2024-11-18 00:13:47 +00:00
73b4b7674e Use global state user instead of local resource in personal page
All checks were successful
Push Workflows / leptos-test (push) Successful in 4m58s
Push Workflows / docker-build (push) Successful in 12m32s
Push Workflows / docs (push) Successful in 1m29s
Push Workflows / test (push) Successful in 2m24s
Push Workflows / build (push) Successful in 3m56s
2024-11-17 18:42:57 -05:00
3d1dc94b06 Merge remote-tracking branch 'origin/main' into 95-fix-home-screen-account-button-ui
Some checks failed
Push Workflows / docker-build (push) Failing after 1s
Push Workflows / leptos-test (push) Failing after 1s
Push Workflows / docs (push) Successful in 1m54s
Push Workflows / test (push) Successful in 3m15s
Push Workflows / build (push) Successful in 4m39s
2024-11-17 17:38:29 -05:00
a3b3174306 Handle SongListItem play/pause clicks
All checks were successful
Push Workflows / docs (push) Successful in 1m9s
Push Workflows / test (push) Successful in 2m34s
Push Workflows / build (push) Successful in 3m26s
Push Workflows / leptos-test (push) Successful in 4m54s
Push Workflows / docker-build (push) Successful in 12m21s
2024-11-17 15:39:08 -05:00
f633cffe69 Remove unnecessary .into() 2024-11-15 22:45:47 -05:00
cf6f7b7db7 Refactor SongList/SongListExtra
Don't use MaybeSignal
Combine into SongListInner
2024-11-15 22:44:57 -05:00
d8fab0e068 Reduce calls to get_user by not having logged_in resource
Some checks failed
Push Workflows / docs (push) Successful in 4m38s
Push Workflows / test (push) Successful in 6m43s
Push Workflows / leptos-test (push) Successful in 9m17s
Push Workflows / build (push) Successful in 10m25s
Push Workflows / docker-build (push) Failing after 16m44s
2024-11-12 17:11:06 -05:00
568f6ada0e Working Albums Page (BETA)
Some checks failed
Push Workflows / docs (push) Successful in 2m44s
Push Workflows / build (push) Failing after 3m13s
Push Workflows / test (push) Successful in 4m54s
Push Workflows / leptos-test (push) Successful in 6m34s
Push Workflows / docker-build (push) Failing after 12m52s
2024-11-12 21:44:30 +00:00
7a1ffaad47 Finished Query for Song Data from Album
Some checks failed
Push Workflows / docs (push) Successful in 1m59s
Push Workflows / build (push) Failing after 2m13s
Push Workflows / test (push) Successful in 2m40s
Push Workflows / leptos-test (push) Successful in 3m1s
Push Workflows / docker-build (push) Failing after 6m1s
2024-11-08 22:16:46 +00:00
f1affc66bc Remove email display on personal and rescale icon/pfp to be bigger & consistent
All checks were successful
Push Workflows / docs (push) Successful in 1m11s
Push Workflows / test (push) Successful in 1m38s
Push Workflows / build (push) Successful in 2m11s
Push Workflows / leptos-test (push) Successful in 4m34s
Push Workflows / docker-build (push) Successful in 12m15s
2024-11-01 17:17:59 -04:00
e60243e50c Redirect to login when logging out 2024-11-01 17:17:06 -04:00
ff24f68eed A very big SQL Query
Some checks failed
Push Workflows / docs (push) Failing after 1m1s
Push Workflows / build (push) Failing after 1m9s
Push Workflows / test (push) Failing after 1m26s
Push Workflows / leptos-test (push) Failing after 8m39s
Push Workflows / docker-build (push) Failing after 24m27s
2024-10-26 03:53:03 +00:00
6aa933be09 Refactor profile picture display to account for no profile picture
All checks were successful
Push Workflows / leptos-test (push) Successful in 2m57s
Push Workflows / docs (push) Successful in 5m36s
Push Workflows / build (push) Successful in 2m50s
Push Workflows / test (push) Successful in 8m56s
Push Workflows / docker-build (push) Successful in 9m8s
2024-10-25 22:59:06 -04:00
fe1b76e6e4 API Endpoints for Album Queries
Some checks failed
Push Workflows / docs (push) Successful in 2m9s
Push Workflows / leptos-test (push) Failing after 2m18s
Push Workflows / build (push) Failing after 2m28s
Push Workflows / test (push) Failing after 2m50s
Push Workflows / docker-build (push) Failing after 6m8s
2024-10-25 21:23:21 +00:00
acf15961cd Fix formatting and styling of profile picture display, as well as display email when logged in as well
Some checks failed
Push Workflows / leptos-test (push) Successful in 2m15s
Push Workflows / docs (push) Successful in 2m21s
Push Workflows / build (push) Successful in 4m58s
Push Workflows / test (push) Successful in 3m38s
Push Workflows / docker-build (push) Failing after 4m23s
2024-10-25 17:01:50 -04:00
53805d8793 Add added_date column to songs table #100
All checks were successful
Push Workflows / docs (push) Successful in 2m46s
Push Workflows / test (push) Successful in 5m8s
Push Workflows / leptos-test (push) Successful in 6m49s
Push Workflows / build (push) Successful in 7m46s
Push Workflows / docker-build (push) Successful in 15m7s
2024-10-24 03:17:06 +00:00
df01bafbd1 Album Page Component
Some checks failed
Push Workflows / docs (push) Successful in 4m40s
Push Workflows / build (push) Failing after 5m14s
Push Workflows / test (push) Failing after 7m23s
Push Workflows / docker-build (push) Failing after 8m1s
Push Workflows / leptos-test (push) Failing after 8m52s
2024-10-22 03:32:55 +00:00
2d7b91413b Display username in profile container when logged in
All checks were successful
Push Workflows / docs (push) Successful in 5m31s
Push Workflows / test (push) Successful in 8m42s
Push Workflows / leptos-test (push) Successful in 12m48s
Push Workflows / build (push) Successful in 15m21s
Push Workflows / docker-build (push) Successful in 34m40s
2024-10-18 19:31:20 -04:00
c2ebd8307f Display user profile picture when logged in instead of generic profile icon 2024-10-18 19:30:45 -04:00
2be665c549 Track logged in status and user as local resource instead of signal 2024-10-18 19:26:10 -04:00
f104a14f98 Remove derive Default for User and make user signal Option type instead
All checks were successful
Push Workflows / docs (push) Successful in 1m18s
Push Workflows / test (push) Successful in 1m33s
Push Workflows / build (push) Successful in 2m13s
Push Workflows / leptos-test (push) Successful in 6m5s
Push Workflows / docker-build (push) Successful in 16m23s
2024-10-15 17:26:30 -04:00
f78066d7a8 Display username in profile when logged in 2024-10-15 17:18:41 -04:00
071dcad0cc Convert borders and margins to rem from px in profile 2024-10-15 17:17:44 -04:00
3149f65a97 Detect user logged in and display "logged in" for profile
All checks were successful
Push Workflows / docs (push) Successful in 5m57s
Push Workflows / test (push) Successful in 9m44s
Push Workflows / leptos-test (push) Successful in 12m4s
Push Workflows / build (push) Successful in 12m35s
Push Workflows / docker-build (push) Successful in 20m27s
2024-10-11 17:21:11 -04:00
ab50826d31 Update formatting of profile login buttons and display
All checks were successful
Push Workflows / docs (push) Successful in 3m14s
Push Workflows / test (push) Successful in 5m29s
Push Workflows / leptos-test (push) Successful in 8m8s
Push Workflows / build (push) Successful in 8m59s
Push Workflows / docker-build (push) Successful in 16m55s
2024-10-04 17:44:10 -04:00
6112e0dfac fixed input bug 2024-05-29 10:54:13 -04:00
af604a9ddc Fix doc comment indentation 2024-05-26 20:44:40 -04:00
bcb24c2a97 added background overlay when adding anything 2024-05-23 00:02:13 -04:00
6676f2c533 upload dropdown closes after selecting what upload 2024-05-22 23:17:52 -04:00
3746c370a2 completed add album component, front and backend 2024-05-22 23:04:21 -04:00
64e93649af Completed Adding Artist component, front and back 2024-05-22 20:24:30 -04:00
fcc5870824 create artist 2024-05-21 22:39:56 -04:00
3ce762ce5b create artist ui created 2024-05-21 22:39:48 -04:00
1ecd13d65f dropdown component complete 2024-05-21 12:41:44 -04:00
be775862f9 created dropdown component 2024-05-21 11:50:41 -04:00
134 changed files with 9145 additions and 5804 deletions

View File

@ -9,3 +9,5 @@
!/Cargo.lock !/Cargo.lock
!/Cargo.toml !/Cargo.toml
!/ascii_art.txt !/ascii_art.txt
!/docs
!/book.toml

View File

@ -18,3 +18,4 @@ DATABASE_URL=postgresql://libretunes:password@localhost:5432/libretunes
LIBRETUNES_AUDIO_PATH=assets/audio LIBRETUNES_AUDIO_PATH=assets/audio
LIBRETUNES_IMAGE_PATH=assets/images LIBRETUNES_IMAGE_PATH=assets/images
LIBRETUNES_DISABLE_SIGNUP=true

View File

@ -7,13 +7,15 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Use Cache
uses: Swatinem/rust-cache@v2
- name: Build project - name: Build project
env: env:
RUSTFLAGS: "-D warnings" RUSTFLAGS: "-D warnings"
run: cargo-leptos build run: cargo-leptos build
docker-build: docker-build:
runs-on: ubuntu-latest runs-on: ubuntu-latest-docker
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -34,22 +36,24 @@ jobs:
with: with:
push: true push: true
tags: "${{ steps.get-image-name.outputs.IMAGE_NAME }}:${{ gitea.sha }}" 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-from: type=gha
cache-to: type=inline cache-to: type=gha,mode=max
- name: Build and push Docker image with "latest" tag - name: Build and push Docker image with "latest" tag
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
if: gitea.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main'
with: with:
push: true push: true
tags: "${{ steps.get-image-name.outputs.IMAGE_NAME }}:latest" tags: "${{ steps.get-image-name.outputs.IMAGE_NAME }}:latest"
cache-from: type=registry,ref=${{ steps.get-image-name.outputs.IMAGE_NAME }}:latest cache-from: type=gha
cache-to: type=inline cache-to: type=gha,mode=max
test: test:
runs-on: libretunes-cicd runs-on: libretunes-cicd
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Use Cache
uses: Swatinem/rust-cache@v2
- name: Test project - name: Test project
run: cargo test --all-targets --all-features run: cargo test --all-targets --all-features
@ -58,6 +62,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Use Cache
uses: Swatinem/rust-cache@v2
- name: Run Leptos tests - name: Run Leptos tests
run: cargo-leptos test run: cargo-leptos test
@ -66,6 +72,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Use Cache
uses: Swatinem/rust-cache@v2
- name: Generate docs - name: Generate docs
run: cargo doc --no-deps run: cargo doc --no-deps
- name: Upload docs - name: Upload docs
@ -73,3 +81,81 @@ jobs:
with: with:
name: docs name: docs
path: target/doc path: target/doc
nix-build:
runs-on: ubuntu-latest
steps:
- name: Update Package Lists
run: apt update
- name: Install Nix
run: apt install -y nix-bin
- name: Build project with Nix
run: nix build --experimental-features 'nix-command flakes' git+$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git?ref=$GITHUB_REF_NAME#default
clippy:
runs-on: libretunes-cicd
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Cache
uses: Swatinem/rust-cache@v2
- name: Run clippy
env:
RUSTFLAGS: "-D warnings"
run: cargo clippy --all-targets --all-features
rustfmt:
runs-on: libretunes-cicd
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run rustfmt
run: cargo fmt --check
mdbook:
runs-on: libretunes-cicd
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Generate mdbook
run: mdbook build
- name: Upload mdbook
uses: actions/upload-artifact@v3
with:
name: mdbook
path: book
mdbook-server:
runs-on: ubuntu-latest-docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ env.registry }}
username: ${{ env.actions_user }}
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- name: Get Image Name
id: get-image-name
run: |
echo "IMAGE_NAME=$(echo ${{ env.registry }}/${{ gitea.repository }}-mdbook | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
file: Dockerfile.mdbook
push: true
tags: "${{ steps.get-image-name.outputs.IMAGE_NAME }}:${{ gitea.sha }}"
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Docker image with "latest" tag
uses: docker/build-push-action@v5
if: gitea.ref == 'refs/heads/main'
with:
file: Dockerfile.mdbook
push: true
tags: "${{ steps.get-image-name.outputs.IMAGE_NAME }}:latest"
cache-from: type=gha
cache-to: type=gha,mode=max

10
.gitignore vendored
View File

@ -31,3 +31,13 @@ playwright/.cache/
# Sass cache # Sass cache
.sass-cache .sass-cache
# Nix-related files
.direnv/
result
# Old TailwindCSS config
style/tailwind.config.js
# mdbook output
book

2099
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,50 +4,70 @@ version = "0.1.0"
edition = "2021" edition = "2021"
build = "src/build.rs" build = "src/build.rs"
[profile.dev]
opt-level = 0
debug = 1
incremental = true
[profile.dev.package."*"]
opt-level = 3
debug = 2
[profile.dev.build-override]
opt-level = 3
[lib] [lib]
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[[bin]]
name = "health_check"
path = "src/health.rs"
required-features = ["health_check"]
[dependencies] [dependencies]
console_error_panic_hook = { version = "0.1", optional = true } console_error_panic_hook = { version = "0.1", optional = true }
cfg-if = "1" cfg-if = "1"
http = { version = "1.0", default-features = false } http = { version = "1.0", default-features = false }
leptos = { version = "0.6", default-features = false, features = ["nightly"] } leptos = { version = "0.8.2", default-features = false, features = ["nightly"] }
leptos_meta = { version = "0.6", features = ["nightly"] } leptos_meta = { version = "0.8.2" }
leptos_axum = { version = "0.6", optional = true } leptos_axum = { version = "0.8.2", optional = true }
leptos_router = { version = "0.6", features = ["nightly"] } leptos_router = { version = "0.8.2", features = ["nightly"] }
wasm-bindgen = { version = "=0.2.95", default-features = false, optional = true } wasm-bindgen = { version = "=0.2.100", default-features = false, optional = true }
leptos_icons = { version = "0.3.0" } leptos_icons = { version = "0.6.1" }
icondata = { version = "0.3.0" } icondata = { version = "0.5.0" }
dotenv = { version = "0.15.0", optional = true }
diesel = { version = "2.1.4", features = ["postgres", "r2d2", "chrono"], default-features = false, optional = true } diesel = { version = "2.1.4", features = ["postgres", "r2d2", "chrono"], default-features = false, optional = true }
lazy_static = { version = "1.4.0", optional = true }
serde = { version = "1.0.195", features = ["derive"], default-features = false } serde = { version = "1.0.195", features = ["derive"], default-features = false }
openssl = { version = "0.10.63", optional = true } openssl = { version = "0.10.63", optional = true }
diesel_migrations = { version = "2.1.0", optional = true } diesel_migrations = { version = "2.1.0", optional = true }
pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true } pbkdf2 = { version = "0.12.2", features = ["simple"], optional = true }
tokio = { version = "1", optional = true, features = ["rt-multi-thread"] } tokio = { version = "1", optional = true, features = ["rt-multi-thread"] }
axum = { version = "0.7.5", features = ["tokio", "http1"], default-features = false, optional = true } axum = { version = "0.8.4", features = ["tokio", "http1"], default-features = false, optional = true }
tower = { version = "0.5.1", optional = true, features = ["util"] } tower = { version = "0.5.1", optional = true, features = ["util"] }
tower-http = { version = "0.6.1", optional = true, features = ["fs"] } tower-http = { version = "0.6.1", optional = true, features = ["fs"] }
thiserror = "1.0.57" thiserror = "1.0.57"
tower-sessions-redis-store = { version = "0.11", optional = true } tower-sessions-redis-store = { version = "0.16", optional = true }
async-trait = { version = "0.1.79", optional = true } async-trait = { version = "0.1.79", optional = true }
axum-login = { version = "0.14.0", optional = true } axum-login = { version = "0.17.0", optional = true }
server_fn = { version = "0.6.11", features = ["multipart"] } server_fn = { version = "0.8.2", features = ["multipart"] }
symphonia = { version = "0.5.4", default-features = false, features = ["mp3"], optional = true } symphonia = { version = "0.5.4", default-features = false, features = ["mp3"], optional = true }
multer = { version = "3.0.0", optional = true } multer = { version = "3.1.0", optional = true }
log = { version = "0.4.21", optional = true } log = { version = "0.4.21", optional = true }
flexi_logger = { version = "0.28.0", optional = true, default-features = false } flexi_logger = { version = "0.28.0", optional = true, default-features = false }
web-sys = "0.3.69" web-sys = "0.3.69"
leptos-use = "0.13.5" leptos-use = "0.16.0-beta2"
image-convert = { version = "0.18.0", optional = true, default-features = false } image-convert = { version = "0.18.0", optional = true, default-features = false }
chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] } chrono = { version = "0.4.38", default-features = false, features = ["serde", "clock"] }
dotenvy = { version = "0.15.7", optional = true }
reqwest = { version = "0.12.9", default-features = false, optional = true }
futures = { version = "0.3.25", default-features = false, optional = true }
once_cell = { version = "1.20", default-features = false, optional = true }
libretunes_macro = { git = "https://git.libretunes.xyz/LibreTunes/LibreTunes-Macro.git", branch = "main" }
clap = { version = "4.5.39", features = ["derive", "env"] }
tokio-tungstenite = { version = "0.26.2", optional = true }
[features] [features]
hydrate = [ hydrate = [
"leptos/hydrate", "leptos/hydrate",
"leptos_meta/hydrate",
"leptos_router/hydrate",
"console_error_panic_hook", "console_error_panic_hook",
"wasm-bindgen", "wasm-bindgen",
"chrono/wasmbind", "chrono/wasmbind",
@ -57,9 +77,8 @@ ssr = [
"leptos/ssr", "leptos/ssr",
"leptos_meta/ssr", "leptos_meta/ssr",
"leptos_router/ssr", "leptos_router/ssr",
"dotenv", "dotenvy",
"diesel", "diesel",
"lazy_static",
"openssl", "openssl",
"diesel_migrations", "diesel_migrations",
"pbkdf2", "pbkdf2",
@ -77,6 +96,23 @@ ssr = [
"leptos-use/ssr", "leptos-use/ssr",
"image-convert", "image-convert",
] ]
reqwest_api = [
"reqwest",
"reqwest/cookies",
"futures",
"once_cell",
# Not needed, but fixes compile errors when building for all targets
# (which is useful for code editors checking for errors)
"server_fn/reqwest"
]
health_check = [
"reqwest_api",
"tokio",
"tokio/rt",
"tokio/macros",
"tokio-tungstenite",
]
# Defines a size-optimized profile for the WASM bundle in release mode # Defines a size-optimized profile for the WASM bundle in release mode
[profile.wasm-release] [profile.wasm-release]
@ -94,8 +130,15 @@ site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written # The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg # Defaults to pkg
site-pkg-dir = "pkg" site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css # The tailwind input file.
style-file = "style/main.scss" #
# Optional, Activates the tailwind build
tailwind-input-file = "style/main.css"
# The tailwind config file.
#
# Optional, defaults to "tailwind.config.js" which if is not present
# is generated for you
tailwind-config-file = "style/tailwind.config.js"
# Assets source dir. All files found here will be copied and synchronized to site-root. # Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir. # The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
# #
@ -116,6 +159,8 @@ browserquery = "defaults"
watch = false watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD" # The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV" env = "DEV"
# Specify the name of the bin target
bin-target = "libretunes"
# The features to use when compiling the bin target # The features to use when compiling the bin target
# #
# Optional. Can be over-ridden with the command line parameter --bin-features # Optional. Can be over-ridden with the command line parameter --bin-features

View File

@ -1,10 +1,12 @@
FROM rust:slim as builder FROM rust:slim AS builder
ENV LEPTOS_TAILWIND_VERSION=v4.0.6
WORKDIR /app WORKDIR /app
RUN rustup default nightly RUN rustup default nightly
RUN rustup target add wasm32-unknown-unknown RUN rustup target add wasm32-unknown-unknown
RUN cargo install cargo-leptos RUN cargo install cargo-leptos@0.2.26 --locked
# Install a few dependencies # Install a few dependencies
RUN set -eux; \ RUN set -eux; \
@ -34,12 +36,13 @@ COPY Cargo.toml Cargo.lock /app/
# Create dummy files to force cargo to build the dependencies # Create dummy files to force cargo to build the dependencies
RUN mkdir /app/src && mkdir /app/style && mkdir /app/assets && \ RUN mkdir /app/src && mkdir /app/style && mkdir /app/assets && \
echo "fn main() {}" | tee /app/src/build.rs > /app/src/main.rs && \ echo "fn main() {}" | tee /app/src/build.rs | tee /app/src/main.rs > /app/src/health.rs && \
touch /app/src/lib.rs && \ touch /app/src/lib.rs && \
touch /app/style/main.scss touch /app/style/main.css
# Prebuild dependencies # Prebuild dependencies
RUN cargo-leptos build --release --precompress RUN cargo-leptos build --release --precompress
RUN cargo build --bin health_check --features health_check --release
RUN rm -rf /app/src /app/style /app/assets RUN rm -rf /app/src /app/style /app/assets
@ -50,10 +53,11 @@ COPY migrations /app/migrations
COPY style /app/style COPY style /app/style
# Touch files to force rebuild # Touch files to force rebuild
RUN touch /app/src/main.rs && touch /app/src/lib.rs && touch /app/src/build.rs RUN touch /app/src/main.rs && touch /app/src/lib.rs && touch /app/src/build.rs && touch /app/src/health.rs
# Actually build the binary # Actually build the binary
RUN cargo-leptos build --release --precompress RUN cargo-leptos build --release --precompress
RUN cargo build --bin health_check --features health_check --release
# Use ldd to list all dependencies of /app/target/release/libretunes, then copy them to /app/libs # 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 # Setting LD_LIBRARY_PATH is necessary to find the ImageMagick libraries
@ -70,6 +74,9 @@ library manager built for collaborative listening."
# Copy the binary and the compressed assets to the "site root" # Copy the binary and the compressed assets to the "site root"
COPY --from=builder /app/target/release/libretunes /libretunes COPY --from=builder /app/target/release/libretunes /libretunes
COPY --from=builder /app/target/site /site COPY --from=builder /app/target/site /site
COPY --from=builder /app/target/release/health_check /health_check
HEALTHCHECK CMD [ "/health_check" ]
# Copy libraries to /lib64 # Copy libraries to /lib64
COPY --from=builder /app/libs /lib64 COPY --from=builder /app/libs /lib64

13
Dockerfile.mdbook Normal file
View File

@ -0,0 +1,13 @@
FROM rust:slim AS builder
WORKDIR /app
RUN cargo install mdbook
COPY book.toml /app/book.toml
COPY docs /app/docs
RUN mdbook build
FROM nginx:alpine AS webserver
COPY --from=builder /app/book /usr/share/nginx/html

4
book.toml Normal file
View File

@ -0,0 +1,4 @@
[book]
language = "en"
src = "docs"
title = "LibreTunes Documentation"

View File

@ -1,22 +0,0 @@
#!/bin/sh
set -e
ZONE_ID=$1
RECORD_NAME=$2
RECORD_COMMENT=$3
API_TOKEN=$4
TUNNEL_ID=$5
curl --request POST --silent \
--url https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $API_TOKEN" \
--data '{
"content": "'$TUNNEL_ID'.cfargotunnel.com",
"name": "'$RECORD_NAME'",
"comment": "'$RECORD_COMMENT'",
"proxied": true,
"type": "CNAME",
"ttl": 1
}' \

View File

@ -1,19 +0,0 @@
#!/bin/sh
set -e
SERVICE=$1
HOSTNAME=$2
TUNNEL_ID=$3
echo "Creating tunnel config for $HOSTNAME"
cat <<EOF > cloudflared-tunnel-config.yml
tunnel: $TUNNEL_ID
credentials-file: /etc/cloudflared/auth.json
ingress:
- hostname: $HOSTNAME
service: $SERVICE
- service: http_status:404
EOF

View File

@ -1,55 +0,0 @@
version: '3'
services:
cloudflare:
image: cloudflare/cloudflared:latest
command: tunnel run
volumes:
- cloudflared-config:/etc/cloudflared:ro
libretunes:
image: registry.mregirouard.com/libretunes/libretunes:${LIBRETUNES_VERSION}
environment:
REDIS_URL: redis://redis:6379
POSTGRES_HOST: postgres
POSTGRES_USER: libretunes
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: libretunes
volumes:
- libretunes-audio:/site/audio
depends_on:
- redis
- postgres
restart: always
redis:
image: redis:latest
volumes:
- libretunes-redis:/data
restart: always
healthcheck:
test: ["CMD-SHELL", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
postgres:
image: postgres:latest
environment:
POSTGRES_USER: libretunes
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: libretunes
volumes:
- libretunes-postgres:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U libretunes"]
interval: 10s
timeout: 5s
retries: 5
volumes:
cloudflared-config:
libretunes-audio:
libretunes-redis:
libretunes-postgres:

View File

@ -1,22 +0,0 @@
#!/bin/sh
set -e
ZONE_ID=$1
RECORD_NAME=$2
RECORD_COMMENT=$3
API_TOKEN=$4
RECORD_ID=$(
curl --request GET --silent \
--url "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=$RECORD_NAME&comment=$RECORD_COMMENT" \
--header "Content-Type: application/json" \
--header "Authorization: Bearer $API_TOKEN" \
| jq -r '.result[0].id')
echo "Deleting DNS record ID $RECORD_ID"
curl --request DELETE --silent \
--url "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
--header "Content-Type: application/json" \
--header "Authorization: Bearer $API_TOKEN"

View File

@ -3,7 +3,7 @@ name: libretunes
services: services:
libretunes: libretunes:
container_name: libretunes container_name: libretunes
# image: registry.mregirouard.com/libretunes/libretunes:latest # image: git.libretunes.xyz/libretunes/libretunes:latest
build: . build: .
ports: ports:
- "3000:3000" - "3000:3000"
@ -15,6 +15,7 @@ services:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
LIBRETUNES_AUDIO_PATH: /assets/audio LIBRETUNES_AUDIO_PATH: /assets/audio
LIBRETUNES_IMAGE_PATH: /assets/images LIBRETUNES_IMAGE_PATH: /assets/images
LIBRETUNES_DISABLE_SIGNUP: "true"
volumes: volumes:
- libretunes-audio:/assets/audio - libretunes-audio:/assets/audio
- libretunes-images:/assets/images - libretunes-images:/assets/images

1
docs/SUMMARY.md Normal file
View File

@ -0,0 +1 @@
# Summary

114
flake.lock generated Normal file
View File

@ -0,0 +1,114 @@
{
"nodes": {
"cargo-leptos": {
"flake": false,
"locked": {
"lastModified": 1736814985,
"narHash": "sha256-v1gNH3pq5db/swsk79nEzgtR4jy3f/xHs4QaLnVcVYU=",
"owner": "leptos-rs",
"repo": "cargo-leptos",
"rev": "87b156f4f0bc0374e7b5557d15bf79f1a12d7569",
"type": "github"
},
"original": {
"owner": "leptos-rs",
"ref": "v0.2.26",
"repo": "cargo-leptos",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1745794561,
"narHash": "sha256-T36rUZHUART00h3dW4sV5tv4MrXKT7aWjNfHiZz7OHg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5461b7fa65f3ca74cef60be837fd559a8918eaa0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"cargo-leptos": "cargo-leptos",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1745894113,
"narHash": "sha256-dxO3caQZMv/pMtcuXdi+SnAtyki6HFbSf1IpgQPXZYc=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "e552fe1b16ffafd678ebe061c22b117e050769ed",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

110
flake.nix Normal file
View File

@ -0,0 +1,110 @@
{
description = "LibreTunes build and development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils";
cargo-leptos = {
url = "github:leptos-rs/cargo-leptos?ref=v0.2.26";
flake = false;
};
};
outputs = { self, nixpkgs, rust-overlay, flake-utils, cargo-leptos, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
# Build a specific version of cargo-leptos
cargo-leptos-build = pkgs.rustPlatform.buildRustPackage {
name = "cargo-leptos";
buildFeatures = ["no_downloads"];
src = cargo-leptos;
cargoHash = "sha256-fyOlMagXrpfMsaLffeXolTgMldN9u6RQ08Zak9MdC4U=";
nativeBuildInputs = with pkgs; [
pkg-config
openssl
];
doCheck = false;
};
buildPkgs = with pkgs; [
(rust-bin.fromRustupToolchainFile ./rust-toolchain.toml)
cargo-leptos-build
clang
openssl
postgresql
imagemagick
pkg-config
tailwindcss_4
];
in
{
devShells.default = pkgs.mkShell {
LIBCLANG_PATH = pkgs.lib.makeLibraryPath [ pkgs.llvmPackages_latest.libclang.lib ];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.libgcc.lib ];
buildInputs = with pkgs; buildPkgs ++ [
diesel-cli
mdbook
];
shellHook = ''
set -a
[[ -f .env ]] && source .env
set +a
'';
};
packages.default = pkgs.rustPlatform.buildRustPackage {
name = "libretunes";
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
# Needed because of git dependency
outputHashes = {
"libretunes_macro-0.1.0" = "sha256-hve1eZV6KMBK5LiW/F801qKds0hXg6ID9pd9fPvKJZY=";
};
};
LIBCLANG_PATH = pkgs.lib.makeLibraryPath [ pkgs.llvmPackages_latest.libclang.lib ];
nativeBuildInputs = with pkgs; buildPkgs ++ [
makeWrapper
];
buildInputs = with pkgs; [
openssl
imagemagick
];
# TODO enable --release builds
# Creates an issue with cargo-leptos trying to create cache directories
# See https://github.com/leptos-rs/cargo-leptos/issues/79
buildPhase = ''
cargo-leptos build --precompress #--release
'';
installPhase = ''
mkdir -p $out/bin
install -t $out target/debug/libretunes
cp -r target/site $out/site
makeWrapper $out/libretunes $out/bin/libretunes \
--set LEPTOS_SITE_ROOT $out/site \
--set LD_LIBRARY_PATH ${pkgs.libgcc.lib}
'';
doCheck = false;
};
}
);
}

View File

@ -0,0 +1,2 @@
ALTER TABLE songs
DROP COLUMN added_date;

View File

@ -0,0 +1,2 @@
ALTER TABLE songs
ADD COLUMN added_date DATE DEFAULT CURRENT_DATE;

View File

@ -0,0 +1,4 @@
ALTER TABLE songs
ALTER COLUMN added_date TYPE DATE USING added_date::DATE,
ALTER COLUMN added_date SET DEFAULT CURRENT_DATE,
ALTER COLUMN added_date SET NOT NULL;

View File

@ -0,0 +1,4 @@
ALTER TABLE songs
ALTER COLUMN added_date TYPE TIMESTAMP USING added_date::TIMESTAMP,
ALTER COLUMN added_date SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN added_date SET NOT NULL;

View File

@ -0,0 +1,4 @@
DROP TRIGGER IF EXISTS playlist_songs_after_insert
ON playlist_songs;
DROP FUNCTION IF EXISTS trg_update_playlists_updated_at();

View File

@ -0,0 +1,15 @@
CREATE OR REPLACE FUNCTION trg_update_playlists_updated_at()
RETURNS TRIGGER AS $$
BEGIN
UPDATE playlists
SET updated_at = NOW()
WHERE id = NEW.playlist_id;
RETURN NEW;
END;
$$
LANGUAGE plpgsql;
CREATE TRIGGER playlist_songs_after_insert
AFTER INSERT ON playlist_songs
FOR EACH ROW
EXECUTE PROCEDURE trg_update_playlists_updated_at();

View File

@ -1,3 +1,4 @@
[toolchain] [toolchain]
channel = "nightly" channel = "nightly"
targets = ["wasm32-unknown-unknown"]

View File

@ -1,39 +0,0 @@
use crate::models::Artist;
use crate::components::dashboard_tile::DashboardTile;
use chrono::NaiveDate;
/// 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<NaiveDate>,
/// Path to album image, relative to the root of the web server.
/// For example, `"/assets/images/Album.jpg"`
pub image_path: String,
}
impl DashboardTile for AlbumData {
fn image_path(&self) -> String {
self.image_path.clone()
}
fn title(&self) -> String {
self.title.clone()
}
fn link(&self) -> String {
format!("/album/{}", self.id)
}
fn description(&self) -> Option<String> {
Some(format!("Album • {}", Artist::display_list(&self.artists)))
}
}

184
src/api/album.rs Normal file
View File

@ -0,0 +1,184 @@
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::models::backend;
use crate::util::backend_state::BackendState;
}
}
#[server(endpoint = "album/get", client = Client)]
pub async fn get_album(id: i32) -> BackendResult<Option<frontend::Album>> {
use crate::models::backend::Album;
use crate::schema::*;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let album = albums::table
.find(id)
.first::<Album>(&mut db_conn)
.optional()
.context("Error loading album from database")?;
let Some(album) = album else { return Ok(None) };
let artists: Vec<backend::Artist> = album_artists::table
.filter(album_artists::album_id.eq(id))
.inner_join(artists::table.on(album_artists::artist_id.eq(artists::id)))
.select(artists::all_columns)
.load(&mut db_conn)
.context("Error loading album artists from database")?;
let img = album
.image_path
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string());
let album = frontend::Album {
id: album.id,
title: album.title,
artists,
release_date: album.release_date,
image_path: img,
};
Ok(Some(album))
}
#[server(endpoint = "album/get_songs", client = Client)]
pub async fn get_songs(id: i32) -> BackendResult<Vec<frontend::Song>> {
use crate::api::auth::get_logged_in_user;
use crate::schema::*;
use std::collections::HashMap;
let user = get_logged_in_user()
.await
.context("Error getting logged-in user")?;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let song_list = if let Some(user) = user {
let song_list: Vec<(
backend::Album,
Option<backend::Song>,
Option<backend::Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = albums::table
.find(id)
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(user.id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(user.id))),
)
.select((
albums::all_columns,
songs::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.order(songs::track.asc())
.load(&mut db_conn)
.context("Error loading album songs from database")?;
song_list
} else {
let song_list: Vec<(
backend::Album,
Option<backend::Song>,
Option<backend::Artist>,
)> = albums::table
.find(id)
.left_join(songs::table.on(albums::id.nullable().eq(songs::album_id)))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.select((
albums::all_columns,
songs::all_columns.nullable(),
artists::all_columns.nullable(),
))
.order(songs::track.asc())
.load(&mut db_conn)
.context("Error loading album songs from database")?;
let song_list: Vec<(
backend::Album,
Option<backend::Song>,
Option<backend::Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = song_list
.into_iter()
.map(|(album, song, artist)| (album, song, artist, None, None))
.collect();
song_list
};
let mut album_songs: HashMap<i32, frontend::Song> = HashMap::with_capacity(song_list.len());
for (album, song, artist, like, dislike) in song_list {
if let Some(song) = song {
if let Some(stored_songdata) = album_songs.get_mut(&song.id) {
// If the song is already in the map, update the artists
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
let image_path = song.image_path.unwrap_or(
album
.image_path
.clone()
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
);
let songdata = frontend::Song {
id: song.id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album: Some(album),
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date,
};
album_songs.insert(song.id, songdata);
}
}
}
// Sort the songs by date
let mut songs: Vec<frontend::Song> = album_songs.into_values().collect();
songs.sort_by(|a, b| a.track.cmp(&b.track));
Ok(songs)
}

62
src/api/albums.rs Normal file
View File

@ -0,0 +1,62 @@
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use cfg_if::cfg_if;
use leptos::prelude::*;
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::util::backend_state::BackendState;
use diesel::prelude::*;
use chrono::NaiveDate;
}
}
/// Add an album to the database
///
/// # Arguments
///
/// * `album_title` - The name of the artist to add
/// * `release_data` - The release date of the album (Optional)
/// * `image_path` - The path to the album's image file (Optional)
///
/// # Returns
/// * `Result<(), Box<dyn Error>>` - A empty result if successful, or an error
///
#[server(endpoint = "albums/add-album", client = Client)]
pub async fn add_album(
album_title: String,
release_date: Option<String>,
image_path: Option<String>,
) -> BackendResult<()> {
use crate::models::backend::NewAlbum;
use crate::schema::albums::{self};
let parsed_release_date = match release_date {
Some(date) => match NaiveDate::parse_from_str(date.trim(), "%Y-%m-%d") {
Ok(parsed_date) => Some(parsed_date),
Err(e) => {
return Err(
InputError::InvalidInput(format!("Error parsing release date: {e}")).into(),
);
}
},
None => None,
};
let image_path_arg = image_path.filter(|image_path| !image_path.is_empty());
let new_album = NewAlbum {
title: album_title,
release_date: parsed_release_date,
image_path: image_path_arg,
};
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::insert_into(albums::table)
.values(&new_album)
.execute(&mut db_conn)
.context("Error inserting new album into database")?;
Ok(())
}

238
src/api/artists.rs Normal file
View File

@ -0,0 +1,238 @@
use leptos::prelude::*;
use cfg_if::cfg_if;
use crate::models::backend::Artist;
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use std::collections::HashMap;
use crate::models::backend::Album;
use crate::models::backend::NewArtist;
use crate::util::backend_state::BackendState;
}
}
/// Add an artist to the database
///
/// # Arguments
///
/// * `artist_name` - The name of the artist to add
///
/// # Returns
/// * `Result<(), Box<dyn Error>>` - A empty result if successful, or an error
///
#[server(endpoint = "artists/add-artist", client = Client)]
pub async fn add_artist(artist_name: String) -> BackendResult<()> {
use crate::schema::artists::dsl::*;
let new_artist = NewArtist { name: artist_name };
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::insert_into(artists)
.values(&new_artist)
.execute(&mut db_conn)
.context("Error inserting new artist into database")?;
Ok(())
}
#[server(endpoint = "artists/get", client = Client)]
pub async fn get_artist_by_id(artist_id: i32) -> BackendResult<Option<Artist>> {
use crate::schema::artists::dsl::*;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let artist = artists
.filter(id.eq(artist_id))
.first::<Artist>(&mut db_conn)
.optional()
.context("Error loading artist from database")?;
Ok(artist)
}
#[server(endpoint = "artists/top_songs", client = Client)]
pub async fn top_songs_by_artist(
artist_id: i32,
limit: Option<i64>,
) -> BackendResult<Vec<(frontend::Song, i64)>> {
use crate::api::auth::get_user;
use crate::models::backend::Song;
use crate::schema::*;
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let song_play_counts: Vec<(i32, i64)> = if let Some(limit) = limit {
song_history::table
.group_by(song_history::song_id)
.select((song_history::song_id, diesel::dsl::count(song_history::id)))
.left_join(song_artists::table.on(song_artists::song_id.eq(song_history::song_id)))
.filter(song_artists::artist_id.eq(artist_id))
.order_by(diesel::dsl::count(song_history::id).desc())
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
.limit(limit)
.load(&mut db_conn)?
} else {
song_history::table
.group_by(song_history::song_id)
.select((song_history::song_id, diesel::dsl::count(song_history::id)))
.left_join(song_artists::table.on(song_artists::song_id.eq(song_history::song_id)))
.filter(song_artists::artist_id.eq(artist_id))
.order_by(diesel::dsl::count(song_history::id).desc())
.left_join(songs::table.on(songs::id.eq(song_history::song_id)))
.load(&mut db_conn)?
};
let song_play_counts: HashMap<i32, i64> = song_play_counts.into_iter().collect();
let top_song_ids: Vec<i32> = song_play_counts.keys().copied().collect();
let top_songs: Vec<(
Song,
Option<Album>,
Option<Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = songs::table
.filter(songs::id.eq_any(top_song_ids))
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(user_id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(user_id))),
)
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_conn)?;
let mut top_songs_map: HashMap<i32, (frontend::Song, i64)> =
HashMap::with_capacity(top_songs.len());
for (song, album, artist, like, dislike) in top_songs {
if let Some((stored_songdata, _)) = top_songs_map.get_mut(&song.id) {
// If the song is already in the map, update the artists
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
let image_path = song.image_path.unwrap_or(
album
.as_ref()
.and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
);
let songdata = frontend::Song {
id: song.id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date,
};
let plays = song_play_counts
.get(&song.id)
.ok_or(BackendError::InternalError(
"Song id not found in history counts",
))?;
top_songs_map.insert(song.id, (songdata, *plays));
}
}
let mut top_songs: Vec<(frontend::Song, i64)> = top_songs_map.into_values().collect();
top_songs.sort_by(|(_, plays1), (_, plays2)| plays2.cmp(plays1));
Ok(top_songs)
}
#[server(endpoint = "artists/albums", client = Client)]
pub async fn albums_by_artist(
artist_id: i32,
limit: Option<i64>,
) -> BackendResult<Vec<frontend::Album>> {
use crate::schema::*;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let album_ids = albums::table
.left_join(album_artists::table)
.filter(album_artists::artist_id.eq(artist_id))
.order_by(albums::release_date.desc())
.select(albums::id);
let album_ids = if let Some(limit) = limit {
album_ids.limit(limit).into_boxed()
} else {
album_ids.into_boxed()
};
let mut albums_map: HashMap<i32, frontend::Album> = HashMap::new();
let album_artists: Vec<(Album, Artist)> = albums::table
.filter(albums::id.eq_any(album_ids))
.inner_join(
album_artists::table
.inner_join(artists::table)
.on(albums::id.eq(album_artists::album_id)),
)
.select((albums::all_columns, artists::all_columns))
.load(&mut db_conn)
.context("Error loading album artists from database")?;
for (album, artist) in album_artists {
if let Some(stored_album) = albums_map.get_mut(&album.id) {
stored_album.artists.push(artist);
} else {
let albumdata = frontend::Album {
id: album.id,
title: album.title,
artists: vec![artist],
release_date: album.release_date,
image_path: album
.image_path
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
};
albums_map.insert(album.id, albumdata);
}
}
let mut albums: Vec<frontend::Album> = albums_map.into_values().collect();
albums.sort_by(|a1, a2| a2.release_date.cmp(&a1.release_date));
Ok(albums)
}

219
src/api/auth.rs Normal file
View File

@ -0,0 +1,219 @@
use leptos::prelude::*;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos_axum::extract;
use axum_login::AuthSession;
use crate::util::auth_backend::AuthBackend;
use crate::util::backend_state::BackendState;
}
}
use crate::api::users::UserCredentials;
use crate::models::backend::{NewUser, User};
use crate::util::error::*;
use crate::util::serverfn_client::Client;
/// Create a new user and log them in
/// Takes in a NewUser struct, with the password in plaintext
/// Returns a Result with the error message if the user could not be created
#[server(endpoint = "signup", client = Client)]
pub async fn signup(new_user: NewUser) -> BackendResult<()> {
// Check LIBRETUNES_DISABLE_SIGNUP env var
if std::env::var("LIBRETUNES_DISABLE_SIGNUP").is_ok_and(|v| v == "true") {
return Err(AuthError::SignupDisabled.into());
}
use crate::api::users::create_user;
// Ensure the user has no id, and is not a self-proclaimed admin
let new_user = NewUser {
admin: false,
..new_user
};
create_user(&new_user)
.await
.context("Error creating user")?;
let mut auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
let credentials = UserCredentials {
username_or_email: new_user.username.clone(),
password: new_user.password.clone().unwrap(),
};
match auth_session.authenticate(credentials).await {
Ok(Some(user)) => auth_session
.login(&user)
.await
.map_err(|e| AuthError::AuthError(format!("Error logging in user: {e}")).into()),
Ok(None) => Err(AuthError::InvalidCredentials.into()),
Err(e) => Err(AuthError::AuthError(format!("Error authenticating user: {e}")).into()),
}
}
/// Log a user in
/// Takes in a username or email and a password in plaintext
/// Returns a Result with a boolean indicating if the login was successful
#[server(endpoint = "login", client = Client)]
pub async fn login(credentials: UserCredentials) -> BackendResult<Option<User>> {
use crate::api::users::validate_user;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let mut auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
let user = validate_user(credentials, &mut db_conn)
.await
.context("Error validating user credentials")?;
if let Some(mut user) = user {
auth_session
.login(&user)
.await
.map_err(|e| AuthError::AuthError(format!("Error logging in user: {e}")))?;
user.password = None;
Ok(Some(user))
} else {
Ok(None)
}
}
/// Log a user out
/// Returns a Result with the error message if the user could not be logged out
#[server(endpoint = "logout", client = Client)]
pub async fn logout() -> BackendResult<()> {
let mut auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
auth_session
.logout()
.await
.map_err(|e| AuthError::AuthError(format!("Error logging out user: {e}")))?;
leptos_axum::redirect("/login");
Ok(())
}
/// Check if a user is logged in
/// Returns a Result with a boolean indicating if the user is logged in
#[server(endpoint = "check_auth", client = Client)]
pub async fn check_auth() -> BackendResult<bool> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
Ok(auth_session.user.is_some())
}
/// Require that a user is logged in
/// Returns a Result with the error message if the user is not logged in
/// Intended to be used at the start of a protected route, to ensure the user is logged in:
/// ```rust
/// use leptos::prelude::*;
/// use libretunes::api::auth::require_auth;
/// use libretunes::util::error::*;
/// #[server(endpoint = "protected_route")]
/// pub async fn protected_route() -> BackendResult<()> {
/// require_auth().await?;
/// // Continue with protected route
/// Ok(())
/// }
/// ```
#[cfg(feature = "ssr")]
pub async fn require_auth() -> BackendResult<()> {
check_auth()
.await
.context("Error checking authentication")
.and_then(|logged_in| {
if logged_in {
Ok(())
} else {
Err(AuthError::Unauthorized.into())
}
})
}
/// Get the current logged-in user
/// Returns a Result with the user if they are logged in
/// Returns an error if the user is not logged in, or if there is an error getting the user
/// Intended to be used in a route to get the current user:
/// ```rust
/// use leptos::prelude::*;
/// use libretunes::api::auth::get_user;
/// use libretunes::util::error::*;
/// #[server(endpoint = "user_route")]
/// pub async fn user_route() -> BackendResult<()> {
/// 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() -> BackendResult<User> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
auth_session.user.ok_or(AuthError::Unauthorized.into())
}
#[server(endpoint = "get_logged_in_user", client = Client)]
pub async fn get_logged_in_user() -> BackendResult<Option<User>> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
let user = auth_session.user.map(|mut user| {
user.password = None;
user
});
Ok(user)
}
/// Check if a user is an admin
/// Returns a Result with a boolean indicating if the user is logged in and an admin
#[server(endpoint = "check_admin", client = Client)]
pub async fn check_admin() -> BackendResult<bool> {
let auth_session = extract::<AuthSession<AuthBackend>>()
.await
.context("Error extracting auth session")?;
Ok(auth_session.user.as_ref().map(|u| u.admin).unwrap_or(false))
}
/// Require that a user is logged in and an admin
/// Returns a Result with the error message if the user is not logged in or is not an admin
/// Intended to be used at the start of a protected route, to ensure the user is logged in and an admin:
/// ```rust
/// use leptos::prelude::*;
/// use libretunes::api::auth::require_admin;
/// use libretunes::util::error::*;
/// #[server(endpoint = "protected_admin_route")]
/// pub async fn protected_admin_route() -> BackendResult<()> {
/// require_admin().await?;
/// // Continue with protected route
/// Ok(())
/// }
/// ```
#[cfg(feature = "ssr")]
pub async fn require_admin() -> BackendResult<()> {
check_admin().await.and_then(|is_admin| {
if is_admin {
Ok(())
} else {
Err(AuthError::AdminRequired.into())
}
})
}

26
src/api/health.rs Normal file
View File

@ -0,0 +1,26 @@
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
#[server(endpoint = "health", client = Client)]
pub async fn health() -> BackendResult<String> {
use crate::util::backend_state::BackendState;
use diesel::connection::SimpleConnection;
use tower_sessions_redis_store::fred::interfaces::ClientLike;
let backend_state = BackendState::get().await?;
backend_state
.get_db_conn()?
.batch_execute("SELECT 1")
.context("Failed to execute database health check query")?;
backend_state
.get_redis_conn()
.ping::<()>(None)
.await
.map_err(|e| BackendError::InternalError(format!("{e}")))
.context("Failed to execute Redis health check ping")?;
Ok("ok".to_string())
}

View File

@ -1,44 +1,56 @@
use crate::models::backend::HistoryEntry;
use crate::models::backend::Song;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use leptos::*; use leptos::prelude::*;
use crate::models::HistoryEntry;
use crate::models::Song;
use cfg_if::cfg_if; use cfg_if::cfg_if;
cfg_if! { cfg_if! {
if #[cfg(feature = "ssr")] { if #[cfg(feature = "ssr")] {
use leptos::server_fn::error::NoCustomError; use crate::util::backend_state::BackendState;
use crate::database::get_db_conn; use crate::api::auth::get_user;
use crate::auth::get_user; }
}
} }
/// Get the history of the current user. /// Get the history of the current user.
#[server(endpoint = "history/get")] #[server(endpoint = "history/get", client = Client)]
pub async fn get_history(limit: Option<i64>) -> Result<Vec<HistoryEntry>, ServerFnError> { pub async fn get_history(limit: Option<i64>) -> BackendResult<Vec<HistoryEntry>> {
let user = get_user().await?; let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
let history = user.get_history(limit, db_con) let mut db_conn = BackendState::get().await?.get_db_conn()?;
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting history: {}", e)))?;
Ok(history) let history = user
.get_history(limit, &mut db_conn)
.context("Error getting history")?;
Ok(history)
} }
/// Get the listen dates and songs of the current user. /// Get the listen dates and songs of the current user.
#[server(endpoint = "history/get_songs")] #[server(endpoint = "history/get_songs", client = Client)]
pub async fn get_history_songs(limit: Option<i64>) -> Result<Vec<(NaiveDateTime, Song)>, ServerFnError> { pub async fn get_history_songs(limit: Option<i64>) -> BackendResult<Vec<(NaiveDateTime, Song)>> {
let user = get_user().await?; let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
let songs = user.get_history_songs(limit, db_con) let mut db_conn = BackendState::get().await?.get_db_conn()?;
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting history songs: {}", e)))?;
Ok(songs) let songs = user
.get_history_songs(limit, &mut db_conn)
.context("Error getting history songs")?;
Ok(songs)
} }
/// Add a song to the history of the current user. /// Add a song to the history of the current user.
#[server(endpoint = "history/add")] #[server(endpoint = "history/add", client = Client)]
pub async fn add_history(song_id: i32) -> Result<(), ServerFnError> { pub async fn add_history(song_id: i32) -> BackendResult<()> {
let user = get_user().await?; let user = get_user().await.context("Error getting logged-in user")?;
let db_con = &mut get_db_conn();
user.add_history(song_id, db_con) let mut db_conn = BackendState::get().await?.get_db_conn()?;
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error adding history: {}", e)))?;
Ok(()) user.add_history(song_id, &mut db_conn)
.context("Error adding song to history")?;
Ok(())
} }

View File

@ -1,3 +1,12 @@
pub mod album;
pub mod albums;
pub mod artists;
pub mod auth;
pub mod health;
pub mod history; pub mod history;
pub mod playlists;
pub mod profile; pub mod profile;
pub mod search;
pub mod songs; pub mod songs;
pub mod upload;
pub mod users;

392
src/api/playlists.rs Normal file
View File

@ -0,0 +1,392 @@
use crate::models::{backend, frontend};
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use cfg_if::cfg_if;
use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData};
cfg_if! {
if #[cfg(feature = "ssr")] {
use crate::api::auth::get_user;
use diesel::prelude::*;
use crate::util::extract_field::extract_field;
use crate::util::backend_state::BackendState;
use std::collections::HashMap;
use log::*;
use crate::schema::*;
}
}
#[cfg(feature = "ssr")]
async fn user_owns_playlist(user_id: i32, playlist_id: i32) -> BackendResult<bool> {
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let exists = playlists::table
.find(playlist_id)
.filter(playlists::owner_id.eq(user_id))
.select(playlists::id)
.first::<i32>(&mut db_conn)
.optional()
.context("Error loading playlist from database")?
.is_some();
Ok(exists)
}
#[server(endpoint = "playlists/get_all", client = Client)]
pub async fn get_playlists() -> BackendResult<Vec<backend::Playlist>> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let playlists = playlists::table
.filter(playlists::owner_id.eq(user_id))
.select(playlists::all_columns)
.load::<backend::Playlist>(&mut db_conn)
.context("Error loading playlists from database")?;
Ok(playlists)
}
#[server(endpoint = "playlists/get", client = Client)]
pub async fn get_playlist(playlist_id: i32) -> BackendResult<backend::Playlist> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let playlist: backend::Playlist = playlists::table
.find(playlist_id)
.filter(playlists::owner_id.eq(user_id))
.select(playlists::all_columns)
.first(&mut db_conn)
.context("Error loading playlist from database")?;
Ok(playlist)
}
#[server(endpoint = "playlists/get_songs", client = Client)]
pub async fn get_playlist_songs(playlist_id: i32) -> BackendResult<Vec<frontend::Song>> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user_id, playlist_id)
.await
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let songs: Vec<(
backend::Song,
Option<backend::Album>,
Option<backend::Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = crate::schema::playlist_songs::table
.filter(crate::schema::playlist_songs::playlist_id.eq(playlist_id))
.inner_join(
crate::schema::songs::table
.on(crate::schema::playlist_songs::song_id.eq(crate::schema::songs::id)),
)
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(user_id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(user_id))),
)
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_conn)
.context("Error loading playlist songs from database")?;
let mut playlist_songs: HashMap<i32, frontend::Song> = HashMap::new();
for (song, album, artist, like, dislike) in songs {
if let Some(stored_songdata) = playlist_songs.get_mut(&song.id) {
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
let image_path = song.image_path.unwrap_or(
album
.as_ref()
.and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
);
let songdata = frontend::Song {
id: song.id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date,
};
playlist_songs.insert(song.id, songdata);
}
}
Ok(playlist_songs.into_values().collect())
}
#[server(endpoint = "playlists/add_song", client = Client)]
pub async fn add_song_to_playlist(playlist_id: i32, song_id: i32) -> BackendResult<()> {
use crate::schema::*;
let user = get_user().await.context("Error getting logged-in user")?;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, playlist_id)
.await
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::insert_into(crate::schema::playlist_songs::table)
.values((
playlist_songs::playlist_id.eq(playlist_id),
playlist_songs::song_id.eq(song_id),
))
.execute(&mut db_conn)
.context("Error adding song to playlist in database")?;
Ok(())
}
#[server(input = MultipartFormData, endpoint = "playlists/create")]
pub async fn create_playlist(data: MultipartData) -> BackendResult<()> {
use crate::models::backend::NewPlaylist;
use image_convert::{to_webp, ImageResource, WEBPConfig};
let user = get_user().await.context("Error getting logged-in user")?;
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap();
let mut playlist_name = None;
let mut picture_data = None;
while let Ok(Some(field)) = data.next_field().await {
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"name" => {
playlist_name = Some(extract_field(field).await?);
}
"picture" => {
// Read the image
let bytes = field
.bytes()
.await
.map_err(|e| InputError::FieldReadError(format!("{e}")))
.context("Error reading bytes of the picture field")?;
// Check if the image is empty
if !bytes.is_empty() {
let reader = std::io::Cursor::new(bytes);
let image_source = ImageResource::from_reader(reader)
.context("Error creating image resource from reader")?;
picture_data = Some(image_source);
}
}
_ => {
warn!("Unknown playlist creation field: {name}");
}
}
}
// Unwrap mandatory fields
let name = playlist_name.ok_or_else(|| {
InputError::MissingField("name".to_string()).context("Missing playlist name")
})?;
let new_playlist = NewPlaylist {
name: name.clone(),
owner_id: user.id,
};
let mut db_conn = BackendState::get().await?.get_db_conn()?;
// Create a transaction to create the playlist
// If saving the image fails, the playlist will not be created
db_conn.transaction(|db_conn| {
let playlist = diesel::insert_into(playlists::table)
.values(&new_playlist)
.get_result::<backend::Playlist>(db_conn)
.context("Error creating playlist in database")?;
// If a picture was provided, save it to the database
if let Some(image_source) = picture_data {
let image_path = format!("assets/images/playlist/{}.webp", playlist.id);
let mut image_target = ImageResource::from_path(&image_path);
to_webp(&mut image_target, &image_source, &WEBPConfig::new())
.map_err(|e| InputError::InvalidInput(format!("{e}")))
.context("Error converting image to webp")?;
}
Ok::<(), BackendError>(())
})
}
#[server(input = MultipartFormData, endpoint = "playlists/edit_image")]
pub async fn edit_playlist_image(data: MultipartData) -> BackendResult<()> {
use image_convert::{to_webp, ImageResource, WEBPConfig};
let user = get_user().await.context("Error getting logged-in user")?;
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap();
let mut playlist_id = None;
let mut picture_data = None;
while let Ok(Some(field)) = data.next_field().await {
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"id" => {
playlist_id = Some(extract_field(field).await?);
}
"picture" => {
// Read the image
let bytes = field
.bytes()
.await
.map_err(|e| InputError::FieldReadError(format!("{e}")))
.context("Error reading bytes of the picture field")?;
// Check if the image is empty
if !bytes.is_empty() {
let reader = std::io::Cursor::new(bytes);
let image_source = ImageResource::from_reader(reader)
.context("Error creating image resource from reader")?;
picture_data = Some(image_source);
}
}
_ => {
warn!("Unknown playlist creation field: {name}");
}
}
}
// Unwrap mandatory fields
let playlist_id = playlist_id
.ok_or_else(|| InputError::MissingField("id".to_string()).context("Missing playlist ID"))?;
let playlist_id: i32 = playlist_id
.parse()
.map_err(|e| InputError::InvalidInput(format!("Invalid playlist ID: {e}")))
.context("Error parsing playlist ID from string")?;
// Make sure the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, playlist_id)
.await
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
// If a picture was provided, save it to the database
if let Some(image_source) = picture_data {
let image_path = format!("assets/images/playlist/{playlist_id}.webp");
let mut image_target = ImageResource::from_path(&image_path);
to_webp(&mut image_target, &image_source, &WEBPConfig::new())
.map_err(|e| InputError::InvalidInput(format!("{e}")))
.context("Error converting image to webp")?;
}
Ok(())
}
#[server(endpoint = "playlists/delete", client = Client)]
pub async fn delete_playlist(playlist_id: i32) -> BackendResult<()> {
use crate::schema::*;
let user = get_user().await.context("Error getting logged-in user")?;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, playlist_id)
.await
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(AccessError::NotFoundOrUnauthorized
.context("Playlist does not exist or does not belong to the user"));
}
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::delete(playlists::table.find(playlist_id))
.execute(&mut db_conn)
.context("Error deleting playlist from database")?;
Ok(())
}
#[server(endpoint = "playlists/rename", client = Client)]
pub async fn rename_playlist(id: i32, new_name: String) -> BackendResult<()> {
use crate::schema::*;
let user = get_user().await.context("Error getting logged-in user")?;
// Check if the playlist exists and belongs to the user
let valid_playlist = user_owns_playlist(user.id, id)
.await
.context("Error checking if playlist exists and is owned by user")?;
if !valid_playlist {
return Err(AccessError::NotFoundOrUnauthorized.into());
}
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::update(playlists::table.find(id))
.set(playlists::name.eq(new_name))
.execute(&mut db_conn)
.context("Error renaming playlist in database")?;
Ok(())
}

View File

@ -1,64 +1,73 @@
use leptos::*; use crate::util::error::*;
use cfg_if::cfg_if;
use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData}; use server_fn::codec::{MultipartData, MultipartFormData};
use cfg_if::cfg_if; use crate::models::frontend;
use crate::util::serverfn_client::Client;
use crate::songdata::SongData;
use crate::artistdata::ArtistData;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
cfg_if! { cfg_if! {
if #[cfg(feature = "ssr")] { if #[cfg(feature = "ssr")] {
use crate::auth::get_user; use crate::api::auth::get_user;
use server_fn::error::NoCustomError;
use crate::database::get_db_conn; use diesel::prelude::*;
use diesel::prelude::*; use diesel::dsl::count;
use diesel::dsl::count; use crate::models::backend::{Album, Artist, Song, HistoryEntry};
use crate::models::*; use crate::models::backend;
use crate::schema::*; use crate::schema::*;
use crate::util::backend_state::BackendState;
use std::collections::HashMap; use std::collections::HashMap;
} }
} }
/// Handle a user uploading a profile picture. Converts the image to webp and saves it to the server. /// 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")] #[server(input = MultipartFormData, endpoint = "/profile/upload_picture")]
pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> { pub async fn upload_picture(data: MultipartData) -> BackendResult<()> {
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None." // Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap(); let mut data = data.into_inner().unwrap();
let field = data.next_field().await let field = data
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting field: {}", e)))? .next_field()
.ok_or_else(|| ServerFnError::<NoCustomError>::ServerError("No field found".to_string()))?; .await
.map_err(|e| InputError::InvalidInput(format!("Error reading multipart data: {e}")))
.context("Error getting next field from multipart data")?
.ok_or_else(|| {
InputError::InvalidInput("Expected a field in the multipart data".to_string())
})?;
if field.name() != Some("picture") { if field.name() != Some("picture") {
return Err(ServerFnError::ServerError("Field name is not 'picture'".to_string())); return Err(InputError::InvalidInput(format!(
} "Expected field 'picture', got '{:?}'",
field.name()
))
.into());
}
// Get user id from session // Get user id from session
let user = get_user().await let user = get_user().await.context("Error getting logged-in user")?;
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting user: {}", e)))?;
let user_id = user.id.ok_or_else(|| ServerFnError::<NoCustomError>::ServerError("User has no id".to_string()))?; // Read the image, and convert it to webp
use image_convert::{to_webp, ImageResource, WEBPConfig};
// Read the image, and convert it to webp let bytes = field
use image_convert::{to_webp, WEBPConfig, ImageResource}; .bytes()
.await
.map_err(|e| InputError::InvalidInput(format!("Error reading bytes from field: {e}")))
.context("Error reading bytes of the picture field")?;
let bytes = field.bytes().await let reader = std::io::Cursor::new(bytes);
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting field bytes: {}", e)))?; let image_source = ImageResource::from_reader(reader)
.map_err(|e| InputError::InvalidInput(format!("Error creating image resource: {e}")))
.context("Error creating image resource from reader")?;
let reader = std::io::Cursor::new(bytes); let profile_picture_path = format!("assets/images/profile/{}.webp", user.id);
let image_source = ImageResource::from_reader(reader) let mut image_target = ImageResource::from_path(&profile_picture_path);
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error creating image resource: {}", e)))?; to_webp(&mut image_target, &image_source, &WEBPConfig::new())
.map_err(|e| InputError::InvalidInput(format!("Error converting image to webp: {e}")))?;
let profile_picture_path = format!("assets/images/profile/{}.webp", user_id); Ok(())
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(())
} }
/// Get a user's recent songs listened to /// Get a user's recent songs listened to
@ -66,235 +75,365 @@ pub async fn upload_picture(data: MultipartData) -> Result<(), ServerFnError> {
/// If not provided, all songs ever listend to are returned. /// If not provided, all songs ever listend to are returned.
/// Returns a list of tuples with the date the song was listened to /// Returns a list of tuples with the date the song was listened to
/// and the song data, sorted by date (most recent first). /// and the song data, sorted by date (most recent first).
#[server(endpoint = "/profile/recent_songs")] #[server(endpoint = "/profile/recent_songs", client = Client)]
pub async fn recent_songs(for_user_id: i32, limit: Option<i64>) -> Result<Vec<(NaiveDateTime, SongData)>, ServerFnError> { pub async fn recent_songs(
let mut db_con = get_db_conn(); for_user_id: i32,
limit: Option<i64>,
) -> BackendResult<Vec<(NaiveDateTime, frontend::Song)>> {
let viewing_user_id = get_user().await.context("Error getting logged-in user")?.id;
// Get the ids of the most recent songs listened to let mut db_conn = BackendState::get().await?.get_db_conn()?;
let history_items: Vec<i32> =
if let Some(limit) = limit {
song_history::table
.filter(song_history::user_id.eq(for_user_id))
.order(song_history::date.desc())
.limit(limit)
.select(song_history::id)
.load(&mut db_con)?
} else {
song_history::table
.filter(song_history::user_id.eq(for_user_id))
.order(song_history::date.desc())
.select(song_history::id)
.load(&mut db_con)?
};
// Take the history ids and get the song data for them // Create an alias for the table so it can be referenced twice in the query
let history: Vec<(HistoryEntry, Song, Option<Album>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)> let history2 = diesel::alias!(song_history as history2);
= song_history::table
.filter(song_history::id.eq_any(history_items))
.inner_join(songs::table)
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(for_user_id))))
.left_join(song_dislikes::table.on(
songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(for_user_id))))
.select((
song_history::all_columns,
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_con)?;
// Process the history data into a map of song ids to song data // Get the ids of the most recent songs listened to
let mut history_songs: HashMap<i32, (NaiveDateTime, SongData)> = HashMap::with_capacity(history.len()); let history_ids = history2
.filter(history2.fields(song_history::user_id).eq(for_user_id))
.order(history2.fields(song_history::date).desc())
.select(history2.fields(song_history::id));
for (history, song, album, artist, like, dislike) in history { let history_ids = if let Some(limit) = limit {
let song_id = history.song_id; history_ids.limit(limit).into_boxed()
} else {
history_ids.into_boxed()
};
if let Some((_, stored_songdata)) = history_songs.get_mut(&song_id) { // Take the history ids and get the song data for them
// If the song is already in the map, update the artists let history: Vec<(
if let Some(artist) = artist { HistoryEntry,
stored_songdata.artists.push(artist); Song,
} Option<Album>,
} else { Option<Artist>,
let like_dislike = match (like, dislike) { Option<(i32, i32)>,
(Some(_), Some(_)) => Some((true, true)), Option<(i32, i32)>,
(Some(_), None) => Some((true, false)), )> = song_history::table
(None, Some(_)) => Some((false, true)), .filter(song_history::id.eq_any(history_ids))
_ => None, .inner_join(songs::table)
}; .left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(viewing_user_id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(viewing_user_id))),
)
.select((
song_history::all_columns,
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_conn)
.context("Error loading recent songs from database")?;
let image_path = song.image_path.unwrap_or( // Process the history data into a map of song ids to song data
album.as_ref().map(|album| album.image_path.clone()).flatten() let mut history_songs: HashMap<i32, (NaiveDateTime, frontend::Song)> = HashMap::new();
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()));
let songdata = SongData { for (history, song, album, artist, like, dislike) in history {
id: song_id, let song_id = history.song_id;
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album: album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path: image_path,
like_dislike: like_dislike,
};
history_songs.insert(song_id, (history.date, songdata)); if let Some((_, stored_songdata)) = history_songs.get_mut(&song_id) {
} // If the song is already in the map, update the artists
} if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
// Sort the songs by date let image_path = song.image_path.unwrap_or(
let mut history_songs: Vec<(NaiveDateTime, SongData)> = history_songs.into_values().collect(); album
history_songs.sort_by(|a, b| b.0.cmp(&a.0)); .as_ref()
Ok(history_songs) .and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
);
let songdata = frontend::Song {
id: song_id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date,
};
history_songs.insert(song_id, (history.date, songdata));
}
}
// Sort the songs by date
let mut history_songs: Vec<(NaiveDateTime, frontend::Song)> =
history_songs.into_values().collect();
history_songs.sort_by(|a, b| b.0.cmp(&a.0));
Ok(history_songs)
} }
/// Get a user's top songs by play count from a date range /// Get a user's top songs by play count from a date range
/// Optionally takes a limit parameter to limit the number of songs returned. /// Optionally takes a limit parameter to limit the number of songs returned.
/// If not provided, all songs listened to in the date range are returned. /// If not provided, all songs listened to in the date range are returned.
/// Returns a list of tuples with the play count and the song data, sorted by play count (most played first). /// Returns a list of tuples with the play count and the song data, sorted by play count (most played first).
#[server(endpoint = "/profile/top_songs")] #[server(endpoint = "/profile/top_songs", client = Client)]
pub async fn top_songs(for_user_id: i32, start_date: NaiveDateTime, end_date: NaiveDateTime, limit: Option<i64>) pub async fn top_songs(
-> Result<Vec<(i64, SongData)>, ServerFnError> for_user_id: i32,
{ start_date: NaiveDateTime,
let mut db_con = get_db_conn(); end_date: NaiveDateTime,
limit: Option<i64>,
) -> BackendResult<Vec<(i64, frontend::Song)>> {
let viewing_user_id = get_user().await.context("Error getting logged-in user")?.id;
// Get the play count and ids of the songs listened to in the date range let mut db_conn = BackendState::get().await?.get_db_conn()?;
let history_counts: Vec<(i32, i64)> =
if let Some(limit) = limit {
song_history::table
.filter(song_history::date.between(start_date, end_date))
.filter(song_history::user_id.eq(for_user_id))
.group_by(song_history::song_id)
.select((song_history::song_id, count(song_history::song_id)))
.order(count(song_history::song_id).desc())
.limit(limit)
.load(&mut db_con)?
} else {
song_history::table
.filter(song_history::date.between(start_date, end_date))
.filter(song_history::user_id.eq(for_user_id))
.group_by(song_history::song_id)
.select((song_history::song_id, count(song_history::song_id)))
.load(&mut db_con)?
};
let history_counts: HashMap<i32, i64> = history_counts.into_iter().collect(); // Get the play count and ids of the songs listened to in the date range
let history_song_ids = history_counts.iter().map(|(song_id, _)| *song_id).collect::<Vec<i32>>(); let history_counts: Vec<(i32, i64)> = if let Some(limit) = limit {
song_history::table
.filter(song_history::date.between(start_date, end_date))
.filter(song_history::user_id.eq(for_user_id))
.group_by(song_history::song_id)
.select((song_history::song_id, count(song_history::song_id)))
.order(count(song_history::song_id).desc())
.limit(limit)
.load(&mut db_conn)
.context("Error loading top song ids and counts from database")?
} else {
song_history::table
.filter(song_history::date.between(start_date, end_date))
.filter(song_history::user_id.eq(for_user_id))
.group_by(song_history::song_id)
.select((song_history::song_id, count(song_history::song_id)))
.load(&mut db_conn)
.context("Error loading top song ids and counts from database")?
};
// Get the song data for the songs listened to in the date range let history_counts: HashMap<i32, i64> = history_counts.into_iter().collect();
let history_songs: Vec<(Song, Option<Album>, Option<Artist>, Option<(i32, i32)>, Option<(i32, i32)>)> let history_song_ids = history_counts.keys().copied().collect::<Vec<i32>>();
= songs::table
.filter(songs::id.eq_any(history_song_ids))
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(song_artists::table.inner_join(artists::table).on(songs::id.eq(song_artists::song_id)))
.left_join(song_likes::table.on(songs::id.eq(song_likes::song_id).and(song_likes::user_id.eq(for_user_id))))
.left_join(song_dislikes::table.on(
songs::id.eq(song_dislikes::song_id).and(song_dislikes::user_id.eq(for_user_id))))
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_con)?;
// Process the history data into a map of song ids to song data // Get the song data for the songs listened to in the date range
let mut history_songs_map: HashMap<i32, (i64, SongData)> = HashMap::with_capacity(history_counts.len()); let history_songs: Vec<(
Song,
Option<Album>,
Option<Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = songs::table
.filter(songs::id.eq_any(history_song_ids))
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(viewing_user_id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(viewing_user_id))),
)
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_conn)
.context("Error loading top songs from database")?;
for (song, album, artist, like, dislike) in history_songs { // Process the history data into a map of song ids to song data
let song_id = song.id let mut history_songs_map: HashMap<i32, (i64, frontend::Song)> =
.ok_or(ServerFnError::ServerError::<NoCustomError>("Song id not found in database".to_string()))?; HashMap::with_capacity(history_counts.len());
if let Some((_, stored_songdata)) = history_songs_map.get_mut(&song_id) { for (song, album, artist, like, dislike) in history_songs {
// If the song is already in the map, update the artists if let Some((_, stored_songdata)) = history_songs_map.get_mut(&song.id) {
if let Some(artist) = artist { // If the song is already in the map, update the artists
stored_songdata.artists.push(artist); if let Some(artist) = artist {
} stored_songdata.artists.push(artist);
} else { }
let like_dislike = match (like, dislike) { } else {
(Some(_), Some(_)) => Some((true, true)), let like_dislike = match (like, dislike) {
(Some(_), None) => Some((true, false)), (Some(_), Some(_)) => Some((true, true)),
(None, Some(_)) => Some((false, true)), (Some(_), None) => Some((true, false)),
_ => None, (None, Some(_)) => Some((false, true)),
}; _ => None,
};
let image_path = song.image_path.unwrap_or( let image_path = song.image_path.unwrap_or(
album.as_ref().map(|album| album.image_path.clone()).flatten() album
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string())); .as_ref()
.and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
);
let songdata = SongData { let songdata = frontend::Song {
id: song_id, id: song.id,
title: song.title, title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(), artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album: album, album,
track: song.track, track: song.track,
duration: song.duration, duration: song.duration,
release_date: song.release_date, release_date: song.release_date,
song_path: song.storage_path, song_path: song.storage_path,
image_path: image_path, image_path,
like_dislike: like_dislike, like_dislike,
}; added_date: song.added_date,
};
let plays = history_counts.get(&song_id) let plays = history_counts
.ok_or(ServerFnError::ServerError::<NoCustomError>("Song id not found in history counts".to_string()))?; .get(&song.id)
.ok_or(BackendError::InternalError(
"Song id not found in history counts",
))?;
history_songs_map.insert(song_id, (*plays, songdata)); history_songs_map.insert(song.id, (*plays, songdata));
} }
} }
// Sort the songs by play count // Sort the songs by play count
let mut history_songs: Vec<(i64, SongData)> = history_songs_map.into_values().collect(); let mut history_songs: Vec<(i64, frontend::Song)> = history_songs_map.into_values().collect();
history_songs.sort_by(|a, b| b.0.cmp(&a.0)); history_songs.sort_by(|a, b| b.0.cmp(&a.0));
Ok(history_songs) Ok(history_songs)
} }
/// Get a user's top artists by play count from a date range /// Get a user's top artists by play count from a date range
/// Optionally takes a limit parameter to limit the number of artists returned. /// Optionally takes a limit parameter to limit the number of artists returned.
/// If not provided, all artists listened to in the date range are returned. /// If not provided, all artists listened to in the date range are returned.
/// Returns a list of tuples with the play count and the artist data, sorted by play count (most played first). /// Returns a list of tuples with the play count and the artist data, sorted by play count (most played first).
#[server(endpoint = "/profile/top_artists")] #[server(endpoint = "/profile/top_artists", client = Client)]
pub async fn top_artists(for_user_id: i32, start_date: NaiveDateTime, end_date: NaiveDateTime, limit: Option<i64>) pub async fn top_artists(
-> Result<Vec<(i64, ArtistData)>, ServerFnError> for_user_id: i32,
{ start_date: NaiveDateTime,
let mut db_con = get_db_conn(); end_date: NaiveDateTime,
limit: Option<i64>,
) -> BackendResult<Vec<(i64, frontend::Artist)>> {
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let artist_counts: Vec<(i64, Artist)> = let artist_counts: Vec<(i64, Artist)> = if let Some(limit) = limit {
if let Some(limit) = limit { song_history::table
song_history::table .filter(song_history::date.between(start_date, end_date))
.filter(song_history::date.between(start_date, end_date)) .filter(song_history::user_id.eq(for_user_id))
.filter(song_history::user_id.eq(for_user_id)) .inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id)))
.inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id))) .inner_join(artists::table.on(song_artists::artist_id.eq(artists::id)))
.inner_join(artists::table.on(song_artists::artist_id.eq(artists::id))) .group_by(artists::id)
.group_by(artists::id) .select((count(artists::id), artists::all_columns))
.select((count(artists::id), artists::all_columns)) .order(count(artists::id).desc())
.order(count(artists::id).desc()) .limit(limit)
.limit(limit) .load(&mut db_conn)
.load(&mut db_con)? .context("Error loading top artists from database")?
} else { } else {
song_history::table song_history::table
.filter(song_history::date.between(start_date, end_date)) .filter(song_history::date.between(start_date, end_date))
.filter(song_history::user_id.eq(for_user_id)) .filter(song_history::user_id.eq(for_user_id))
.inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id))) .inner_join(song_artists::table.on(song_history::song_id.eq(song_artists::song_id)))
.inner_join(artists::table.on(song_artists::artist_id.eq(artists::id))) .inner_join(artists::table.on(song_artists::artist_id.eq(artists::id)))
.group_by(artists::id) .group_by(artists::id)
.select((count(artists::id), artists::all_columns)) .select((count(artists::id), artists::all_columns))
.order(count(artists::id).desc()) .order(count(artists::id).desc())
.load(&mut db_con)? .load(&mut db_conn)
}; .context("Error loading top artists from database")?
};
let artist_data: Vec<(i64, ArtistData)> = artist_counts.into_iter().map(|(plays, artist)| { let artist_data: Vec<(i64, frontend::Artist)> = artist_counts
(plays, ArtistData { .into_iter()
id: artist.id.unwrap(), .map(|(plays, artist)| {
name: artist.name, (
image_path: format!("/assets/images/artists/{}.webp", artist.id.unwrap()), plays,
}) frontend::Artist {
}).collect(); id: artist.id,
name: artist.name,
image_path: format!("/assets/images/artist/{}.webp", artist.id),
},
)
})
.collect();
Ok(artist_data) Ok(artist_data)
}
#[server(endpoint = "/profile/liked_songs", client = Client)]
pub async fn get_liked_songs() -> BackendResult<Vec<frontend::Song>> {
let user_id = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let songs: Vec<(
backend::Song,
Option<backend::Album>,
Option<backend::Artist>,
)> = crate::schema::song_likes::table
.filter(crate::schema::song_likes::user_id.eq(user_id))
.inner_join(
crate::schema::songs::table
.on(crate::schema::song_likes::song_id.eq(crate::schema::songs::id)),
)
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
))
.load(&mut db_conn)
.context("Error loading liked songs from database")?;
let mut liked_songs: HashMap<i32, frontend::Song> = HashMap::new();
for (song, album, artist) in songs {
if let Some(stored_songdata) = liked_songs.get_mut(&song.id) {
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let image_path = song.image_path.unwrap_or(
album
.as_ref()
.and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
);
let songdata = frontend::Song {
id: song.id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike: Some((true, false)),
added_date: song.added_date,
};
liked_songs.insert(song.id, songdata);
}
}
Ok(liked_songs.into_values().collect())
} }

321
src/api/search.rs Normal file
View File

@ -0,0 +1,321 @@
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::sql_types::*;
use diesel::*;
use diesel::pg::Pg;
use diesel::expression::AsExpression;
use std::collections::HashMap;
use crate::models::backend;
use crate::util::backend_state::BackendState;
// Define pg_trgm operators
// Functions do not use indices for queries, so we need to use operators
diesel::infix_operator!(Similarity, " % ", backend: Pg);
diesel::infix_operator!(Distance, " <-> ", Float, backend: Pg);
// Create functions to make use of the operators in queries
fn trgm_similar<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Similarity<T::Expression, U::Expression> {
Similarity::new(left.as_expression(), right.as_expression())
}
fn trgm_distance<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Distance<T::Expression, U::Expression> {
Distance::new(left.as_expression(), right.as_expression())
}
}
}
/// A simple type for search results
/// A vector of tuples containing the item and its score
pub type SearchResults<T> = Vec<(T, f32)>;
/// Search for albums by title
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the album titles
/// `limit` - The maximum number of results to return
///
/// # Returns
/// A Result containing a vector of albums if the search was successful, or an error if the search failed
#[server(endpoint = "search_albums", client = Client)]
pub async fn search_albums(
query: String,
limit: i64,
) -> BackendResult<SearchResults<frontend::Album>> {
use crate::schema::*;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let album_ids = albums::table
.filter(trgm_similar(albums::title, query.clone()))
.order_by(trgm_distance(albums::title, query.clone()).desc())
.limit(limit)
.select(albums::id)
.load::<i32>(&mut db_conn)
.context("Error loading album ids from database")?;
let mut albums_map: HashMap<i32, (frontend::Album, f32)> = HashMap::new();
let album_artists: Vec<(backend::Album, backend::Artist, f32)> = albums::table
.filter(albums::id.eq_any(album_ids))
.inner_join(
album_artists::table
.inner_join(artists::table)
.on(albums::id.eq(album_artists::album_id)),
)
.select((
albums::all_columns,
artists::all_columns,
trgm_distance(albums::title, query.clone()),
))
.load(&mut db_conn)
.context("Error loading album artists from database")?;
for (album, artist, score) in album_artists {
if let Some((stored_album, _score)) = albums_map.get_mut(&album.id) {
stored_album.artists.push(artist);
} else {
let albumdata = frontend::Album {
id: album.id,
title: album.title,
artists: vec![artist],
release_date: album.release_date,
image_path: album
.image_path
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
};
albums_map.insert(album.id, (albumdata, score));
}
}
let mut albums: Vec<(frontend::Album, f32)> = albums_map.into_values().collect();
albums.sort_by(|(_a, a_score), (_b, b_score)| b_score.total_cmp(a_score));
Ok(albums)
}
/// Search for artists by name
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the artist names
/// `limit` - The maximum number of results to return
///
/// # Returns
/// A Result containing a vector of artists if the search was successful, or an error if the search failed
#[server(endpoint = "search_artists", client = Client)]
pub async fn search_artists(
query: String,
limit: i64,
) -> BackendResult<SearchResults<frontend::Artist>> {
use crate::schema::*;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let artist_list = artists::table
.filter(trgm_similar(artists::name, query.clone()))
.order_by(trgm_distance(artists::name, query.clone()).desc())
.limit(limit)
.select((artists::all_columns, trgm_distance(artists::name, query)))
.load::<(backend::Artist, f32)>(&mut db_conn)
.context("Error loading artists from database")?;
let artist_data = artist_list
.into_iter()
.map(|(artist, score)| {
(
frontend::Artist {
id: artist.id,
name: artist.name,
image_path: format!("/assets/images/artist/{}.webp", artist.id),
},
score,
)
})
.collect();
Ok(artist_data)
}
/// Search for songs by title
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the song titles
/// `limit` - The maximum number of results to return
///
/// # Returns
/// A Result containing a vector of songs if the search was successful, or an error if the search failed
#[server(endpoint = "search_songs", client = Client)]
pub async fn search_songs(
query: String,
limit: i64,
) -> BackendResult<SearchResults<frontend::Song>> {
use crate::api::auth::get_logged_in_user;
use crate::schema::*;
let user = get_logged_in_user()
.await
.context("Error getting logged-in user")?;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let song_list = if let Some(user) = user {
let song_list: Vec<(
backend::Song,
Option<backend::Album>,
Option<backend::Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
f32,
)> = songs::table
.filter(trgm_similar(songs::title, query.clone()))
.order_by(trgm_distance(songs::title, query.clone()).desc())
.limit(limit)
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(user.id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(user.id))),
)
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
trgm_distance(songs::title, query.clone()),
))
.load(&mut db_conn)
.context("Error loading songs from database")?;
song_list
} else {
let song_list: Vec<(
backend::Song,
Option<backend::Album>,
Option<backend::Artist>,
f32,
)> = songs::table
.filter(trgm_similar(songs::title, query.clone()))
.order_by(trgm_distance(songs::title, query.clone()).desc())
.limit(limit)
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
trgm_distance(songs::title, query.clone()),
))
.load(&mut db_conn)
.context("Error loading songs from database")?;
song_list
.into_iter()
.map(|(song, album, artist, score)| (song, album, artist, None, None, score))
.collect()
};
let mut search_songs: HashMap<i32, (frontend::Song, f32)> =
HashMap::with_capacity(song_list.len());
for (song, album, artist, like, dislike, score) in song_list {
if let Some((stored_songdata, _score)) = search_songs.get_mut(&song.id) {
// If the song is already in the map, update the artists
if let Some(artist) = artist {
stored_songdata.artists.push(artist);
}
} else {
let like_dislike = match (like, dislike) {
(Some(_), Some(_)) => Some((true, true)),
(Some(_), None) => Some((true, false)),
(None, Some(_)) => Some((false, true)),
_ => None,
};
let image_path = song.image_path.unwrap_or(
album
.as_ref()
.and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string()),
);
let songdata = frontend::Song {
id: song.id,
title: song.title,
artists: artist.map(|artist| vec![artist]).unwrap_or_default(),
album,
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path,
image_path,
like_dislike,
added_date: song.added_date,
};
search_songs.insert(song.id, (songdata, score));
}
}
// Sort the songs by date
let mut songs: Vec<(frontend::Song, f32)> = search_songs.into_values().collect();
songs.sort_by(|(_a, a_score), (_b, b_score)| b_score.total_cmp(a_score));
Ok(songs)
}
/// Search for songs, albums, and artists by title or name
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the
/// song titles, album titles, and artist names
/// `limit` - The maximum number of results to return for each type
///
/// # Returns
/// A Result containing a tuple of vectors of albums, artists, and songs if the search was successful,
#[server(endpoint = "search", client = Client)]
pub async fn search(
query: String,
limit: i64,
) -> BackendResult<(
SearchResults<frontend::Album>,
SearchResults<frontend::Artist>,
SearchResults<frontend::Song>,
)> {
let albums = search_albums(query.clone(), limit);
let artists = search_artists(query.clone(), limit);
let songs = search_songs(query, limit);
use tokio::join;
let (albums, artists, songs) = join!(albums, artists, songs);
let albums = albums.context("Error searching for albums")?;
let artists = artists.context("Error searching for artists")?;
let songs = songs.context("Error searching for songs")?;
Ok((albums, artists, songs))
}

View File

@ -1,55 +1,175 @@
use leptos::*; use leptos::prelude::*;
use cfg_if::cfg_if; use cfg_if::cfg_if;
use crate::models::frontend;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
cfg_if! { cfg_if! {
if #[cfg(feature = "ssr")] { if #[cfg(feature = "ssr")] {
use leptos::server_fn::error::NoCustomError; use crate::util::backend_state::BackendState;
use crate::database::get_db_conn; use crate::api::auth::get_user;
use crate::auth::get_user; use crate::models::backend::{Song, Album, Artist};
} use diesel::prelude::*;
}
} }
/// Like or unlike a song /// Like or unlike a song
#[server(endpoint = "songs/set_like")] #[server(endpoint = "songs/set_like", client = Client)]
pub async fn set_like_song(song_id: i32, like: bool) -> Result<(), ServerFnError> { pub async fn set_like_song(song_id: i32, like: bool) -> BackendResult<()> {
let user = get_user().await.map_err(|e| ServerFnError::<NoCustomError>:: let user = get_user().await.context("Error getting logged-in user")?;
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>:: let mut db_conn = BackendState::get().await?.get_db_conn()?;
ServerError(format!("Error liking song: {}", e)))
user.set_like_song(song_id, like, &mut db_conn)
.await
.context("Error setting like status for song")
} }
/// Dislike or remove dislike from a song /// Dislike or remove dislike from a song
#[server(endpoint = "songs/set_dislike")] #[server(endpoint = "songs/set_dislike", client = Client)]
pub async fn set_dislike_song(song_id: i32, dislike: bool) -> Result<(), ServerFnError> { pub async fn set_dislike_song(song_id: i32, dislike: bool) -> BackendResult<()> {
let user = get_user().await.map_err(|e| ServerFnError::<NoCustomError>:: let user = get_user().await.context("Error getting logged-in user")?;
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>:: let mut db_conn = BackendState::get().await?.get_db_conn()?;
ServerError(format!("Error disliking song: {}", e)))
user.set_dislike_song(song_id, dislike, &mut db_conn)
.await
.context("Error setting dislike status for song")
} }
/// Get the like and dislike status of a song /// Get the like and dislike status of a song
#[server(endpoint = "songs/get_like_dislike")] #[server(endpoint = "songs/get_like_dislike", client = Client)]
pub async fn get_like_dislike_song(song_id: i32) -> Result<(bool, bool), ServerFnError> { pub async fn get_like_dislike_song(song_id: i32) -> BackendResult<(bool, bool)> {
let user = get_user().await.map_err(|e| ServerFnError::<NoCustomError>:: let user = get_user().await.context("Error getting logged-in user")?;
ServerError(format!("Error getting user: {}", e)))?;
let db_con = &mut get_db_conn(); let mut db_conn = BackendState::get().await?.get_db_conn()?;
// TODO this could probably be done more efficiently with a tokio::try_join, but // 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 // 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>:: let like = user
ServerError(format!("Error getting song liked: {}", e)))?; .get_like_song(song_id, &mut db_conn)
let dislike = user.get_dislike_song(song_id, db_con).await.map_err(|e| ServerFnError::<NoCustomError>:: .await
ServerError(format!("Error getting song disliked: {}", e)))?; .context("Error getting song like status")?;
Ok((like, dislike)) let dislike = user
.get_dislike_song(song_id, &mut db_conn)
.await
.context("Error getting song dislike status")?;
Ok((like, dislike))
}
#[server(endpoint = "songs/get", client = Client)]
pub async fn get_song_by_id(song_id: i32) -> BackendResult<Option<frontend::Song>> {
use crate::schema::*;
let user_id: i32 = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let song_parts: Vec<(
Song,
Option<Album>,
Option<Artist>,
Option<(i32, i32)>,
Option<(i32, i32)>,
)> = songs::table
.find(song_id)
.left_join(albums::table.on(songs::album_id.eq(albums::id.nullable())))
.left_join(
song_artists::table
.inner_join(artists::table)
.on(songs::id.eq(song_artists::song_id)),
)
.left_join(
song_likes::table.on(songs::id
.eq(song_likes::song_id)
.and(song_likes::user_id.eq(user_id))),
)
.left_join(
song_dislikes::table.on(songs::id
.eq(song_dislikes::song_id)
.and(song_dislikes::user_id.eq(user_id))),
)
.select((
songs::all_columns,
albums::all_columns.nullable(),
artists::all_columns.nullable(),
song_likes::all_columns.nullable(),
song_dislikes::all_columns.nullable(),
))
.load(&mut db_conn)
.context("Error loading song from database")?;
let song = song_parts.first().cloned();
let artists = song_parts
.into_iter()
.filter_map(|(_, _, artist, _, _)| artist)
.collect::<Vec<_>>();
match song {
Some((song, album, _artist, like, dislike)) => {
// Use song image path, or fall back to album image path, or fall back to placeholder
let image_path = song.image_path.clone().unwrap_or_else(|| {
album
.as_ref()
.and_then(|album| album.image_path.clone())
.unwrap_or("/assets/images/placeholders/MusicPlaceholder.svg".to_string())
});
Ok(Some(frontend::Song {
id: song.id,
title: song.title.clone(),
artists,
album: album.clone(),
track: song.track,
duration: song.duration,
release_date: song.release_date,
song_path: song.storage_path.clone(),
image_path,
like_dislike: Some((like.is_some(), dislike.is_some())),
added_date: song.added_date,
}))
}
None => Ok(None),
}
}
#[server(endpoint = "songs/plays", client = Client)]
pub async fn get_song_plays(song_id: i32) -> BackendResult<i64> {
use crate::schema::*;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let plays = song_history::table
.filter(song_history::song_id.eq(song_id))
.count()
.get_result::<i64>(&mut db_conn)
.context("Error getting song plays")?;
Ok(plays)
}
#[server(endpoint = "songs/my-plays", client = Client)]
pub async fn get_my_song_plays(song_id: i32) -> BackendResult<i64> {
use crate::schema::*;
let user_id: i32 = get_user().await.context("Error getting logged-in user")?.id;
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let plays = song_history::table
.filter(
song_history::song_id
.eq(song_id)
.and(song_history::user_id.eq(user_id)),
)
.count()
.get_result::<i64>(&mut db_conn)
.context("Error getting song plays for user")?;
Ok(plays)
} }

323
src/api/upload.rs Normal file
View File

@ -0,0 +1,323 @@
use crate::util::error::*;
use leptos::prelude::*;
use server_fn::codec::{MultipartData, MultipartFormData};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use multer::Field;
use crate::util::backend_state::BackendState;
use crate::util::extract_field::extract_field;
use diesel::prelude::*;
use log::*;
use chrono::NaiveDate;
}
}
/// Validate the artist ids in a multipart field
/// Expects a field with a comma-separated list of artist ids, and ensures each is a valid artist id in the database
#[cfg(feature = "ssr")]
async fn validate_artist_ids(artist_ids: Field<'static>) -> BackendResult<Vec<i32>> {
use crate::models::backend::Artist;
use diesel::result::Error::NotFound;
// Extract the artist id from the field
match artist_ids.text().await {
Ok(artist_ids) => {
let artist_ids = artist_ids.trim_end_matches(',').split(',');
let mut db_conn = BackendState::get().await?.get_db_conn()?;
artist_ids
.filter(|artist_id| !artist_id.is_empty())
.map(|artist_id| {
// Parse the artist id as an integer
if let Ok(artist_id) = artist_id.parse::<i32>() {
// Check if the artist exists
let artist = crate::schema::artists::dsl::artists
.find(artist_id)
.first::<Artist>(&mut db_conn);
match artist {
Ok(_) => Ok(artist_id),
Err(NotFound) => Err(AccessError::NotFound.context("Artist not found")),
Err(e) => Err(e.context("Error finding artist id")),
}
} else {
Err(InputError::InvalidInput("Error parsing artist id".to_string()).into())
}
})
.collect()
}
Err(e) => Err(InputError::FieldReadError(format!("Error reading artist ids: {e}")).into()),
}
}
/// Validate the album id in a multipart field
/// Expects a field with an album id, and ensures it is a valid album id in the database
#[cfg(feature = "ssr")]
async fn validate_album_id(album_id: Field<'static>) -> BackendResult<Option<i32>> {
use crate::models::backend::Album;
use diesel::result::Error::NotFound;
// Extract the album id from the field
match album_id.text().await {
Ok(album_id) => {
if album_id.is_empty() {
return Ok(None);
}
// Parse the album id as an integer
if let Ok(album_id) = album_id.parse::<i32>() {
let mut db_conn = BackendState::get().await?.get_db_conn()?;
// Check if the album exists
let album = crate::schema::albums::dsl::albums
.find(album_id)
.first::<Album>(&mut db_conn);
match album {
Ok(_) => Ok(Some(album_id)),
Err(NotFound) => Err(AccessError::NotFound.context("Album not found")),
Err(e) => Err(e.context("Error finding album id")),
}
} else {
Err(InputError::InvalidInput("Error parsing album id".to_string()).into())
}
}
Err(e) => Err(InputError::FieldReadError(format!("Error reading album id: {e}")).into()),
}
}
/// Validate the track number in a multipart field
/// Expects a field with a track number, and ensures it is a valid track number (non-negative integer)
#[cfg(feature = "ssr")]
async fn validate_track_number(track_number: Field<'static>) -> BackendResult<Option<i32>> {
match track_number.text().await {
Ok(track_number) => {
if track_number.is_empty() {
return Ok(None);
}
if let Ok(track_number) = track_number.parse::<i32>() {
if track_number < 0 {
Err(
InputError::InvalidInput("Track number must be positive or 0".to_string())
.into(),
)
} else {
Ok(Some(track_number))
}
} else {
Err(InputError::InvalidInput("Error parsing track number".to_string()).into())
}
}
Err(e) => {
Err(InputError::FieldReadError(format!("Error reading track number: {e}")).into())
}
}
}
/// Validate the release date in a multipart field
/// Expects a field with a release date, and ensures it is a valid date in the format [year]-[month]-[day]
#[cfg(feature = "ssr")]
async fn validate_release_date(release_date: Field<'static>) -> BackendResult<Option<NaiveDate>> {
match release_date.text().await {
Ok(release_date) => {
if release_date.trim().is_empty() {
return Ok(None);
}
let release_date = NaiveDate::parse_from_str(release_date.trim(), "%Y-%m-%d");
match release_date {
Ok(release_date) => Ok(Some(release_date)),
Err(_) => Err(InputError::InvalidInput(
"Invalid release date format, expected YYYY-MM-DD".to_string(),
)
.into()),
}
}
Err(e) => Err(InputError::InvalidInput(format!("Error reading release date: {e}")).into()),
}
}
/// Handle the file upload form
#[server(input = MultipartFormData, endpoint = "/upload")]
pub async fn upload(data: MultipartData) -> BackendResult<()> {
// Safe to unwrap - "On the server side, this always returns Some(_). On the client side, always returns None."
let mut data = data.into_inner().unwrap();
let mut title = None;
let mut artist_ids = None;
let mut album_id = None;
let mut track = None;
let mut release_date = None;
let mut file_name = None;
let mut duration = None;
// Fetch the fields from the form data
while let Ok(Some(mut field)) = data.next_field().await {
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"title" => {
title = Some(
extract_field(field)
.await
.context("Error extracting title field")?,
);
}
"artist_ids" => {
artist_ids = Some(
validate_artist_ids(field)
.await
.context("Error validating artist ids")?,
);
}
"album_id" => {
album_id = Some(
validate_album_id(field)
.await
.context("Error validating album id")?,
);
}
"track_number" => {
track = Some(
validate_track_number(field)
.await
.context("Error validating track number")?,
);
}
"release_date" => {
release_date = Some(
validate_release_date(field)
.await
.context("Error validating release date")?,
);
}
"file" => {
use crate::util::audio::extract_metadata;
use std::fs::OpenOptions;
use std::io::{Seek, Write};
use symphonia::core::codecs::CODEC_TYPE_MP3;
// Some logging is done here where there is high potential for bugs / failures,
// or behavior that we may wish to change in the future
// Create file name
let title = title.clone().ok_or(InputError::InvalidInput(
"Title field must be present and must precede file field".to_string(),
))?;
let clean_title = title.replace(" ", "_").replace("/", "_");
let date_str = chrono::Utc::now().format("%Y-%m-%d_%H:%M:%S").to_string();
let upload_path = format!("assets/audio/upload-{date_str}_{clean_title}.mp3");
file_name = Some(format!("upload-{date_str}_{clean_title}.mp3"));
debug!("Saving uploaded file {}", upload_path);
// Save file to disk
// Use these open options to create the file, write to it, then read from it
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(upload_path.clone())
.context("Error opening file for upload")?;
while let Some(chunk) = field.chunk().await.map_err(|e| {
InputError::FieldReadError(format!("Error reading file chunk: {e}"))
})? {
file.write_all(&chunk)
.context("Error writing field chunk to file")?;
}
file.flush().context("Error flusing file")?;
// Rewind the file so the duration can be measured
file.rewind().context("Error rewinding file")?;
// Get the codec and duration of the file
let (file_codec, file_duration) = extract_metadata(file)
.context("Error extracting metadata from uploaded file")?;
if file_codec != CODEC_TYPE_MP3 {
return Err(InputError::InvalidInput(format!(
"Invalid uploaded audio file codec: {file_codec}"
))
.into());
}
duration = Some(file_duration);
}
_ => {
warn!("Unknown file upload field: {}", name);
}
}
}
// Unwrap mandatory fields
let title = title.ok_or(InputError::MissingField("title".to_string()))?;
let artist_ids = artist_ids.unwrap_or(vec![]);
let file_name = file_name.ok_or(InputError::MissingField("file".to_string()))?;
let duration = duration.ok_or(InputError::MissingField("duration".to_string()))?;
let duration = i32::try_from(duration)
.map_err(|e| InputError::InvalidInput(format!("Error parsing duration: {e}")))
.context("Error converting duration to i32")?;
let album_id = album_id.unwrap_or(None);
let track = track.unwrap_or(None);
let release_date = release_date.unwrap_or(None);
if album_id.is_some() != track.is_some() {
return Err(InputError::InvalidInput(
"Album id and track number must both be present or both be absent".to_string(),
)
.into());
}
// Create the song
use crate::models::backend::{NewSong, Song};
let song = NewSong {
title,
album_id,
track,
duration,
release_date,
storage_path: file_name,
image_path: None,
};
let mut db_conn = BackendState::get().await?.get_db_conn()?;
// Save the song to the database
let song = song
.insert_into(crate::schema::songs::table)
.get_result::<Song>(&mut db_conn)
.context("Error adding song to database")?;
// Save the song's artists to the database
use crate::schema::song_artists;
use diesel::ExpressionMethods;
let artist_ids = artist_ids
.into_iter()
.map(|artist_id| {
(
song_artists::song_id.eq(song.id),
song_artists::artist_id.eq(artist_id),
)
})
.collect::<Vec<_>>();
diesel::insert_into(crate::schema::song_artists::table)
.values(&artist_ids)
.execute(&mut db_conn)
.context("Error saving song artists to database")?;
Ok(())
}

176
src/api/users.rs Normal file
View File

@ -0,0 +1,176 @@
cfg_if::cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use pbkdf2::{
password_hash::{
rand_core::OsRng,
PasswordHasher, PasswordHash, SaltString, PasswordVerifier, Error
},
Pbkdf2
};
use crate::models::backend::NewUser;
use crate::util::backend_state::BackendState;
use crate::util::database::PgPooledConn;
}
}
use crate::models::backend::User;
use crate::util::error::*;
use crate::util::serverfn_client::Client;
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserCredentials {
pub username_or_email: String,
pub password: String,
}
/// Get a user from the database by username or email
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
#[cfg(feature = "ssr")]
pub async fn find_user(
username_or_email: String,
db_conn: &mut PgPooledConn,
) -> BackendResult<Option<User>> {
use crate::schema::users::dsl::*;
// Look for either a username or email that matches the input, and return an option with None if no user is found
let user = users
.filter(username.eq(username_or_email.clone()))
.or_filter(email.eq(username_or_email))
.first::<User>(db_conn)
.optional()
.context("Error loading user from database")?;
Ok(user)
}
/// Get a user from the database by ID
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
#[cfg(feature = "ssr")]
pub async fn find_user_by_id(
user_id: i32,
db_conn: &mut PgPooledConn,
) -> BackendResult<Option<User>> {
use crate::schema::users::dsl::*;
let user = users
.filter(id.eq(user_id))
.first::<User>(db_conn)
.optional()
.context("Error loading user from database")?;
Ok(user)
}
/// Create a new user in the database
/// Returns an empty Result if successful, or an error if there was a problem
#[cfg(feature = "ssr")]
pub async fn create_user(new_user: &NewUser) -> BackendResult<()> {
use crate::schema::users::dsl::*;
let new_password =
new_user
.password
.clone()
.ok_or(BackendError::InputError(InputError::MissingField(
"password".to_string(),
)))?;
let salt = SaltString::generate(&mut OsRng);
let password_hash = Pbkdf2
.hash_password(new_password.as_bytes(), &salt)
.map_err(|e| AuthError::AuthError(format!("Error hashing password: {e}")))?
.to_string();
let new_user = NewUser {
password: Some(password_hash),
..new_user.clone()
};
let mut db_conn = BackendState::get().await?.get_db_conn()?;
diesel::insert_into(users)
.values(&new_user)
.execute(&mut db_conn)
.context("Error inserting new user into database")?;
Ok(())
}
/// Validate a user's credentials
/// Returns a Result with the user if the credentials are valid, None if not valid, or an error if there was a problem
#[cfg(feature = "ssr")]
pub async fn validate_user(
credentials: UserCredentials,
db_conn: &mut PgPooledConn,
) -> BackendResult<Option<User>> {
let db_user = find_user(credentials.username_or_email.clone(), db_conn)
.await
.context("Error finding user in database")?;
// If the user is not found, return None
let db_user = match db_user {
Some(user) => user,
None => return Ok(None),
};
let db_password = db_user.password.clone().ok_or(AuthError::AuthError(
"No password stored for user".to_string(),
))?;
let password_hash = PasswordHash::new(&db_password)
.map_err(|e| AuthError::AuthError(format!("{e}")))
.context("Error parsing password hash from database")?;
match Pbkdf2.verify_password(credentials.password.as_bytes(), &password_hash) {
Ok(()) => {}
Err(Error::Password) => {
return Ok(None);
}
Err(e) => {
return Err(
AuthError::AuthError(format!("{e}")).context("Error verifying password hash")
);
}
}
Ok(Some(db_user))
}
/// Get a user from the database by username or email
/// Returns a Result with the user if found, None if not found, or an error if there was a problem
#[server(endpoint = "find_user", client = Client)]
pub async fn get_user(username_or_email: String) -> BackendResult<Option<User>> {
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let mut user = find_user(username_or_email, &mut db_conn)
.await
.context("Error finding user by username or email")?;
// Remove the password hash before returning the user
if let Some(user) = user.as_mut() {
user.password = None;
}
Ok(user)
}
#[server(endpoint = "get_user_by_id", client = Client)]
pub async fn get_user_by_id(user_id: i32) -> BackendResult<Option<User>> {
let mut db_conn = BackendState::get().await?.get_db_conn()?;
let mut user = find_user_by_id(user_id, &mut db_conn)
.await
.context("Error finding user by ID")?;
// Remove the password hash before returning the user
if let Some(user) = user.as_mut() {
user.password = None;
}
Ok(user)
}

View File

@ -1,14 +1,40 @@
use crate::playbar::PlayBar; use crate::components::error_template::{AppError, ErrorTemplate};
use crate::playbar::CustomTitle; use crate::components::playbar::CustomTitle;
use crate::queue::Queue; use crate::components::playbar::PlayBar;
use leptos::*; use crate::components::queue::Queue;
use leptos_meta::*; use crate::pages::album::*;
use leptos_router::*; use crate::pages::artist::*;
use crate::pages::dashboard::*;
use crate::pages::liked_songs::*;
use crate::pages::login::*; use crate::pages::login::*;
use crate::pages::signup::*; use crate::pages::playlist::*;
use crate::pages::profile::*; use crate::pages::profile::*;
use crate::error_template::{AppError, ErrorTemplate}; use crate::pages::search::*;
use crate::pages::signup::*;
use crate::pages::song::*;
use crate::util::state::GlobalState; use crate::util::state::GlobalState;
use leptos::prelude::*;
use leptos_meta::*;
use leptos_router::components::*;
use leptos_router::*;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=options.clone() />
<HydrationScripts options/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
@ -17,7 +43,9 @@ pub fn App() -> impl IntoView {
provide_context(GlobalState::new()); provide_context(GlobalState::new());
let upload_open = create_rw_signal(false); let upload_open = RwSignal::new(false);
let add_artist_open = RwSignal::new(false);
let add_album_open = RwSignal::new(false);
view! { view! {
// injects a stylesheet into the document <head> // injects a stylesheet into the document <head>
@ -28,50 +56,65 @@ pub fn App() -> impl IntoView {
<CustomTitle /> <CustomTitle />
// content for this welcome page // content for this welcome page
<Router fallback=|| { <Router>
let mut outside_errors = Errors::default();
outside_errors.insert_with_default_key(AppError::NotFound);
view! {
<ErrorTemplate outside_errors/>
}
.into_view()
}>
<main> <main>
<Routes> <Routes fallback=|| {
<Route path="" view=move || view! { <HomePage upload_open=upload_open/> }> let mut outside_errors = Errors::default();
<Route path="" view=Dashboard /> outside_errors.insert_with_default_key(AppError::NotFound);
<Route path="dashboard" view=Dashboard /> view! {
<Route path="search" view=Search /> <ErrorTemplate outside_errors/>
<Route path="user/:id" view=Profile /> }
<Route path="user" view=Profile /> .into_view()
</Route> }>
<Route path="/login" view=Login /> <ParentRoute path=path!("") view=move || view! { <HomePage upload_open=upload_open add_artist_open=add_artist_open add_album_open=add_album_open/> }>
<Route path="/signup" view=Signup /> <Route path=path!("") view=Dashboard />
<Route path=path!("dashboard") view=Dashboard />
<Route path=path!("search") view=Search />
<Route path=path!("user/:id") view=Profile />
<Route path=path!("user") view=Profile />
<Route path=path!("album/:id") view=AlbumPage />
<Route path=path!("artist/:id") view=ArtistPage />
<Route path=path!("song/:id") view=SongPage />
<Route path=path!("playlist/:id") view=PlaylistPage />
<Route path=path!("liked") view=LikedSongsPage />
</ParentRoute>
<Route path=path!("/login") view=Login />
<Route path=path!("/signup") view=Signup />
</Routes> </Routes>
</main> </main>
</Router> </Router>
} }
} }
use crate::components::sidebar::*; use crate::components::add_album::AddAlbum;
use crate::components::dashboard::*; use crate::components::add_artist::AddArtist;
use crate::components::search::*;
use crate::components::personal::Personal; use crate::components::personal::Personal;
use crate::components::sidebar::*;
use crate::components::upload::*; use crate::components::upload::*;
/// Renders the home page of your application. /// Renders the home page of your application.
#[component] #[component]
fn HomePage(upload_open: RwSignal<bool>) -> impl IntoView { fn HomePage(
upload_open: RwSignal<bool>,
add_artist_open: RwSignal<bool>,
add_album_open: RwSignal<bool>,
) -> impl IntoView {
view! { view! {
<div class="home-container"> <section class="bg-black h-screen flex">
<Upload open=upload_open/> <Upload open=upload_open/>
<Sidebar upload_open=upload_open/> <AddArtist open=add_artist_open/>
<AddAlbum open=add_album_open/>
<Sidebar upload_open=upload_open add_artist_open=add_artist_open add_album_open=add_album_open/>
// This <Outlet /> will render the child route components // This <Outlet /> will render the child route components
<Outlet /> <div class="flex flex-col flex-grow min-w-0">
<div class="home-card">
<Outlet />
</div>
</div>
<Personal /> <Personal />
<PlayBar />
<Queue /> <Queue />
</div> <PlayBar />
</section>
} }
} }

View File

@ -1,34 +0,0 @@
use crate::components::dashboard_tile::DashboardTile;
use serde::{Serialize, Deserialize};
/// Holds information about an artist
///
/// Intended to be used in the front-end
#[derive(Clone, Serialize, Deserialize)]
pub struct ArtistData {
/// Artist id
pub id: i32,
/// Artist name
pub name: String,
/// Path to artist image, relative to the root of the web server.
/// For example, `"/assets/images/Artist.jpg"`
pub image_path: String,
}
impl DashboardTile for ArtistData {
fn image_path(&self) -> String {
self.image_path.clone()
}
fn title(&self) -> String {
self.name.clone()
}
fn link(&self) -> String {
format!("/artist/{}", self.id)
}
fn description(&self) -> Option<String> {
Some("Artist".to_string())
}
}

View File

@ -1,195 +0,0 @@
use leptos::*;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos::server_fn::error::NoCustomError;
use leptos_axum::extract;
use axum_login::AuthSession;
use crate::auth_backend::AuthBackend;
}
}
use crate::models::User;
use crate::users::UserCredentials;
/// Create a new user and log them in
/// Takes in a NewUser struct, with the password in plaintext
/// Returns a Result with the error message if the user could not be created
#[server(endpoint = "signup")]
pub async fn signup(new_user: User) -> Result<(), ServerFnError> {
use crate::users::create_user;
// Ensure the user has no id, and is not a self-proclaimed admin
let new_user = User {
id: None,
admin: false,
..new_user
};
create_user(&new_user).await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error creating user: {}", e)))?;
let mut auth_session = extract::<AuthSession<AuthBackend>>().await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
let credentials = UserCredentials {
username_or_email: new_user.username.clone(),
password: new_user.password.clone().unwrap()
};
match auth_session.authenticate(credentials).await {
Ok(Some(user)) => {
auth_session.login(&user).await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {}", e)))
},
Ok(None) => {
Err(ServerFnError::<NoCustomError>::ServerError("Error authenticating user: User not found".to_string()))
},
Err(e) => {
Err(ServerFnError::<NoCustomError>::ServerError(format!("Error authenticating user: {}", e)))
}
}
}
/// Log a user in
/// Takes in a username or email and a password in plaintext
/// Returns a Result with a boolean indicating if the login was successful
#[server(endpoint = "login")]
pub async fn login(credentials: UserCredentials) -> Result<Option<User>, ServerFnError> {
use crate::users::validate_user;
let mut auth_session = extract::<AuthSession<AuthBackend>>().await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
let user = validate_user(credentials).await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error validating user: {}", e)))?;
if let Some(mut user) = user {
auth_session.login(&user).await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error logging in user: {}", e)))?;
user.password = None;
Ok(Some(user))
} else {
Ok(None)
}
}
/// Log a user out
/// Returns a Result with the error message if the user could not be logged out
#[server(endpoint = "logout")]
pub async fn logout() -> Result<(), ServerFnError> {
let mut auth_session = extract::<AuthSession<AuthBackend>>().await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
auth_session.logout().await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
Ok(())
}
/// Check if a user is logged in
/// Returns a Result with a boolean indicating if the user is logged in
#[server(endpoint = "check_auth")]
pub async fn check_auth() -> Result<bool, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
Ok(auth_session.user.is_some())
}
/// Require that a user is logged in
/// Returns a Result with the error message if the user is not logged in
/// Intended to be used at the start of a protected route, to ensure the user is logged in:
/// ```rust
/// use leptos::*;
/// use libretunes::auth::require_auth;
/// #[server(endpoint = "protected_route")]
/// pub async fn protected_route() -> Result<(), ServerFnError> {
/// require_auth().await?;
/// // Continue with protected route
/// Ok(())
/// }
/// ```
#[cfg(feature = "ssr")]
pub async fn require_auth() -> Result<(), ServerFnError> {
check_auth().await.and_then(|logged_in| {
if logged_in {
Ok(())
} else {
Err(ServerFnError::<NoCustomError>::ServerError(format!("Unauthorized")))
}
})
}
/// Get the current logged-in user
/// Returns a Result with the user if they are logged in
/// Returns an error if the user is not logged in, or if there is an error getting the user
/// Intended to be used in a route to get the current user:
/// ```rust
/// use leptos::*;
/// use libretunes::auth::get_user;
/// #[server(endpoint = "user_route")]
/// pub async fn user_route() -> Result<(), ServerFnError> {
/// let user = get_user().await?;
/// println!("Logged in as: {}", user.username);
/// // Do something with the user
/// Ok(())
/// }
/// ```
#[cfg(feature = "ssr")]
pub async fn get_user() -> Result<User, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
auth_session.user.ok_or(ServerFnError::<NoCustomError>::ServerError("User not logged in".to_string()))
}
#[server(endpoint = "get_logged_in_user")]
pub async fn get_logged_in_user() -> Result<Option<User>, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
let user = auth_session.user.map(|mut user| {
user.password = None;
user
});
Ok(user)
}
/// Check if a user is an admin
/// Returns a Result with a boolean indicating if the user is logged in and an admin
#[server(endpoint = "check_admin")]
pub async fn check_admin() -> Result<bool, ServerFnError> {
let auth_session = extract::<AuthSession<AuthBackend>>().await
.map_err(|e| ServerFnError::<NoCustomError>::ServerError(format!("Error getting auth session: {}", e)))?;
Ok(auth_session.user.as_ref().map(|u| u.admin).unwrap_or(false))
}
/// Require that a user is logged in and an admin
/// Returns a Result with the error message if the user is not logged in or is not an admin
/// Intended to be used at the start of a protected route, to ensure the user is logged in and an admin:
/// ```rust
/// use leptos::*;
/// use libretunes::auth::require_admin;
/// #[server(endpoint = "protected_admin_route")]
/// pub async fn protected_admin_route() -> Result<(), ServerFnError> {
/// require_admin().await?;
/// // Continue with protected route
/// Ok(())
/// }
/// ```
#[cfg(feature = "ssr")]
pub async fn require_admin() -> Result<(), ServerFnError> {
check_admin().await.and_then(|is_admin| {
if is_admin {
Ok(())
} else {
Err(ServerFnError::<NoCustomError>::ServerError(format!("Unauthorized")))
}
})
}

View File

@ -1,48 +0,0 @@
use axum_login::{AuthnBackend, AuthUser, UserId};
use crate::users::UserCredentials;
use leptos::server_fn::error::ServerFnErrorErr;
use crate::models::User;
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use async_trait::async_trait;
}
}
impl AuthUser for User {
type Id = i32;
// TODO: Ideally, we shouldn't have to unwrap here
fn id(&self) -> Self::Id {
self.id.unwrap()
}
fn session_auth_hash(&self) -> &[u8] {
self.password.as_ref().unwrap().as_bytes()
}
}
#[derive(Clone)]
pub struct AuthBackend;
#[cfg(feature = "ssr")]
#[async_trait]
impl AuthnBackend for AuthBackend {
type User = User;
type Credentials = UserCredentials;
type Error = ServerFnErrorErr;
async fn authenticate(&self, creds: Self::Credentials) -> Result<Option<Self::User>, Self::Error> {
crate::users::validate_user(creds).await
.map_err(|e| ServerFnErrorErr::ServerError(format!("Error validating user: {}", e)))
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
crate::users::find_user_by_id(*user_id).await
.map_err(|e| ServerFnErrorErr::ServerError(format!("Error getting user: {}", e)))
}
}

View File

@ -1,10 +0,0 @@
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;
pub mod loading;
pub mod error;

View File

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

View File

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

View File

@ -1,10 +0,0 @@
use leptos::*;
#[component]
pub fn Dashboard() -> impl IntoView {
view! {
<div class="dashboard-container home-component">
<h1 class="dashboard-header">Dashboard</h1>
</div>
}
}

View File

@ -1,118 +1,121 @@
use crate::components::dashboard_tile::*;
use leptos::html::Ul; use leptos::html::Ul;
use leptos::leptos_dom::*; use leptos::leptos_dom::*;
use leptos::*; use leptos::prelude::*;
use leptos_use::{use_element_size, UseElementSizeReturn, use_scroll, UseScrollReturn}; use leptos::text_prop::TextProp;
use crate::components::dashboard_tile::DashboardTile;
use leptos_icons::*; use leptos_icons::*;
use leptos_use::{use_element_size, use_scroll, UseElementSizeReturn, UseScrollReturn};
/// A row of dashboard tiles, with a title /// A row of dashboard tiles, with a title
pub struct DashboardRow { #[component]
pub title: String, pub fn DashboardRow(
pub tiles: Vec<Box<dyn DashboardTile>>, #[prop(into)] title: TextProp,
} #[prop(default=vec![])] tiles: Vec<DashboardTile>,
) -> impl IntoView {
let list_ref = NodeRef::<Ul>::new();
impl DashboardRow { // Scroll functions attempt to align the left edge of the scroll area with the left edge of a tile
pub fn new(title: String, tiles: Vec<Box<dyn DashboardTile>>) -> Self { // This is done by scrolling to the nearest multiple of the tile width, plus some for padding
Self {
title,
tiles,
}
}
}
impl IntoView for DashboardRow { let scroll_left = move |_| {
fn into_view(self) -> View { if let Some(scroll_element) = list_ref.get_untracked() {
let list_ref = create_node_ref::<Ul>(); let client_width = scroll_element.client_width() as f64;
let current_pos = scroll_element.scroll_left() as f64;
let desired_pos = current_pos - client_width;
// Scroll functions attempt to align the left edge of the scroll area with the left edge of a tile if let Some(first_tile) = scroll_element.first_element_child() {
// This is done by scrolling to the nearest multiple of the tile width, plus some for padding let tile_width = first_tile.client_width() as f64;
let scroll_pos = desired_pos + (tile_width - (desired_pos % tile_width));
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
} else {
warn!("Could not get first tile to scroll left");
// Fall back to scrolling by the client width if we can't get the tile width
scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
}
} else {
warn!("Could not get scroll element to scroll left");
}
};
let scroll_left = move |_| { let scroll_right = move |_| {
if let Some(scroll_element) = list_ref.get_untracked() { if let Some(scroll_element) = list_ref.get_untracked() {
let client_width = scroll_element.client_width() as f64; let client_width = scroll_element.client_width() as f64;
let current_pos = scroll_element.scroll_left() as f64; let current_pos = scroll_element.scroll_left() as f64;
let desired_pos = current_pos - client_width; let desired_pos = current_pos + client_width;
if let Some(first_tile) = scroll_element.first_element_child() { if let Some(first_tile) = scroll_element.first_element_child() {
let tile_width = first_tile.client_width() as f64; let tile_width = first_tile.client_width() as f64;
let scroll_pos = desired_pos + (tile_width - (desired_pos % tile_width)); let scroll_pos = desired_pos - (desired_pos % tile_width);
scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0); scroll_element.scroll_to_with_x_and_y(scroll_pos, 0.0);
} else { } else {
warn!("Could not get first tile to scroll left"); 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 // 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); scroll_element.scroll_to_with_x_and_y(desired_pos, 0.0);
} }
} else { } else {
warn!("Could not get scroll element to scroll left"); warn!("Could not get scroll element to scroll right");
} }
}; };
let scroll_right = move |_| { let UseElementSizeReturn {
if let Some(scroll_element) = list_ref.get_untracked() { width: scroll_element_width,
let client_width = scroll_element.client_width() as f64; ..
let current_pos = scroll_element.scroll_left() as f64; } = use_element_size(list_ref);
let desired_pos = current_pos + client_width; let UseScrollReturn { x: scroll_x, .. } = use_scroll(list_ref);
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 scroll_right_hidden = Signal::derive(move || {
let UseScrollReturn { x: scroll_x, .. } = use_scroll(list_ref); if let Some(scroll_element) = list_ref.get() {
if scroll_element.scroll_width() as f64 - scroll_element_width.get() <= scroll_x.get() {
"visibility: hidden"
} else {
""
}
} else {
""
}
});
let scroll_right_hidden = Signal::derive(move || { let scroll_left_hidden = Signal::derive(move || {
if let Some(scroll_element) = list_ref.get() { if scroll_x.get() <= 0.0 {
if scroll_element.scroll_width() as f64 - scroll_element_width.get() <= scroll_x.get() { "visibility: hidden"
"visibility: hidden" } else {
} else { ""
"" }
} });
} else {
""
}
});
let scroll_left_hidden = Signal::derive(move || { view! {
if scroll_x.get() <= 0.0 { <div>
"visibility: hidden" <div class="flex">
} else { <h2 class="text-xl font-bold">{move || title.get()}</h2>
"" <div class="m-auto mr-0">
} <button class="control" on:click=scroll_left tabindex=-1 style=scroll_left_hidden>
}); <Icon icon={icondata::FiChevronLeft} {..} class="w-7 h-7" />
</button>
view! { <button class="control" on:click=scroll_right tabindex=-1 style=scroll_right_hidden>
<div class="dashboard-tile-row"> <Icon icon={icondata::FiChevronRight} {..} class="w-7 h-7" />
<div class="dashboard-tile-row-title-row"> </button>
<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> </div>
<ul _ref={list_ref}>
{self.tiles.into_iter().map(|tile_info| {
view! {
<li>
{ tile_info.into_view() }
</li>
}
}).collect::<Vec<_>>()}
</ul>
</div> </div>
}.into_view() <ul class="flex overflow-x-hidden scroll-smooth ps-0"
} style="mask-image: linear-gradient(90deg, black, 95%, transparent);
-webkit-mask-image: linear-gradient(90deg, black, 95%, transparent);" node_ref={list_ref}>
{tiles.into_iter().map(|tile| {
view! {
<li>
<div class="mr-2.5">
<a href={move || tile.link.get()}>
<img class="w-50 h-50 max-w-none rounded-md mr-5"
src={move || tile.image_path.get()} alt="dashboard-tile" />
<p class="text-lg font-semibold">{move || tile.title.get()}</p>
<p>
{move || tile.description.as_ref().map(|desc| desc.get())}
</p>
</a>
</div>
</li>
}
}).collect::<Vec<_>>()}
</ul>
</div>
}.into_view()
} }

View File

@ -1,27 +1,14 @@
use leptos::leptos_dom::*; use leptos::prelude::*;
use leptos::*; use leptos::text_prop::TextProp;
pub trait DashboardTile { #[slot]
fn image_path(&self) -> String; pub struct DashboardTile {
fn title(&self) -> String; #[prop(into)]
fn link(&self) -> String; image_path: TextProp,
fn description(&self) -> Option<String> { None } #[prop(into)]
} title: TextProp,
#[prop(into)]
impl IntoView for &dyn DashboardTile { link: TextProp,
fn into_view(self) -> View { #[prop(into, optional)]
let link = self.link(); description: Option<TextProp>,
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()
}
} }

View File

@ -1,45 +1,40 @@
use leptos::*; use leptos::prelude::*;
use leptos::text_prop::TextProp;
use leptos_icons::*; use leptos_icons::*;
use std::fmt::Display; use std::fmt::Display;
#[component] #[component]
pub fn ServerError<E: Display + 'static>( pub fn ServerError<E: Display + 'static>(
#[prop(optional, into, default="An Error Occurred".into())] #[prop(optional, into, default="An Error Occurred".into())] title: TextProp,
title: TextProp, #[prop(optional, into)] message: TextProp,
#[prop(optional, into)] #[prop(optional, into)] error: Option<ServerFnError<E>>,
message: TextProp,
#[prop(optional, into)]
error: Option<ServerFnError<E>>,
) -> impl IntoView { ) -> impl IntoView {
view!{ view! {
<div class="error-container"> <div class="error-container">
<div class="error-header"> <div class="error-header">
<Icon icon=icondata::BiErrorSolid /> <Icon icon={icondata::BiErrorSolid} />
<h1>{title}</h1> <h1>{move || title.get()}</h1>
</div> </div>
<p>{message}</p> <p>{move || message.get()}</p>
<p>{error.map(|error| format!("{}", error))}</p> <p>{error.map(|error| format!("{error}"))}</p>
</div> </div>
} }
} }
#[component] #[component]
pub fn Error<E: Display + 'static>( pub fn Error<E: Display + 'static>(
#[prop(optional, into, default="An Error Occurred".into())] #[prop(optional, into, default="An Error Occurred".into())] title: TextProp,
title: TextProp, #[prop(optional, into)] message: TextProp,
#[prop(optional, into)] #[prop(optional, into)] error: Option<E>,
message: TextProp,
#[prop(optional, into)]
error: Option<E>,
) -> impl IntoView { ) -> impl IntoView {
view! { view! {
<div class="error-container"> <div class="text-red-800">
<div class="error-header"> <div class="grid grid-cols-[max-content_1fr] gap-1">
<Icon icon=icondata::BiErrorSolid /> <Icon icon={icondata::BiErrorSolid} {..} class="self-center" />
<h1>{title}</h1> <h1 class="self-center">{move || title.get()}</h1>
</div> </div>
<p>{message}</p> <p>{move || message.get()}</p>
<p>{error.map(|error| format!("{}", error))}</p> <p>{error.map(|error| format!("{error}"))}</p>
</div> </div>
} }
} }

View File

@ -1,5 +1,5 @@
use http::status::StatusCode; use http::status::StatusCode;
use leptos::*; use leptos::prelude::*;
use thiserror::Error; use thiserror::Error;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@ -12,7 +12,7 @@ pub enum AppError {
} }
impl AppError { impl AppError {
pub fn status_code(&self) -> StatusCode { pub const fn status_code(&self) -> StatusCode {
match self { match self {
AppError::NotFound => StatusCode::NOT_FOUND, AppError::NotFound => StatusCode::NOT_FOUND,
} }
@ -27,7 +27,7 @@ pub fn ErrorTemplate(
#[prop(optional)] errors: Option<RwSignal<Errors>>, #[prop(optional)] errors: Option<RwSignal<Errors>>,
) -> impl IntoView { ) -> impl IntoView {
let errors = match outside_errors { let errors = match outside_errors {
Some(e) => create_rw_signal(e), Some(e) => RwSignal::new(e),
None => match errors { None => match errors {
Some(e) => e, Some(e) => e,
None => panic!("No Errors found and we expected errors!"), None => panic!("No Errors found and we expected errors!"),
@ -51,7 +51,7 @@ pub fn ErrorTemplate(
response.set_status(errors[0].status_code()); response.set_status(errors[0].status_code());
} }
} }
view! { view! {
<h1>{if errors.len() > 1 {"Errors"} else {"Error"}}</h1> <h1>{if errors.len() > 1 {"Errors"} else {"Error"}}</h1>
<For <For

View File

@ -0,0 +1,34 @@
use leptos::prelude::*;
use leptos::text_prop::TextProp;
#[component]
pub fn FancyInput(
#[prop(into)] label: TextProp,
#[prop(optional, into)] password: Signal<bool>,
#[prop(optional)] required: bool,
#[prop(optional)] value: RwSignal<String>,
) -> impl IntoView {
view! {
<div class="relative mt-12 mb-3">
<input
class="peer text-lg w-full relative p-1 z-20 border-none outline-none bg-transparent text-white"
type={move || if password.get() { "password" } else { "text" }}
required={required}
placeholder=""
bind:value={value}
/>
<span
class="absolute left-0 text-lg transition-all duration-500
text-lg peer-[:not(:placeholder-shown)]:text-base peer-focus:text-base
text-black peer-[:not(:placeholder-shown)]:text-neutral-700 peer-focus:text-neutral-700;
peer-[:not(:placeholder-shown)]:translate-y-[-30px] peer-focus:translate-y-[-30px]"
>
{label.get()}
</span>
<div
class="w-full h-[2px] rounded-md bg-accent-light absolute bottom-0 left-0
transition-all duration-500 peer-[:not(:placeholder-shown)]:h-10 peer-focus:h-10"
></div>
</div>
}
}

View File

@ -1,19 +1,26 @@
use leptos::*; use leptos::prelude::*;
/// A loading indicator /// A loading indicator
#[component] #[component]
pub fn Loading() -> impl IntoView { pub fn Loading() -> impl IntoView {
view! { let dots_style = "h-2 w-2 bg-accent rounded-full animate-pulse";
<div class="loading"></div>
} view! {
<div class="flex space-x-1 justify-center items-center my-2">
<span class="sr-only">"Loading..."</span>
<div class=dots_style style="animation-duration: 900ms; animation-delay: 0ms;" />
<div class=dots_style style="animation-duration: 900ms; animation-delay: 300ms"/>
<div class=dots_style style="animation-duration: 900ms; animation-delay: 600ms;" />
</div>
}
} }
/// A full page, centered loading indicator /// A full page, centered loading indicator
#[component] #[component]
pub fn LoadingPage() -> impl IntoView { pub fn LoadingPage() -> impl IntoView {
view!{ view! {
<div class="loading-page"> <div class="loading-page">
<Loading /> <Loading />
</div> </div>
} }
} }

98
src/components/menu.rs Normal file
View File

@ -0,0 +1,98 @@
use crate::components::upload_dropdown::*;
use leptos::prelude::*;
use leptos_icons::*;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum MenuEntry {
Dashboard,
Search,
}
impl MenuEntry {
pub const fn path(&self) -> &'static str {
match self {
MenuEntry::Dashboard => "/",
MenuEntry::Search => "/search",
}
}
pub const fn icon(&self) -> icondata::Icon {
match self {
MenuEntry::Dashboard => icondata::OcHomeFillLg,
MenuEntry::Search => icondata::BiSearchRegular,
}
}
pub const fn title(&self) -> &'static str {
match self {
MenuEntry::Dashboard => "Dashboard",
MenuEntry::Search => "Search",
}
}
pub const fn all() -> [MenuEntry; 2] {
[MenuEntry::Dashboard, MenuEntry::Search]
}
}
#[component]
pub fn MenuItem(entry: MenuEntry, #[prop(into)] active: Signal<bool>) -> impl IntoView {
view! {
<a class="menu-btn" href={entry.path().to_string()}
style={move || if active() {"color: var(--color-menu-active);"} else {""}}
>
<Icon height="1.7rem" width="1.7rem" icon={entry.icon()} {..} class="mr-2" />
<h2>{entry.title()}</h2>
</a>
}
}
#[component]
pub fn Menu(
upload_open: RwSignal<bool>,
add_artist_open: RwSignal<bool>,
add_album_open: RwSignal<bool>,
) -> impl IntoView {
use leptos_router::hooks::use_location;
let location = use_location();
let active_entry = Signal::derive(move || {
let path = location.pathname.get();
MenuEntry::all()
.into_iter()
.find(|entry| entry.path() == path)
});
let dropdown_open = RwSignal::new(false);
view! {
<div class="home-card">
<Show
when=move || {upload_open.get() || add_artist_open.get() || add_album_open.get()}
fallback=move || view! {}
>
<div class="upload-overlay" on:click=move |_| {
upload_open.set(false);
add_artist_open.set(false);
add_album_open.set(false);
}></div>
</Show>
<div class="flex">
<h1 class="text-xl font-bold">"LibreTunes"</h1>
<div class="upload-dropdown-container">
<UploadDropdownBtn dropdown_open=dropdown_open/>
<Show
when= move || dropdown_open()
fallback=move || view! {}
>
<UploadDropdown dropdown_open=dropdown_open upload_open=upload_open add_artist_open=add_artist_open add_album_open=add_album_open/>
</Show>
</div>
</div>
{MenuEntry::all().into_iter().map(|entry| {
let active = Signal::derive(move || active_entry.get() == Some(entry));
view! { <MenuItem entry active /> }
}).collect::<Vec<_>>()}
</div>
}
}

17
src/components/mod.rs Normal file
View File

@ -0,0 +1,17 @@
pub mod add_album;
pub mod add_artist;
pub mod dashboard_row;
pub mod dashboard_tile;
pub mod error;
pub mod error_template;
pub mod fancy_input;
pub mod loading;
pub mod menu;
pub mod personal;
pub mod playbar;
pub mod queue;
pub mod sidebar;
pub mod song;
pub mod song_list;
pub mod upload;
pub mod upload_dropdown;

View File

@ -1,11 +1,17 @@
use crate::api::auth::logout;
use crate::util::state::GlobalState;
use leptos::html::Div;
use leptos::leptos_dom::*; use leptos::leptos_dom::*;
use leptos::*; use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::*; use leptos_icons::*;
use leptos_use::on_click_outside_with_options;
use leptos_use::OnClickOutsideOptions;
#[component] #[component]
pub fn Personal() -> impl IntoView { pub fn Personal() -> impl IntoView {
view! { view! {
<div class=" personal-container"> <div class="home-card">
<Profile /> <Profile />
</div> </div>
} }
@ -13,30 +19,102 @@ pub fn Personal() -> impl IntoView {
#[component] #[component]
pub fn Profile() -> impl IntoView { pub fn Profile() -> impl IntoView {
let (dropdown_open, set_dropdown_open) = create_signal(false); let dropdown_open = RwSignal::new(false);
let user = GlobalState::logged_in_user();
let open_dropdown = move |_| { let toggle_dropdown = move |_| dropdown_open.set(!dropdown_open.get());
set_dropdown_open.update(|value| *value = !*value);
log!("opened dropdown"); let profile_photo = NodeRef::<Div>::new();
let dropdown = NodeRef::<Div>::new();
let _ = on_click_outside_with_options(
dropdown,
move |_| dropdown_open.set(false),
OnClickOutsideOptions::default().ignore(profile_photo),
);
let user_profile_picture = move || {
user.get().and_then(|user| {
if let Some(user) = user {
Some(format!("/assets/images/profile/{}.webp", user.id))
} else {
None
}
})
}; };
view! { view! {
<div class="profile-container"> <div class="flex w-50 relative">
<div class="profile-icon" on:click=open_dropdown> <div class="text-lg self-center">
<Icon icon=icondata::CgProfile /> <Suspense
fallback=|| view!{
<h1>Not Logged In</h1>
}>
<Show
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
fallback=|| view!{
<h1>Not Logged In</h1>
}>
<h1>{move || user.get().map(|user| user.map(|user| user.username))}</h1>
</Show>
</Suspense>
</div> </div>
<div class="dropdown-container" style={move || if dropdown_open() {"display: flex"} else {"display: none"}}> <div class="self-center hover:scale-105 transition-transform cursor-pointer ml-auto"
<DropDownNotLoggedIn /> on:click=toggle_dropdown node_ref=profile_photo>
<Suspense fallback=|| view! { <Icon icon={icondata::CgProfile} width="45" height="45"/> }>
<Show
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
fallback=|| view! { <Icon icon={icondata::CgProfile} width="45" height="45"/> }
>
<object class="w-11 h-11 rounded-full pointer-events-none"
data={user_profile_picture} type="image/webp">
<Icon icon={icondata::CgProfile} width="45" height="45" {..} />
</object>
</Show>
</Suspense>
</div> </div>
<Show when=dropdown_open >
<div class="absolute bg-bg-light rounded-lg border-2 border-neutral-700 top-12
right-3 p-1 text-right" node_ref=dropdown>
<Suspense
fallback=|| view!{
<DropDownNotLoggedIn />
}>
<Show
when=move || user.get().map(|user| user.is_some()).unwrap_or(false)
fallback=|| view!{
<DropDownNotLoggedIn />
}>
<DropDownLoggedIn />
</Show>
</Suspense>
</div>
</Show>
</div> </div>
} }
} }
#[component] #[component]
pub fn DropDownNotLoggedIn() -> impl IntoView { pub fn DropDownNotLoggedIn() -> impl IntoView {
view! { view! {
<div class="dropdown-not-logged"> <a href="/login"><button class="auth-button">"Log In"</button></a><br/>
<h1>Not Logged in!</h1> <a href="/signup"><button class="auth-button">"Sign Up"</button></a>
<a href="/login"><button class="auth-button">Log In</button></a> }
<a href="/signup"><button class="auth-button">Sign up</button></a> }
</div> #[component]
pub fn DropDownLoggedIn() -> impl IntoView {
let logout = move |_| {
spawn_local(async move {
let result = logout().await;
if let Err(err) = result {
log!("Error logging out: {:?}", err);
} else {
let user = GlobalState::logged_in_user();
user.refetch();
log!("Logged out successfully");
}
});
};
view! {
<button on:click=logout class="auth-button">"Log Out"</button>
} }
} }

View File

@ -1,22 +1,23 @@
use crate::models::Artist;
use crate::songdata::SongData;
use crate::api::songs; use crate::api::songs;
use crate::models::backend::Artist;
use crate::models::frontend;
use crate::util::state::GlobalState; use crate::util::state::GlobalState;
use leptos::ev::MouseEvent; use leptos::ev::MouseEvent;
use leptos::html::{Audio, Div}; use leptos::html::{Audio, Div};
use leptos::leptos_dom::*; use leptos::leptos_dom::*;
use leptos_meta::Title; use leptos::prelude::*;
use leptos::*; use leptos::task::spawn_local;
use leptos_icons::*; use leptos_icons::*;
use leptos_use::{utils::Pausable, use_interval_fn}; use leptos_meta::Title;
use leptos_use::{use_interval_fn, utils::Pausable};
/// Width and height of the forward/backward skip buttons /// Width and height of the forward/backward skip buttons
const SKIP_BTN_SIZE: &str = "3.5em"; const SKIP_BTN_SIZE: &str = "3em";
/// Width and height of the play/pause button /// Width and height of the play/pause button
const PLAY_BTN_SIZE: &str = "5em"; const PLAY_BTN_SIZE: &str = "4em";
// Width and height of the queue button // Width and height of the queue button
const QUEUE_BTN_SIZE: &str = "3.5em"; const QUEUE_BTN_SIZE: &str = "2.5em";
/// Threshold in seconds for skipping to the previous song instead of skipping to the start of the current song /// Threshold in seconds for skipping to the previous song instead of skipping to the start of the current song
const MIN_SKIP_BACK_TIME: f64 = 5.0; const MIN_SKIP_BACK_TIME: f64 = 5.0;
@ -30,46 +31,47 @@ const HISTORY_LISTEN_THRESHOLD: u64 = MIN_SKIP_BACK_TIME as u64;
// TODO Handle errors better, when getting audio HTML element and when playing/pausing audio // 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 /// Get the current time and duration of the current song, if available
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `status` - The `PlayStatus` to get the audio element from, as a signal /// * `status` - The `PlayStatus` to get the audio element from, as a signal
/// ///
/// # Returns /// # Returns
/// ///
/// * `None` if the audio element is not available /// * `None` if the audio element is not available
/// * `Some((current_time, duration))` if the audio element is available /// * `Some((current_time, duration))` if the audio element is available
/// ///
pub fn get_song_time_duration() -> Option<(f64, f64)> { pub fn get_song_time_duration() -> Option<(f64, f64)> {
GlobalState::play_status().with_untracked(|status| { GlobalState::play_status().with_untracked(|status| {
if let Some(audio) = status.get_audio() { if let Some(audio) = status.get_audio() {
Some((audio.current_time(), audio.duration())) Some((audio.current_time(), audio.duration()))
} else { } else {
error!("Unable to get current duration: Audio element not available"); error!("Unable to get current duration: Audio element not available");
None None
} }
}) })
} }
/// Skip to a certain time in the current song, optionally playing it /// Skip to a certain time in the current song, optionally playing it
/// ///
/// If the given time is +/- infinity or NaN, logs an error and returns /// If the given time is +/- infinity or NaN, logs an error and returns
/// Logs an error if the audio element is not available, or if playing the song fails /// Logs an error if the audio element is not available, or if playing the song fails
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `status` - The `PlayStatus` to get the audio element from, as a signal /// * `status` - The `PlayStatus` to get the audio element from, as a signal
/// * `time` - The time to skip to, in seconds /// * `time` - The time to skip to, in seconds
/// ///
pub fn skip_to(time: f64) { pub fn skip_to(time: f64) {
if time.is_infinite() || time.is_nan() { if time.is_infinite() || time.is_nan() {
error!("Unable to skip to non-finite time: {}", time); error!("Unable to skip to non-finite time: {}", time);
return return;
} }
GlobalState::play_status().update(|status| { GlobalState::play_status().update(|status| {
if let Some(audio) = status.get_audio() { if let Some(audio) = status.get_audio() {
audio.set_current_time(time); audio.set_current_time(time);
status.playing = true;
log!("Player skipped to time: {}", time); log!("Player skipped to time: {}", time);
} else { } else {
error!("Unable to skip to time: Audio element not available"); error!("Unable to skip to time: Audio element not available");
@ -77,63 +79,9 @@ pub fn skip_to(time: f64) {
}); });
} }
/// Play or pause the current song
///
/// Logs an error if the audio element is not available, or if playing/pausing the song fails
///
/// # Arguments
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
/// * `play` - `true` to play the song, `false` to pause it
///
pub fn set_playing(play: bool) {
GlobalState::play_status().update(|status| {
if let Some(audio) = status.get_audio() {
if play {
if let Err(e) = audio.play() {
error!("Unable to play audio: {:?}", e);
} else {
status.playing = true;
log!("Successfully played audio");
}
} else {
if let Err(e) = audio.pause() {
error!("Unable to pause audio: {:?}", e);
} else {
status.playing = false;
log!("Successfully paused audio");
}
}
} else {
error!("Unable to play/pause audio: Audio element not available");
}
});
}
fn toggle_queue() { fn toggle_queue() {
GlobalState::play_status().update(|status| {
status.queue_open = !status.queue_open;
});
}
/// Set the source of the audio player
///
/// Logs an error if the audio element is not available
///
///
/// # Arguments
/// * `status` - The `PlayStatus` to get the audio element from, as a signal
/// * `src` - The source to set the audio player to
///
fn set_play_src(src: String) {
GlobalState::play_status().update(|status| { GlobalState::play_status().update(|status| {
if let Some(audio) = status.get_audio() { status.queue_open = !status.queue_open;
audio.set_src(&src);
log!("Player set src to: {}", src);
} else {
error!("Unable to set src: Audio element not available");
}
}); });
} }
@ -160,10 +108,7 @@ fn PlayControls() -> impl IntoView {
if let Some(last_played_song) = last_played_song { if let Some(last_played_song) = last_played_song {
// Push the popped song to the front of the queue, and play it // Push the popped song to the front of the queue, and play it
let next_src = last_played_song.song_path.clone();
status.update(|status| status.queue.push_front(last_played_song)); status.update(|status| status.queue.push_front(last_played_song));
set_play_src(next_src);
set_playing(true);
} else { } else {
warn!("Unable to skip back: No previous song"); warn!("Unable to skip back: No previous song");
} }
@ -173,28 +118,18 @@ fn PlayControls() -> impl IntoView {
// Default to skipping to start of current song, and playing // Default to skipping to start of current song, and playing
log!("Skipping to start of current song"); log!("Skipping to start of current song");
skip_to(0.0); skip_to(0.0);
set_playing(true);
}; };
let skip_forward = move |_| { let skip_forward = move |_| {
if let Some(duration) = get_song_time_duration() { if let Some(duration) = get_song_time_duration() {
skip_to(duration.1); skip_to(duration.1);
set_playing(true); } else {
} else { error!("Unable to skip forward: Unable to get current duration");
error!("Unable to skip forward: Unable to get current duration"); }
}
}; };
let toggle_play = move |_| { let toggle_play = move |_| {
let playing = status.with_untracked(|status| { status.playing }); status.update(|status| status.playing = !status.playing);
set_playing(!playing);
};
// We use this to prevent the buttons from being focused when clicked
// If buttons were focused on clicks, then pressing space bar to play/pause would "click" the button
// and trigger unwanted behavior
let prevent_focus = move |e: MouseEvent| {
e.prevent_default();
}; };
// Change the icon based on whether the song is playing or not // Change the icon based on whether the song is playing or not
@ -209,27 +144,25 @@ fn PlayControls() -> impl IntoView {
}); });
view! { view! {
<div class="playcontrols" align="center"> <div class="flex place-content-center">
<button class="control" on:click=skip_back>
<Icon width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon={icondata::BsSkipStartFill} />
</button>
<button on:click=skip_back on:mousedown=prevent_focus> <button class="control" on:click=toggle_play>
<Icon class="controlbtn" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=icondata::BsSkipStartFill /> <Icon width=PLAY_BTN_SIZE height=PLAY_BTN_SIZE icon={icon} />
</button> </button>
<button on:click=toggle_play on:mousedown=prevent_focus>
<Icon class="controlbtn" width=PLAY_BTN_SIZE height=PLAY_BTN_SIZE icon={icon} />
</button>
<button on:click=skip_forward on:mousedown=prevent_focus>
<Icon class="controlbtn" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=icondata::BsSkipEndFill />
</button>
<button class="control" on:click=skip_forward>
<Icon width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon={icondata::BsSkipEndFill} />
</button>
</div> </div>
} }
} }
/// The elapsed time and total time of the current song /// The elapsed time and total time of the current song
#[component] #[component]
fn PlayDuration(elapsed_secs: MaybeSignal<i64>, total_secs: MaybeSignal<i64>) -> impl IntoView { fn PlayDuration(elapsed_secs: Signal<i64>, total_secs: Signal<i64>) -> impl IntoView {
// Create a derived signal that formats the elapsed and total seconds into a string // Create a derived signal that formats the elapsed and total seconds into a string
let play_duration = Signal::derive(move || { let play_duration = Signal::derive(move || {
let elapsed_mins = (elapsed_secs.get() - elapsed_secs.get() % 60) / 60; let elapsed_mins = (elapsed_secs.get() - elapsed_secs.get() % 60) / 60;
@ -238,12 +171,12 @@ fn PlayDuration(elapsed_secs: MaybeSignal<i64>, total_secs: MaybeSignal<i64>) ->
let total_secs = total_secs.get() % 60; let total_secs = total_secs.get() % 60;
// Format as "MM:SS / MM:SS" // Format as "MM:SS / MM:SS"
format!("{}:{:0>2} / {}:{:0>2}", elapsed_mins, elapsed_secs, total_mins, total_secs) format!("{elapsed_mins}:{elapsed_secs:0>2} / {total_mins}:{total_secs:0>2}")
}); });
view! { view! {
<div class="playduration" align="right"> <div class="text-controls p-1">
{play_duration} {play_duration}
</div> </div>
} }
} }
@ -254,34 +187,46 @@ fn MediaInfo() -> impl IntoView {
let status = GlobalState::play_status(); let status = GlobalState::play_status();
let name = Signal::derive(move || { let name = Signal::derive(move || {
status.with(|status| { status.with(|status| {
status.queue.front().map_or("No media playing".into(), |song| song.title.clone()) status
}) .queue
.front()
.map_or("No media playing".into(), |song| song.title.clone())
})
}); });
let artist = Signal::derive(move || { let artist = Signal::derive(move || {
status.with(|status| { status.with(|status| {
status.queue.front().map_or("".into(), |song| format!("{}", Artist::display_list(&song.artists))) status.queue.front().map_or("".into(), |song| {
}) Artist::display_list(&song.artists).to_string()
}); })
})
});
let album = Signal::derive(move || { let album = Signal::derive(move || {
status.with(|status| { status.with(|status| {
status.queue.front().map_or("".into(), |song| status.queue.front().map_or("".into(), |song| {
song.album.as_ref().map_or("".into(), |album| album.title.clone())) song.album
}) .as_ref()
}); .map_or("".into(), |album| album.title.clone())
})
})
});
let image = Signal::derive(move || { let image = Signal::derive(move || {
status.with(|status| { status.with(|status| {
status.queue.front().map_or("/images/placeholders/MusicPlaceholder.svg".into(), status
|song| song.image_path.clone()) .queue
}) .front()
}); .map_or("/images/placeholders/MusicPlaceholder.svg".into(), |song| {
song.image_path.clone()
})
})
});
view! { view! {
<img class="media-info-img" align="left" src={image}/> <img class="w-[60px] p-1" src={image}/>
<div class="media-info-text"> <div class="text-controls p-1">
{name} {name}
<br/> <br/>
{artist} - {album} {artist} - {album}
@ -295,27 +240,33 @@ fn LikeDislike() -> impl IntoView {
let status = GlobalState::play_status(); let status = GlobalState::play_status();
let like_icon = Signal::derive(move || { let like_icon = Signal::derive(move || {
status.with(|status| { status.with(|status| match status.queue.front() {
match status.queue.front() { Some(frontend::Song {
Some(SongData { like_dislike: Some((true, _)), .. }) => icondata::TbThumbUpFilled, like_dislike: Some((true, _)),
_ => icondata::TbThumbUp, ..
} }) => icondata::TbThumbUpFilled,
_ => icondata::TbThumbUp,
}) })
}); });
let dislike_icon = Signal::derive(move || { let dislike_icon = Signal::derive(move || {
status.with(|status| { status.with(|status| match status.queue.front() {
match status.queue.front() { Some(frontend::Song {
Some(SongData { like_dislike: Some((_, true)), .. }) => icondata::TbThumbDownFilled, like_dislike: Some((_, true)),
_ => icondata::TbThumbDown, ..
} }) => icondata::TbThumbDownFilled,
_ => icondata::TbThumbDown,
}) })
}); });
let toggle_like = move |_| { let toggle_like = move |_| {
status.update(|status| { status.update(|status| {
match status.queue.front_mut() { match status.queue.front_mut() {
Some(SongData { id, like_dislike: Some((liked, disliked)), .. }) => { Some(frontend::Song {
id,
like_dislike: Some((liked, disliked)),
..
}) => {
*liked = !*liked; *liked = !*liked;
if *liked { if *liked {
@ -329,8 +280,10 @@ fn LikeDislike() -> impl IntoView {
error!("Error liking song: {:?}", e); error!("Error liking song: {:?}", e);
} }
}); });
}, }
Some(SongData { id, like_dislike, .. }) => { Some(frontend::Song {
id, like_dislike, ..
}) => {
// This arm should only be reached if like_dislike is None // 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 // 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. // liked or disliked. Therefore, clicking the like button should like the song.
@ -343,10 +296,9 @@ fn LikeDislike() -> impl IntoView {
error!("Error liking song: {:?}", e); error!("Error liking song: {:?}", e);
} }
}); });
}, }
_ => { _ => {
log!("Unable to like song: No song in queue"); log!("Unable to like song: No song in queue");
return;
} }
} }
}); });
@ -355,7 +307,11 @@ fn LikeDislike() -> impl IntoView {
let toggle_dislike = move |_| { let toggle_dislike = move |_| {
status.update(|status| { status.update(|status| {
match status.queue.front_mut() { match status.queue.front_mut() {
Some(SongData { id, like_dislike: Some((liked, disliked)), .. }) => { Some(frontend::Song {
id,
like_dislike: Some((liked, disliked)),
..
}) => {
*disliked = !*disliked; *disliked = !*disliked;
if *disliked { if *disliked {
@ -369,36 +325,37 @@ fn LikeDislike() -> impl IntoView {
error!("Error disliking song: {:?}", e); error!("Error disliking song: {:?}", e);
} }
}); });
}, }
Some(SongData { id, like_dislike, .. }) => { Some(frontend::Song {
id, like_dislike, ..
}) => {
// This arm should only be reached if like_dislike is None // 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 // 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. // liked or disliked. Therefore, clicking the dislike button should dislike the song.
*like_dislike = Some((false, true)); *like_dislike = Some((false, true));
let id = *id; let id = *id;
spawn_local(async move { spawn_local(async move {
if let Err(e) = songs::set_dislike_song(id, true).await { if let Err(e) = songs::set_dislike_song(id, true).await {
error!("Error disliking song: {:?}", e); error!("Error disliking song: {:?}", e);
} }
}); });
}, }
_ => { _ => {
log!("Unable to dislike song: No song in queue"); log!("Unable to dislike song: No song in queue");
return;
} }
} }
}); });
}; };
view! { view! {
<div class="like-dislike"> <div class="flex">
<button on:click=toggle_dislike> <button class="control scale-x-[-1] p-1" on:click=toggle_dislike>
<Icon class="controlbtn hmirror" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=dislike_icon /> <Icon width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon={dislike_icon} />
</button> </button>
<button on:click=toggle_like> <button class="control p-1" on:click=toggle_like>
<Icon class="controlbtn" width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon=like_icon /> <Icon width=SKIP_BTN_SIZE height=SKIP_BTN_SIZE icon={like_icon} />
</button> </button>
</div> </div>
} }
@ -406,9 +363,9 @@ fn LikeDislike() -> impl IntoView {
/// The play progress bar, and click handler for skipping to a certain time in the song /// The play progress bar, and click handler for skipping to a certain time in the song
#[component] #[component]
fn ProgressBar(percentage: MaybeSignal<f64>) -> impl IntoView { fn ProgressBar(percentage: Signal<f64>) -> impl IntoView {
// Keep a reference to the progress bar div so we can get its width and calculate the time to skip to // Keep a reference to the progress bar div so we can get its width and calculate the time to skip to
let progress_bar_ref = create_node_ref::<Div>(); let progress_bar_ref = NodeRef::<Div>::new();
let progress_jump = move |e: MouseEvent| { let progress_jump = move |e: MouseEvent| {
let x_click_pos = e.offset_x() as f64; let x_click_pos = e.offset_x() as f64;
@ -418,11 +375,10 @@ fn ProgressBar(percentage: MaybeSignal<f64>) -> impl IntoView {
let width = progress_bar.offset_width() as f64; let width = progress_bar.offset_width() as f64;
let percentage = x_click_pos / width * 100.0; let percentage = x_click_pos / width * 100.0;
if let Some(duration) = get_song_time_duration() { if let Some(duration) = get_song_time_duration() {
let time = duration.1 * percentage / 100.0; let time = duration.1 * percentage / 100.0;
skip_to(time); skip_to(time);
set_playing(true); } else {
} else {
error!("Unable to skip to time: Unable to get current duration"); error!("Unable to skip to time: Unable to get current duration");
} }
} else { } else {
@ -434,11 +390,13 @@ fn ProgressBar(percentage: MaybeSignal<f64>) -> impl IntoView {
let bar_width_style = Signal::derive(move || format!("width: {}%;", percentage.get())); let bar_width_style = Signal::derive(move || format!("width: {}%;", percentage.get()));
view! { view! {
<div class="invisible-media-progress" _ref=progress_bar_ref on:click=progress_jump> // Larger click area <div class="w-full h-[14px] translate-y-[50%] pt-[7px] cursor-pointer" node_ref=progress_bar_ref on:click=progress_jump> // Larger click area
<div class="media-progress"> // "Unfilled" progress bar <div class="bg-controls-active h-[3px]"> // "Unfilled" progress bar
<div class="media-progress-solid" style=bar_width_style> // "Filled" progress bar <div class="from-play-grad-start to-play-grad-end bg-linear-90 h-[3px]"
</div> style=bar_width_style /> // "Filled" progress bar
</div> <div class="from-play-grad-start to-play-grad-end bg-linear-90 h-[3px]
translate-y-[-3px] blur-[3px]" style=bar_width_style /> // "Filled" progress bar blur
</div>
</div> </div>
} }
} }
@ -447,36 +405,37 @@ fn ProgressBar(percentage: MaybeSignal<f64>) -> impl IntoView {
fn QueueToggle() -> impl IntoView { fn QueueToggle() -> impl IntoView {
let update_queue = move |_| { let update_queue = move |_| {
toggle_queue(); toggle_queue();
log!("queue button pressed, queue status: {:?}", log!(
GlobalState::play_status().with_untracked(|status| status.queue_open)); "queue button pressed, queue status: {:?}",
}; GlobalState::play_status().with_untracked(|status| status.queue_open)
);
// We use this to prevent the buttons from being focused when clicked
// If buttons were focused on clicks, then pressing space bar to play/pause would "click" the button
// and trigger unwanted behavior
let prevent_focus = move |e: MouseEvent| {
e.prevent_default();
}; };
view! { view! {
<div class="queue-toggle"> <button id="queue-toggle-btn" class="control p-1" on:click=update_queue>
<button on:click=update_queue on:mousedown=prevent_focus> <Icon width=QUEUE_BTN_SIZE height=QUEUE_BTN_SIZE icon={icondata::RiPlayListMediaFill} />
<Icon class="controlbtn" width=QUEUE_BTN_SIZE height=QUEUE_BTN_SIZE icon=icondata::RiPlayListMediaFill />
</button> </button>
</div>
} }
} }
/// Renders the title of the page based on the currently playing song /// Renders the title of the page based on the currently playing song
#[component] #[component]
pub fn CustomTitle() -> impl IntoView { pub fn CustomTitle() -> impl IntoView {
let title = create_memo(move |_| { let title = Memo::new(move |_| {
GlobalState::play_status().with(|play_status| { GlobalState::play_status().with(|play_status| {
play_status.queue.front().map_or("LibreTunes".to_string(), |song_data| { play_status
format!("{} - {} | {}",song_data.title.clone(),Artist::display_list(&song_data.artists), "LibreTunes") .queue
.front()
.map_or("LibreTunes".to_string(), |song_data| {
format!(
"{} - {} | {}",
song_data.title.clone(),
Artist::display_list(&song_data.artists),
"LibreTunes"
)
}) })
}) })
}); });
view! { view! {
<Title text=title /> <Title text=title />
} }
@ -485,110 +444,151 @@ pub fn CustomTitle() -> impl IntoView {
/// The main play bar component, containing the progress bar, media info, play controls, and play duration /// The main play bar component, containing the progress bar, media info, play controls, and play duration
#[component] #[component]
pub fn PlayBar() -> impl IntoView { pub fn PlayBar() -> impl IntoView {
use web_sys::wasm_bindgen::JsCast;
let status = GlobalState::play_status(); let status = GlobalState::play_status();
// Listen for key down events -- arrow keys don't seem to trigger key press events // Listen for key down events -- arrow keys don't seem to trigger key press events
let _arrow_key_handle = window_event_listener(ev::keydown, move |e: ev::KeyboardEvent| { let _arrow_key_handle =
if e.key() == "ArrowRight" { window_event_listener(leptos::ev::keydown, move |e: leptos::ev::KeyboardEvent| {
e.prevent_default(); // Skip if the event target is an input element
log!("Right arrow key pressed, skipping forward by {} seconds", ARROW_KEY_SKIP_TIME); if let Some(true) = e
.target()
if let Some(duration) = get_song_time_duration() { .map(|t| t.has_type::<web_sys::HtmlInputElement>())
let mut time = duration.0 + ARROW_KEY_SKIP_TIME; {
time = time.clamp(0.0, duration.1); return;
skip_to(time);
set_playing(true);
} else {
error!("Unable to skip forward: Unable to get current duration");
} }
} else if e.key() == "ArrowLeft" { if e.key() == "ArrowRight" {
e.prevent_default(); e.prevent_default();
log!("Left arrow key pressed, skipping backward by {} seconds", ARROW_KEY_SKIP_TIME); log!(
"Right arrow key pressed, skipping forward by {} seconds",
ARROW_KEY_SKIP_TIME
);
if let Some(duration) = get_song_time_duration() { if let Some(duration) = get_song_time_duration() {
let mut time = duration.0 - ARROW_KEY_SKIP_TIME; let mut time = duration.0 + ARROW_KEY_SKIP_TIME;
time = time.clamp(0.0, duration.1); time = time.clamp(0.0, duration.1);
skip_to(time); skip_to(time);
set_playing(true); } else {
} else { error!("Unable to skip forward: Unable to get current duration");
error!("Unable to skip backward: Unable to get current duration"); }
} else if e.key() == "ArrowLeft" {
e.prevent_default();
log!(
"Left arrow key pressed, skipping backward by {} seconds",
ARROW_KEY_SKIP_TIME
);
if let Some(duration) = get_song_time_duration() {
let mut time = duration.0 - ARROW_KEY_SKIP_TIME;
time = time.clamp(0.0, duration.1);
skip_to(time);
} else {
error!("Unable to skip backward: Unable to get current duration");
}
} }
} });
});
// Listen for space bar presses to play/pause // Listen for space bar presses to play/pause
let _space_bar_handle = window_event_listener(ev::keypress, move |e: ev::KeyboardEvent| { let _space_bar_handle =
if e.key() == " " { window_event_listener(leptos::ev::keypress, move |e: leptos::ev::KeyboardEvent| {
e.prevent_default(); // Skip if the event target is an input element
log!("Space bar pressed, toggling play/pause"); if let Some(true) = e
.target()
.map(|t| t.has_type::<web_sys::HtmlInputElement>())
{
return;
}
let playing = status.with_untracked(|status| status.playing); if e.key() == " " {
set_playing(!playing); e.prevent_default();
} log!("Space bar pressed, toggling play/pause");
});
status.update(|status| status.playing = !status.playing);
}
});
// Keep a reference to the audio element so we can set its source and play/pause it // Keep a reference to the audio element so we can set its source and play/pause it
let audio_ref = create_node_ref::<Audio>(); let audio_ref = NodeRef::<Audio>::new();
status.update(|status| status.audio_player = Some(audio_ref)); status.update(|status| status.audio_player = Some(audio_ref));
// Create signals for song time and progress Effect::new(move |_| {
let (elapsed_secs, set_elapsed_secs) = create_signal(0); status.with(|status| {
let (total_secs, set_total_secs) = create_signal(0); if let Some(audio) = status.get_audio() {
let (percentage, set_percentage) = create_signal(0.0); if status.playing {
if let Err(e) = audio.play() {
audio_ref.on_load(move |audio| { log!("Unable to play audio: {:?}", e);
log!("Audio element loaded"); }
} else if let Err(e) = audio.pause() {
status.with_untracked(|status| { log!("Unable to pause audio: {:?}", e);
// Start playing the first song in the queue, if available
if let Some(song) = status.queue.front() {
log!("Starting playing with song: {}", song.title);
// Don't use the set_play_src / set_playing helper function
// here because we already have access to the audio element
audio.set_src(&song.song_path);
if let Err(e) = audio.play() {
error!("Error playing audio on load: {:?}", e);
} else {
log!("Audio playing on load");
} }
} else { } else {
log!("Queue is empty, no first song to play"); error!("Unable to play/pause audio: Audio element not available");
} }
}); });
}); });
let current_song_id = create_memo(move |_| { // Create signals for song time and progress
status.with(|status| { let (elapsed_secs, set_elapsed_secs) = signal(0);
status.queue.front().map(|song| song.id) let (total_secs, set_total_secs) = signal(0);
}) let (percentage, set_percentage) = signal(0.0);
let current_song_id =
Memo::new(move |_| status.with(|status| status.queue.front().map(|song| song.id)));
let current_song_src = Memo::new(move |_| {
status.with(|status| status.queue.front().map(|song| song.song_path.clone()))
});
Effect::new(move |_| {
current_song_src.with(|src| {
GlobalState::play_status().with_untracked(|status| {
if let Some(audio) = status.get_audio() {
if let Some(src) = src {
audio.set_src(src);
if let Err(e) = audio.play() {
error!("Error playing audio: {:?}", e);
} else {
log!("Audio playing");
}
} else {
audio.set_src("");
}
} else {
error!("Unable to set audio source: Audio element not available");
}
});
});
}); });
// Track the last song that was added to the history to prevent duplicates // Track the last song that was added to the history to prevent duplicates
let last_history_song_id = create_rw_signal(None); let last_history_song_id = RwSignal::new(None);
let Pausable { let Pausable {
is_active: hist_timeout_pending, is_active: hist_timeout_pending,
resume: resume_hist_timeout, resume: resume_hist_timeout,
pause: pause_hist_timeout, pause: pause_hist_timeout,
.. ..
} = use_interval_fn(move || { } = use_interval_fn(
if last_history_song_id.get_untracked() == current_song_id.get_untracked() { move || {
return; if last_history_song_id.get_untracked() == current_song_id.get_untracked() {
} return;
}
if let Some(current_song_id) = current_song_id.get_untracked() { if let Some(current_song_id) = current_song_id.get_untracked() {
last_history_song_id.set(Some(current_song_id)); last_history_song_id.set(Some(current_song_id));
spawn_local(async move { spawn_local(async move {
if let Err(e) = crate::api::history::add_history(current_song_id).await { if let Err(e) = crate::api::history::add_history(current_song_id).await {
error!("Error adding song {} to history: {}", current_song_id, e); error!("Error adding song {} to history: {}", current_song_id, e);
} }
}); });
} }
}, HISTORY_LISTEN_THRESHOLD * 1000); },
HISTORY_LISTEN_THRESHOLD * 1000,
);
// Initially pause the timeout, since the audio starts off paused // Initially pause the timeout, since the audio starts off paused
pause_hist_timeout(); pause_hist_timeout();
@ -611,7 +611,10 @@ pub fn PlayBar() -> impl IntoView {
set_total_secs(audio.duration() as i64); set_total_secs(audio.duration() as i64);
if elapsed_secs.get_untracked() > 0 { if elapsed_secs.get_untracked() > 0 {
set_percentage(elapsed_secs.get_untracked() as f64 / total_secs.get_untracked() as f64 * 100.0); set_percentage(
elapsed_secs.get_untracked() as f64 / total_secs.get_untracked() as f64
* 100.0,
);
} else { } else {
set_percentage(0.0); set_percentage(0.0);
} }
@ -641,40 +644,26 @@ pub fn PlayBar() -> impl IntoView {
log!("Queue empty, no previous song to add to history"); log!("Queue empty, no previous song to add to history");
} }
}); });
// Get the next song to play, if available
let next_src = status.with_untracked(|status| {
status.queue.front().map(|song| song.song_path.clone())
});
if let Some(audio) = audio_ref.get() {
if let Some(next_src) = next_src {
log!("Playing next song: {}", next_src);
audio.set_src(&next_src);
if let Err(e) = audio.play() {
error!("Error playing audio after song change: {:?}", e);
} else {
log!("Audio playing after song change");
}
}
} else {
error!("Unable to play next song: Audio element not available");
}
}; };
view! { view! {
<audio _ref=audio_ref on:play=on_play on:pause=on_pause <audio node_ref=audio_ref on:play=on_play on:pause=on_pause
on:timeupdate=on_time_update on:ended=on_end type="audio/mpeg" /> on:timeupdate=on_time_update on:ended=on_end />
<div class="playbar"> <div class="fixed bottom-0 w-full">
<ProgressBar percentage=percentage.into() /> <ProgressBar percentage=percentage.into() />
<div class="playbar-left-group"> <div class="flex items-center w-full bg-bg-light">
<MediaInfo /> <div class="flex-1 flex">
<LikeDislike /> <MediaInfo />
</div> <LikeDislike />
<PlayControls /> </div>
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() /> <div class="flex-1">
<QueueToggle /> <PlayControls />
</div>
<div class="flex-1 flex flex-col items-end">
<PlayDuration elapsed_secs=elapsed_secs.into() total_secs=total_secs.into() />
<QueueToggle />
</div>
</div>
</div> </div>
} }
} }

142
src/components/queue.rs Normal file
View File

@ -0,0 +1,142 @@
use crate::components::song::Song;
use crate::models::backend::Artist;
use crate::util::state::GlobalState;
use leptos::ev::DragEvent;
use leptos::ev::MouseEvent;
use leptos::html::Div;
use leptos::leptos_dom::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_use::on_click_outside_with_options;
use leptos_use::OnClickOutsideOptions;
const RM_BTN_SIZE: &str = "2.5rem";
fn remove_song_fn(index: usize) {
if index == 0 {
log!("Error: Trying to remove currently playing song (index 0) from queue");
} else {
log!("Remove Song from Queue: Song is not currently playing, deleting song from queue and not adding to history");
GlobalState::play_status().update(|status| {
status.queue.remove(index);
});
}
}
#[component]
pub fn Queue() -> impl IntoView {
let status = GlobalState::play_status();
let remove_song = move |index: usize| {
remove_song_fn(index);
log!("Removed song {}", index + 1);
};
let prevent_focus = move |e: MouseEvent| {
e.prevent_default();
};
let index_being_dragged = RwSignal::new(-1);
let index_being_hovered = RwSignal::new(-1);
let on_drag_start = move |_e: DragEvent, index: usize| {
// set the index of the item being dragged
index_being_dragged.set(index as i32);
};
let on_drop = move |e: DragEvent| {
e.prevent_default();
// if the index of the item being dragged is not the same as the index of the item being hovered over
if index_being_dragged.get() != index_being_hovered.get()
&& index_being_dragged.get() > 0
&& index_being_hovered.get() > 0
{
// get the index of the item being dragged
let dragged_index = index_being_dragged.get_untracked() as usize;
// get the index of the item being hovered over
let hovered_index = index_being_hovered.get_untracked() as usize;
// update the queue
status.update(|status| {
// remove the dragged item from the list
let dragged_item = status.queue.remove(dragged_index);
// insert the dragged item at the index of the item being hovered over
status.queue.insert(hovered_index, dragged_item.unwrap());
});
// reset the index of the item being dragged
index_being_dragged.set(-1);
// reset the index of the item being hovered over
index_being_hovered.set(-1);
log!(
"drag end. Moved item from index {} to index {}",
dragged_index,
hovered_index
);
} else {
// reset the index of the item being dragged
index_being_dragged.set(-1);
// reset the index of the item being hovered over
index_being_hovered.set(-1);
}
};
let on_drag_enter = move |_e: DragEvent, index: usize| {
// set the index of the item being hovered over
index_being_hovered.set(index as i32);
};
let on_drag_over = move |e: DragEvent| {
e.prevent_default();
};
let queue = NodeRef::<Div>::new();
let _ = on_click_outside_with_options(
queue,
move |_| {
status.update(|status| {
status.queue_open = false;
});
},
OnClickOutsideOptions::default().ignore(["#queue-toggle-btn"]),
);
view! {
<Show
when=move || status.with(|status| status.queue_open)
fallback=|| view!{""}>
<div class="queue" node_ref=queue>
<div class="queue-header">
<h2>Queue</h2>
</div>
<ul>
{
move || status.with(|status| status.queue.iter()
.enumerate()
.map(|(index, song)| view! {
<div class="queue-item"
draggable="true"
on:dragstart=move |e: DragEvent| on_drag_start(e, index)
on:drop=on_drop
on:dragenter=move |e: DragEvent| on_drag_enter(e, index)
on:dragover=on_drag_over
>
<Song song_image_path=song.image_path.clone() song_title=song.title.clone() song_artist=Artist::display_list(&song.artists) />
<Show
when=move || index != 0
fallback=|| view!{
<p>Playing</p>
}>
<button on:click=move |_| remove_song(index) on:mousedown=prevent_focus>
<Icon width=RM_BTN_SIZE height=RM_BTN_SIZE icon={icondata::CgTrash} {..} class="remove-song" />
</button>
</Show>
</div>
})
.collect::<Vec<_>>())
}
</ul>
</div>
</Show>
}
}

View File

@ -1,10 +0,0 @@
use leptos::*;
#[component]
pub fn Search() -> impl IntoView {
view! {
<div class="search-container home-component">
<h1>Searching...</h1>
</div>
}
}

View File

@ -1,54 +1,168 @@
use leptos::leptos_dom::*; use crate::components::error::Error;
use leptos::*; use crate::components::loading::*;
use crate::components::menu::*;
use crate::util::state::GlobalState;
use leptos::either::*;
use leptos::html::Div;
use leptos::prelude::*;
use leptos_icons::*; use leptos_icons::*;
use crate::components::upload::*; use leptos_router::components::{Form, A};
use leptos_router::hooks::use_location;
use leptos_use::{on_click_outside_with_options, OnClickOutsideOptions};
use std::sync::Arc;
use web_sys::Response;
#[component] #[component]
pub fn Sidebar(upload_open: RwSignal<bool>) -> impl IntoView { pub fn Sidebar(
use leptos_router::use_location; upload_open: RwSignal<bool>,
add_artist_open: RwSignal<bool>,
add_album_open: RwSignal<bool>,
) -> impl IntoView {
view! {
<div class="flex flex-col w-[250px] min-w-[250px]">
<Menu upload_open add_artist_open add_album_open />
<Playlists />
</div>
}
}
#[component]
fn AddPlaylistDialog(open: RwSignal<bool>, node_ref: NodeRef<Div>) -> impl IntoView {
let playlist_name = RwSignal::new("".to_string());
let loading = RwSignal::new(false);
let error_msg = RwSignal::new(None);
let handle_response = Arc::new(move |response: &Response| {
loading.set(false);
if response.ok() {
open.set(false);
GlobalState::playlists().refetch();
} else {
error_msg.set(Some("Failed to create playlist".to_string()));
}
});
view! {
<dialog class="fixed top-0 left-0 w-full h-full bg-black/50 flex items-center justify-center" class:open=open>
<div node_ref=node_ref class="bg-neutral-800 rounded-lg p-4 w-1/3 text-white">
<div class="flex items-center pb-3">
<h1 class="text-2xl">"Create Playlist"</h1>
<button id="add-playlist-dialog-btn" class="control ml-auto" on:click=move |_| open.set(false)>
<Icon icon={icondata::IoClose} {..} class="w-7 h-7" />
</button>
</div>
<Form action="/api/playlists/create" on_response=handle_response.clone()
method="POST" enctype="multipart/form-data".to_string()>
<div class="grid grid-cols-[auto_1fr] gap-4">
<label for="new-playlist-name">"Playlist Name"</label>
<input id="new-playlist-name" name="name"
class="bg-neutral-800 text-neutral-200 border border-neutral-600 rounded-lg p-2 outline-none"
type="text" placeholder="My Playlist" bind:value=playlist_name required autocomplete="off" />
<label for="new-playlist-img">"Cover Image"</label>
<input id="new-playlist-img" name="picture" type="file" accept="image/*" />
</div>
{move || {
error_msg.get().map(|error| {
view! {
<Error<String>
message=error.clone()
/>
}
})
}}
<div class="flex justify-end">
<button type="submit" class="control-solid" on:click=move |_| {
error_msg.set(None);
loading.set(true);
}>
"Create"
</button>
</div>
</Form>
</div>
</dialog>
}
}
#[component]
pub fn Playlists() -> impl IntoView {
let location = use_location(); let location = use_location();
let on_dashboard = Signal::derive( let liked_songs_active = Signal::derive(move || location.pathname.get().ends_with("/liked"));
move || location.pathname.get().starts_with("/dashboard") || location.pathname.get() == "/",
);
let on_search = Signal::derive( let add_playlist_open = RwSignal::new(false);
move || location.pathname.get().starts_with("/search"),
let create_playlist = move |_| {
leptos::logging::log!("Creating playlist");
add_playlist_open.set(true);
};
let add_playlist_dialog = NodeRef::<Div>::new();
let _dialog_close_handler = on_click_outside_with_options(
add_playlist_dialog,
move |_| add_playlist_open.set(false),
OnClickOutsideOptions::default().ignore(["#add-playlist-dialog-btn"]),
); );
view! { view! {
<div class="sidebar-container"> <div class="home-card">
<div class="sidebar-top-container"> <div class="flex items-center mb-2">
<h2 class="header">LibreTunes</h2> <h1 class="p-2 text-xl">"Playlists"</h1>
<UploadBtn dialog_open=upload_open /> <button class="control-solid ml-auto" on:click=create_playlist>
<a class="buttons" href="/dashboard" style={move || if on_dashboard() {"color: #e1e3e1"} else {""}} > <Icon icon={icondata::AiPlusOutlined} {..} class="w-4 h-4" />
<Icon icon=icondata::OcHomeFillLg />
<h1>Dashboard</h1>
</a>
<a class="buttons" href="/search" style={move || if on_search() {"color: #e1e3e1"} else {""}}>
<Icon icon=icondata::BiSearchRegular />
<h1>Search</h1>
</a>
</div>
<Bottom />
</div>
}
}
#[component]
pub fn Bottom() -> impl IntoView {
view! {
<div class="sidebar-bottom-container">
<div class="heading">
<h1 class="header">Playlists</h1>
<button class="add-playlist">
<div class="add-sign">
<Icon icon=icondata::IoAddSharp />
</div>
New Playlist
</button> </button>
</div> </div>
<div>
<Transition
fallback=move || view! { <Loading /> }
>
<A href={"/liked".to_string()} {..}
style={move || if liked_songs_active() {"background-color: var(--color-neutral-700);"} else {""}}
class="flex items-center hover:bg-neutral-700 rounded-md my-1"
>
<img class="w-15 h-15 rounded-xl p-2"
src="/assets/images/placeholders/MusicPlaceholder.svg" />
<h2 class="pr-3 my-2">"Liked Songs"</h2>
</A>
{move || GlobalState::playlists().get().map(|playlists| {
match playlists {
Ok(playlists) => Either::Left(view! {
{playlists.into_iter().map(|playlist| {
let active = Signal::derive(move || {
location.pathname.get().ends_with(&format!("/playlist/{}", playlist.id))
});
view! {
<A href={format!("/playlist/{}", playlist.id)} {..}
style={move || if active() {"background-color: var(--color-neutral-700);"} else {""}}
class="flex items-center hover:bg-neutral-700 rounded-md my-1" >
<img class="w-15 h-15 rounded-xl p-2 object-cover"
src={format!("/assets/images/playlist/{}.webp", playlist.id)}
onerror={crate::util::img_fallback::MUSIC_IMG_FALLBACK} />
<h2 class="pr-3 my-2">{playlist.name}</h2>
</A>
}
}).collect::<Vec<_>>()}
}),
Err(error) => Either::Right(error.to_component()),
}
})}
</Transition>
</div>
</div> </div>
<Show
when=add_playlist_open
fallback=move || view! {}
>
<AddPlaylistDialog node_ref=add_playlist_dialog open=add_playlist_open />
</Show>
} }
} }

14
src/components/song.rs Normal file
View File

@ -0,0 +1,14 @@
use leptos::prelude::*;
#[component]
pub fn Song(song_image_path: String, song_title: String, song_artist: String) -> impl IntoView {
view! {
<div class="queue-song">
<img src={song_image_path} alt={song_title.clone()} />
<div class="queue-song-info">
<h3>{song_title}</h3>
<p>{song_artist}</p>
</div>
</div>
}
}

View File

@ -1,230 +1,296 @@
use leptos::*; use std::rc::Rc;
use leptos::either::*;
use leptos::logging::*; use leptos::logging::*;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::*; use leptos_icons::*;
use crate::api::songs::*; use crate::api::songs::*;
use crate::songdata::SongData; use crate::models::backend::{Album, Artist};
use crate::models::{Album, Artist}; use crate::models::frontend;
use crate::util::state::GlobalState;
const LIKE_DISLIKE_BTN_SIZE: &str = "2em"; const LIKE_DISLIKE_BTN_SIZE: &str = "2em";
#[component] #[component]
pub fn SongList(songs: MaybeSignal<Vec<SongData>>) -> impl IntoView { pub fn SongList(songs: Vec<frontend::Song>) -> impl IntoView {
view! { __SongListInner(
<table class="song-list"> songs.into_iter().map(|song| (song, ())).collect::<Vec<_>>(),
{ false,
songs.with(|songs| { )
let mut first_song = true;
songs.iter().map(|song| {
let playing = first_song.into();
first_song = false;
let extra = Option::<()>::None;
view! {
<SongListItem song={song.clone()} song_playing=playing extra />
}
}).collect::<Vec<_>>()
})
}
</table>
}
} }
#[component] #[component]
pub fn SongListExtra<T>(songs: MaybeSignal<Vec<(SongData, T)>>) -> impl IntoView where pub fn SongListExtra<T>(songs: Vec<(frontend::Song, T)>) -> impl IntoView
T: Clone + IntoView + 'static where
T: Clone + IntoView + 'static,
{ {
view! { __SongListInner(songs, true)
<table class="song-list"> }
{
songs.with(|songs| {
let mut first_song = true;
songs.iter().map(|(song, extra)| { // TODO these arguments shouldn't need a leading underscore,
let playing = first_song.into(); // but for some reason the compiler thinks they are unused
first_song = false; #[component]
fn SongListInner<T>(_songs: Vec<(frontend::Song, T)>, _show_extra: bool) -> impl IntoView
where
T: Clone + IntoView + 'static,
{
let songs = Rc::new(_songs);
let songs_2 = songs.clone();
view! { // Signal that acts as a callback for a song list item to queue songs after it in the list
<SongListItem song={song.clone()} song_playing=playing extra=Some(extra.clone()) /> let (handle_queue_remaining, do_queue_remaining) = signal(None);
} Effect::new(move |_| {
}).collect::<Vec<_>>() let clicked_index = handle_queue_remaining.get();
})
} if let Some(index) = clicked_index {
</table> GlobalState::play_status().update(|status| {
} let song: &(frontend::Song, T) =
songs.get(index).expect("Invalid song list item index");
if status.queue.front().map(|song| song.id) == Some(song.0.id) {
// If the clicked song is already at the front of the queue, just play it
status.playing = true;
} else {
// Otherwise, add the currently playing song to the history,
// clear the queue, and queue the clicked song and other after it
if let Some(last_playing) = status.queue.pop_front() {
status.history.push_back(last_playing);
}
status.queue.clear();
status
.queue
.extend(songs.iter().skip(index).map(|(song, _)| song.clone()));
status.playing = true;
}
});
}
});
view! {
<table class="w-full">
<tbody>
{
songs_2.iter().enumerate().map(|(list_index, (song, extra))| {
let song_id = song.id;
let playing = RwSignal::new(false);
Effect::new(move |_| {
GlobalState::play_status().with(|status| {
playing.set(status.queue.front().map(|song| song.id) == Some(song_id) && status.playing);
});
});
view! {
<SongListItem song={song.clone()} song_playing=playing.into()
extra={if _show_extra { Some(extra.clone()) } else { None }} list_index do_queue_remaining/>
}
}).collect::<Vec<_>>()
}
</tbody>
</table>
}
} }
#[component] #[component]
pub fn SongListItem<T>(song: SongData, song_playing: MaybeSignal<bool>, extra: Option<T>) -> impl IntoView where pub fn SongListItem<T>(
T: IntoView + 'static song: frontend::Song,
song_playing: Signal<bool>,
extra: Option<T>,
list_index: usize,
do_queue_remaining: WriteSignal<Option<usize>>,
) -> impl IntoView
where
T: IntoView + 'static,
{ {
let liked = create_rw_signal(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false)); let liked = RwSignal::new(song.like_dislike.map(|(liked, _)| liked).unwrap_or(false));
let disliked = create_rw_signal(song.like_dislike.map(|(_, disliked)| disliked).unwrap_or(false)); let disliked = RwSignal::new(
song.like_dislike
view! { .map(|(_, disliked)| disliked)
<tr class="song-list-item"> .unwrap_or(false),
<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> view! {
<td class="song-artists"><SongArtists artists=song.artists /></td> <tr class="group border-b border-t border-neutral-600 last-of-type:border-b-0
<td class="song-list-spacer"></td> first-of-type:border-t-0 hover:bg-neutral-700 [&>*]:px-2">
<td class="song-album"><SongAlbum album=song.album /></td> <td class="relative w-13 h-13"><SongImage image_path=song.image_path song_playing
<td class="song-list-spacer-big"></td> list_index do_queue_remaining /></td>
<td class="song-like-dislike"><SongLikeDislike song_id=song.id liked disliked/></td> <td><p>{song.title}</p></td>
<td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td> <td></td>
{extra.map(|extra| view! { <td><SongArtists artists=song.artists /></td>
<td class="song-list-spacer"></td> <td></td>
<td>{extra}</td> <td><SongAlbum album=song.album /></td>
})} <td></td>
</tr> <td><SongLikeDislike song_id=song.id liked disliked/></td>
} <td>{format!("{}:{:02}", song.duration / 60, song.duration % 60)}</td>
{extra.map(|extra| view! {
<td></td>
<td>{extra}</td>
})}
</tr>
}
} }
/// Display the song's image, with an overlay if the song is playing /// 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 /// When the song list item is hovered, the overlay will show the play button
#[component] #[component]
fn SongImage(image_path: String, song_playing: MaybeSignal<bool>) -> impl IntoView { pub fn SongImage(
view! { image_path: String,
<img class="song-image" src={image_path}/> song_playing: Signal<bool>,
{if song_playing.get() { list_index: usize,
view! { <Icon class="song-image-overlay song-playing-overlay" icon=icondata::BsPauseFill /> }.into_view() do_queue_remaining: WriteSignal<Option<usize>>,
} else { ) -> impl IntoView {
view! { <Icon class="song-image-overlay hide-until-hover" icon=icondata::BsPlayFill /> }.into_view() let play_song = move |_| {
}} do_queue_remaining.set(Some(list_index));
} };
let pause_song = move |_| {
GlobalState::play_status().update(|status| {
status.playing = false;
});
};
view! {
<img class="group-hover:brightness-45" src={image_path}/>
{move || if song_playing.get() {
Either::Left(view! { <Icon icon={icondata::BsPauseFill} on:click={pause_song}
{..} class="w-6 h-6 absolute top-1/2 left-1/2 translate-[-50%]" /> })
} else {
Either::Right(view! { <Icon icon={icondata::BsPlayFill} on:click={play_song}
{..} class="w-6 h-6 opacity-0 group-hover:opacity-100 absolute top-1/2 left-1/2 translate-[-50%]" /> })
}}
}
} }
/// Displays a song's artists, with links to their artist pages /// Displays a song's artists, with links to their artist pages
#[component] #[component]
fn SongArtists(artists: Vec<Artist>) -> impl IntoView { pub fn SongArtists(artists: Vec<Artist>) -> impl IntoView {
let num_artists = artists.len() as isize; let num_artists = artists.len() as isize;
artists.iter().enumerate().map(|(i, artist)| { artists
let i = i as isize; .iter()
.enumerate()
view! { .map(|(i, artist)| {
{ let i = i as isize;
if let Some(id) = artist.id {
view! { <a href={format!("/artist/{}", id)}>{artist.name.clone()}</a> }.into_view() view! {
} else { <a class="hover:underline active:text-controls-active"
view! { <span>{artist.name.clone()}</span> }.into_view() href={format!("/artist/{}", artist.id)}>{artist.name.clone()}</a>
} {
} use std::cmp::Ordering;
{if i < num_artists - 2 { ", " } else if i == num_artists - 2 { " & " } else { "" }}
} match i.cmp(&(num_artists - 2)) {
}).collect::<Vec<_>>() Ordering::Less => ", ",
Ordering::Equal => " & ",
Ordering::Greater => "",
}
}
}
})
.collect::<Vec<_>>()
} }
/// Display a song's album, with a link to the album page /// Display a song's album, with a link to the album page
#[component] #[component]
fn SongAlbum(album: Option<Album>) -> impl IntoView { pub fn SongAlbum(album: Option<Album>) -> impl IntoView {
album.as_ref().map(|album| { album.as_ref().map(|album| {
view! { view! {
<span> <span>
{ <a class="hover:underline active:text-controls-active"
if let Some(id) = album.id { href={format!("/album/{}", album.id)}>{album.title.clone()}</a>
view! { <a href={format!("/album/{}", id)}>{album.title.clone()}</a> }.into_view() </span>
} 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 /// Display like and dislike buttons for a song, and indicate if the song is liked or disliked
#[component] #[component]
fn SongLikeDislike( pub fn SongLikeDislike(
#[prop(into)] #[prop(into)] song_id: Signal<i32>,
song_id: MaybeSignal<i32>, liked: RwSignal<bool>,
liked: RwSignal<bool>, disliked: RwSignal<bool>,
disliked: RwSignal<bool>) -> impl IntoView ) -> impl IntoView {
{ let like_icon = Signal::derive(move || {
let like_icon = Signal::derive(move || { if liked.get() {
if liked.get() { icondata::TbThumbUpFilled
icondata::TbThumbUpFilled } else {
} else { icondata::TbThumbUp
icondata::TbThumbUp }
} });
});
let dislike_icon = Signal::derive(move || { let dislike_icon = Signal::derive(move || {
if disliked.get() { if disliked.get() {
icondata::TbThumbDownFilled icondata::TbThumbDownFilled
} else { } else {
icondata::TbThumbDown icondata::TbThumbDown
} }
}); });
let like_class = MaybeProp::derive(move || { let like_class = Signal::derive(move || {
if liked.get() { if liked.get() {
Some(TextProp::from("controlbtn")) ""
} else { } else {
Some(TextProp::from("controlbtn hide-until-hover")) "opacity-0 group-hover:opacity-100"
} }
}); });
let dislike_class = MaybeProp::derive(move || { let dislike_class = Signal::derive(move || {
if disliked.get() { if disliked.get() {
Some(TextProp::from("controlbtn hmirror")) ""
} else { } else {
Some(TextProp::from("controlbtn hmirror hide-until-hover")) "opacity-0 group-hover:opacity-100"
} }
}); });
// If an error occurs, check the like/dislike status again to ensure consistency // If an error occurs, check the like/dislike status again to ensure consistency
let check_like_dislike = move || { let check_like_dislike = move || {
spawn_local(async move { spawn_local(async move {
match get_like_dislike_song(song_id.get_untracked()).await { if let Ok((like, dislike)) = get_like_dislike_song(song_id.get_untracked()).await {
Ok((like, dislike)) => { liked.set(like);
liked.set(like); disliked.set(dislike);
disliked.set(dislike); }
}, });
Err(_) => {} };
}
});
};
let toggle_like = move |_| { let toggle_like = move |_| {
let new_liked = !liked.get_untracked(); let new_liked = !liked.get_untracked();
liked.set(new_liked); liked.set(new_liked);
disliked.set(disliked.get_untracked() && !liked.get_untracked()); disliked.set(disliked.get_untracked() && !liked.get_untracked());
spawn_local(async move {
match set_like_song(song_id.get_untracked(), new_liked).await {
Ok(_) => {},
Err(e) => {
error!("Error setting like: {}", e);
check_like_dislike();
}
}
});
};
let toggle_dislike = move |_| { spawn_local(async move {
disliked.set(!disliked.get_untracked()); match set_like_song(song_id.get_untracked(), new_liked).await {
liked.set(liked.get_untracked() && !disliked.get_untracked()); Ok(_) => {}
Err(e) => {
error!("Error setting like: {}", e);
check_like_dislike();
}
}
});
};
spawn_local(async move { let toggle_dislike = move |_| {
match set_dislike_song(song_id.get_untracked(), disliked.get_untracked()).await { disliked.set(!disliked.get_untracked());
Ok(_) => {}, liked.set(liked.get_untracked() && !disliked.get_untracked());
Err(e) => {
error!("Error setting dislike: {}", e);
check_like_dislike();
}
}
});
};
view! { spawn_local(async move {
<button on:click=toggle_dislike> match set_dislike_song(song_id.get_untracked(), disliked.get_untracked()).await {
<Icon class=dislike_class width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon=dislike_icon /> Ok(_) => {}
</button> Err(e) => {
<button on:click=toggle_like> error!("Error setting dislike: {}", e);
<Icon class=like_class width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon=like_icon /> check_like_dislike();
</button> }
} }
});
};
view! {
<button class="control scale-x-[-1]" on:click=toggle_dislike>
<Icon width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon={dislike_icon} {..} class=dislike_class />
</button>
<button class="control" on:click=toggle_like>
<Icon width=LIKE_DISLIKE_BTN_SIZE height=LIKE_DISLIKE_BTN_SIZE icon={like_icon} {..} class=like_class />
</button>
}
} }

View File

@ -1,245 +1,262 @@
use std::rc::Rc; use crate::api::search::search_albums;
use crate::api::search::search_artists;
use crate::models::backend::{Album, Artist};
use leptos::leptos_dom::*; use leptos::leptos_dom::*;
use leptos::*; use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::*; use leptos_icons::*;
use leptos_router::Form; use leptos_router::components::Form;
use std::sync::Arc;
use web_sys::Response; use web_sys::Response;
use crate::search::search_artists;
use crate::search::search_albums;
use crate::models::Artist;
use crate::models::Album;
#[component] #[component]
pub fn UploadBtn(dialog_open: RwSignal<bool>) -> impl IntoView { pub fn UploadBtn(dialog_open: RwSignal<bool>) -> impl IntoView {
let open_dialog = move |_| { let open_dialog = move |_| {
dialog_open.set(true); dialog_open.set(true);
}; };
view! { view! {
<button class="upload-btn" on:click=open_dialog> <button class="upload-btn add-btns" on:click=open_dialog>
<div class="add-sign"> Upload Song
<Icon icon=icondata::IoAddSharp /> </button>
</div> }
Upload
</button>
}
} }
#[component] #[component]
pub fn Upload(open: RwSignal<bool>) -> impl IntoView { pub fn Upload(open: RwSignal<bool>) -> impl IntoView {
// Create signals for the artist input and the filtered artists // Create signals for the artist input and the filtered artists
let (artists, set_artists) = create_signal("".to_string()); let (artists, set_artists) = signal("".to_string());
let (filtered_artists, set_filtered_artists) = create_signal(vec![]); let (filtered_artists, set_filtered_artists) = signal(vec![]);
let (albums, set_albums) = create_signal("".to_string()); let (albums, set_albums) = signal("".to_string());
let (filtered_albums, set_filtered_albums) = create_signal(vec![]); let (filtered_albums, set_filtered_albums) = signal(vec![]);
let (error_msg, set_error_msg) = create_signal::<Option<String>>(None); let (error_msg, set_error_msg) = signal::<Option<String>>(None);
let close_dialog = move |ev: leptos::ev::MouseEvent| { let close_dialog = move |ev: leptos::ev::MouseEvent| {
ev.prevent_default(); ev.prevent_default();
open.set(false); open.set(false);
}; };
// Create a filter function to handle filtering artists // Create a filter function to handle filtering artists
// Allow users to search for artists by name, converts the artist name to artist id to be handed off to backend // Allow users to search for artists by name, converts the artist name to artist id to be handed off to backend
let handle_filter_artists = move |ev: leptos::ev::Event| { let handle_filter_artists = move |ev: leptos::ev::Event| {
ev.prevent_default(); ev.prevent_default();
let artist_input: String = event_target_value(&ev); let artist_input: String = event_target_value(&ev);
//Get the artist that we are currently searching for //Get the artist that we are currently searching for
let mut all_artists: Vec<&str> = artist_input.split(",").collect(); let mut all_artists: Vec<&str> = artist_input.split(",").collect();
let search = all_artists.pop().unwrap().to_string(); let search = all_artists.pop().unwrap().to_string();
//Update the artist signal with the input
set_artists.update(|value: &mut String| *value = artist_input);
spawn_local(async move { //Update the artist signal with the input
let filter_results = search_artists(search, 3).await; set_artists.update(|value: &mut String| *value = artist_input);
if let Err(err) = filter_results {
log!("Error filtering artists: {:?}", err);
} else if let Ok(artists) = filter_results {
log!("Filtered artists: {:?}", artists);
set_filtered_artists.update(|value| *value = artists); spawn_local(async move {
} let filter_results = search_artists(search, 3).await;
}) let filter_results = filter_results.map(|artists| {
}; artists
// Create a filter function to handle filtering albums .into_iter()
// Allow users to search for albums by title, converts the album title to album id to be handed off to backend .map(|(artist, _score)| Artist {
let handle_filter_albums = move |ev: leptos::ev::Event| { id: artist.id,
ev.prevent_default(); name: artist.name,
})
.collect::<Vec<_>>()
});
let album_input: String = event_target_value(&ev); if let Err(err) = filter_results {
log!("Error filtering artists: {:?}", err);
//Update the album signal with the input } else if let Ok(artists) = filter_results {
set_albums.update(|value: &mut String| *value = album_input); log!("Filtered artists: {:?}", artists);
spawn_local(async move { set_filtered_artists.update(|value| *value = artists);
let filter_results = search_albums(albums.get_untracked(), 3).await; }
if let Err(err) = filter_results { });
log!("Error filtering albums: {:?}", err); };
} else if let Ok(albums) = filter_results { // Create a filter function to handle filtering albums
log!("Filtered albums: {:?}", albums); // Allow users to search for albums by title, converts the album title to album id to be handed off to backend
set_filtered_albums.update(|value| *value = albums); let handle_filter_albums = move |ev: leptos::ev::Event| {
} ev.prevent_default();
})
};
let handle_response = Rc::new(move |response: &Response| { let album_input: String = event_target_value(&ev);
if response.ok() {
set_error_msg.update(|value| *value = None);
set_filtered_artists.update(|value| *value = vec![]);
set_filtered_albums.update(|value| *value = vec![]);
set_artists.update(|value| *value = "".to_string());
set_albums.update(|value| *value = "".to_string());
open.set(false);
} else {
// TODO: Extract error message from response
set_error_msg.update(|value| *value = Some("Error uploading song".to_string()));
}
});
view! { //Update the album signal with the input
<Show when=open fallback=move || view! {}> set_albums.update(|value: &mut String| *value = album_input);
<div class="upload-container" open=open>
<div class="close-button" on:click=close_dialog><Icon icon=icondata::IoClose /></div> spawn_local(async move {
<div class="upload-header"> let filter_results = search_albums(albums.get_untracked(), 3).await;
<h1>Upload Song</h1> let filter_results = filter_results.map(|albums| {
</div> albums
<Form action="/api/upload" method="POST" enctype=String::from("multipart/form-data") .into_iter()
class="upload-form" on_response=handle_response.clone()> .map(|(album, _score)| Album {
<div class="input-bx"> id: album.id,
<input type="text" name="title" required class="text-input" required/> title: album.title,
<span>Title</span> release_date: album.release_date,
</div> image_path: Some(album.image_path),
<div class="artists has-search"> })
<div class="input-bx"> .collect::<Vec<_>>()
<input type="text" name="artist_ids" class="text-input" prop:value=artists on:input=handle_filter_artists/> });
<span>Artists</span>
</div> if let Err(err) = filter_results {
<Show log!("Error filtering albums: {:?}", err);
when=move || {filtered_artists.get().len() > 0} } else if let Ok(albums) = filter_results {
fallback=move || view! {} log!("Filtered albums: {:?}", albums);
> set_filtered_albums.update(|value| *value = albums);
<ul class="artist_results search-results"> }
{ });
move || filtered_artists.get().iter().enumerate().map(|(_index,filtered_artist)| view! { };
<Artist artist=filtered_artist.clone() artists=artists set_artists=set_artists set_filtered=set_filtered_artists/>
}).collect::<Vec<_>>() let handle_response = Arc::new(move |response: &Response| {
} if response.ok() {
</ul> set_error_msg.update(|value| *value = None);
</Show> set_filtered_artists.update(|value| *value = vec![]);
</div> set_filtered_albums.update(|value| *value = vec![]);
<div class="albums has-search"> set_artists.update(|value| *value = "".to_string());
<div class="input-bx"> set_albums.update(|value| *value = "".to_string());
<input type="text" name="album_id" class="text-input" prop:value=albums on:input=handle_filter_albums/> open.set(false);
<span>Album ID</span> } else {
</div> // TODO: Extract error message from response
<Show set_error_msg.update(|value| *value = Some("Error uploading song".to_string()));
when=move || {filtered_albums.get().len() > 0} }
fallback=move || view! {} });
>
<ul class="album_results search-results"> view! {
{ <Show when=open fallback=move || view! {}>
move || filtered_albums.get().iter().enumerate().map(|(_index,filtered_album)| view! { <dialog class="upload-container" open=open>
<Album album=filtered_album.clone() _albums=albums set_albums=set_albums set_filtered=set_filtered_albums/> <div class="close-button" on:click=close_dialog><Icon icon={icondata::IoClose} /></div>
}).collect::<Vec<_>>() <div class="upload-header">
} <h1>Upload Song</h1>
</ul> </div>
</Show> <Form action="/api/upload" method="POST" enctype=String::from("multipart/form-data")
</div> on_response=handle_response.clone() {..} class="upload-form" >
<div class="input-bx">
<div class="input-bx"> <input type="text" name="title" required class="text-input" required/>
<input type="number" name="track_number" class="text-input"/> <span>Title</span>
<span>Track Number</span> </div>
</div> <div class="artists has-search">
<div class="release-date"> <div class="input-bx">
<div class="left"> <input type="text" name="artist_ids" class="text-input" prop:value=artists on:input=handle_filter_artists/>
<span>Release</span> <span>Artists</span>
<span>Date</span> </div>
</div> <Show
<input class="info" type="date" name="release_date"/> when=move || {!filtered_artists.get().is_empty()}
</div> fallback=move || view! {}
<div class="file"> >
<span>File</span> <ul class="artist_results search-results">
<input class="info" type="file" accept=".mp3" name="file" required/> {
</div> move || filtered_artists.get().iter().map(|filtered_artist| view! {
<button type="submit" class="upload-button">Upload</button> <Artist artist=filtered_artist.clone() artists=artists set_artists=set_artists set_filtered=set_filtered_artists/>
</Form> }).collect::<Vec<_>>()
<Show }
when=move || {error_msg.get().is_some()} </ul>
fallback=move || view! {} </Show>
> </div>
<div class="error-msg"> <div class="albums has-search">
<Icon icon=icondata::IoAlertCircleSharp /> <div class="input-bx">
{error_msg.get().as_ref().unwrap()} <input type="text" name="album_id" class="text-input" prop:value=albums on:input=handle_filter_albums/>
</div> <span>Album ID</span>
</Show> </div>
</div> <Show
</Show> when=move || {!filtered_albums.get().is_empty()}
} fallback=move || view! {}
>
<ul class="album_results search-results">
{
move || filtered_albums.get().iter().map(|filtered_album| view! {
<Album album=filtered_album.clone() _albums=albums set_albums=set_albums set_filtered=set_filtered_albums/>
}).collect::<Vec<_>>()
}
</ul>
</Show>
</div>
<div class="input-bx">
<input type="number" name="track_number" class="text-input"/>
<span>Track Number</span>
</div>
<div class="release-date">
<div class="left">
<span>Release</span>
<span>Date</span>
</div>
<input class="info" type="date" name="release_date"/>
</div>
<div class="file">
<span>File</span>
<input class="info" type="file" accept=".mp3" name="file" required/>
</div>
<button type="submit" class="upload-button">Upload</button>
</Form>
<Show
when=move || {error_msg.get().is_some()}
fallback=move || view! {}
>
<div class="error-msg">
<Icon icon={icondata::IoAlertCircleSharp} />
{error_msg.get().unwrap()}
</div>
</Show>
</dialog>
</Show>
}
} }
#[component] #[component]
pub fn Artist(artist: Artist, artists: ReadSignal<String>, set_artists: WriteSignal<String>, set_filtered: WriteSignal<Vec<Artist>>) -> impl IntoView { pub fn Artist(
// Converts artist name to artist id and adds it to the artist input artist: Artist,
let add_artist = move |_| { artists: ReadSignal<String>,
//Create an empty string to hold previous artist ids set_artists: WriteSignal<String>,
let mut s: String = String::from(""); set_filtered: WriteSignal<Vec<Artist>>,
//Get the current artist input ) -> impl IntoView {
let all_artirts: String = artists.get(); // Converts artist name to artist id and adds it to the artist input
//Split the input into a vector of artists separated by commas let add_artist = move |_| {
let mut ids: Vec<&str> = all_artirts.split(",").collect(); //Create an empty string to hold previous artist ids
//If there is only one artist in the input, get their id equivalent and add it to the string let mut s: String = String::from("");
if ids.len() == 1 { //Get the current artist input
let value_str = match artist.id.clone() { let all_artirts: String = artists.get();
Some(v) => v.to_string(), //Split the input into a vector of artists separated by commas
None => String::from("None"), let mut ids: Vec<&str> = all_artirts.split(",").collect();
}; //If there is only one artist in the input, get their id equivalent and add it to the string
s.push_str(&value_str); if ids.len() == 1 {
s.push_str(","); s.push_str(&artist.id.to_string());
set_artists.update(|value| *value = s); s.push(',');
//If there are multiple artists in the input, pop the last artist by string off the vector, set_artists.update(|value| *value = s);
//get their id equivalent, and add it to the string //If there are multiple artists in the input, pop the last artist by string off the vector,
} else { //get their id equivalent, and add it to the string
ids.pop(); } else {
for id in ids { ids.pop();
s.push_str(id); for id in ids {
s.push_str(","); s.push_str(id);
} s.push(',');
let value_str = match artist.id.clone() { }
Some(v) => v.to_string(), s.push_str(&artist.id.to_string());
None => String::from("None"), s.push(',');
}; set_artists.update(|value| *value = s);
s.push_str(&value_str); }
s.push_str(","); //Clear the search results
set_artists.update(|value| *value = s); set_filtered.update(|value| *value = vec![]);
} };
//Clear the search results
set_filtered.update(|value| *value = vec![]);
};
view! { view! {
<div class="artist result" on:click=add_artist> <div class="artist result" on:click=add_artist>
{artist.name.clone()} {artist.name.clone()}
</div> </div>
} }
} }
#[component] #[component]
pub fn Album(album: Album, _albums: ReadSignal<String>, set_albums: WriteSignal<String>, set_filtered: WriteSignal<Vec<Album>>) -> impl IntoView { pub fn Album(
//Converts album title to album id to upload a song album: Album,
let add_album = move |_| { _albums: ReadSignal<String>,
let value_str = match album.id.clone() { set_albums: WriteSignal<String>,
Some(v) => v.to_string(), set_filtered: WriteSignal<Vec<Album>>,
None => String::from("None"), ) -> impl IntoView {
}; //Converts album title to album id to upload a song
set_albums.update(|value| *value = value_str); let add_album = move |_| {
set_filtered.update(|value| *value = vec![]); set_albums.update(|value| *value = album.id.to_string());
}; set_filtered.update(|value| *value = vec![]);
view! { };
<div class="album result" on:click=add_album> view! {
{album.title.clone()} <div class="album result" on:click=add_album>
</div> {album.title.clone()}
} </div>
} }
}

View File

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

View File

@ -1,115 +0,0 @@
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use leptos::logging::log;
use lazy_static::lazy_static;
use std::env;
use diesel::{
pg::PgConnection,
r2d2::ConnectionManager,
r2d2::PooledConnection,
r2d2::Pool,
};
use diesel_migrations::{
embed_migrations,
EmbeddedMigrations,
MigrationHarness,
};
// See https://leward.eu/notes-on-diesel-a-rust-orm/
// Define some types to make it easier to work with Diesel
type PgPool = Pool<ConnectionManager<PgConnection>>;
pub type PgPooledConn = PooledConnection<ConnectionManager<PgConnection>>;
// Keep a global instance of the pool
lazy_static! {
static ref DB_POOL: PgPool = init_db_pool();
}
/// Initialize the database pool
///
/// Uses DATABASE_URL environment variable to connect to the database if set,
/// otherwise builds a connection string from other environment variables.
///
/// Will panic if either the DATABASE_URL or POSTGRES_HOST environment variables
/// are not set, or if there is an error creating the pool.
///
/// # Returns
/// A database pool object, which can be used to get pooled connections
fn init_db_pool() -> PgPool {
let database_url = env::var("DATABASE_URL").unwrap_or_else(|_| {
// Build the database URL from environment variables
// Construct a separate log_url to avoid logging the password
let mut log_url = "postgres://".to_string();
let mut url = "postgres://".to_string();
if let Ok(user) = env::var("POSTGRES_USER") {
url.push_str(&user);
log_url.push_str(&user);
if let Ok(password) = env::var("POSTGRES_PASSWORD") {
url.push_str(":");
log_url.push_str(":");
url.push_str(&password);
log_url.push_str("********");
}
url.push_str("@");
log_url.push_str("@");
}
let host = env::var("POSTGRES_HOST").expect("DATABASE_URL or POSTGRES_HOST must be set");
url.push_str(&host);
log_url.push_str(&host);
if let Ok(port) = env::var("POSTGRES_PORT") {
url.push_str(":");
url.push_str(&port);
log_url.push_str(":");
log_url.push_str(&port);
}
if let Ok(dbname) = env::var("POSTGRES_DB") {
url.push_str("/");
url.push_str(&dbname);
log_url.push_str("/");
log_url.push_str(&dbname);
}
log!("Connecting to database: {}", log_url);
url
});
let manager = ConnectionManager::<PgConnection>::new(database_url);
PgPool::builder()
.build(manager)
.expect("Failed to create pool.")
}
/// Get a pooled connection to the database
///
/// Will panic if there is an error getting a connection from the pool.
///
/// # Returns
/// A pooled connection to the database
pub fn get_db_conn() -> PgPooledConn {
DB_POOL.get().expect("Failed to get a database connection from the pool.")
}
/// Embedded database migrations into the binary
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
/// Run any pending migrations in the database
/// Always safe to call, as it will only run migrations that have not already been run
pub fn migrate() {
let db_con = &mut get_db_conn();
db_con.run_pending_migrations(DB_MIGRATIONS).expect("Could not run database migrations");
}
}
}

View File

@ -1,70 +0,0 @@
use cfg_if::cfg_if;
cfg_if! { if #[cfg(feature = "ssr")] {
use axum::{
body::Body,
extract::State,
response::IntoResponse,
http::{Request, Response, StatusCode, Uri},
};
use axum::response::Response as AxumResponse;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use leptos::*;
use crate::app::App;
use std::str::FromStr;
pub async fn file_and_error_handler(uri: Uri, State(options): State<LeptosOptions>, req: Request<Body>) -> AxumResponse {
let root = options.site_root.clone();
let res = get_static_file(uri.clone(), &root).await.unwrap();
if res.status() == StatusCode::OK {
res.into_response()
} else {
let handler = leptos_axum::render_app_to_stream(options.to_owned(), App);
handler(req).await.into_response()
}
}
pub async fn get_static_file(uri: Uri, root: &str) -> Result<Response<Body>, (StatusCode, String)> {
let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap();
// `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot`
// This path is relative to the cargo root
match ServeDir::new(root).oneshot(req).await.ok() {
Some(res) => Ok(res.into_response()),
None => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong"),
)),
}
}
pub enum AssetType {
Audio,
Image,
}
pub async fn get_asset_file(filename: String, asset_type: AssetType) -> Result<Response<Body>, (StatusCode, String)> {
const DEFAULT_AUDIO_PATH: &str = "assets/audio";
const DEFAULT_IMAGE_PATH: &str = "assets/images";
let root = match asset_type {
AssetType::Audio => std::env::var("LIBRETUNES_AUDIO_PATH").unwrap_or(DEFAULT_AUDIO_PATH.to_string()),
AssetType::Image => std::env::var("LIBRETUNES_IMAGE_PATH").unwrap_or(DEFAULT_IMAGE_PATH.to_string()),
};
// Create a Uri from the filename
// ServeDir expects a leading `/`
let uri = Uri::from_str(format!("/{}", filename).as_str());
match uri {
Ok(uri) => get_static_file(uri, root.as_str()).await,
Err(_) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Attempted to serve an invalid file"),
)),
}
}
}}

21
src/health.rs Normal file
View File

@ -0,0 +1,21 @@
use libretunes::api::health::health;
use server_fn::client::set_server_url;
#[tokio::main]
pub async fn main() {
let host = std::env::args()
.nth(1)
.unwrap_or("http://localhost:3000".to_string());
println!("Runing health check against {host}");
set_server_url(Box::leak(host.into_boxed_str()));
match health().await {
Ok(result) => println!("Health check result: {result:?}"),
Err(err) => {
println!("Error: {err}");
std::process::exit(1);
}
}
}

View File

@ -1,29 +1,43 @@
#![warn(
unsafe_code,
clippy::cognitive_complexity,
clippy::dbg_macro,
clippy::debug_assert_with_mut_call,
clippy::doc_link_with_quotes,
clippy::doc_markdown,
clippy::empty_line_after_outer_attr,
clippy::float_cmp,
clippy::float_cmp_const,
clippy::float_equality_without_abs,
keyword_idents,
clippy::missing_const_for_fn,
non_ascii_idents,
noop_method_call,
clippy::print_stderr,
clippy::print_stdout,
clippy::semicolon_if_nothing_returned,
clippy::unseparated_literal_suffix,
clippy::suspicious_operation_groupings,
unused_import_braces,
clippy::unused_self,
clippy::use_debug,
clippy::useless_let_if_seq,
clippy::wildcard_dependencies
)]
#![allow(clippy::unused_unit, clippy::unit_arg, clippy::type_complexity)]
#![recursion_limit = "256"]
pub mod api;
pub mod app; pub mod app;
pub mod auth; pub mod components;
pub mod songdata;
pub mod albumdata;
pub mod artistdata;
pub mod playstatus;
pub mod playbar;
pub mod database;
pub mod queue;
pub mod song;
pub mod models; pub mod models;
pub mod pages; pub mod pages;
pub mod components;
pub mod users;
pub mod search;
pub mod fileserv;
pub mod error_template;
pub mod api;
pub mod upload;
pub mod util; pub mod util;
use cfg_if::cfg_if; use cfg_if::cfg_if;
cfg_if! { cfg_if! {
if #[cfg(feature = "ssr")] { if #[cfg(feature = "ssr")] {
pub mod auth_backend;
pub mod schema; pub mod schema;
} }
} }
@ -39,7 +53,7 @@ if #[cfg(feature = "hydrate")] {
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
leptos::mount_to_body(App); leptos::mount::hydrate_body(App);
} }
} }
} }

View File

@ -1,12 +1,4 @@
// Needed for building in Docker container #![recursion_limit = "256"]
// See https://github.com/clux/muslrust?tab=readme-ov-file#diesel-and-pq-builds
// See https://github.com/sgrif/pq-sys/issues/25
#[cfg(target_env = "musl")]
extern crate openssl;
#[cfg(target_env = "musl")]
#[macro_use]
extern crate diesel;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
extern crate diesel_migrations; extern crate diesel_migrations;
@ -14,64 +6,118 @@ extern crate diesel_migrations;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
use axum::{routing::get, Router, extract::Path}; use axum::{body::Body, extract::Request, http::Response, middleware::Next};
use leptos::*; use axum::{extract::Path, middleware::from_fn, routing::get, Router};
use axum_login::tower_sessions::SessionManagerLayer;
use axum_login::AuthManagerLayerBuilder;
use http::StatusCode;
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes}; use leptos_axum::{generate_route_list, LeptosRoutes};
use libretunes::app::*; use libretunes::app::*;
use libretunes::fileserv::{file_and_error_handler, get_asset_file, get_static_file, AssetType}; use libretunes::util::auth_backend::AuthBackend;
use axum_login::tower_sessions::SessionManagerLayer; use libretunes::util::backend_state::BackendState;
use tower_sessions_redis_store::{fred::prelude::*, RedisStore}; use libretunes::util::config::load_config;
use axum_login::AuthManagerLayerBuilder; use libretunes::util::fileserv::{
use libretunes::auth_backend::AuthBackend; file_and_error_handler, get_asset_file, get_static_file, AssetType,
};
use libretunes::util::require_auth::require_auth_middleware;
use log::*; use log::*;
use tower_sessions_redis_store::RedisStore;
flexi_logger::Logger::try_with_env_or_str("debug").unwrap().format(flexi_logger::opt_format).start().unwrap(); flexi_logger::Logger::try_with_env_or_str("debug")
.unwrap()
.format(flexi_logger::opt_format)
.start()
.unwrap();
info!("\n{}", include_str!("../ascii_art.txt")); info!("\n{}", include_str!("../ascii_art.txt"));
let config = load_config().unwrap_or_else(|err| {
error!("Failed to load configuration: {}", err);
std::process::exit(1);
});
let state = BackendState::from_config(config)
.await
.unwrap_or_else(|err| {
error!("Failed to initialize backend state: {}", err);
std::process::exit(1);
});
info!("Starting Leptos server..."); info!("Starting Leptos server...");
use dotenv::dotenv; use dotenvy::dotenv;
dotenv().ok(); dotenv().ok();
debug!("Running database migrations..."); debug!("Running database migrations...");
let mut db_conn = state.get_db_conn().unwrap_or_else(|err| {
error!("Failed to get database connection: {}", err);
std::process::exit(1);
});
// Bring the database up to date // Bring the database up to date
libretunes::database::migrate(); libretunes::util::database::migrate(&mut db_conn);
drop(db_conn); // Close the connection after migrations
debug!("Connecting to Redis..."); debug!("Setting up session store...");
let session_store = RedisStore::new(state.get_redis_conn());
let redis_url = std::env::var("REDIS_URL").expect("REDIS_URL must be set");
let redis_config = RedisConfig::from_url(&redis_url).expect(&format!("Unable to parse Redis URL: {}", redis_url));
let redis_pool = RedisPool::new(redis_config, None, None, None, 1).expect("Unable to create Redis pool");
redis_pool.connect();
redis_pool.wait_for_connect().await.expect("Unable to connect to Redis");
let session_store = RedisStore::new(redis_pool);
let session_layer = SessionManagerLayer::new(session_store); let session_layer = SessionManagerLayer::new(session_store);
let auth_backend = AuthBackend; let auth_backend = AuthBackend {
backend_state: state.clone(),
};
let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build(); let auth_layer = AuthManagerLayerBuilder::new(auth_backend, session_layer).build();
let conf = get_configuration(None).await.unwrap(); // A middleware that injects the backend state into the request extensions,
// allowing it to be extracted later in the request lifecycle
let backend_state_middleware = move |mut req: Request, next: Next| {
let state = state.clone();
async move {
req.extensions_mut().insert(state);
let response = next.run(req).await;
Ok::<Response<Body>, (StatusCode, &'static str)>(response)
}
};
let conf = get_configuration(None).unwrap();
let leptos_options = conf.leptos_options; let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr; let addr = leptos_options.site_addr;
// Generate the list of routes in your Leptos App // Generate the list of routes in your Leptos App
let routes = generate_route_list(App); let routes = generate_route_list(App);
let app = Router::new() let app = Router::new()
.leptos_routes(&leptos_options, routes, App) .leptos_routes(&leptos_options, routes, {
.route("/assets/audio/:song", get(|Path(song) : Path<String>| get_asset_file(song, AssetType::Audio))) let leptos_options = leptos_options.clone();
.route("/assets/images/:image", get(|Path(image) : Path<String>| get_asset_file(image, AssetType::Image))) move || shell(leptos_options.clone())
.route("/assets/*uri", get(|uri| get_static_file(uri, ""))) })
.route(
"/assets/audio/{song}",
get(|Path(song): Path<String>| get_asset_file(song, AssetType::Audio)),
)
.route(
"/assets/images/{image}",
get(|Path(image): Path<String>| get_asset_file(image, AssetType::Image)),
)
.route("/assets/{*uri}", get(|uri| get_static_file(uri, "")))
.layer(from_fn(require_auth_middleware))
.layer(auth_layer) .layer(auth_layer)
.layer(from_fn(backend_state_middleware))
.fallback(file_and_error_handler) .fallback(file_and_error_handler)
.with_state(leptos_options); .with_state(leptos_options);
let listener = tokio::net::TcpListener::bind(&addr).await.expect(&format!("Could not bind to {}", &addr)); let listener = tokio::net::TcpListener::bind(&addr)
.await
.unwrap_or_else(|_| panic!("Could not bind to {}", &addr));
info!("Listening on http://{}", &addr); info!("Listening on http://{}", &addr);
axum::serve(listener, app.into_make_service()).await.expect("Server failed"); axum::serve(listener, app.into_make_service())
.await
.expect("Server failed");
} }
#[cfg(not(feature = "ssr"))] #[cfg(not(feature = "ssr"))]

View File

@ -1,648 +0,0 @@
use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::database::PgPooledConn;
use std::error::Error;
}
}
// These "models" are used to represent the data in the database
// Diesel uses these models to generate the SQL queries that are used to interact with the database.
// These types are also used for API endpoints, for consistency. Because the file must be compiled
// for both the server and the client, we use the `cfg_attr` attribute to conditionally add
// diesel-specific attributes to the models when compiling for the server
/// Model for a "User", used for querying the database
/// Various fields are wrapped in Options, because they are not always wanted for inserts/retrieval
/// Using deserialize_as makes Diesel use the specified type when deserializing from the database,
/// and then call .into() to convert it into the Option
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::users))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
/// A unique id for the user
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
// #[cfg_attr(feature = "ssr", diesel(skip_insertion))] // This feature is not yet released
pub id: Option<i32>,
/// The user's username
pub username: String,
/// The user's email
pub email: String,
/// The user's password, stored as a hash
#[cfg_attr(feature = "ssr", diesel(deserialize_as = String))]
pub password: Option<String>,
/// The time the user was created
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
pub created_at: Option<NaiveDateTime>,
/// Whether the user is an admin
pub admin: bool,
}
impl User {
/// Get the history of songs listened to by this user from the database
///
/// The returned history will be ordered by date in descending order,
/// and a limit of N will select the N most recent entries.
/// The `id` field of this user must be present (Some) to get history
///
/// # Arguments
///
/// * `limit` - An optional limit on the number of history entries to return
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<HistoryEntry>, Box<dyn Error>>` -
/// A result indicating success with a vector of history entries, or an error
///
#[cfg(feature = "ssr")]
pub fn get_history(self: &Self, limit: Option<i64>, conn: &mut PgPooledConn) ->
Result<Vec<HistoryEntry>, Box<dyn Error>> {
use crate::schema::song_history::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to get history")?;
let my_history =
if let Some(limit) = limit {
song_history
.filter(user_id.eq(my_id))
.order(date.desc())
.limit(limit)
.load(conn)?
} else {
song_history
.filter(user_id.eq(my_id))
.load(conn)?
};
Ok(my_history)
}
/// Get the history of songs listened to by this user from the database
///
/// The returned history will be ordered by date in descending order,
/// and a limit of N will select the N most recent entries.
/// The `id` field of this user must be present (Some) to get history
///
/// # Arguments
///
/// * `limit` - An optional limit on the number of history entries to return
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<(SystemTime, Song)>, Box<dyn Error>>` -
/// A result indicating success with a vector of listen dates and songs, or an error
///
#[cfg(feature = "ssr")]
pub fn get_history_songs(self: &Self, limit: Option<i64>, conn: &mut PgPooledConn) ->
Result<Vec<(NaiveDateTime, Song)>, Box<dyn Error>> {
use crate::schema::songs::dsl::*;
use crate::schema::song_history::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to get history")?;
let my_history =
if let Some(limit) = limit {
song_history
.inner_join(songs)
.filter(user_id.eq(my_id))
.order(date.desc())
.limit(limit)
.select((date, songs::all_columns()))
.load(conn)?
} else {
song_history
.inner_join(songs)
.filter(user_id.eq(my_id))
.order(date.desc())
.select((date, songs::all_columns()))
.load(conn)?
};
Ok(my_history)
}
/// Add a song to this user's history in the database
///
/// The date of the history entry will be the current time
/// The `id` field of this user must be present (Some) to add history
///
/// # Arguments
///
/// * `song_id` - The id of the song to add to this user's history
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn add_history(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
use crate::schema::song_history;
let my_id = self.id.ok_or("Artist id must be present (Some) to add history")?;
diesel::insert_into(song_history::table)
.values((song_history::user_id.eq(my_id), song_history::song_id.eq(song_id)))
.execute(conn)?;
Ok(())
}
/// Check if this user has listened to a song
///
/// The `id` field of this user must be present (Some) to check history
///
/// # Arguments
///
/// * `song_id` - The id of the song to check if this user has listened to
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<bool, Box<dyn Error>>` - A result indicating success with a boolean value, or an error
///
#[cfg(feature = "ssr")]
pub fn has_listened_to(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<bool, Box<dyn Error>> {
use crate::schema::song_history::{self, user_id};
let my_id = self.id.ok_or("Artist id must be present (Some) to check history")?;
let has_listened = song_history::table
.filter(user_id.eq(my_id))
.filter(song_history::song_id.eq(song_id))
.first::<HistoryEntry>(conn)
.optional()?
.is_some();
Ok(has_listened)
}
/// Like or unlike a song for this user
/// If likeing a song, remove dislike if it exists
#[cfg(feature = "ssr")]
pub async fn set_like_song(self: &Self, song_id: i32, like: bool, conn: &mut PgPooledConn) ->
Result<(), Box<dyn Error>> {
use log::*;
debug!("Setting like for song {} to {}", song_id, like);
use crate::schema::song_likes;
use crate::schema::song_dislikes;
let my_id = self.id.ok_or("User id must be present (Some) to like/un-like a song")?;
if like {
diesel::insert_into(song_likes::table)
.values((song_likes::song_id.eq(song_id), song_likes::user_id.eq(my_id)))
.execute(conn)?;
// Remove dislike if it exists
diesel::delete(song_dislikes::table.filter(song_dislikes::song_id.eq(song_id)
.and(song_dislikes::user_id.eq(my_id))))
.execute(conn)?;
} else {
diesel::delete(song_likes::table.filter(song_likes::song_id.eq(song_id).and(song_likes::user_id.eq(my_id))))
.execute(conn)?;
}
Ok(())
}
/// Get the like status of a song for this user
#[cfg(feature = "ssr")]
pub async fn get_like_song(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<bool, Box<dyn Error>> {
use crate::schema::song_likes;
let my_id = self.id.ok_or("User id must be present (Some) to get like status of a song")?;
let like = song_likes::table
.filter(song_likes::song_id.eq(song_id).and(song_likes::user_id.eq(my_id)))
.first::<(i32, i32)>(conn)
.optional()?
.is_some();
Ok(like)
}
/// Get songs liked by this user
#[cfg(feature = "ssr")]
pub async fn get_liked_songs(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Song>, Box<dyn Error>> {
use crate::schema::songs::dsl::*;
use crate::schema::song_likes::dsl::*;
let my_id = self.id.ok_or("User id must be present (Some) to get liked songs")?;
let my_songs = songs
.inner_join(song_likes)
.filter(user_id.eq(my_id))
.select(songs::all_columns())
.load(conn)?;
Ok(my_songs)
}
/// Dislike or remove dislike from a song for this user
/// If disliking a song, remove like if it exists
#[cfg(feature = "ssr")]
pub async fn set_dislike_song(self: &Self, song_id: i32, dislike: bool, conn: &mut PgPooledConn) ->
Result<(), Box<dyn Error>> {
use log::*;
debug!("Setting dislike for song {} to {}", song_id, dislike);
use crate::schema::song_likes;
use crate::schema::song_dislikes;
let my_id = self.id.ok_or("User id must be present (Some) to dislike/un-dislike a song")?;
if dislike {
diesel::insert_into(song_dislikes::table)
.values((song_dislikes::song_id.eq(song_id), song_dislikes::user_id.eq(my_id)))
.execute(conn)?;
// Remove like if it exists
diesel::delete(song_likes::table.filter(song_likes::song_id.eq(song_id)
.and(song_likes::user_id.eq(my_id))))
.execute(conn)?;
} else {
diesel::delete(song_dislikes::table.filter(song_dislikes::song_id.eq(song_id)
.and(song_dislikes::user_id.eq(my_id))))
.execute(conn)?;
}
Ok(())
}
/// Get the dislike status of a song for this user
#[cfg(feature = "ssr")]
pub async fn get_dislike_song(self: &Self, song_id: i32, conn: &mut PgPooledConn) -> Result<bool, Box<dyn Error>> {
use crate::schema::song_dislikes;
let my_id = self.id.ok_or("User id must be present (Some) to get dislike status of a song")?;
let dislike = song_dislikes::table
.filter(song_dislikes::song_id.eq(song_id).and(song_dislikes::user_id.eq(my_id)))
.first::<(i32, i32)>(conn)
.optional()?
.is_some();
Ok(dislike)
}
/// Get songs disliked by this user
#[cfg(feature = "ssr")]
pub async fn get_disliked_songs(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Song>, Box<dyn Error>> {
use crate::schema::songs::dsl::*;
use crate::schema::song_likes::dsl::*;
let my_id = self.id.ok_or("User id must be present (Some) to get disliked songs")?;
let my_songs = songs
.inner_join(song_likes)
.filter(user_id.eq(my_id))
.select(songs::all_columns())
.load(conn)?;
Ok(my_songs)
}
}
/// Model for an artist
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable, Identifiable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::artists))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Artist {
/// A unique id for the artist
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The artist's name
pub name: String,
}
impl Artist {
/// Add an album to this artist in the database
///
/// # Arguments
///
/// * `new_album_id` - The id of the album to add to this artist
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn add_album(self: &Self, new_album_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
use crate::schema::album_artists::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to add an album")?;
diesel::insert_into(album_artists)
.values((album_id.eq(new_album_id), artist_id.eq(my_id)))
.execute(conn)?;
Ok(())
}
/// Get albums by artist from the database
///
/// The `id` field of this artist must be present (Some) to get albums
///
/// # Arguments
///
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<Album>, Box<dyn Error>>` - A result indicating success with a vector of albums, or an error
///
#[cfg(feature = "ssr")]
pub fn get_albums(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Album>, Box<dyn Error>> {
use crate::schema::albums::dsl::*;
use crate::schema::album_artists::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to get albums")?;
let my_albums = albums
.inner_join(album_artists)
.filter(artist_id.eq(my_id))
.select(albums::all_columns())
.load(conn)?;
Ok(my_albums)
}
/// Add a song to this artist in the database
///
/// The `id` field of this artist must be present (Some) to add a song
///
/// # Arguments
///
/// * `new_song_id` - The id of the song to add to this artist
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn add_song(self: &Self, new_song_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
use crate::schema::song_artists::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to add an album")?;
diesel::insert_into(song_artists)
.values((song_id.eq(new_song_id), artist_id.eq(my_id)))
.execute(conn)?;
Ok(())
}
/// Get songs by this artist from the database
///
/// The `id` field of this artist must be present (Some) to get songs
///
/// # Arguments
///
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<Song>, Box<dyn Error>>` - A result indicating success with a vector of songs, or an error
///
#[cfg(feature = "ssr")]
pub fn get_songs(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Song>, Box<dyn Error>> {
use crate::schema::songs::dsl::*;
use crate::schema::song_artists::dsl::*;
let my_id = self.id.ok_or("Artist id must be present (Some) to get songs")?;
let my_songs = songs
.inner_join(song_artists)
.filter(artist_id.eq(my_id))
.select(songs::all_columns())
.load(conn)?;
Ok(my_songs)
}
/// Display a list of artists as a string.
///
/// For one artist, displays [artist1]. For two artists, displays [artist1] & [artist2].
/// For three or more artists, displays [artist1], [artist2], & [artist3].
pub fn display_list(artists: &Vec<Artist>) -> String {
let mut artist_list = String::new();
for (i, artist) in artists.iter().enumerate() {
if i == 0 {
artist_list.push_str(&artist.name);
} else if i == artists.len() - 1 {
artist_list.push_str(&format!(" & {}", artist.name));
} else {
artist_list.push_str(&format!(", {}", artist.name));
}
}
artist_list
}
}
/// Model for an album
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable, Identifiable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::albums))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Album {
/// A unique id for the album
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The album's title
pub title: String,
/// The album's release date
pub release_date: Option<NaiveDate>,
/// The path to the album's image file
pub image_path: Option<String>,
}
impl Album {
/// Add an artist to this album in the database
///
/// The `id` field of this album must be present (Some) to add an artist
///
/// # Arguments
///
/// * `new_artist_id` - The id of the artist to add to this album
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn add_artist(self: &Self, new_artist_id: i32, conn: &mut PgPooledConn) -> Result<(), Box<dyn Error>> {
use crate::schema::album_artists::dsl::*;
let my_id = self.id.ok_or("Album id must be present (Some) to add an artist")?;
diesel::insert_into(album_artists)
.values((album_id.eq(my_id), artist_id.eq(new_artist_id)))
.execute(conn)?;
Ok(())
}
/// Get songs by this artist from the database
///
/// The `id` field of this album must be present (Some) to get songs
///
/// # Arguments
///
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<Song>, Box<dyn Error>>` - A result indicating success with a vector of songs, or an error
///
#[cfg(feature = "ssr")]
pub fn get_songs(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Song>, Box<dyn Error>> {
use crate::schema::songs::dsl::*;
use crate::schema::song_artists::dsl::*;
let my_id = self.id.ok_or("Album id must be present (Some) to get songs")?;
let my_songs = songs
.inner_join(song_artists)
.filter(album_id.eq(my_id))
.select(songs::all_columns())
.load(conn)?;
Ok(my_songs)
}
}
/// Model for a song
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::songs))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize)]
pub struct Song {
/// A unique id for the song
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The song's title
pub title: String,
/// The album the song is from
pub album_id: Option<i32>,
/// The track number of the song on the album
pub track: Option<i32>,
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
pub release_date: Option<NaiveDate>,
/// The path to the song's audio file
pub storage_path: String,
/// The path to the song's image file
pub image_path: Option<String>,
}
impl Song {
/// Add an artist to this song in the database
///
/// The `id` field of this song must be present (Some) to add an artist
///
/// # Arguments
///
/// * `new_artist_id` - The id of the artist to add to this song
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<Artist>, Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn get_artists(self: &Self, conn: &mut PgPooledConn) -> Result<Vec<Artist>, Box<dyn Error>> {
use crate::schema::artists::dsl::*;
use crate::schema::song_artists::dsl::*;
let my_id = self.id.ok_or("Song id must be present (Some) to get artists")?;
let my_artists = artists
.inner_join(song_artists)
.filter(song_id.eq(my_id))
.select(artists::all_columns())
.load(conn)?;
Ok(my_artists)
}
/// Get the album for this song from the database
///
/// # Arguments
///
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Option<Album>, Box<dyn Error>>` - A result indicating success with an album, or None if
/// the song does not have an album, or an error
///
#[cfg(feature = "ssr")]
pub fn get_album(self: &Self, conn: &mut PgPooledConn) -> Result<Option<Album>, Box<dyn Error>> {
use crate::schema::albums::dsl::*;
if let Some(album_id) = self.album_id {
let my_album = albums
.filter(id.eq(album_id))
.first::<Album>(conn)?;
Ok(Some(my_album))
} else {
Ok(None)
}
}
}
/// Model for a history entry
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::song_history))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize)]
pub struct HistoryEntry {
/// A unique id for the history entry
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The id of the user who listened to the song
pub user_id: i32,
/// The date the song was listened to
pub date: NaiveDateTime,
/// The id of the song that was listened to
pub song_id: i32,
}
/// Model for a playlist
#[cfg_attr(feature = "ssr", derive(Queryable, Selectable, Insertable))]
#[cfg_attr(feature = "ssr", diesel(table_name = crate::schema::playlists))]
#[cfg_attr(feature = "ssr", diesel(check_for_backend(diesel::pg::Pg)))]
#[derive(Serialize, Deserialize)]
pub struct Playlist {
/// A unique id for the playlist
#[cfg_attr(feature = "ssr", diesel(deserialize_as = i32))]
pub id: Option<i32>,
/// The time the playlist was created
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
pub created_at: Option<NaiveDateTime>,
/// The time the playlist was last updated
#[cfg_attr(feature = "ssr", diesel(deserialize_as = NaiveDateTime))]
pub updated_at: Option<NaiveDateTime>,
/// The id of the user who owns the playlist
pub owner_id: i32,
/// The name of the playlist
pub name: String,
}

View File

@ -0,0 +1,18 @@
use chrono::NaiveDate;
use libretunes_macro::db_type;
use serde::{Deserialize, Serialize};
/// Model for an album
#[db_type(crate::schema::albums)]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Album {
/// A unique id for the album
#[omit_new]
pub id: i32,
/// The album's title
pub title: String,
/// The album's release date
pub release_date: Option<NaiveDate>,
/// The path to the album's image file
pub image_path: Option<String>,
}

View File

@ -0,0 +1,35 @@
use libretunes_macro::db_type;
use serde::{Deserialize, Serialize};
/// Model for an artist
#[db_type(crate::schema::artists)]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Artist {
/// A unique id for the artist
#[omit_new]
pub id: i32,
/// The artist's name
pub name: String,
}
impl Artist {
/// Display a list of artists as a string.
///
/// For one artist, displays [artist1]. For two artists, displays [artist1] & [artist2].
/// For three or more artists, displays [artist1], [artist2], & [artist3].
pub fn display_list(artists: &[Artist]) -> String {
let mut artist_list = String::new();
for (i, artist) in artists.iter().enumerate() {
if i == 0 {
artist_list.push_str(&artist.name);
} else if i == artists.len() - 1 {
artist_list.push_str(&format!(" & {}", artist.name));
} else {
artist_list.push_str(&format!(", {}", artist.name));
}
}
artist_list
}
}

View File

@ -0,0 +1,18 @@
use chrono::NaiveDateTime;
use libretunes_macro::db_type;
use serde::{Deserialize, Serialize};
/// Model for a history entry
#[db_type(crate::schema::song_history)]
#[derive(Serialize, Deserialize)]
pub struct HistoryEntry {
/// A unique id for the history entry
#[omit_new]
pub id: i32,
/// The id of the user who listened to the song
pub user_id: i32,
/// The date the song was listened to
pub date: NaiveDateTime,
/// The id of the song that was listened to
pub song_id: i32,
}

25
src/models/backend/mod.rs Normal file
View File

@ -0,0 +1,25 @@
// These "models" are used to represent the data in the database
// Diesel uses these models to generate the SQL queries that are used to interact with the database.
// These types are also used for API endpoints, for consistency. Because the file must be compiled
// for both the server and the client, we use the `cfg_attr` attribute to conditionally add
// diesel-specific attributes to the models when compiling for the serverub mod user;
pub mod album;
pub mod artist;
pub mod history_entry;
pub mod playlist;
pub mod song;
pub mod user;
pub use album::Album;
pub use album::NewAlbum;
pub use artist::Artist;
pub use artist::NewArtist;
pub use history_entry::HistoryEntry;
pub use history_entry::NewHistoryEntry;
pub use playlist::NewPlaylist;
pub use playlist::Playlist;
pub use song::NewSong;
pub use song::Song;
pub use user::NewUser;
pub use user::User;

View File

@ -0,0 +1,22 @@
use chrono::NaiveDateTime;
use libretunes_macro::db_type;
use serde::{Deserialize, Serialize};
/// Model for a playlist
#[db_type(crate::schema::playlists)]
#[derive(Serialize, Deserialize, Clone)]
pub struct Playlist {
/// A unique id for the playlist
#[omit_new]
pub id: i32,
/// The time the playlist was created
#[omit_new]
pub created_at: NaiveDateTime,
/// The time the playlist was last updated
#[omit_new]
pub updated_at: NaiveDateTime,
/// The id of the user who owns the playlist
pub owner_id: i32,
/// The name of the playlist
pub name: String,
}

View File

@ -0,0 +1,28 @@
use chrono::{NaiveDate, NaiveDateTime};
use libretunes_macro::db_type;
use serde::{Deserialize, Serialize};
#[db_type(crate::schema::songs)]
#[derive(Clone, Serialize, Deserialize)]
pub struct Song {
/// A unique id for the song
#[omit_new]
pub id: i32,
/// The song's title
pub title: String,
/// The album the song is from
pub album_id: Option<i32>,
/// The track number of the song on the album
pub track: Option<i32>,
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
pub release_date: Option<NaiveDate>,
/// The path to the song's audio file
pub storage_path: String,
/// The path to the song's image file
pub image_path: Option<String>,
/// The date the song was added to the database
#[omit_new]
pub added_date: NaiveDateTime,
}

298
src/models/backend/user.rs Normal file
View File

@ -0,0 +1,298 @@
use chrono::NaiveDateTime;
use libretunes_macro::db_type;
use serde::{Deserialize, Serialize};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::prelude::*;
use crate::util::database::PgPooledConn;
use crate::models::backend::{Song, HistoryEntry};
use crate::util::error::*;
}
}
// Model for a "User", used for querying the database
/// Various fields are wrapped in Options, because they are not always wanted for inserts/retrieval
/// Using `deserialize_as` makes Diesel use the specified type when deserializing from the database,
/// and then call `.into()` to convert it into the Option
#[db_type(crate::schema::users)]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
/// A unique id for the user
#[omit_new]
pub id: i32,
/// The user's username
pub username: String,
/// The user's email
pub email: String,
/// The user's password, stored as a hash
#[cfg_attr(feature = "ssr", diesel(deserialize_as = String))]
pub password: Option<String>,
/// The time the user was created
#[omit_new]
pub created_at: NaiveDateTime,
/// Whether the user is an admin
pub admin: bool,
}
impl User {
/// Get the history of songs listened to by this user from the database
///
/// The returned history will be ordered by date in descending order,
/// and a limit of N will select the N most recent entries.
/// The `id` field of this user must be present (Some) to get history
///
/// # Arguments
///
/// * `limit` - An optional limit on the number of history entries to return
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<HistoryEntry>, Box<dyn Error>>` -
/// A result indicating success with a vector of history entries, or an error
///
#[cfg(feature = "ssr")]
pub fn get_history(
&self,
limit: Option<i64>,
conn: &mut PgPooledConn,
) -> BackendResult<Vec<HistoryEntry>> {
use crate::schema::song_history::dsl::*;
let my_history = if let Some(limit) = limit {
song_history
.filter(user_id.eq(self.id))
.order(date.desc())
.limit(limit)
.load(conn)
.context("Error getting user history")?
} else {
song_history.filter(user_id.eq(self.id)).load(conn)?
};
Ok(my_history)
}
/// Get the history of songs listened to by this user from the database
///
/// The returned history will be ordered by date in descending order,
/// and a limit of N will select the N most recent entries.
/// The `id` field of this user must be present (Some) to get history
///
/// # Arguments
///
/// * `limit` - An optional limit on the number of history entries to return
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<Vec<(SystemTime, Song)>, Box<dyn Error>>` -
/// A result indicating success with a vector of listen dates and songs, or an error
///
#[cfg(feature = "ssr")]
pub fn get_history_songs(
&self,
limit: Option<i64>,
conn: &mut PgPooledConn,
) -> BackendResult<Vec<(NaiveDateTime, Song)>> {
use crate::schema::song_history::dsl::*;
use crate::schema::songs::dsl::*;
let my_history = if let Some(limit) = limit {
song_history
.inner_join(songs)
.filter(user_id.eq(self.id))
.order(date.desc())
.limit(limit)
.select((date, songs::all_columns()))
.load(conn)
.context("Error getting user history songs")?
} else {
song_history
.inner_join(songs)
.filter(user_id.eq(self.id))
.order(date.desc())
.select((date, songs::all_columns()))
.load(conn)
.context("Error getting user history songs")?
};
Ok(my_history)
}
/// Add a song to this user's history in the database
///
/// The date of the history entry will be the current time
/// The `id` field of this user must be present (Some) to add history
///
/// # Arguments
///
/// * `song_id` - The id of the song to add to this user's history
/// * `conn` - A mutable reference to a database connection
///
/// # Returns
///
/// * `Result<(), Box<dyn Error>>` - A result indicating success with an empty value, or an error
///
#[cfg(feature = "ssr")]
pub fn add_history(&self, song_id: i32, conn: &mut PgPooledConn) -> BackendResult<()> {
use crate::schema::song_history;
diesel::insert_into(song_history::table)
.values((
song_history::user_id.eq(self.id),
song_history::song_id.eq(song_id),
))
.execute(conn)
.context("Error adding song to history")?;
Ok(())
}
/// Like or unlike a song for this user
/// If likeing a song, remove dislike if it exists
#[cfg(feature = "ssr")]
pub async fn set_like_song(
&self,
song_id: i32,
like: bool,
conn: &mut PgPooledConn,
) -> BackendResult<()> {
use log::*;
debug!("Setting like for song {} to {}", song_id, like);
use crate::schema::song_dislikes;
use crate::schema::song_likes;
if like {
diesel::insert_into(song_likes::table)
.values((
song_likes::song_id.eq(song_id),
song_likes::user_id.eq(self.id),
))
.execute(conn)
.context("Error liking song")?;
// Remove dislike if it exists
diesel::delete(
song_dislikes::table.filter(
song_dislikes::song_id
.eq(song_id)
.and(song_dislikes::user_id.eq(self.id)),
),
)
.execute(conn)
.context("Error removing dislike for song")?;
} else {
diesel::delete(
song_likes::table.filter(
song_likes::song_id
.eq(song_id)
.and(song_likes::user_id.eq(self.id)),
),
)
.execute(conn)
.context("Error removing like for song")?;
}
Ok(())
}
/// Get the like status of a song for this user
#[cfg(feature = "ssr")]
pub async fn get_like_song(
&self,
song_id: i32,
conn: &mut PgPooledConn,
) -> BackendResult<bool> {
use crate::schema::song_likes;
let like = song_likes::table
.filter(
song_likes::song_id
.eq(song_id)
.and(song_likes::user_id.eq(self.id)),
)
.first::<(i32, i32)>(conn)
.optional()
.context("Error checking if song is liked")?
.is_some();
Ok(like)
}
/// Dislike or remove dislike from a song for this user
/// If disliking a song, remove like if it exists
#[cfg(feature = "ssr")]
pub async fn set_dislike_song(
&self,
song_id: i32,
dislike: bool,
conn: &mut PgPooledConn,
) -> BackendResult<()> {
use log::*;
debug!("Setting dislike for song {} to {}", song_id, dislike);
use crate::schema::song_dislikes;
use crate::schema::song_likes;
if dislike {
diesel::insert_into(song_dislikes::table)
.values((
song_dislikes::song_id.eq(song_id),
song_dislikes::user_id.eq(self.id),
))
.execute(conn)
.context("Error disliking song")?;
// Remove like if it exists
diesel::delete(
song_likes::table.filter(
song_likes::song_id
.eq(song_id)
.and(song_likes::user_id.eq(self.id)),
),
)
.execute(conn)
.context("Error removing like for song")?;
} else {
diesel::delete(
song_dislikes::table.filter(
song_dislikes::song_id
.eq(song_id)
.and(song_dislikes::user_id.eq(self.id)),
),
)
.execute(conn)
.context("Error removing dislike for song")?;
}
Ok(())
}
/// Get the dislike status of a song for this user
#[cfg(feature = "ssr")]
pub async fn get_dislike_song(
&self,
song_id: i32,
conn: &mut PgPooledConn,
) -> BackendResult<bool> {
use crate::schema::song_dislikes;
let dislike = song_dislikes::table
.filter(
song_dislikes::song_id
.eq(song_id)
.and(song_dislikes::user_id.eq(self.id)),
)
.first::<(i32, i32)>(conn)
.optional()
.context("Error checking if song is disliked")?
.is_some();
Ok(dislike)
}
}

View File

@ -0,0 +1,35 @@
use crate::components::dashboard_tile::DashboardTile;
use crate::models::backend::Artist;
use serde::{Deserialize, Serialize};
use chrono::NaiveDate;
/// Holds information about an album
///
/// Intended to be used in the front-end
#[derive(Serialize, Deserialize, Clone)]
pub struct Album {
/// Album id
pub id: i32,
/// Album title
pub title: String,
/// Album artists
pub artists: Vec<Artist>,
/// Album release date
pub release_date: Option<NaiveDate>,
/// Path to album image, relative to the root of the web server.
/// For example, `"/assets/images/Album.jpg"`
pub image_path: String,
}
impl From<Album> for DashboardTile {
fn from(val: Album) -> Self {
DashboardTile {
image_path: val.image_path.into(),
title: val.title.into(),
link: format!("/album/{}", val.id).into(),
description: Some(format!("Album • {}", Artist::display_list(&val.artists)).into()),
}
}
}

View File

@ -0,0 +1,27 @@
use crate::components::dashboard_tile::DashboardTile;
use serde::{Deserialize, Serialize};
/// Holds information about an artist
///
/// Intended to be used in the front-end
#[derive(Clone, Serialize, Deserialize)]
pub struct Artist {
/// Artist id
pub id: i32,
/// Artist name
pub name: String,
/// Path to artist image, relative to the root of the web server.
/// For example, `"/assets/images/Artist.jpg"`
pub image_path: String,
}
impl From<Artist> for DashboardTile {
fn from(val: Artist) -> Self {
DashboardTile {
image_path: val.image_path.into(),
title: val.name.into(),
link: format!("/artist/{}", val.id).into(),
description: Some("Artist".into()),
}
}
}

View File

@ -0,0 +1,9 @@
pub mod album;
pub mod artist;
pub mod playstatus;
pub mod song;
pub use album::Album;
pub use artist::Artist;
pub use playstatus::PlayStatus;
pub use song::Song;

View File

@ -0,0 +1,65 @@
use leptos::html::Audio;
use leptos::prelude::*;
use std::collections::VecDeque;
use web_sys::HtmlAudioElement;
use crate::models::frontend;
/// Represents the global state of the audio player feature of `LibreTunes`
pub struct PlayStatus {
/// Whether or not the audio player is currently playing
pub playing: bool,
/// Whether or not the queue is open
pub queue_open: bool,
/// A reference to the HTML audio element
pub audio_player: Option<NodeRef<Audio>>,
/// A queue of songs that have been played, ordered from oldest to newest
pub history: VecDeque<frontend::Song>,
/// A queue of songs that have yet to be played, ordered from next up to last
pub queue: VecDeque<frontend::Song>,
}
impl PlayStatus {
/// Returns the HTML audio element if it has been created and is present, otherwise returns None
///
/// Instead of:
/// ```
/// use leptos::prelude::*;
/// let status = libretunes::models::frontend::PlayStatus::default();
/// if let Some(audio) = status.audio_player {
/// if let Some(audio) = audio.get() {
/// let _ = audio.play();
/// }
/// }
/// ```
///
/// You can do:
/// ```
/// let status = libretunes::models::frontend::PlayStatus::default();
/// if let Some(audio) = status.get_audio() {
/// let _ = audio.play();
/// }
/// ```
pub fn get_audio(&self) -> Option<HtmlAudioElement> {
if let Some(audio) = &self.audio_player {
if let Some(audio) = audio.get() {
return Some(audio);
}
}
None
}
}
impl Default for PlayStatus {
/// Creates a paused `PlayStatus` with no audio player, no progress update handle, and empty queue/history
fn default() -> Self {
Self {
playing: false,
queue_open: false,
audio_player: None,
history: VecDeque::new(),
queue: VecDeque::new(),
}
}
}

View File

@ -0,0 +1,77 @@
use crate::components::dashboard_tile::DashboardTile;
use crate::models::backend::{self, Album, Artist};
use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize};
/// Holds information about a song
///
/// Intended to be used in the front-end, as it includes artist and album objects, rather than just their ids.
#[derive(Serialize, Deserialize, Clone)]
pub struct Song {
/// Song id
pub id: i32,
/// Song name
pub title: String,
/// Song artists
pub artists: Vec<Artist>,
/// Song album
pub album: Option<Album>,
/// The track number of the song on the album
pub track: Option<i32>,
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
pub release_date: Option<NaiveDate>,
/// Path to song file, relative to the root of the web server.
/// For example, `"/assets/audio/Song.mp3"`
pub song_path: String,
/// Path to song image, relative to the root of the web server.
/// For example, `"/assets/images/Song.jpg"`
pub image_path: String,
/// Whether the song is liked by the user
pub like_dislike: Option<(bool, bool)>,
/// The date the song was added to the database
pub added_date: NaiveDateTime,
}
impl TryInto<backend::Song> for Song {
type Error = Box<dyn std::error::Error>;
/// Convert a `SongData` object into a Song object
///
/// The SongData/Song conversions are also not truly reversible,
/// due to the way the `image_path` data is handled.
fn try_into(self) -> Result<backend::Song, Self::Error> {
Ok(backend::Song {
id: self.id,
title: self.title,
album_id: self.album.map(|album| album.id),
track: self.track,
duration: self.duration,
release_date: self.release_date,
storage_path: self.song_path,
// Note that if the source of the image_path was the album, the image_path
// will be set to the album's image_path instead of None
image_path: if self.image_path == "/assets/images/placeholder.jpg" {
None
} else {
Some(self.image_path)
},
added_date: self.added_date,
})
}
}
impl From<Song> for DashboardTile {
fn from(val: Song) -> Self {
DashboardTile {
image_path: val.image_path.into(),
title: val.title.into(),
link: format!("/song/{}", val.id).into(),
description: Some(format!("Song • {}", Artist::display_list(&val.artists)).into()),
}
}
}

2
src/models/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod backend;
pub mod frontend;

View File

@ -1,3 +0,0 @@
pub mod login;
pub mod signup;
pub mod profile;

105
src/pages/album.rs Normal file
View File

@ -0,0 +1,105 @@
use crate::api::album::*;
use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use crate::models::frontend;
use leptos::either::*;
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
#[component]
pub fn AlbumPage() -> impl IntoView {
let params = use_params_map();
view! {
{move || params.with(|params| {
match params.get("id").map(|id| id.parse::<i32>()) {
Some(Ok(id)) => {
Either::Left(view! { <AlbumIdPage id /> })
},
Some(Err(e)) => {
Either::Right(view! {
<Error<String>
title="Invalid Album ID"
error=e.to_string()
/>
})
},
None => {
Either::Right(view! {
<Error<String>
title="No Album ID"
message="You must specify an album ID to view its page."
/>
})
}
}
})}
}
}
#[component]
fn AlbumIdPage(#[prop(into)] id: Signal<i32>) -> impl IntoView {
let album = Resource::new(id, get_album);
let show_songs = RwSignal::new(false);
view! {
<Transition
fallback=move || view! { <Loading /> }
>
{move || album.get().map(|album| {
match album {
Ok(Some(album)) => {
show_songs.set(true);
EitherOf3::A(view! { <AlbumInfo album /> })
},
Ok(None) => EitherOf3::B(view! {
<Error<String>
title="Album Not Found"
message=format!("Album with ID {} not found", id.get())
/>
}),
Err(error) => EitherOf3::C(error.to_component()),
}
})}
</Transition>
<Show when=show_songs>
<AlbumSongs id />
</Show>
}
}
#[component]
fn AlbumInfo(album: frontend::Album) -> impl IntoView {
view! {
<div class="flex">
<img class="w-70 h-70 p-5" src={album.image_path} alt="Album Cover" />
<div class="self-center">
<h1 class="text-4xl">{album.title}</h1>
<SongArtists artists=album.artists />
</div>
</div>
}
.into_view()
}
#[component]
fn AlbumSongs(#[prop(into)] id: Signal<i32>) -> impl IntoView {
let songs = Resource::new(id, get_songs);
view! {
<Transition
fallback= move || view! { <Loading /> }
>
{move || songs.get().map(|songs| {
match songs {
Ok(songs) => Either::Left(
view! { <SongList songs=songs /> }
),
Err(error) => Either::Right(error.to_component()),
}
})}
</Transition>
}
}

170
src/pages/artist.rs Normal file
View File

@ -0,0 +1,170 @@
use leptos::either::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_router::hooks::use_params_map;
use crate::models::backend::Artist;
use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use crate::api::artists::*;
#[component]
pub fn ArtistPage() -> impl IntoView {
let params = use_params_map();
view! {
{move || params.with(|params| {
match params.get("id").map(|id| id.parse::<i32>()) {
Some(Ok(id)) => {
Either::Left(view! { <ArtistIdProfile id /> })
},
Some(Err(e)) => {
Either::Right(view! {
<Error<String>
title="Invalid Artist ID"
error=e.to_string()
/>
})
},
None => {
Either::Right(view! {
<Error<String>
title="No Artist ID"
message="You must specify an artist ID to view their page."
/>
})
}
}
})}
}
}
#[component]
fn ArtistIdProfile(#[prop(into)] id: Signal<i32>) -> impl IntoView {
let artist_info = Resource::new(move || id.get(), get_artist_by_id);
let show_details = RwSignal::new(false);
view! {
<Transition
fallback=move || view! { <LoadingPage /> }
>
{move || artist_info.get().map(|artist| {
match artist {
Ok(Some(artist)) => {
show_details.set(true);
EitherOf3::A(view! { <ArtistProfile artist /> })
},
Ok(None) => EitherOf3::B(view! {
<Error<String>
title="Artist Not Found"
message=format!("Artist with ID {} not found", id.get())
/>
}),
Err(error) => EitherOf3::C(error.to_component())
}
})}
</Transition>
<div hidden={move || !show_details.get()}>
<TopSongsByArtist artist_id=id />
<AlbumsByArtist artist_id=id />
</div>
}
}
#[component]
fn ArtistProfile(artist: Artist) -> impl IntoView {
let profile_image_path = format!("/assets/images/artist/{}.webp", artist.id);
leptos::logging::log!("Artist name: {}", artist.name);
view! {
<div class="flex">
<object class="w-35 h-35 rounded-full p-5" data={profile_image_path.clone()} type="image/webp">
<Icon icon={icondata::CgProfile} width="100" height="100" {..} class="artist-image" />
</object>
<h1 class="text-4xl self-center">{artist.name}</h1>
</div>
}
}
#[component]
fn TopSongsByArtist(#[prop(into)] artist_id: Signal<i32>) -> impl IntoView {
let top_songs = Resource::new(
move || artist_id.get(),
|artist_id| async move {
let top_songs = top_songs_by_artist(artist_id, Some(10)).await;
top_songs.map(|top_songs| {
top_songs
.into_iter()
.map(|(song, plays)| {
let plays = if plays == 1 {
"1 play".to_string()
} else {
format!("{plays} plays")
};
(song, plays)
})
.collect::<Vec<_>>()
})
},
);
view! {
<h2 class="text-xl font-bold">"Top Songs"</h2>
<Transition
fallback=move || view! { <Loading /> }
>
{move || top_songs.get().map(|top_songs| {
match top_songs {
Ok(top_songs) => {
Either::Left(view! {
<SongListExtra songs=top_songs />
})
},
Err(error) => Either::Right(error.to_component()),
}
})}
</Transition>
}
}
#[component]
fn AlbumsByArtist(#[prop(into)] artist_id: Signal<i32>) -> impl IntoView {
use crate::components::dashboard_row::*;
let albums = Resource::new(
move || artist_id.get(),
|artist_id| async move {
let albums = albums_by_artist(artist_id, None).await;
albums.map(|albums| albums.into_iter().collect::<Vec<_>>())
},
);
view! {
<Transition
fallback=move || view! { <Loading /> }
>
{move || albums.get().map(|albums| {
match albums {
Ok(albums) => Either::Left({
let tiles = albums.into_iter().map(|album| {
album.into()
}).collect::<Vec<_>>();
view! {
<DashboardRow title="Albums" tiles />
}
}),
Err(error) => Either::Right(error.to_component()),
}
})}
</Transition>
}
}

8
src/pages/dashboard.rs Normal file
View File

@ -0,0 +1,8 @@
use leptos::prelude::*;
#[component]
pub fn Dashboard() -> impl IntoView {
view! {
<h1>"Dashboard"</h1>
}
}

38
src/pages/liked_songs.rs Normal file
View File

@ -0,0 +1,38 @@
use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use leptos::either::*;
use leptos::prelude::*;
use crate::api::profile::get_liked_songs;
#[component]
pub fn LikedSongsPage() -> impl IntoView {
let liked_songs = Resource::new(|| (), |_| get_liked_songs());
view! {
<h1 class="text-4xl">"Liked Songs"</h1>
<Transition
fallback=move || view! { <LoadingPage /> }
>
{move || liked_songs.get().map(|songs| {
match songs {
Ok(songs) => {
Either::Left(view! {
<p class="text-neutral-500">{songs.len()} " liked songs"</p>
<SongList songs />
})
},
Err(e) => {
Either::Right(view! {
<Error<String>
title="Error loading liked songs"
error=e.to_string()
/>
})
}
}
})}
</Transition>
}
}

View File

@ -1,107 +1,94 @@
use crate::auth::login; use crate::api::auth::login;
use crate::api::users::UserCredentials;
use crate::components::fancy_input::*;
use crate::components::loading::Loading;
use crate::util::state::GlobalState; use crate::util::state::GlobalState;
use leptos::leptos_dom::*; use leptos::leptos_dom::*;
use leptos::*; use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::*; use leptos_icons::*;
use crate::users::UserCredentials;
#[component] #[component]
pub fn Login() -> impl IntoView { pub fn Login() -> impl IntoView {
let (username_or_email, set_username_or_email) = create_signal("".to_string()); let username_or_email = RwSignal::new("".to_string());
let (password, set_password) = create_signal("".to_string()); let password = RwSignal::new("".to_string());
let (show_password, set_show_password) = create_signal(false); let loading = RwSignal::new(false);
let error_msg = RwSignal::new(None);
let toggle_password = move |_| {
set_show_password.update(|show_password| *show_password = !*show_password);
log!("showing password");
};
let on_submit = move |ev: leptos::ev::SubmitEvent| { let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
let username_or_email1 = username_or_email.get();
let password1 = password.get();
spawn_local(async move { spawn_local(async move {
loading.set(true);
error_msg.set(None);
let user_credentials = UserCredentials { let user_credentials = UserCredentials {
username_or_email: username_or_email1, username_or_email: username_or_email.get_untracked(),
password: password1 password: password.get_untracked(),
}; };
let user = GlobalState::logged_in_user(); let user = GlobalState::logged_in_user();
let login_result = login(user_credentials).await; let login_result = login(user_credentials).await;
if let Err(err) = login_result { if let Err(err) = login_result {
// Handle the error here, e.g., log it or display to the user // Handle the error here, e.g., log it or display to the user
log!("Error logging in: {:?}", err); log!("Error logging in: {:?}", err);
error_msg.set(Some(err.to_string()));
// Since we're not sure what the state is, manually refetch the user // Since we're not sure what the state is, manually refetch the user
user.refetch(); user.refetch();
} else if let Ok(Some(login_user)) = login_result { } else if let Ok(Some(login_user)) = login_result {
// Manually set the user to the new user, avoiding a refetch // Manually set the user to the new user, avoiding a refetch
user.set(Some(login_user)); user.set(Some(Some(login_user)));
// Redirect to the login page // Redirect to the login page
log!("Logged in Successfully!"); log!("Logged in Successfully!");
leptos_router::use_navigate()("/", Default::default()); leptos_router::hooks::use_navigate()("/", Default::default());
log!("Navigated to home page after login"); log!("Navigated to home page after login");
} else if let Ok(None) = login_result { } else if let Ok(None) = login_result {
log!("Invalid username or password"); log!("Invalid username or password");
error_msg.set(Some("Invalid username or password".to_string()));
// User could be already logged in or not, so refetch the user // User could be already logged in or not, so refetch the user
user.refetch(); user.refetch();
} }
loading.set(false);
}); });
}; };
view! { view! {
<div class="auth-page-container"> <section class="bg-white dark:bg-black flex items-center justify-center h-screen">
<div class="login-container"> <div class="rounded-lg shadow bg-white w-full p-12 max-w-md relative">
<a class="return" href="/"><Icon icon=icondata::IoReturnUpBackSharp /></a> <a class="hover:bg-neutral-400 transition-all duration-500
<div class="header"> rounded-md absolute left-5 top-5 p-1" href="/">
<h1>LibreTunes</h1> <Icon icon={icondata::IoReturnUpBackSharp} height="1.5rem" width="1.5rem"/>
</div> </a>
<form class="login-form" action="POST" on:submit=on_submit> <h1 class="text-5xl font-bold text-accent text-center p-1">"LibreTunes"</h1>
<div class="input-box"> <form on:submit=on_submit>
<input class="login-info" type="text" required <FancyInput label="Username/Email" required=true value=username_or_email />
on:input = move |ev| { <FancyInput label="Password" password=true required=true value=password />
set_username_or_email(event_target_value(&ev)); <a class="hover-link my-1">"Forgot Password?"</a>
log!("username/email changed to: {}", username_or_email.get()); <div
} class="text-red-800 text-base"
prop:value=username_or_email style="min-height: calc(var(--text-base--line-height) * var(--text-base));"
/> >
<span>Username/Email</span> { move || error_msg.get() }
<i></i>
</div> </div>
<div class="input-box"> <Show
<input class="login-password" type={move || if show_password() { "text" } else { "password"} } required when=move || !loading.get()
on:input = move |ev| { fallback=move || view! { <div class="p-3 my-2"> <Loading /> </div> }
set_password(event_target_value(&ev)); >
log!("password changed to: {}", password.get()); <input class="bg-accent rounded-md text-white text-base
} w-full p-3 my-2 font-semibold cursor-pointer" type="submit" value="Login" />
/> </Show>
<span>Password</span> <span class="text-base text-neutral-500 my-1">
<i></i> "New here?"
<Show <a class="hover-link ml-2" href="/signup">"Create an Account"</a>
when=move || {show_password() == false}
fallback=move || view!{ <button on:click=toggle_password class="login-password-visibility">
<Icon icon=icondata::AiEyeInvisibleFilled />
</button> /> }
>
<button on:click=toggle_password class="login-password-visibility">
<Icon icon=icondata::AiEyeFilled />
</button>
</Show>
</div>
<a href="" class="forgot-pw">Forgot Password?</a>
<input type="submit" value="Login" />
<span class="go-to-signup">
New here? <a href="/signup">Create an Account</a>
</span> </span>
</form> </form>
</div> </div>
</div> </section>
} }
} }

10
src/pages/mod.rs Normal file
View File

@ -0,0 +1,10 @@
pub mod album;
pub mod artist;
pub mod dashboard;
pub mod liked_songs;
pub mod login;
pub mod playlist;
pub mod profile;
pub mod search;
pub mod signup;
pub mod song;

218
src/pages/playlist.rs Normal file
View File

@ -0,0 +1,218 @@
use crate::api::playlists::*;
use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use crate::models::backend;
use crate::util::state::GlobalState;
use leptos::either::*;
use leptos::ev::{keydown, KeyboardEvent};
use leptos::html::{Button, Input};
use leptos::logging::*;
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::*;
use leptos_router::components::Form;
use leptos_router::hooks::{use_navigate, use_params_map};
use leptos_use::{on_click_outside, use_event_listener};
use std::sync::Arc;
use web_sys::Response;
#[component]
pub fn PlaylistPage() -> impl IntoView {
let params = use_params_map();
view! {
{move || params.with(|params| {
match params.get("id").map(|id| id.parse::<i32>()) {
Some(Ok(id)) => {
Either::Left(view! { <PlaylistIdPage id /> })
},
Some(Err(e)) => {
Either::Right(view! {
<Error<String>
title="Invalid Playlist ID"
error=e.to_string()
/>
})
},
None => {
Either::Right(view! {
<Error<String>
title="No Playlist ID"
message="You must specify a playlist ID to view its page."
/>
})
}
}
})}
}
}
#[component]
fn PlaylistIdPage(#[prop(into)] id: Signal<i32>) -> impl IntoView {
let playlist_songs = Resource::new(id, get_playlist_songs);
view! {
<Transition
fallback=move || view! { <LoadingPage /> }
>
{move || GlobalState::playlists().get().map(|playlists| {
let playlist = playlists.map(|playlists| {
playlists.into_iter().find(|playlist| playlist.id == id.get())
});
match playlist {
Ok(Some(playlist)) => {
Either::Left(view! { <PlaylistInfo playlist /> })
},
Ok(None) => {
Either::Right(view! {
<Error<String>
title="Playlist not found"
message="The playlist you are looking for does not exist."
/>
})
}
Err(e) => Either::Right(view! {
<Error<String>
title="Error loading playlist"
error=e.to_string()
/>
}),
}
})}
{move || playlist_songs.get().map(|playlist_songs| {
match playlist_songs {
Ok(playlist_songs) => {
Either::Left(view! { <SongList songs=playlist_songs /> })
},
Err(e) => Either::Right(view! {
<Error<String>
title="Error loading playlist songs"
error=e.to_string()
/>
}),
}
})}
</Transition>
}
}
#[component]
fn PlaylistInfo(playlist: backend::Playlist) -> impl IntoView {
let on_img_edit_response = Arc::new(move |response: &Response| {
if response.ok() {
// TODO inform browser that image has changed
} else {
error!("Error editing playlist image: {}", response.status());
// TODO toast
}
});
let playing = RwSignal::new(false);
let editing_name = RwSignal::new(false);
let playlist_name = RwSignal::new(playlist.name.clone());
let name_edit_input = NodeRef::<Input>::new();
let edit_complete = move || {
editing_name.set(false);
spawn_local(async move {
if let Err(e) = rename_playlist(playlist.id, playlist_name.get_untracked()).await {
error!("Error editing playlist name: {}", e);
// TODO toast
} else {
GlobalState::playlists().refetch();
}
});
};
let _edit_close_handler = on_click_outside(name_edit_input, move |_| edit_complete());
let _edit_enter_handler =
use_event_listener(name_edit_input, keydown, move |event: KeyboardEvent| {
if event.key() == "Enter" {
event.prevent_default();
edit_complete();
}
});
let on_play = move |_| {
playing.set(!playing.get());
};
let delete_btn = NodeRef::<Button>::new();
let confirm_delete = RwSignal::new(false);
let on_delete = move |_| {
if confirm_delete.get_untracked() {
spawn_local(async move {
if let Err(e) = delete_playlist(playlist.id).await {
error!("Error deleting playlist: {}", e);
} else {
GlobalState::playlists().refetch();
use_navigate()("/", Default::default());
}
});
} else {
confirm_delete.set(true);
}
};
let _delete_escape_handler = on_click_outside(delete_btn, move |_| {
confirm_delete.set(false);
});
let on_edit = move |_| {
editing_name.set(true);
name_edit_input.on_load(move |input| {
input.select();
});
};
view! {
<div class="flex items-center">
<div class="group relative">
<img class="w-70 h-70 p-5 rounded-4xl group-hover:brightness-45 transition-all"
src={format!("/assets/images/playlist/{}.webp", playlist.id)} onerror={crate::util::img_fallback::MUSIC_IMG_FALLBACK} />
<Form action="/api/playlists/edit_image" method="POST" enctype="multipart/form-data".to_string() on_response=on_img_edit_response.clone()>
<input type="hidden" name="id" value={playlist.id} />
<label for="edit-playlist-img">
<Icon icon={icondata::BiPencilSolid} {..} class="absolute bottom-10 right-10 w-8 h-8 control opacity-0 group-hover:opacity-100" />
</label>
<input id="edit-playlist-img" type="file" accept="image/*" class="hidden" onchange="form.submit()" />
</Form>
</div>
<div>
<Show
when=move || editing_name.get()
fallback=move || view! {
<h1 class="text-4xl" on:click=on_edit>{playlist_name}</h1>
}
>
<input type="text" bind:value=playlist_name
class="bg-neutral-800 text-neutral-200 border border-neutral-600 rounded-lg p-2 w-full outline-none text-4xl"
required
node_ref=name_edit_input autocomplete="off" />
</Show>
<p>{format!("Last Updated {}", playlist.updated_at.format("%B %-d %Y"))}</p>
<div class="flex">
<button class="control" on:click=on_play>
{move || if playing.get() {
Either::Left(view! { <Icon icon={icondata::BsPauseFill} {..} class="w-12 h-12" /> })
} else {
Either::Right(view! { <Icon icon={icondata::BsPlayFill} {..} class="w-12 h-12" /> })
}}
</button>
<button node_ref=delete_btn class="control" on:click=on_delete style={move || if confirm_delete.get() { "color: red;" } else { "" }} >
<Icon icon={icondata::AiDeleteOutlined} {..} class="w-12 h-12" />
</button>
</div>
</div>
</div>
}
}

View File

@ -1,18 +1,17 @@
use leptos::*; use leptos::either::*;
use leptos_router::use_params_map; use leptos::prelude::*;
use leptos_icons::*; use leptos_icons::*;
use server_fn::error::NoCustomError; use leptos_router::hooks::use_params_map;
use crate::components::dashboard_row::DashboardRow; use crate::components::dashboard_row::DashboardRow;
use crate::components::dashboard_tile::DashboardTile;
use crate::components::song_list::*;
use crate::components::loading::*;
use crate::components::error::*; use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use crate::api::profile::*; use crate::api::profile::*;
use crate::models::User; use crate::api::users::get_user_by_id;
use crate::users::get_user_by_id; use crate::models::backend::User;
use crate::util::state::GlobalState; use crate::util::state::GlobalState;
/// Duration in seconds backwards from now to aggregate history data for /// Duration in seconds backwards from now to aggregate history data for
@ -30,287 +29,280 @@ const TOP_ARTISTS_COUNT: i64 = 10;
/// Shows the current user's profile if no id is specified, or a user's profile if an id is specified in the path /// Shows the current user's profile if no id is specified, or a user's profile if an id is specified in the path
#[component] #[component]
pub fn Profile() -> impl IntoView { pub fn Profile() -> impl IntoView {
let params = use_params_map(); let params = use_params_map();
view! { view! {
<div class="profile-container home-component"> {move || params.with(|params| {
{move || params.with(|params| { match params.get("id").map(|id| id.parse::<i32>()) {
match params.get("id").map(|id| id.parse::<i32>()) { None => {
None => { // No id specified, show the current user's profile
// No id specified, show the current user's profile EitherOf3::A(view! { <OwnProfile /> })
view! { <OwnProfile /> }.into_view() },
}, Some(Ok(id)) => {
Some(Ok(id)) => { // Id specified, get the user and show their profile
// Id specified, get the user and show their profile EitherOf3::B(view! { <UserIdProfile id /> })
view! { <UserIdProfile id /> }.into_view() },
}, Some(Err(e)) => {
Some(Err(e)) => { // Invalid id, return an error
// Invalid id, return an error EitherOf3::C(view! {
view! { <Error<String>
<Error<String> title="Invalid User ID"
title="Invalid User ID" error=e.to_string()
error=e.to_string() />
/> })
}.into_view() }
} }
} })}
})} }
</div>
}
} }
/// Show the logged in user's profile /// Show the logged in user's profile
#[component] #[component]
fn OwnProfile() -> impl IntoView { fn OwnProfile() -> impl IntoView {
view! { view! {
<Transition <Transition
fallback=move || view! { <LoadingPage /> } fallback=move || view! { <LoadingPage /> }
> >
{move || GlobalState::logged_in_user().get().map(|user| { {move || GlobalState::logged_in_user().get().map(|user| {
match user { match user {
Some(user) => { Some(user) => {
let user_id = user.id.unwrap(); let user_id = user.id;
view! { Either::Left(view! {
<UserProfile user /> <UserProfile user />
<TopSongs user_id={user_id} /> <TopSongs user_id={user_id} />
<RecentSongs user_id={user_id} /> <RecentSongs user_id={user_id} />
<TopArtists user_id={user_id} /> <TopArtists user_id={user_id} />
}.into_view() })
}, },
None => view! { None => Either::Right(view! {
<Error<String> <Error<String>
title="Not Logged In" title="Not Logged In"
message="You must be logged in to view your profile" message="You must be logged in to view your profile"
/> />
}.into_view(), }),
} }
})} })}
</Transition> </Transition>
} }
} }
/// Show a user's profile by ID /// Show a user's profile by ID
#[component] #[component]
fn UserIdProfile(#[prop(into)] id: MaybeSignal<i32>) -> impl IntoView { fn UserIdProfile(#[prop(into)] id: Signal<i32>) -> impl IntoView {
let user_info = create_resource(move || id.get(), move |id| { let user_info = Resource::new(move || id.get(), get_user_by_id);
get_user_by_id(id)
});
// Show the details if the user is found // Show the details if the user is found
let show_details = create_rw_signal(false); let show_details = RwSignal::new(false);
view!{ view! {
<Transition <Transition
fallback=move || view! { <LoadingPage /> } fallback=move || view! { <LoadingPage /> }
> >
{move || user_info.get().map(|user| { {move || user_info.get().map(|user| {
match user { match user {
Ok(Some(user)) => { Ok(Some(user)) => {
show_details.set(true); show_details.set(true);
view! { <UserProfile user /> }.into_view() EitherOf3::A(view! { <UserProfile user /> })
}, },
Ok(None) => { Ok(None) => {
show_details.set(false); show_details.set(false);
view! { EitherOf3::B(view! {
<Error<String> <Error<String>
title="User Not Found" title="User Not Found"
message=format!("User with ID {} not found", id.get()) message=format!("User with ID {} not found", id.get())
/> />
}.into_view() })
}, },
Err(error) => { Err(error) => {
show_details.set(false); show_details.set(false);
EitherOf3::C(error.to_component())
view! { }
<ServerError<NoCustomError> }
title="Error Getting User" })}
error </Transition>
/> <div hidden={move || !show_details.get()}>
}.into_view() <TopSongs user_id={id} />
} <RecentSongs user_id={id} />
} <TopArtists user_id={id} />
})} </div>
</Transition> }
<div hidden={move || !show_details.get()}>
<TopSongs user_id={id} />
<RecentSongs user_id={id} />
<TopArtists user_id={id} />
</div>
}
} }
/// Show a profile for a User object /// Show a profile for a User object
#[component] #[component]
fn UserProfile(user: User) -> impl IntoView { fn UserProfile(user: User) -> impl IntoView {
let user_id = user.id.unwrap(); let profile_image_path = format!("/assets/images/profile/{}.webp", user.id);
let profile_image_path = format!("/assets/images/profile/{}.webp", user_id);
view! { view! {
<div class="profile-header"> <div class="flex">
<object class="profile-image" data={profile_image_path.clone()} type="image/webp"> <object class="w-35 h-35 rounded-full p-5" data={profile_image_path.clone()} type="image/webp">
<Icon class="profile-image" icon=icondata::CgProfile width="75" height="75"/> <Icon icon={icondata::CgProfile} width="100" height="100" />
</object> </object>
<h1>{user.username}</h1> <h1 class="text-4xl self-center">{user.username}</h1>
</div> </div>
<div class="profile-details"> <p class="m-2">
<p> {user.email}
{user.email} {format!(" • Joined {}", user.created_at.format("%B %Y"))}
{ {
user.created_at.map(|created_at| { if user.admin {
format!(" • Joined {}", created_at.format("%B %Y")) " • Admin"
}) } else {
} ""
{ }
if user.admin { }
" • Admin" </p>
} else { }
""
}
}
</p>
</div>
}
} }
/// Show a list of top songs for a user /// Show a list of top songs for a user
#[component] #[component]
fn TopSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView { fn TopSongs(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
let top_songs = create_resource(move || user_id.get(), |user_id| async move { let top_songs = Resource::new(
use chrono::{Local, Duration}; move || user_id.get(),
let now = Local::now(); |user_id| async move {
let start = now - Duration::seconds(HISTORY_SECS); use chrono::{Duration, Local};
let top_songs = top_songs(user_id, start.naive_utc(), now.naive_utc(), Some(TOP_SONGS_COUNT)).await; let now = Local::now();
let start = now - Duration::seconds(HISTORY_SECS);
let top_songs = top_songs(
user_id,
start.naive_utc(),
now.naive_utc(),
Some(TOP_SONGS_COUNT),
)
.await;
top_songs.map(|top_songs| { top_songs.map(|top_songs| {
top_songs.into_iter().map(|(plays, song)| { top_songs
let plays = if plays == 1 { .into_iter()
format!("{} Play", plays) .map(|(plays, song)| {
} else { let plays = if plays == 1 {
format!("{} Plays", plays) format!("{plays} Play")
}; } else {
format!("{plays} Plays")
};
(song, plays) (song, plays)
}).collect::<Vec<_>>() })
}) .collect::<Vec<_>>()
}); })
},
);
view! { view! {
<h2>{format!("Top Songs {}", HISTORY_MESSAGE)}</h2>
<Transition <h2 class="text-xl font-bold">{format!("Top Songs {HISTORY_MESSAGE}")}</h2>
fallback=move || view! { <Loading /> } <Transition
> fallback=move || view! { <Loading /> }
<ErrorBoundary >
fallback=|errors| view! { {move ||
{move || errors.get() top_songs.get().map(|top_songs| {
.into_iter() match top_songs {
.map(|(_, e)| view! { <p>{e.to_string()}</p>}) Ok(top_songs) => Either::Left({
.collect_view() view! {
} <SongListExtra songs=top_songs />
} }
> }),
{move || Err(err) => Either::Right(err.to_component()),
top_songs.get().map(|top_songs| { }
top_songs.map(|top_songs| { })
view! { }
<SongListExtra songs={top_songs.into()} /> </Transition>
} }
})
})
}
</ErrorBoundary>
</Transition>
}
} }
/// Show a list of recently played songs for a user /// Show a list of recently played songs for a user
#[component] #[component]
fn RecentSongs(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView { fn RecentSongs(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
let recent_songs = create_resource(move || user_id.get(), |user_id| async move { let recent_songs = Resource::new(
let recent_songs = recent_songs(user_id, Some(RECENT_SONGS_COUNT)).await; move || user_id.get(),
|user_id| async move {
let recent_songs = recent_songs(user_id, Some(RECENT_SONGS_COUNT)).await;
recent_songs.map(|recent_songs| { recent_songs.map(|recent_songs| {
recent_songs.into_iter().map(|(_date, song)| { recent_songs
song .into_iter()
}).collect::<Vec<_>>() .map(|(_date, song)| song)
}) .collect::<Vec<_>>()
}); })
},
);
view! { view! {
<h2>"Recently Played"</h2> <h2 class="text-xl font-bold">"Recently Played"</h2>
<Transition <Transition
fallback=move || view! { <Loading /> } fallback=move || view! { <Loading /> }
> >
<ErrorBoundary {move ||
fallback=|errors| view! { recent_songs.get().map(|recent_songs| {
{move || errors.get() match recent_songs {
.into_iter() Ok(recent_songs) => Either::Left(view! {
.map(|(_, e)| view! { <p>{e.to_string()}</p>}) <SongList songs=recent_songs />
.collect_view() }),
} Err(err) => Either::Right(err.to_component()),
} }
> })
{move || }
recent_songs.get().map(|recent_songs| { </Transition>
recent_songs.map(|recent_songs| { }
view! {
<SongList songs={recent_songs.into()} />
}
})
})
}
</ErrorBoundary>
</Transition>
}
} }
/// Show a list of top artists for a user /// Show a list of top artists for a user
#[component] #[component]
fn TopArtists(#[prop(into)] user_id: MaybeSignal<i32>) -> impl IntoView { fn TopArtists(#[prop(into)] user_id: Signal<i32>) -> impl IntoView {
let top_artists = create_resource(move || user_id.get(), |user_id| async move { let top_artists = Resource::new(
use chrono::{Local, Duration}; move || user_id.get(),
|user_id| async move {
use chrono::{Duration, Local};
let now = Local::now(); let now = Local::now();
let start = now - Duration::seconds(HISTORY_SECS); let start = now - Duration::seconds(HISTORY_SECS);
let top_artists = top_artists(user_id, start.naive_utc(), now.naive_utc(), Some(TOP_ARTISTS_COUNT)).await; let top_artists = top_artists(
user_id,
start.naive_utc(),
now.naive_utc(),
Some(TOP_ARTISTS_COUNT),
)
.await;
top_artists.map(|top_artists| { top_artists.map(|top_artists| {
top_artists.into_iter().map(|(_plays, artist)| { top_artists
artist .into_iter()
}).collect::<Vec<_>>() .map(|(_plays, artist)| artist)
}) .collect::<Vec<_>>()
}); })
},
);
view! { view! {
<Transition <Transition
fallback=move || view! { fallback=move || view! {
<h2>{format!("Top Artists {}", HISTORY_MESSAGE)}</h2> <h2 class="text-xl font-bold">{format!("Top Artists {HISTORY_MESSAGE}")}</h2>
<Loading /> <Loading />
} }
> >
<ErrorBoundary {move ||
fallback=|errors| view! { top_artists.get().map(|top_artists| {
<h2>{format!("Top Artists {}", HISTORY_MESSAGE)}</h2> match top_artists {
{move || errors.get() Ok(top_artists) => Either::Left({
.into_iter() let tiles = top_artists.into_iter().map(|artist| {
.map(|(_, e)| view! { <p>{e.to_string()}</p>}) artist.into()
.collect_view() }).collect::<Vec<_>>();
}
}
>
{move ||
top_artists.get().map(|top_artists| {
top_artists.map(|top_artists| {
let tiles = top_artists.into_iter().map(|artist| {
Box::new(artist) as Box<dyn DashboardTile>
}).collect::<Vec<_>>();
DashboardRow::new(format!("Top Artists {}", HISTORY_MESSAGE), tiles) view! {
}) <DashboardRow title=format!("Top Artists {}", HISTORY_MESSAGE) tiles />
}) }
} }),
</ErrorBoundary> Err(err) => Either::Right(
</Transition> view! {
} <h2 class="text-xl font-bold">{format!("Top Artists {HISTORY_MESSAGE}")}</h2>
{err.to_component()}
}
)
}
})
}
</Transition>
}
} }

86
src/pages/search.rs Normal file
View File

@ -0,0 +1,86 @@
use crate::api::search::search;
use crate::components::dashboard_row::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use leptos::either::*;
use leptos::html::Input;
use leptos::prelude::*;
use leptos_router::hooks::query_signal;
#[component]
pub fn Search() -> impl IntoView {
// Sync the search query with the URL parameter "query"
let (query, set_query) = query_signal::<String>("query");
let search_sig = RwSignal::new(query.get_untracked().unwrap_or(String::new()));
Effect::new(move || set_query.set(Some(search_sig.get())));
Effect::new(move || search_sig.set(query.get().unwrap_or(String::new())));
let search = Resource::new(
move || search_sig.get(),
move |search_sig| search(search_sig, 10),
);
let input_ref = NodeRef::<Input>::new();
input_ref.on_load(move |input| {
// Select all text in the input field, or just focus it if empty
input.select();
});
view! {
<input node_ref=input_ref
class="bg-neutral-800 text-neutral-200 border border-neutral-600 rounded-lg p-2 w-full outline-none"
type="text" placeholder="Search..." bind:value=search_sig autofocus />
<Suspense
fallback=|| view! { <Loading /> }
>
{move || {
search.get().map(|results| {
match results {
Ok((albums, artists, songs)) => Either::Right(
view! {
{
(albums.is_empty() && artists.is_empty() && songs.is_empty()).then(|| {
view! {
<h2 class="text-xl text-neutral-500">"No Results"</h2>
}
})
}
{
(!albums.is_empty()).then(|| {
view! {
<DashboardRow
title="Albums"
tiles=albums.into_iter().map(|(album, _score)| album.into()).collect()
/>
}
})
}
{
(!artists.is_empty()).then(|| {
view! {
<DashboardRow
title="Artists"
tiles=artists.into_iter().map(|(artist, _score)| artist.into()).collect()
/>
}
})
}
{
(!songs.is_empty()).then(|| {
view! {
<h2 class="text-xl font-bold">"Songs"</h2>
<SongList songs=songs.into_iter().map(|(song, _score)| song).collect() />
}
})
}
}
),
Err(err) => Either::Left(err.to_component()),
}
})
}}
</Suspense>
}
}

View File

@ -1,110 +1,89 @@
use crate::auth::signup; use crate::api::auth::signup;
use crate::models::User; use crate::components::fancy_input::*;
use crate::components::loading::Loading;
use crate::models::backend::NewUser;
use crate::util::state::GlobalState; use crate::util::state::GlobalState;
use leptos::leptos_dom::*; use leptos::leptos_dom::*;
use leptos::*; use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_icons::*; use leptos_icons::*;
#[component] #[component]
pub fn Signup() -> impl IntoView { pub fn Signup() -> impl IntoView {
let (username, set_username) = create_signal("".to_string()); let username = RwSignal::new("".to_string());
let (email, set_email) = create_signal("".to_string()); let email = RwSignal::new("".to_string());
let (password, set_password) = create_signal("".to_string()); let password = RwSignal::new("".to_string());
let (show_password, set_show_password) = create_signal(false); let loading = RwSignal::new(false);
let error_msg = RwSignal::new(None);
let toggle_password = move |_| {
set_show_password.update(|show_password| *show_password = !*show_password);
log!("showing password");
};
let on_submit = move |ev: leptos::ev::SubmitEvent| { let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default(); ev.prevent_default();
let mut new_user = User { let new_user = NewUser {
id: None, username: username.get_untracked(),
username: username.get(), email: email.get_untracked(),
email: email.get(), password: Some(password.get_untracked()),
password: Some(password.get()),
created_at: None,
admin: false, admin: false,
}; };
log!("new user: {:?}", new_user); log!("new user: {:?}", new_user);
loading.set(true);
error_msg.set(None);
let user = GlobalState::logged_in_user(); let user = GlobalState::logged_in_user();
spawn_local(async move { spawn_local(async move {
if let Err(err) = signup(new_user.clone()).await { if let Err(err) = signup(new_user.clone()).await {
// Handle the error here, e.g., log it or display to the user // Handle the error here, e.g., log it or display to the user
log!("Error signing up: {:?}", err); log!("Error signing up: {:?}", err);
error_msg.set(Some(err.to_string()));
// Since we're not sure what the state is, manually refetch the user // Since we're not sure what the state is, manually refetch the user
user.refetch(); user.refetch();
} else { } else {
// Manually set the user to the new user, avoiding a refetch user.refetch();
new_user.password = None;
user.set(Some(new_user));
// Redirect to the login page // Redirect to the login page
log!("Signed up successfully!"); log!("Signed up successfully!");
leptos_router::use_navigate()("/", Default::default()); leptos_router::hooks::use_navigate()("/", Default::default());
log!("Navigated to home page after signup") log!("Navigated to home page after signup");
} }
loading.set(false);
}); });
}; };
view! { view! {
<div class="auth-page-container"> <section class="bg-white dark:bg-black flex items-center justify-center h-screen">
<div class="signup-container"> <div class="rounded-lg shadow bg-white w-full p-12 max-w-md relative">
<a class="return" href="/"><Icon icon=icondata::IoReturnUpBackSharp /></a> <a class="hover:bg-neutral-400 transition-all duration-500
<div class="header"> rounded-md absolute left-5 top-5 p-1" href="/">
<h1>LibreTunes</h1> <Icon icon={icondata::IoReturnUpBackSharp} height="1.5rem" width="1.5rem"/>
</div> </a>
<form class="signup-form" action="POST" on:submit=on_submit> <h1 class="text-5xl font-bold text-accent text-center p-1">"LibreTunes"</h1>
<div class="input-box"> <form on:submit=on_submit>
<input class="signup-email" type="text" required <FancyInput label="Email" required=true value=email />
on:input = move |ev| { <FancyInput label="Username" required=true value=username />
set_email(event_target_value(&ev)); <FancyInput label="Password" password=true required=true value=password />
log!("email changed to: {}", email.get()); <div
} class="text-red-800 text-base"
prop:value=email style="min-height: calc(var(--text-base--line-height) * var(--text-base));"
/> >
<span>Email</span> { move || error_msg.get() }
<i></i>
</div> </div>
<div class="input-box"> <Show
<input class="signup-username" type="text" required when=move || !loading.get()
on:input = move |ev| { fallback=move || view! { <div class="p-3 my-2"> <Loading /> </div> }
set_username(event_target_value(&ev)); >
log!("username changed to: {}", username.get()); <input class="bg-accent rounded-md text-white text-base
} w-full p-3 my-2 font-semibold cursor-pointer" type="submit" value="Sign Up" />
/> </Show>
<span>Username</span> <span class="text-base text-neutral-500 my-1">
<i></i> "Already have an account?"
</div> <a class="hover-link ml-2" href="/login" >"Go to Login"</a>
<div class="input-box">
<input class="signup-password" type={move || if show_password() { "text" } else { "password"} } required style="width: 90%;"
on:input = move |ev| {
set_password(event_target_value(&ev));
log!("password changed to: {}", password.get());
}
/>
<span>Password</span>
<i></i>
<Show
when=move || {show_password() == false}
fallback=move || view!{ <button on:click=toggle_password class="password-visibility"> <Icon icon=icondata::AiEyeInvisibleFilled /></button> /> }
>
<button on:click=toggle_password class="password-visibility">
<Icon icon=icondata::AiEyeFilled />
</button>
</Show>
</div>
<input type="submit" value="Sign Up" />
<span class="go-to-login">
Already Have an Account? <a href="/login" class="link" >Go to Login</a>
</span> </span>
</form> </form>
</div> </div>
</div> </section>
} }
} }

177
src/pages/song.rs Normal file
View File

@ -0,0 +1,177 @@
use leptos::either::*;
use leptos::prelude::*;
use leptos_icons::*;
use leptos_router::hooks::use_params_map;
use crate::api::songs;
use crate::api::songs::*;
use crate::components::error::*;
use crate::components::loading::*;
use crate::components::song_list::*;
use crate::models::frontend;
use crate::util::state::GlobalState;
use std::borrow::Borrow;
use std::rc::Rc;
#[component]
pub fn SongPage() -> impl IntoView {
let params = use_params_map();
view! {
{move || params.with(|params| {
match params.get("id").map(|id| id.parse::<i32>()) {
Some(Ok(id)) => {
Either::Left(view! { <SongDetails id /> })
},
Some(Err(e)) => {
Either::Right(view! {
<Error<String>
title="Invalid Song ID"
error=e.to_string()
/>
})
},
None => {
Either::Right(view! {
<Error<String>
title="No Song ID"
message="You must specify a song ID to view its page."
/>
})
}
}
})}
}
}
#[component]
fn SongDetails(#[prop(into)] id: Signal<i32>) -> impl IntoView {
let song_info = Resource::new(move || id.get(), get_song_by_id);
view! {
<Transition
fallback=move || view! { <LoadingPage /> }
>
{move || song_info.get().map(|song| {
match song {
Ok(Some(song)) => {
EitherOf3::A(view! { <SongOverview song /> })
},
Ok(None) => {
EitherOf3::B(view! {
<Error<String>
title="Song Not Found"
message=format!("Song with ID {} not found", id.get())
/>
})
},
Err(error) => EitherOf3::C(error.to_component()),
}
})}
</Transition>
<SongPlays id />
<MySongPlays id />
}
}
#[component]
fn SongOverview(song: frontend::Song) -> impl IntoView {
let playing = RwSignal::new(false);
let icon = Signal::derive(move || {
if playing.get() {
icondata::BsPauseFill
} else {
icondata::BsPlayFill
}
});
Effect::new(move |_| {
GlobalState::play_status().with(|status| {
playing
.set(status.queue.front().map(|song| song.id) == Some(song.id) && status.playing);
});
});
let song_rc = Rc::new(song.clone());
let toggle_play_song = move |_| {
GlobalState::play_status().update(|status| {
if status.queue.front().map(|song| song.id) == Some(song_rc.id) {
status.playing = !status.playing;
} else {
if let Some(last_playing) = status.queue.front() {
status.queue.push_front(last_playing.clone());
}
status.queue.clear();
status.queue.push_front(
<Rc<frontend::Song> as Borrow<frontend::Song>>::borrow(&song_rc).clone(),
);
status.playing = true;
}
});
};
view! {
<div class="flex">
<div class="relative w-35 h-35">
<img src=song.image_path />
<Icon icon on:click={toggle_play_song} {..}
class="control w-15 h-15 absolute top-1/2 left-1/2 translate-[-50%]" />
</div>
<div class="self-center p-2">
<h1 class="text-4xl">{song.title}</h1>
<p><SongArtists artists=song.artists /></p>
<p><SongAlbum album=song.album /></p>
</div>
</div>
<p>{format!("Duration: {}:{:02}", song.duration / 60, song.duration % 60)}</p>
}
}
#[component]
fn SongPlays(#[prop(into)] id: Signal<i32>) -> impl IntoView {
let plays = Resource::new(move || id.get(), songs::get_song_plays);
view! {
<Transition
fallback=move || view! { <Loading /> }
>
{move || plays.get().map(|plays| {
match plays {
Ok(plays) => {
Either::Left(view! {
<p>{format!("Plays: {plays}")}</p>
})
},
Err(error) => Either::Right(error.to_component())
}
})}
</Transition>
}
}
#[component]
fn MySongPlays(#[prop(into)] id: Signal<i32>) -> impl IntoView {
let plays = Resource::new(move || id.get(), songs::get_my_song_plays);
view! {
<Transition
fallback=move || view! { <Loading /> }
>
{move || plays.get().map(|plays| {
match plays {
Ok(plays) => {
Either::Left(view! {
<p>{format!("My Plays: {plays}")}</p>
})
},
Err(error) => {
Either::Right(error.to_component())
}
}
})}
</Transition>
}
}

View File

@ -1,64 +0,0 @@
use leptos::HtmlElement;
use leptos::NodeRef;
use leptos::html::Audio;
use std::collections::VecDeque;
use crate::songdata::SongData;
/// Represents the global state of the audio player feature of LibreTunes
pub struct PlayStatus {
/// Whether or not the audio player is currently playing
pub playing: bool,
/// Whether or not the queue is open
pub queue_open: bool,
/// A reference to the HTML audio element
pub audio_player: Option<NodeRef<Audio>>,
/// A queue of songs that have been played, ordered from oldest to newest
pub history: VecDeque<SongData>,
/// A queue of songs that have yet to be played, ordered from next up to last
pub queue: VecDeque<SongData>,
}
impl PlayStatus {
/// Returns the HTML audio element if it has been created and is present, otherwise returns None
///
/// Instead of:
/// ```
/// let status = libretunes::playstatus::PlayStatus::default();
/// if let Some(audio) = status.audio_player {
/// if let Some(audio) = audio.get() {
/// let _ = audio.play();
/// }
/// }
/// ```
///
/// You can do:
/// ```
/// let status = libretunes::playstatus::PlayStatus::default();
/// if let Some(audio) = status.get_audio() {
/// let _ = audio.play();
/// }
/// ```
pub fn get_audio(&self) -> Option<HtmlElement<Audio>> {
if let Some(audio) = &self.audio_player {
if let Some(audio) = audio.get() {
return Some(audio);
}
}
None
}
}
impl Default for PlayStatus {
/// Creates a paused PlayStatus with no audio player, no progress update handle, and empty queue/history
fn default() -> Self {
Self {
playing: false,
queue_open: false,
audio_player: None,
history: VecDeque::new(),
queue: VecDeque::new(),
}
}
}

View File

@ -1,122 +0,0 @@
use crate::models::Artist;
use crate::song::Song;
use crate::util::state::GlobalState;
use leptos::ev::MouseEvent;
use leptos::leptos_dom::*;
use leptos::*;
use leptos_icons::*;
use leptos::ev::DragEvent;
const RM_BTN_SIZE: &str = "2.5rem";
fn remove_song_fn(index: usize) {
if index == 0 {
log!("Error: Trying to remove currently playing song (index 0) from queue");
} else {
log!("Remove Song from Queue: Song is not currently playing, deleting song from queue and not adding to history");
GlobalState::play_status().update(|status| {
status.queue.remove(index);
});
}
}
#[component]
pub fn Queue() -> impl IntoView {
let status = GlobalState::play_status();
let remove_song = move |index: usize| {
remove_song_fn(index);
log!("Removed song {}", index + 1);
};
let prevent_focus = move |e: MouseEvent| {
e.prevent_default();
};
let index_being_dragged = create_rw_signal(-1);
let index_being_hovered = create_rw_signal(-1);
let on_drag_start = move |_e: DragEvent, index: usize| {
// set the index of the item being dragged
index_being_dragged.set(index as i32);
};
let on_drop = move |e: DragEvent| {
e.prevent_default();
// if the index of the item being dragged is not the same as the index of the item being hovered over
if index_being_dragged.get() != index_being_hovered.get() && index_being_dragged.get() > 0 && index_being_hovered.get() > 0 {
// get the index of the item being dragged
let dragged_index = index_being_dragged.get_untracked() as usize;
// get the index of the item being hovered over
let hovered_index = index_being_hovered.get_untracked() as usize;
// update the queue
status.update(|status| {
// remove the dragged item from the list
let dragged_item = status.queue.remove(dragged_index);
// insert the dragged item at the index of the item being hovered over
status.queue.insert(hovered_index, dragged_item.unwrap());
});
// reset the index of the item being dragged
index_being_dragged.set(-1);
// reset the index of the item being hovered over
index_being_hovered.set(-1);
log!("drag end. Moved item from index {} to index {}", dragged_index, hovered_index);
}
else {
// reset the index of the item being dragged
index_being_dragged.set(-1);
// reset the index of the item being hovered over
index_being_hovered.set(-1);
}
};
let on_drag_enter = move |_e: DragEvent, index: usize| {
// set the index of the item being hovered over
index_being_hovered.set(index as i32);
};
let on_drag_over = move |e: DragEvent| {
e.prevent_default();
};
view!{
<Show
when=move || status.with(|status| status.queue_open)
fallback=|| view!{""}>
<div class="queue">
<div class="queue-header">
<h2>Queue</h2>
</div>
<ul>
{
move || status.with(|status| status.queue.iter()
.enumerate()
.map(|(index, song)| view! {
<div class="queue-item"
draggable="true"
on:dragstart=move |e: DragEvent| on_drag_start(e, index)
on:drop=on_drop
on:dragenter=move |e: DragEvent| on_drag_enter(e, index)
on:dragover=on_drag_over
>
<Song song_image_path=song.image_path.clone() song_title=song.title.clone() song_artist=Artist::display_list(&song.artists) />
<Show
when=move || index != 0
fallback=|| view!{
<p>Playing</p>
}>
<button on:click=move |_| remove_song(index) on:mousedown=prevent_focus>
<Icon class="remove-song" width=RM_BTN_SIZE height=RM_BTN_SIZE icon=icondata::CgTrash />
</button>
</Show>
</div>
})
.collect::<Vec<_>>())
}
</ul>
</div>
</Show>
}
}

View File

@ -96,6 +96,7 @@ diesel::table! {
release_date -> Nullable<Date>, release_date -> Nullable<Date>,
storage_path -> Varchar, storage_path -> Varchar,
image_path -> Nullable<Varchar>, image_path -> Nullable<Varchar>,
added_date -> Timestamp,
} }
} }

View File

@ -1,109 +0,0 @@
use leptos::*;
use crate::models::{Artist, Album, Song};
use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "ssr")] {
use diesel::sql_types::*;
use diesel::*;
use diesel::pg::Pg;
use diesel::expression::AsExpression;
use crate::database::get_db_conn;
// Define pg_trgm operators
// Functions do not use indices for queries, so we need to use operators
diesel::infix_operator!(Similarity, " % ", backend: Pg);
diesel::infix_operator!(Distance, " <-> ", Float, backend: Pg);
// Create functions to make use of the operators in queries
fn trgm_similar<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Similarity<T::Expression, U::Expression> {
Similarity::new(left.as_expression(), right.as_expression())
}
fn trgm_distance<T: AsExpression<Text>, U: AsExpression<Text>>(left: T, right: U)
-> Distance<T::Expression, U::Expression> {
Distance::new(left.as_expression(), right.as_expression())
}
}
}
/// Search for albums by title
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the album titles
/// `limit` - The maximum number of results to return
///
/// # Returns
/// A Result containing a vector of albums if the search was successful, or an error if the search failed
#[server(endpoint = "search_albums")]
pub async fn search_albums(query: String, limit: i64) -> Result<Vec<Album>, ServerFnError> {
use crate::schema::albums::dsl::*;
Ok(albums
.filter(trgm_similar(title, query.clone()))
.order_by(trgm_distance(title, query))
.limit(limit)
.load(&mut get_db_conn())?)
}
/// Search for artists by name
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the artist names
/// `limit` - The maximum number of results to return
///
/// # Returns
/// A Result containing a vector of artists if the search was successful, or an error if the search failed
#[server(endpoint = "search_artists")]
pub async fn search_artists(query: String, limit: i64) -> Result<Vec<Artist>, ServerFnError> {
use crate::schema::artists::dsl::*;
Ok(artists
.filter(trgm_similar(name, query.clone()))
.order_by(trgm_distance(name, query))
.limit(limit)
.load(&mut get_db_conn())?)
}
/// Search for songs by title
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the song titles
/// `limit` - The maximum number of results to return
///
/// # Returns
/// A Result containing a vector of songs if the search was successful, or an error if the search failed
#[server(endpoint = "search_songs")]
pub async fn search_songs(query: String, limit: i64) -> Result<Vec<Song>, ServerFnError> {
use crate::schema::songs::dsl::*;
Ok(songs
.filter(trgm_similar(title, query.clone()))
.order_by(trgm_distance(title, query))
.limit(limit)
.load(&mut get_db_conn())?)
}
/// Search for songs, albums, and artists by title or name
///
/// # Arguments
/// `query` - The search query. This will be used to perform a fuzzy search on the
/// song titles, album titles, and artist names
/// `limit` - The maximum number of results to return for each type
///
/// # Returns
/// A Result containing a tuple of vectors of albums, artists, and songs if the search was successful,
#[server(endpoint = "search")]
pub async fn search(query: String, limit: i64) -> Result<(Vec<Album>, Vec<Artist>, Vec<Song>), ServerFnError> {
let albums = search_albums(query.clone(), limit);
let artists = search_artists(query.clone(), limit);
let songs = search_songs(query.clone(), limit);
use tokio::join;
let (albums, artists, songs) = join!(albums, artists, songs);
Ok((albums?, artists?, songs?))
}

View File

@ -1,14 +0,0 @@
use leptos::*;
#[component]
pub fn Song(song_image_path: String, song_title: String, song_artist: String) -> impl IntoView {
view!{
<div class="queue-song">
<img src={song_image_path} alt={song_title.clone()} />
<div class="queue-song-info">
<h3>{song_title}</h3>
<p>{song_artist}</p>
</div>
</div>
}
}

View File

@ -1,82 +0,0 @@
use crate::models::{Album, Artist, Song};
use crate::components::dashboard_tile::DashboardTile;
use serde::{Serialize, Deserialize};
use chrono::NaiveDate;
/// Holds information about a song
///
/// Intended to be used in the front-end, as it includes artist and album objects, rather than just their ids.
#[derive(Serialize, Deserialize, Clone)]
pub struct SongData {
/// Song id
pub id: i32,
/// Song name
pub title: String,
/// Song artists
pub artists: Vec<Artist>,
/// Song album
pub album: Option<Album>,
/// The track number of the song on the album
pub track: Option<i32>,
/// The duration of the song in seconds
pub duration: i32,
/// The song's release date
pub release_date: Option<NaiveDate>,
/// Path to song file, relative to the root of the web server.
/// For example, `"/assets/audio/Song.mp3"`
pub song_path: String,
/// Path to song image, relative to the root of the web server.
/// For example, `"/assets/images/Song.jpg"`
pub image_path: String,
/// Whether the song is liked by the user
pub like_dislike: Option<(bool, bool)>,
}
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)))
}
}

Some files were not shown because too many files have changed in this diff Show More